Compare commits
41 Commits
4c0beb2a2a
...
v1.0.0-bet
| Author | SHA1 | Date | |
|---|---|---|---|
| b5d6d91851 | |||
| d475d03d0c | |||
| 0f00fed122 | |||
| ef73b3c629 | |||
| bbf31baee5 | |||
| 80227b3ed3 | |||
| d00c5a1982 | |||
| c054017d7e | |||
| d065e70cd1 | |||
| 7a6b6feacc | |||
| 55c5ebbf17 | |||
| 3fecda175b | |||
| 2423cdca19 | |||
| 4b021bef01 | |||
| dcf28e2b60 | |||
| 624140c061 | |||
| fcb2b671e7 | |||
| a86219dcb0 | |||
| c22a7291c7 | |||
| d7996bf32e | |||
| 2dcf825105 | |||
| 3debfa81d7 | |||
| 4ba2049756 | |||
| b7ffdc8431 | |||
| e152154c3b | |||
| ff5ae8280c | |||
| 41cc8f4032 | |||
| 8ebd1c3525 | |||
| bd1910ce03 | |||
| cba3f976c6 | |||
| 971a82df1b | |||
| 6d863d8bbe | |||
| 10ded51d2f | |||
| e44ce7e3f6 | |||
| a7f9a7ceeb | |||
| ebf0e86828 | |||
| 2ec98e14d0 | |||
| 31df6d7937 | |||
| 67ccfcb132 | |||
| e3141aba19 | |||
| bc588114c4 |
6
.github/workflows/release.yml
vendored
@@ -21,7 +21,7 @@ jobs:
|
|||||||
os: windows-11-arm
|
os: windows-11-arm
|
||||||
target: aarch64-pc-windows-msvc
|
target: aarch64-pc-windows-msvc
|
||||||
- platform: macos-x64
|
- platform: macos-x64
|
||||||
os: macos-13
|
os: macos-15-intel
|
||||||
target: x86_64-apple-darwin
|
target: x86_64-apple-darwin
|
||||||
- platform: macos-arm64
|
- platform: macos-arm64
|
||||||
os: macos-latest
|
os: macos-latest
|
||||||
@@ -130,7 +130,7 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
- name: Make get-crate-version executable
|
- name: Make get-crate-version executable
|
||||||
run: chmod +x script/get-crate-version
|
run: chmod +x script/get-crate-version
|
||||||
@@ -163,8 +163,6 @@ jobs:
|
|||||||
generate_release_notes: true
|
generate_release_notes: true
|
||||||
files: |
|
files: |
|
||||||
artifacts/**/*
|
artifacts/**/*
|
||||||
env:
|
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
|
|
||||||
- name: Output release info
|
- name: Output release info
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
859
Cargo.lock
generated
@@ -4,15 +4,14 @@ members = ["crates/*"]
|
|||||||
default-members = ["crates/coop"]
|
default-members = ["crates/coop"]
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
version = "0.3.0"
|
version = "1.0.0-beta"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
publish = false
|
publish = false
|
||||||
|
|
||||||
[workspace.dependencies]
|
[workspace.dependencies]
|
||||||
|
|
||||||
# GPUI
|
# GPUI
|
||||||
gpui = { git = "https://github.com/zed-industries/zed" }
|
gpui = { git = "https://github.com/zed-industries/zed" }
|
||||||
gpui_platform = { git = "https://github.com/zed-industries/zed" }
|
gpui_platform = { git = "https://github.com/zed-industries/zed", features = ["font-kit", "x11", "wayland", "runtime_shaders"] }
|
||||||
gpui_linux = { git = "https://github.com/zed-industries/zed" }
|
gpui_linux = { git = "https://github.com/zed-industries/zed" }
|
||||||
gpui_windows = { 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_macos = { git = "https://github.com/zed-industries/zed" }
|
||||||
@@ -22,6 +21,7 @@ reqwest_client = { git = "https://github.com/zed-industries/zed" }
|
|||||||
# Nostr
|
# Nostr
|
||||||
nostr-lmdb = { git = "https://github.com/rust-nostr/nostr" }
|
nostr-lmdb = { git = "https://github.com/rust-nostr/nostr" }
|
||||||
nostr-connect = { git = "https://github.com/rust-nostr/nostr" }
|
nostr-connect = { git = "https://github.com/rust-nostr/nostr" }
|
||||||
|
nostr-blossom = { git = "https://github.com/rust-nostr/nostr" }
|
||||||
nostr-sdk = { git = "https://github.com/rust-nostr/nostr" }
|
nostr-sdk = { git = "https://github.com/rust-nostr/nostr" }
|
||||||
nostr = { git = "https://github.com/rust-nostr/nostr", features = [ "nip96", "nip59", "nip49", "nip44" ] }
|
nostr = { git = "https://github.com/rust-nostr/nostr", features = [ "nip96", "nip59", "nip49", "nip44" ] }
|
||||||
|
|
||||||
|
|||||||
3
assets/icons/book.svg
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
|
||||||
|
<path d="M4.75 20V4.75C4.75 3.64543 5.64543 2.75 6.75 2.75H19.25V18.75H6C5.30964 18.75 4.75 19.3096 4.75 20ZM4.75 20C4.75 20.6904 5.30964 21.25 6 21.25H19.25" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M12 12.25C9.75 12.25 9.75 13.75 9.75 13.75H14.25C14.25 13.75 14.25 12.25 12 12.25Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M13 9.25C13 9.80228 12.5523 10.25 12 10.25C11.4477 10.25 11 9.80228 11 9.25C11 8.69772 11.4477 8.25 12 8.25C12.5523 8.25 13 8.69772 13 9.25Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M11.5 9.25H12.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 866 B |
@@ -1,3 +1,3 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
|
||||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M7.10352 4C7.42998 2.84575 8.49122 2 9.75 2H14.25C15.5088 2 16.57 2.84575 16.8965 4H18.25C19.7688 4 21 5.23122 21 6.75V19.25C21 20.7688 19.7688 22 18.25 22H5.75C4.23122 22 3 20.7688 3 19.25V6.75C3 5.23122 4.23122 4 5.75 4H7.10352ZM8.5 4.75V6.25C8.5 6.38807 8.61193 6.5 8.75 6.5H15.25C15.3881 6.5 15.5 6.38807 15.5 6.25V4.75C15.5 4.05964 14.9404 3.5 14.25 3.5H9.75C9.05964 3.5 8.5 4.05964 8.5 4.75Z" fill="currentColor"/>
|
<path d="M8.75 8.75V4C8.75 3.30964 9.30964 2.75 10 2.75H20C20.6904 2.75 21.25 3.30964 21.25 4V14C21.25 14.6904 20.6904 15.25 20 15.25H15.25M14 8.75H4C3.30964 8.75 2.75 9.30964 2.75 10V20C2.75 20.6904 3.30964 21.25 4 21.25H14C14.6904 21.25 15.25 20.6904 15.25 20V10C15.25 9.30964 14.6904 8.75 14 8.75Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
</svg>
|
</svg>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 550 B After Width: | Height: | Size: 472 B |
3
assets/icons/device.svg
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
|
||||||
|
<path d="M14.25 10.75C14.25 9.64543 15.1454 8.75 16.25 8.75H20.25C21.3546 8.75 22.25 9.64543 22.25 10.75V19.25C22.25 20.3546 21.3546 21.25 20.25 21.25H16.25C15.1454 21.25 14.25 20.3546 14.25 19.25V10.75Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M17.25 18.25H19.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M20.25 8.75V5.75C20.25 4.64543 19.3546 3.75 18.25 3.75H5.75C4.64543 3.75 3.75 4.64543 3.75 5.75V14.75C3.75 15.8546 2.85457 16.75 1.75 16.75V18.25C1.75 19.3546 2.64543 20.25 3.75 20.25H14.25" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M3.75 16.75H14.25" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 898 B |
3
assets/icons/group.svg
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
|
||||||
|
<circle cx="12" cy="8.75" r="3" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><circle cx="4" cy="9.80005" r="2" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><circle cx="20" cy="9.80005" r="2" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M7.25 16.625V16.5C7.25 13.8766 9.37665 11.75 12 11.75C14.6234 11.75 16.75 13.8766 16.75 16.5V16.625C16.75 17.5225 16.0225 18.25 15.125 18.25H8.875C7.97754 18.25 7.25 17.5225 7.25 16.625Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M4.25 17.2602H2.75C1.64543 17.2602 0.706551 16.3538 0.919944 15.2701C1.25877 13.5493 2.15049 12.3257 4 11.8301" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M19.75 17.2601H21.25C22.3546 17.2601 23.2935 16.3538 23.08 15.27C22.7412 13.5493 21.8495 12.3257 20 11.8301" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.1 KiB |
@@ -1,3 +1,3 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
|
||||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M19.25 4C20.7688 4 22 5.23122 22 6.75V17.25C22 18.7688 20.7688 20 19.25 20H4.75C3.23122 20 2 18.7688 2 17.25V6.75C2 5.23122 3.23122 4 4.75 4H19.25ZM6.25 7.5C5.83579 7.5 5.5 7.83579 5.5 8.25V15.75C5.5 16.1642 5.83579 16.5 6.25 16.5C6.66421 16.5 7 16.1642 7 15.75V8.25C7 7.83579 6.66421 7.5 6.25 7.5Z" fill="currentColor"/>
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M9 5.5V18.5H19.25C19.9404 18.5 20.5 17.9404 20.5 17.25V6.75C20.5 6.05964 19.9404 5.5 19.25 5.5H9ZM2 6.75C2 5.23122 3.23122 4 4.75 4H19.25C20.7688 4 22 5.23122 22 6.75V17.25C22 18.7688 20.7688 20 19.25 20H4.75C3.23122 20 2 18.7688 2 17.25V6.75Z" fill="currentColor"/>
|
||||||
</svg>
|
</svg>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 451 B After Width: | Height: | Size: 396 B |
@@ -1,3 +1,3 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
|
||||||
<path d="M2.75 6.75C2.75 5.64543 3.64543 4.75 4.75 4.75H19.25C20.3546 4.75 21.25 5.64543 21.25 6.75V17.25C21.25 18.3546 20.3546 19.25 19.25 19.25H4.75C3.64543 19.25 2.75 18.3546 2.75 17.25V6.75Z" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round"/><path d="M6.25 8.25V15.75" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
|
<path d="M2.75 6.75C2.75 5.64543 3.64543 4.75 4.75 4.75H19.25C20.3546 4.75 21.25 5.64543 21.25 6.75V17.25C21.25 18.3546 20.3546 19.25 19.25 19.25H4.75C3.64543 19.25 2.75 18.3546 2.75 17.25V6.75Z" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round"/><path d="M8.25 5V12V19" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round"/>
|
||||||
</svg>
|
</svg>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 435 B After Width: | Height: | Size: 433 B |
@@ -1,3 +1,3 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
|
||||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M4.75 4C3.23122 4 2 5.23122 2 6.75V17.25C2 18.7688 3.23122 20 4.75 20H19.25C20.7688 20 22 18.7688 22 17.25V6.75C22 5.23122 20.7688 4 19.25 4H4.75ZM17.75 7.5C18.1642 7.5 18.5 7.83579 18.5 8.25V15.75C18.5 16.1642 18.1642 16.5 17.75 16.5C17.3358 16.5 17 16.1642 17 15.75V8.25C17 7.83579 17.3358 7.5 17.75 7.5Z" fill="currentColor"/>
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M15 5.5V18.5H4.75C4.05964 18.5 3.5 17.9404 3.5 17.25V6.75C3.5 6.05964 4.05964 5.5 4.75 5.5H15ZM22 6.75C22 5.23122 20.7688 4 19.25 4H4.75C3.23122 4 2 5.23122 2 6.75V17.25C2 18.7688 3.23122 20 4.75 20H19.25C20.7688 20 22 18.7688 22 17.25V6.75Z" fill="currentColor"/>
|
||||||
</svg>
|
</svg>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 459 B After Width: | Height: | Size: 394 B |
@@ -1,3 +1,3 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
|
||||||
<path d="M2.75 6.75C2.75 5.64543 3.64543 4.75 4.75 4.75H19.25C20.3546 4.75 21.25 5.64543 21.25 6.75V17.25C21.25 18.3546 20.3546 19.25 19.25 19.25H4.75C3.64543 19.25 2.75 18.3546 2.75 17.25V6.75Z" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round"/><path d="M17.75 8.25V15.75" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
|
<path d="M15.75 5V19" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round"/><path d="M2.75 6.75C2.75 5.64543 3.64543 4.75 4.75 4.75H19.25C20.3546 4.75 21.25 5.64543 21.25 6.75V17.25C21.25 18.3546 20.3546 19.25 19.25 19.25H4.75C3.64543 19.25 2.75 18.3546 2.75 17.25V6.75Z" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round"/>
|
||||||
</svg>
|
</svg>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 436 B After Width: | Height: | Size: 431 B |
3
assets/icons/refresh.svg
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
|
||||||
|
<path d="M13 21C13.5523 21 14 20.5523 14 20C14 19.4477 13.5523 19 13 19C12.4477 19 12 19.4477 12 20C12 20.5523 12.4477 21 13 21Z" fill="currentColor"/><path d="M21 11C21 10.4477 20.5523 9.99999 20 9.99999C19.4477 9.99999 19 10.4477 19 11C19 11.5523 19.4477 12 20 12C20.5523 12 21 11.5523 21 11Z" fill="currentColor"/><path d="M19.9295 14.2679C20.4078 14.5441 20.5716 15.1557 20.2955 15.634C20.0193 16.1123 19.4078 16.2761 18.9295 16C18.4512 15.7238 18.2873 15.1123 18.5634 14.634C18.8396 14.1557 19.4512 13.9918 19.9295 14.2679Z" fill="currentColor"/><path d="M17.3676 19.2942C17.8459 19.0181 18.0098 18.4065 17.7336 17.9282C17.4575 17.4499 16.8459 17.286 16.3676 17.5621C15.8893 17.8383 15.7254 18.4499 16.0016 18.9282C16.2777 19.4065 16.8893 19.5703 17.3676 19.2942Z" fill="currentColor"/><path d="M18.9269 7.99998C18.4487 8.27612 17.8371 8.11225 17.5609 7.63396C17.2848 7.15566 17.4487 6.54407 17.9269 6.26793C18.4052 5.99179 19.0168 6.15566 19.293 6.63396C19.5691 7.11225 19.4052 7.72384 18.9269 7.99998Z" fill="currentColor"/><path d="M9.25 14.75V20.25H3.75" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M15.2493 4.41452C14.2521 3.98683 13.1537 3.75 12 3.75C7.44365 3.75 3.75 7.44365 3.75 12C3.75 15.498 5.92698 18.4875 9 19.6876" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.4 KiB |
3
assets/icons/reset.svg
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
|
||||||
|
<path d="M13 17.25C16.4518 17.25 19.25 14.4518 19.25 11V10.5C19.25 6.77208 16.2279 3.75 12.5 3.75C8.77208 3.75 5.75 6.77208 5.75 10.5V20" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M2 16.5L5.75 20.25L9.5 16.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 435 B |
3
assets/icons/scan.svg
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
|
||||||
|
<path d="M7.25 4.75H4.75C3.64543 4.75 2.75 5.64543 2.75 6.75V9.25M16.75 4.75H19.25C20.3546 4.75 21.25 5.64543 21.25 6.75V9.25M21.25 14.75V17.25C21.25 18.3546 20.3546 19.25 19.25 19.25H16.75M7.25 19.25H4.75C3.64543 19.25 2.75 18.3546 2.75 17.25V14.75M7.75 9.75V14.25M16.25 9.75V14.25M12 9.75V12.25" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 468 B |
3
assets/icons/settings2.svg
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
|
||||||
|
<path d="M13 19.25H5.95C4.82989 19.25 4.26984 19.25 3.84202 19.032C3.46569 18.8403 3.15973 18.5343 2.96799 18.158C2.75 17.7302 2.75 17.1701 2.75 16.05V7.95C2.75 6.82989 2.75 6.26984 2.96799 5.84202C3.15973 5.46569 3.46569 5.15973 3.84202 4.96799C4.26984 4.75 4.8299 4.75 5.95 4.75H18.05C19.1701 4.75 19.7302 4.75 20.158 4.96799C20.5343 5.15973 20.8403 5.46569 21.032 5.84202C21.25 6.26984 21.25 6.8299 21.25 7.95V11" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/><path d="M3 5.63635L10.9761 10.3898C11.6069 10.7657 12.3931 10.7657 13.0239 10.3898L21 5.63635" stroke="currentColor" stroke-width="1.5"/><path d="M21.8148 15.375L21.1669 15.7491M16.1856 18.625L16.8335 18.2509M21.8147 18.625L21.1669 18.251M16.1855 15.375L16.8335 15.7491M19.0002 20.25L19.0002 19.4375M19.0002 13.75V14.5625M21.1669 17C21.1669 16.6053 21.0613 16.2352 20.8769 15.9165C20.5022 15.269 19.8021 14.8333 19.0002 14.8333C18.1983 14.8333 17.4981 15.269 17.1235 15.9165C16.9391 16.2352 16.8335 16.6053 16.8335 17C16.8335 17.3947 16.9391 17.7648 17.1235 18.0835C17.4982 18.731 18.1983 19.1667 19.0002 19.1667C19.8021 19.1667 20.5022 18.731 20.8769 18.0835C21.0613 17.7648 21.1669 17.3947 21.1669 17Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.3 KiB |
3
assets/icons/user-key.svg
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
|
||||||
|
<path d="M12.9996 12.8145C12.675 12.7719 12.3415 12.75 11.9996 12.75C8.55174 12.75 5.94978 14.981 4.9305 18.114C4.56744 19.23 5.50919 20.25 6.68275 20.25H13.9996M15.7496 6.5C15.7496 8.57107 14.0706 10.25 11.9996 10.25C9.92851 10.25 8.24958 8.57107 8.24958 6.5C8.24958 4.42893 9.92851 2.75 11.9996 2.75C14.0706 2.75 15.7496 4.42893 15.7496 6.5ZM15.7496 14C15.7496 12.7574 16.7569 11.75 17.9996 11.75C19.2422 11.75 20.2496 12.7574 20.2496 14C20.2496 14.7801 19.8526 15.4675 19.2496 15.8711V17L18.7496 17.9356L19.2496 18.9678V20L17.9996 21L16.7496 20V15.8711C16.1466 15.4675 15.7496 14.7801 15.7496 14Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 771 B |
@@ -9,9 +9,9 @@
|
|||||||
"elevated_surface_background": "#232634",
|
"elevated_surface_background": "#232634",
|
||||||
"panel_background": "#303446",
|
"panel_background": "#303446",
|
||||||
"overlay": "#c6d0f51a",
|
"overlay": "#c6d0f51a",
|
||||||
"title_bar": "#00000000",
|
"title_bar": "#292c3c",
|
||||||
"title_bar_inactive": "#303446",
|
"title_bar_inactive": "#232634",
|
||||||
"window_border": "#626880",
|
"window_border": "#737994",
|
||||||
"border": "#626880",
|
"border": "#626880",
|
||||||
"border_variant": "#51576d",
|
"border_variant": "#51576d",
|
||||||
"border_focused": "#8caaee",
|
"border_focused": "#8caaee",
|
||||||
@@ -23,50 +23,52 @@
|
|||||||
"text_muted": "#b5bfe2",
|
"text_muted": "#b5bfe2",
|
||||||
"text_placeholder": "#a5adce",
|
"text_placeholder": "#a5adce",
|
||||||
"text_accent": "#8caaee",
|
"text_accent": "#8caaee",
|
||||||
"icon": "#c6d0f5",
|
"icon": "#b5bfe2",
|
||||||
"icon_muted": "#b5bfe2",
|
"icon_muted": "#a5adce",
|
||||||
"icon_accent": "#8caaee",
|
"icon_accent": "#8caaee",
|
||||||
"element_foreground": "#303446",
|
"element_foreground": "#232634",
|
||||||
"element_background": "#8caaee",
|
"element_background": "#8caaee",
|
||||||
"element_hover": "#8caaeee6",
|
"element_hover": "#babbf1",
|
||||||
"element_active": "#7e99d6",
|
"element_active": "#7e99d6",
|
||||||
"element_selected": "#7088be",
|
"element_selected": "#7088bf",
|
||||||
"element_disabled": "#8caaee4d",
|
"element_disabled": "#8caaee4d",
|
||||||
"secondary_foreground": "#8caaee",
|
"secondary_foreground": "#7088bf",
|
||||||
"secondary_background": "#414559",
|
"secondary_background": "#292c3c",
|
||||||
"secondary_hover": "#8caaee1a",
|
"secondary_hover": "#8caaee33",
|
||||||
"secondary_active": "#51576d",
|
"secondary_active": "#232634",
|
||||||
"secondary_selected": "#51576d",
|
"secondary_selected": "#232634",
|
||||||
"secondary_disabled": "#8caaee4d",
|
"secondary_disabled": "#8caaee4d",
|
||||||
"danger_foreground": "#303446",
|
"danger_foreground": "#232634",
|
||||||
"danger_background": "#e78284",
|
"danger_background": "#e78284",
|
||||||
"danger_hover": "#e78284e6",
|
"danger_hover": "#ea999c",
|
||||||
"danger_active": "#d07576",
|
"danger_active": "#d07576",
|
||||||
"danger_selected": "#b96869",
|
"danger_selected": "#b96869",
|
||||||
"danger_disabled": "#e782844d",
|
"danger_disabled": "#e782844d",
|
||||||
"warning_foreground": "#303446",
|
"warning_foreground": "#232634",
|
||||||
"warning_background": "#e5c890",
|
"warning_background": "#e5c890",
|
||||||
"warning_hover": "#e5c890e6",
|
"warning_hover": "#ef9f76",
|
||||||
"warning_active": "#ceb481",
|
"warning_active": "#ceb482",
|
||||||
"warning_selected": "#b7a072",
|
"warning_selected": "#b7a074",
|
||||||
"warning_disabled": "#e5c8904d",
|
"warning_disabled": "#e5c8904d",
|
||||||
"ghost_element_background": "#00000000",
|
"ghost_element_background": "#00000000",
|
||||||
"ghost_element_background_alt": "#414559",
|
"ghost_element_background_alt": "#414559",
|
||||||
"ghost_element_hover": "#c6d0f51a",
|
"ghost_element_hover": "#c6d0f533",
|
||||||
"ghost_element_active": "#51576d",
|
"ghost_element_active": "#51576d",
|
||||||
"ghost_element_selected": "#51576d",
|
"ghost_element_selected": "#51576d",
|
||||||
"ghost_element_disabled": "#c6d0f50d",
|
"ghost_element_disabled": "#c6d0f50d",
|
||||||
"tab_inactive_background": "#414559",
|
"tab_inactive_background": "#292c3c",
|
||||||
"tab_hover_background": "#51576d",
|
"tab_inactive_foreground": "#b5bfe2",
|
||||||
"tab_active_background": "#626880",
|
"tab_active_background": "#303446",
|
||||||
|
"tab_active_foreground": "#c6d0f5",
|
||||||
|
"tab_hover_foreground": "#babbf1",
|
||||||
"scrollbar_thumb_background": "#c6d0f533",
|
"scrollbar_thumb_background": "#c6d0f533",
|
||||||
"scrollbar_thumb_hover_background": "#c6d0f54d",
|
"scrollbar_thumb_hover_background": "#c6d0f580",
|
||||||
"scrollbar_thumb_border": "#00000000",
|
"scrollbar_thumb_border": "#00000000",
|
||||||
"scrollbar_track_background": "#00000000",
|
"scrollbar_track_background": "#00000000",
|
||||||
"scrollbar_track_border": "#51576d",
|
"scrollbar_track_border": "#51576d",
|
||||||
"drop_target_background": "#8caaee1a",
|
"drop_target_background": "#8caaee1a",
|
||||||
"cursor": "#99d1db",
|
"cursor": "#f2d5cf",
|
||||||
"selection": "#99d1db40"
|
"selection": "#949cbb40"
|
||||||
},
|
},
|
||||||
"dark": {
|
"dark": {
|
||||||
"background": "#303446",
|
"background": "#303446",
|
||||||
@@ -74,9 +76,9 @@
|
|||||||
"elevated_surface_background": "#232634",
|
"elevated_surface_background": "#232634",
|
||||||
"panel_background": "#303446",
|
"panel_background": "#303446",
|
||||||
"overlay": "#c6d0f51a",
|
"overlay": "#c6d0f51a",
|
||||||
"title_bar": "#00000000",
|
"title_bar": "#292c3c",
|
||||||
"title_bar_inactive": "#303446",
|
"title_bar_inactive": "#232634",
|
||||||
"window_border": "#626880",
|
"window_border": "#737994",
|
||||||
"border": "#626880",
|
"border": "#626880",
|
||||||
"border_variant": "#51576d",
|
"border_variant": "#51576d",
|
||||||
"border_focused": "#8caaee",
|
"border_focused": "#8caaee",
|
||||||
@@ -88,49 +90,51 @@
|
|||||||
"text_muted": "#b5bfe2",
|
"text_muted": "#b5bfe2",
|
||||||
"text_placeholder": "#a5adce",
|
"text_placeholder": "#a5adce",
|
||||||
"text_accent": "#8caaee",
|
"text_accent": "#8caaee",
|
||||||
"icon": "#c6d0f5",
|
"icon": "#b5bfe2",
|
||||||
"icon_muted": "#b5bfe2",
|
"icon_muted": "#a5adce",
|
||||||
"icon_accent": "#8caaee",
|
"icon_accent": "#8caaee",
|
||||||
"element_foreground": "#303446",
|
"element_foreground": "#232634",
|
||||||
"element_background": "#8caaee",
|
"element_background": "#8caaee",
|
||||||
"element_hover": "#8caaeee6",
|
"element_hover": "#babbf1",
|
||||||
"element_active": "#7e99d6",
|
"element_active": "#7e99d6",
|
||||||
"element_selected": "#7088be",
|
"element_selected": "#7088bf",
|
||||||
"element_disabled": "#8caaee4d",
|
"element_disabled": "#8caaee4d",
|
||||||
"secondary_foreground": "#8caaee",
|
"secondary_foreground": "#7088bf",
|
||||||
"secondary_background": "#414559",
|
"secondary_background": "#292c3c",
|
||||||
"secondary_hover": "#8caaee1a",
|
"secondary_hover": "#8caaee33",
|
||||||
"secondary_active": "#51576d",
|
"secondary_active": "#232634",
|
||||||
"secondary_selected": "#51576d",
|
"secondary_selected": "#232634",
|
||||||
"secondary_disabled": "#8caaee4d",
|
"secondary_disabled": "#8caaee4d",
|
||||||
"danger_foreground": "#303446",
|
"danger_foreground": "#232634",
|
||||||
"danger_background": "#e78284",
|
"danger_background": "#e78284",
|
||||||
"danger_hover": "#e78284e6",
|
"danger_hover": "#ea999c",
|
||||||
"danger_active": "#d07576",
|
"danger_active": "#d07576",
|
||||||
"danger_selected": "#b96869",
|
"danger_selected": "#b96869",
|
||||||
"danger_disabled": "#e782844d",
|
"danger_disabled": "#e782844d",
|
||||||
"warning_foreground": "#303446",
|
"warning_foreground": "#232634",
|
||||||
"warning_background": "#e5c890",
|
"warning_background": "#e5c890",
|
||||||
"warning_hover": "#e5c890e6",
|
"warning_hover": "#ef9f76",
|
||||||
"warning_active": "#ceb481",
|
"warning_active": "#ceb482",
|
||||||
"warning_selected": "#b7a072",
|
"warning_selected": "#b7a074",
|
||||||
"warning_disabled": "#e5c8904d",
|
"warning_disabled": "#e5c8904d",
|
||||||
"ghost_element_background": "#00000000",
|
"ghost_element_background": "#00000000",
|
||||||
"ghost_element_background_alt": "#414559",
|
"ghost_element_background_alt": "#414559",
|
||||||
"ghost_element_hover": "#c6d0f51a",
|
"ghost_element_hover": "#c6d0f533",
|
||||||
"ghost_element_active": "#51576d",
|
"ghost_element_active": "#51576d",
|
||||||
"ghost_element_selected": "#51576d",
|
"ghost_element_selected": "#51576d",
|
||||||
"ghost_element_disabled": "#c6d0f50d",
|
"ghost_element_disabled": "#c6d0f50d",
|
||||||
"tab_inactive_background": "#414559",
|
"tab_inactive_background": "#292c3c",
|
||||||
"tab_hover_background": "#51576d",
|
"tab_inactive_foreground": "#b5bfe2",
|
||||||
"tab_active_background": "#626880",
|
"tab_active_background": "#303446",
|
||||||
|
"tab_active_foreground": "#c6d0f5",
|
||||||
|
"tab_hover_foreground": "#babbf1",
|
||||||
"scrollbar_thumb_background": "#c6d0f533",
|
"scrollbar_thumb_background": "#c6d0f533",
|
||||||
"scrollbar_thumb_hover_background": "#c6d0f54d",
|
"scrollbar_thumb_hover_background": "#c6d0f580",
|
||||||
"scrollbar_thumb_border": "#00000000",
|
"scrollbar_thumb_border": "#00000000",
|
||||||
"scrollbar_track_background": "#00000000",
|
"scrollbar_track_background": "#00000000",
|
||||||
"scrollbar_track_border": "#51576d",
|
"scrollbar_track_border": "#51576d",
|
||||||
"drop_target_background": "#8caaee1a",
|
"drop_target_background": "#8caaee1a",
|
||||||
"cursor": "#99d1db",
|
"cursor": "#f2d5cf",
|
||||||
"selection": "#99d1db40"
|
"selection": "#949cbb40"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,9 +9,9 @@
|
|||||||
"elevated_surface_background": "#dce0e8",
|
"elevated_surface_background": "#dce0e8",
|
||||||
"panel_background": "#eff1f5",
|
"panel_background": "#eff1f5",
|
||||||
"overlay": "#4c4f691a",
|
"overlay": "#4c4f691a",
|
||||||
"title_bar": "#00000000",
|
"title_bar": "#e6e9ef",
|
||||||
"title_bar_inactive": "#eff1f5",
|
"title_bar_inactive": "#dce0e8",
|
||||||
"window_border": "#acb0be",
|
"window_border": "#9ca0b0",
|
||||||
"border": "#acb0be",
|
"border": "#acb0be",
|
||||||
"border_variant": "#bcc0cc",
|
"border_variant": "#bcc0cc",
|
||||||
"border_focused": "#1e66f5",
|
"border_focused": "#1e66f5",
|
||||||
@@ -23,50 +23,52 @@
|
|||||||
"text_muted": "#5c5f77",
|
"text_muted": "#5c5f77",
|
||||||
"text_placeholder": "#6c6f85",
|
"text_placeholder": "#6c6f85",
|
||||||
"text_accent": "#1e66f5",
|
"text_accent": "#1e66f5",
|
||||||
"icon": "#4c4f69",
|
"icon": "#5c5f77",
|
||||||
"icon_muted": "#5c5f77",
|
"icon_muted": "#6c6f85",
|
||||||
"icon_accent": "#1e66f5",
|
"icon_accent": "#1e66f5",
|
||||||
"element_foreground": "#eff1f5",
|
"element_foreground": "#eff1f5",
|
||||||
"element_background": "#1e66f5",
|
"element_background": "#1e66f5",
|
||||||
"element_hover": "#1e66f5e6",
|
"element_hover": "#8839ef",
|
||||||
"element_active": "#1b5cdc",
|
"element_active": "#1c5ce0",
|
||||||
"element_selected": "#1852c3",
|
"element_selected": "#1a52cc",
|
||||||
"element_disabled": "#1e66f54d",
|
"element_disabled": "#1e66f54d",
|
||||||
"secondary_foreground": "#1e66f5",
|
"secondary_foreground": "#1a52cc",
|
||||||
"secondary_background": "#e6e9ef",
|
"secondary_background": "#e6e9ef",
|
||||||
"secondary_hover": "#1e66f51a",
|
"secondary_hover": "#8839ef33",
|
||||||
"secondary_active": "#dce0e8",
|
"secondary_active": "#dce0e8",
|
||||||
"secondary_selected": "#dce0e8",
|
"secondary_selected": "#dce0e8",
|
||||||
"secondary_disabled": "#1e66f54d",
|
"secondary_disabled": "#1e66f54d",
|
||||||
"danger_foreground": "#eff1f5",
|
"danger_foreground": "#eff1f5",
|
||||||
"danger_background": "#d20f39",
|
"danger_background": "#d20f39",
|
||||||
"danger_hover": "#d20f39e6",
|
"danger_hover": "#e64553",
|
||||||
"danger_active": "#bc0e33",
|
"danger_active": "#bd0d33",
|
||||||
"danger_selected": "#a60c2d",
|
"danger_selected": "#a80b2d",
|
||||||
"danger_disabled": "#d20f394d",
|
"danger_disabled": "#d20f394d",
|
||||||
"warning_foreground": "#4c4f69",
|
"warning_foreground": "#4c4f69",
|
||||||
"warning_background": "#df8e1d",
|
"warning_background": "#df8e1d",
|
||||||
"warning_hover": "#df8e1de6",
|
"warning_hover": "#fe640b",
|
||||||
"warning_active": "#c9801a",
|
"warning_active": "#c9801a",
|
||||||
"warning_selected": "#b47217",
|
"warning_selected": "#b47217",
|
||||||
"warning_disabled": "#df8e1d4d",
|
"warning_disabled": "#df8e1d4d",
|
||||||
"ghost_element_background": "#00000000",
|
"ghost_element_background": "#00000000",
|
||||||
"ghost_element_background_alt": "#e6e9ef",
|
"ghost_element_background_alt": "#ccd0da",
|
||||||
"ghost_element_hover": "#4c4f691a",
|
"ghost_element_hover": "#4c4f6933",
|
||||||
"ghost_element_active": "#dce0e8",
|
"ghost_element_active": "#bcc0cc",
|
||||||
"ghost_element_selected": "#dce0e8",
|
"ghost_element_selected": "#bcc0cc",
|
||||||
"ghost_element_disabled": "#4c4f690d",
|
"ghost_element_disabled": "#4c4f690d",
|
||||||
"tab_inactive_background": "#e6e9ef",
|
"tab_inactive_background": "#e6e9ef",
|
||||||
"tab_hover_background": "#dce0e8",
|
"tab_inactive_foreground": "#5c5f77",
|
||||||
"tab_active_background": "#ccd0da",
|
"tab_active_background": "#eff1f5",
|
||||||
|
"tab_active_foreground": "#4c4f69",
|
||||||
|
"tab_hover_foreground": "#8839ef",
|
||||||
"scrollbar_thumb_background": "#4c4f6933",
|
"scrollbar_thumb_background": "#4c4f6933",
|
||||||
"scrollbar_thumb_hover_background": "#4c4f694d",
|
"scrollbar_thumb_hover_background": "#4c4f6980",
|
||||||
"scrollbar_thumb_border": "#00000000",
|
"scrollbar_thumb_border": "#00000000",
|
||||||
"scrollbar_track_background": "#00000000",
|
"scrollbar_track_background": "#00000000",
|
||||||
"scrollbar_track_border": "#dce0e8",
|
"scrollbar_track_border": "#bcc0cc",
|
||||||
"drop_target_background": "#1e66f51a",
|
"drop_target_background": "#1e66f51a",
|
||||||
"cursor": "#04a5e5",
|
"cursor": "#dc8a78",
|
||||||
"selection": "#04a5e540"
|
"selection": "#7c7f9340"
|
||||||
},
|
},
|
||||||
"dark": {
|
"dark": {
|
||||||
"background": "#eff1f5",
|
"background": "#eff1f5",
|
||||||
@@ -74,9 +76,9 @@
|
|||||||
"elevated_surface_background": "#dce0e8",
|
"elevated_surface_background": "#dce0e8",
|
||||||
"panel_background": "#eff1f5",
|
"panel_background": "#eff1f5",
|
||||||
"overlay": "#4c4f691a",
|
"overlay": "#4c4f691a",
|
||||||
"title_bar": "#00000000",
|
"title_bar": "#e6e9ef",
|
||||||
"title_bar_inactive": "#eff1f5",
|
"title_bar_inactive": "#dce0e8",
|
||||||
"window_border": "#acb0be",
|
"window_border": "#9ca0b0",
|
||||||
"border": "#acb0be",
|
"border": "#acb0be",
|
||||||
"border_variant": "#bcc0cc",
|
"border_variant": "#bcc0cc",
|
||||||
"border_focused": "#1e66f5",
|
"border_focused": "#1e66f5",
|
||||||
@@ -88,49 +90,51 @@
|
|||||||
"text_muted": "#5c5f77",
|
"text_muted": "#5c5f77",
|
||||||
"text_placeholder": "#6c6f85",
|
"text_placeholder": "#6c6f85",
|
||||||
"text_accent": "#1e66f5",
|
"text_accent": "#1e66f5",
|
||||||
"icon": "#4c4f69",
|
"icon": "#5c5f77",
|
||||||
"icon_muted": "#5c5f77",
|
"icon_muted": "#6c6f85",
|
||||||
"icon_accent": "#1e66f5",
|
"icon_accent": "#1e66f5",
|
||||||
"element_foreground": "#eff1f5",
|
"element_foreground": "#eff1f5",
|
||||||
"element_background": "#1e66f5",
|
"element_background": "#1e66f5",
|
||||||
"element_hover": "#1e66f5e6",
|
"element_hover": "#8839ef",
|
||||||
"element_active": "#1b5cdc",
|
"element_active": "#1c5ce0",
|
||||||
"element_selected": "#1852c3",
|
"element_selected": "#1a52cc",
|
||||||
"element_disabled": "#1e66f54d",
|
"element_disabled": "#1e66f54d",
|
||||||
"secondary_foreground": "#1e66f5",
|
"secondary_foreground": "#1a52cc",
|
||||||
"secondary_background": "#e6e9ef",
|
"secondary_background": "#e6e9ef",
|
||||||
"secondary_hover": "#1e66f51a",
|
"secondary_hover": "#8839ef33",
|
||||||
"secondary_active": "#dce0e8",
|
"secondary_active": "#dce0e8",
|
||||||
"secondary_selected": "#dce0e8",
|
"secondary_selected": "#dce0e8",
|
||||||
"secondary_disabled": "#1e66f54d",
|
"secondary_disabled": "#1e66f54d",
|
||||||
"danger_foreground": "#eff1f5",
|
"danger_foreground": "#eff1f5",
|
||||||
"danger_background": "#d20f39",
|
"danger_background": "#d20f39",
|
||||||
"danger_hover": "#d20f39e6",
|
"danger_hover": "#e64553",
|
||||||
"danger_active": "#bc0e33",
|
"danger_active": "#bd0d33",
|
||||||
"danger_selected": "#a60c2d",
|
"danger_selected": "#a80b2d",
|
||||||
"danger_disabled": "#d20f394d",
|
"danger_disabled": "#d20f394d",
|
||||||
"warning_foreground": "#4c4f69",
|
"warning_foreground": "#4c4f69",
|
||||||
"warning_background": "#df8e1d",
|
"warning_background": "#df8e1d",
|
||||||
"warning_hover": "#df8e1de6",
|
"warning_hover": "#fe640b",
|
||||||
"warning_active": "#c9801a",
|
"warning_active": "#c9801a",
|
||||||
"warning_selected": "#b47217",
|
"warning_selected": "#b47217",
|
||||||
"warning_disabled": "#df8e1d4d",
|
"warning_disabled": "#df8e1d4d",
|
||||||
"ghost_element_background": "#00000000",
|
"ghost_element_background": "#00000000",
|
||||||
"ghost_element_background_alt": "#e6e9ef",
|
"ghost_element_background_alt": "#ccd0da",
|
||||||
"ghost_element_hover": "#4c4f691a",
|
"ghost_element_hover": "#4c4f6933",
|
||||||
"ghost_element_active": "#dce0e8",
|
"ghost_element_active": "#bcc0cc",
|
||||||
"ghost_element_selected": "#dce0e8",
|
"ghost_element_selected": "#bcc0cc",
|
||||||
"ghost_element_disabled": "#4c4f690d",
|
"ghost_element_disabled": "#4c4f690d",
|
||||||
"tab_inactive_background": "#e6e9ef",
|
"tab_inactive_background": "#e6e9ef",
|
||||||
"tab_hover_background": "#dce0e8",
|
"tab_inactive_foreground": "#5c5f77",
|
||||||
"tab_active_background": "#ccd0da",
|
"tab_active_background": "#eff1f5",
|
||||||
|
"tab_active_foreground": "#4c4f69",
|
||||||
|
"tab_hover_foreground": "#8839ef",
|
||||||
"scrollbar_thumb_background": "#4c4f6933",
|
"scrollbar_thumb_background": "#4c4f6933",
|
||||||
"scrollbar_thumb_hover_background": "#4c4f694d",
|
"scrollbar_thumb_hover_background": "#4c4f6980",
|
||||||
"scrollbar_thumb_border": "#00000000",
|
"scrollbar_thumb_border": "#00000000",
|
||||||
"scrollbar_track_background": "#00000000",
|
"scrollbar_track_background": "#00000000",
|
||||||
"scrollbar_track_border": "#dce0e8",
|
"scrollbar_track_border": "#bcc0cc",
|
||||||
"drop_target_background": "#1e66f51a",
|
"drop_target_background": "#1e66f51a",
|
||||||
"cursor": "#04a5e5",
|
"cursor": "#dc8a78",
|
||||||
"selection": "#04a5e540"
|
"selection": "#7c7f9340"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,9 +9,9 @@
|
|||||||
"elevated_surface_background": "#181926",
|
"elevated_surface_background": "#181926",
|
||||||
"panel_background": "#24273a",
|
"panel_background": "#24273a",
|
||||||
"overlay": "#cad3f51a",
|
"overlay": "#cad3f51a",
|
||||||
"title_bar": "#00000000",
|
"title_bar": "#1e2030",
|
||||||
"title_bar_inactive": "#24273a",
|
"title_bar_inactive": "#181926",
|
||||||
"window_border": "#5b6078",
|
"window_border": "#6e738d",
|
||||||
"border": "#5b6078",
|
"border": "#5b6078",
|
||||||
"border_variant": "#494d64",
|
"border_variant": "#494d64",
|
||||||
"border_focused": "#8aadf4",
|
"border_focused": "#8aadf4",
|
||||||
@@ -23,50 +23,52 @@
|
|||||||
"text_muted": "#b8c0e0",
|
"text_muted": "#b8c0e0",
|
||||||
"text_placeholder": "#a5adcb",
|
"text_placeholder": "#a5adcb",
|
||||||
"text_accent": "#8aadf4",
|
"text_accent": "#8aadf4",
|
||||||
"icon": "#cad3f5",
|
"icon": "#b8c0e0",
|
||||||
"icon_muted": "#b8c0e0",
|
"icon_muted": "#a5adcb",
|
||||||
"icon_accent": "#8aadf4",
|
"icon_accent": "#8aadf4",
|
||||||
"element_foreground": "#24273a",
|
"element_foreground": "#181926",
|
||||||
"element_background": "#8aadf4",
|
"element_background": "#8aadf4",
|
||||||
"element_hover": "#8aadf4e6",
|
"element_hover": "#b7bdf8",
|
||||||
"element_active": "#7c9cdc",
|
"element_active": "#7c9cdc",
|
||||||
"element_selected": "#6e8bc4",
|
"element_selected": "#6e8bc5",
|
||||||
"element_disabled": "#8aadf44d",
|
"element_disabled": "#8aadf44d",
|
||||||
"secondary_foreground": "#8aadf4",
|
"secondary_foreground": "#6e8bc5",
|
||||||
"secondary_background": "#363a4f",
|
"secondary_background": "#1e2030",
|
||||||
"secondary_hover": "#8aadf41a",
|
"secondary_hover": "#8aadf433",
|
||||||
"secondary_active": "#494d64",
|
"secondary_active": "#181926",
|
||||||
"secondary_selected": "#494d64",
|
"secondary_selected": "#181926",
|
||||||
"secondary_disabled": "#8aadf44d",
|
"secondary_disabled": "#8aadf44d",
|
||||||
"danger_foreground": "#24273a",
|
"danger_foreground": "#181926",
|
||||||
"danger_background": "#ed8796",
|
"danger_background": "#ed8796",
|
||||||
"danger_hover": "#ed8796e6",
|
"danger_hover": "#ee99a0",
|
||||||
"danger_active": "#d57a87",
|
"danger_active": "#d57a87",
|
||||||
"danger_selected": "#bd6d78",
|
"danger_selected": "#be6d78",
|
||||||
"danger_disabled": "#ed87964d",
|
"danger_disabled": "#ed87964d",
|
||||||
"warning_foreground": "#24273a",
|
"warning_foreground": "#181926",
|
||||||
"warning_background": "#eed49f",
|
"warning_background": "#eed49f",
|
||||||
"warning_hover": "#eed49fe6",
|
"warning_hover": "#f5a97f",
|
||||||
"warning_active": "#d6bf8f",
|
"warning_active": "#d6bf8f",
|
||||||
"warning_selected": "#beaa7f",
|
"warning_selected": "#beaa7f",
|
||||||
"warning_disabled": "#eed49f4d",
|
"warning_disabled": "#eed49f4d",
|
||||||
"ghost_element_background": "#00000000",
|
"ghost_element_background": "#00000000",
|
||||||
"ghost_element_background_alt": "#363a4f",
|
"ghost_element_background_alt": "#363a4f",
|
||||||
"ghost_element_hover": "#cad3f51a",
|
"ghost_element_hover": "#cad3f533",
|
||||||
"ghost_element_active": "#494d64",
|
"ghost_element_active": "#494d64",
|
||||||
"ghost_element_selected": "#494d64",
|
"ghost_element_selected": "#494d64",
|
||||||
"ghost_element_disabled": "#cad3f50d",
|
"ghost_element_disabled": "#cad3f50d",
|
||||||
"tab_inactive_background": "#363a4f",
|
"tab_inactive_background": "#1e2030",
|
||||||
"tab_hover_background": "#494d64",
|
"tab_inactive_foreground": "#b8c0e0",
|
||||||
"tab_active_background": "#5b6078",
|
"tab_active_background": "#24273a",
|
||||||
|
"tab_active_foreground": "#cad3f5",
|
||||||
|
"tab_hover_foreground": "#b7bdf8",
|
||||||
"scrollbar_thumb_background": "#cad3f533",
|
"scrollbar_thumb_background": "#cad3f533",
|
||||||
"scrollbar_thumb_hover_background": "#cad3f54d",
|
"scrollbar_thumb_hover_background": "#cad3f580",
|
||||||
"scrollbar_thumb_border": "#00000000",
|
"scrollbar_thumb_border": "#00000000",
|
||||||
"scrollbar_track_background": "#00000000",
|
"scrollbar_track_background": "#00000000",
|
||||||
"scrollbar_track_border": "#494d64",
|
"scrollbar_track_border": "#494d64",
|
||||||
"drop_target_background": "#8aadf41a",
|
"drop_target_background": "#8aadf41a",
|
||||||
"cursor": "#91d7e3",
|
"cursor": "#f4dbd6",
|
||||||
"selection": "#91d7e340"
|
"selection": "#939ab740"
|
||||||
},
|
},
|
||||||
"dark": {
|
"dark": {
|
||||||
"background": "#24273a",
|
"background": "#24273a",
|
||||||
@@ -74,9 +76,9 @@
|
|||||||
"elevated_surface_background": "#181926",
|
"elevated_surface_background": "#181926",
|
||||||
"panel_background": "#24273a",
|
"panel_background": "#24273a",
|
||||||
"overlay": "#cad3f51a",
|
"overlay": "#cad3f51a",
|
||||||
"title_bar": "#00000000",
|
"title_bar": "#1e2030",
|
||||||
"title_bar_inactive": "#24273a",
|
"title_bar_inactive": "#181926",
|
||||||
"window_border": "#5b6078",
|
"window_border": "#6e738d",
|
||||||
"border": "#5b6078",
|
"border": "#5b6078",
|
||||||
"border_variant": "#494d64",
|
"border_variant": "#494d64",
|
||||||
"border_focused": "#8aadf4",
|
"border_focused": "#8aadf4",
|
||||||
@@ -88,49 +90,51 @@
|
|||||||
"text_muted": "#b8c0e0",
|
"text_muted": "#b8c0e0",
|
||||||
"text_placeholder": "#a5adcb",
|
"text_placeholder": "#a5adcb",
|
||||||
"text_accent": "#8aadf4",
|
"text_accent": "#8aadf4",
|
||||||
"icon": "#cad3f5",
|
"icon": "#b8c0e0",
|
||||||
"icon_muted": "#b8c0e0",
|
"icon_muted": "#a5adcb",
|
||||||
"icon_accent": "#8aadf4",
|
"icon_accent": "#8aadf4",
|
||||||
"element_foreground": "#24273a",
|
"element_foreground": "#181926",
|
||||||
"element_background": "#8aadf4",
|
"element_background": "#8aadf4",
|
||||||
"element_hover": "#8aadf4e6",
|
"element_hover": "#b7bdf8",
|
||||||
"element_active": "#7c9cdc",
|
"element_active": "#7c9cdc",
|
||||||
"element_selected": "#6e8bc4",
|
"element_selected": "#6e8bc5",
|
||||||
"element_disabled": "#8aadf44d",
|
"element_disabled": "#8aadf44d",
|
||||||
"secondary_foreground": "#8aadf4",
|
"secondary_foreground": "#6e8bc5",
|
||||||
"secondary_background": "#363a4f",
|
"secondary_background": "#1e2030",
|
||||||
"secondary_hover": "#8aadf41a",
|
"secondary_hover": "#8aadf433",
|
||||||
"secondary_active": "#494d64",
|
"secondary_active": "#181926",
|
||||||
"secondary_selected": "#494d64",
|
"secondary_selected": "#181926",
|
||||||
"secondary_disabled": "#8aadf44d",
|
"secondary_disabled": "#8aadf44d",
|
||||||
"danger_foreground": "#24273a",
|
"danger_foreground": "#181926",
|
||||||
"danger_background": "#ed8796",
|
"danger_background": "#ed8796",
|
||||||
"danger_hover": "#ed8796e6",
|
"danger_hover": "#ee99a0",
|
||||||
"danger_active": "#d57a87",
|
"danger_active": "#d57a87",
|
||||||
"danger_selected": "#bd6d78",
|
"danger_selected": "#be6d78",
|
||||||
"danger_disabled": "#ed87964d",
|
"danger_disabled": "#ed87964d",
|
||||||
"warning_foreground": "#24273a",
|
"warning_foreground": "#181926",
|
||||||
"warning_background": "#eed49f",
|
"warning_background": "#eed49f",
|
||||||
"warning_hover": "#eed49fe6",
|
"warning_hover": "#f5a97f",
|
||||||
"warning_active": "#d6bf8f",
|
"warning_active": "#d6bf8f",
|
||||||
"warning_selected": "#beaa7f",
|
"warning_selected": "#beaa7f",
|
||||||
"warning_disabled": "#eed49f4d",
|
"warning_disabled": "#eed49f4d",
|
||||||
"ghost_element_background": "#00000000",
|
"ghost_element_background": "#00000000",
|
||||||
"ghost_element_background_alt": "#363a4f",
|
"ghost_element_background_alt": "#363a4f",
|
||||||
"ghost_element_hover": "#cad3f51a",
|
"ghost_element_hover": "#cad3f533",
|
||||||
"ghost_element_active": "#494d64",
|
"ghost_element_active": "#494d64",
|
||||||
"ghost_element_selected": "#494d64",
|
"ghost_element_selected": "#494d64",
|
||||||
"ghost_element_disabled": "#cad3f50d",
|
"ghost_element_disabled": "#cad3f50d",
|
||||||
"tab_inactive_background": "#363a4f",
|
"tab_inactive_background": "#1e2030",
|
||||||
"tab_hover_background": "#494d64",
|
"tab_inactive_foreground": "#b8c0e0",
|
||||||
"tab_active_background": "#5b6078",
|
"tab_active_background": "#24273a",
|
||||||
|
"tab_active_foreground": "#cad3f5",
|
||||||
|
"tab_hover_foreground": "#b7bdf8",
|
||||||
"scrollbar_thumb_background": "#cad3f533",
|
"scrollbar_thumb_background": "#cad3f533",
|
||||||
"scrollbar_thumb_hover_background": "#cad3f54d",
|
"scrollbar_thumb_hover_background": "#cad3f580",
|
||||||
"scrollbar_thumb_border": "#00000000",
|
"scrollbar_thumb_border": "#00000000",
|
||||||
"scrollbar_track_background": "#00000000",
|
"scrollbar_track_background": "#00000000",
|
||||||
"scrollbar_track_border": "#494d64",
|
"scrollbar_track_border": "#494d64",
|
||||||
"drop_target_background": "#8aadf41a",
|
"drop_target_background": "#8aadf41a",
|
||||||
"cursor": "#91d7e3",
|
"cursor": "#f4dbd6",
|
||||||
"selection": "#91d7e340"
|
"selection": "#939ab740"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,9 +9,9 @@
|
|||||||
"elevated_surface_background": "#11111b",
|
"elevated_surface_background": "#11111b",
|
||||||
"panel_background": "#1e1e2e",
|
"panel_background": "#1e1e2e",
|
||||||
"overlay": "#cdd6f41a",
|
"overlay": "#cdd6f41a",
|
||||||
"title_bar": "#00000000",
|
"title_bar": "#181825",
|
||||||
"title_bar_inactive": "#1e1e2e",
|
"title_bar_inactive": "#11111b",
|
||||||
"window_border": "#585b70",
|
"window_border": "#6c7086",
|
||||||
"border": "#585b70",
|
"border": "#585b70",
|
||||||
"border_variant": "#45475a",
|
"border_variant": "#45475a",
|
||||||
"border_focused": "#89b4fa",
|
"border_focused": "#89b4fa",
|
||||||
@@ -23,50 +23,52 @@
|
|||||||
"text_muted": "#bac2de",
|
"text_muted": "#bac2de",
|
||||||
"text_placeholder": "#a6adc8",
|
"text_placeholder": "#a6adc8",
|
||||||
"text_accent": "#89b4fa",
|
"text_accent": "#89b4fa",
|
||||||
"icon": "#cdd6f4",
|
"icon": "#bac2de",
|
||||||
"icon_muted": "#bac2de",
|
"icon_muted": "#a6adc8",
|
||||||
"icon_accent": "#89b4fa",
|
"icon_accent": "#89b4fa",
|
||||||
"element_foreground": "#1e1e2e",
|
"element_foreground": "#11111b",
|
||||||
"element_background": "#89b4fa",
|
"element_background": "#89b4fa",
|
||||||
"element_hover": "#89b4fae6",
|
"element_hover": "#b4befe",
|
||||||
"element_active": "#7ba2e1",
|
"element_active": "#7ba2e1",
|
||||||
"element_selected": "#6d90c8",
|
"element_selected": "#6d90c9",
|
||||||
"element_disabled": "#89b4fa4d",
|
"element_disabled": "#89b4fa4d",
|
||||||
"secondary_foreground": "#89b4fa",
|
"secondary_foreground": "#6d90c9",
|
||||||
"secondary_background": "#313244",
|
"secondary_background": "#181825",
|
||||||
"secondary_hover": "#89b4fa1a",
|
"secondary_hover": "#89b4fa33",
|
||||||
"secondary_active": "#45475a",
|
"secondary_active": "#11111b",
|
||||||
"secondary_selected": "#45475a",
|
"secondary_selected": "#11111b",
|
||||||
"secondary_disabled": "#89b4fa4d",
|
"secondary_disabled": "#89b4fa4d",
|
||||||
"danger_foreground": "#1e1e2e",
|
"danger_foreground": "#11111b",
|
||||||
"danger_background": "#f38ba8",
|
"danger_background": "#f38ba8",
|
||||||
"danger_hover": "#f38ba8e6",
|
"danger_hover": "#eba0ac",
|
||||||
"danger_active": "#db7d97",
|
"danger_active": "#db7d98",
|
||||||
"danger_selected": "#c36f86",
|
"danger_selected": "#c46f88",
|
||||||
"danger_disabled": "#f38ba84d",
|
"danger_disabled": "#f38ba84d",
|
||||||
"warning_foreground": "#1e1e2e",
|
"warning_foreground": "#11111b",
|
||||||
"warning_background": "#f9e2af",
|
"warning_background": "#f9e2af",
|
||||||
"warning_hover": "#f9e2afe6",
|
"warning_hover": "#fab387",
|
||||||
"warning_active": "#e0cb9e",
|
"warning_active": "#e0cb9e",
|
||||||
"warning_selected": "#c7b48d",
|
"warning_selected": "#c8b48d",
|
||||||
"warning_disabled": "#f9e2af4d",
|
"warning_disabled": "#f9e2af4d",
|
||||||
"ghost_element_background": "#00000000",
|
"ghost_element_background": "#00000000",
|
||||||
"ghost_element_background_alt": "#313244",
|
"ghost_element_background_alt": "#313244",
|
||||||
"ghost_element_hover": "#cdd6f41a",
|
"ghost_element_hover": "#cdd6f433",
|
||||||
"ghost_element_active": "#45475a",
|
"ghost_element_active": "#45475a",
|
||||||
"ghost_element_selected": "#45475a",
|
"ghost_element_selected": "#45475a",
|
||||||
"ghost_element_disabled": "#cdd6f50d",
|
"ghost_element_disabled": "#cdd6f40d",
|
||||||
"tab_inactive_background": "#313244",
|
"tab_inactive_background": "#181825",
|
||||||
"tab_hover_background": "#45475a",
|
"tab_inactive_foreground": "#bac2de",
|
||||||
"tab_active_background": "#585b70",
|
"tab_active_background": "#1e1e2e",
|
||||||
"scrollbar_thumb_background": "#cdd6f533",
|
"tab_active_foreground": "#cdd6f4",
|
||||||
"scrollbar_thumb_hover_background": "#cdd6f54d",
|
"tab_hover_foreground": "#b4befe",
|
||||||
|
"scrollbar_thumb_background": "#cdd6f433",
|
||||||
|
"scrollbar_thumb_hover_background": "#cdd6f580",
|
||||||
"scrollbar_thumb_border": "#00000000",
|
"scrollbar_thumb_border": "#00000000",
|
||||||
"scrollbar_track_background": "#00000000",
|
"scrollbar_track_background": "#00000000",
|
||||||
"scrollbar_track_border": "#45475a",
|
"scrollbar_track_border": "#45475a",
|
||||||
"drop_target_background": "#89b4fa1a",
|
"drop_target_background": "#89b4fa1a",
|
||||||
"cursor": "#89dceb",
|
"cursor": "#f5e0dc",
|
||||||
"selection": "#89dceb40"
|
"selection": "#9399b240"
|
||||||
},
|
},
|
||||||
"dark": {
|
"dark": {
|
||||||
"background": "#1e1e2e",
|
"background": "#1e1e2e",
|
||||||
@@ -74,9 +76,9 @@
|
|||||||
"elevated_surface_background": "#11111b",
|
"elevated_surface_background": "#11111b",
|
||||||
"panel_background": "#1e1e2e",
|
"panel_background": "#1e1e2e",
|
||||||
"overlay": "#cdd6f41a",
|
"overlay": "#cdd6f41a",
|
||||||
"title_bar": "#00000000",
|
"title_bar": "#181825",
|
||||||
"title_bar_inactive": "#1e1e2e",
|
"title_bar_inactive": "#11111b",
|
||||||
"window_border": "#585b70",
|
"window_border": "#6c7086",
|
||||||
"border": "#585b70",
|
"border": "#585b70",
|
||||||
"border_variant": "#45475a",
|
"border_variant": "#45475a",
|
||||||
"border_focused": "#89b4fa",
|
"border_focused": "#89b4fa",
|
||||||
@@ -88,49 +90,51 @@
|
|||||||
"text_muted": "#bac2de",
|
"text_muted": "#bac2de",
|
||||||
"text_placeholder": "#a6adc8",
|
"text_placeholder": "#a6adc8",
|
||||||
"text_accent": "#89b4fa",
|
"text_accent": "#89b4fa",
|
||||||
"icon": "#cdd6f4",
|
"icon": "#bac2de",
|
||||||
"icon_muted": "#bac2de",
|
"icon_muted": "#a6adc8",
|
||||||
"icon_accent": "#89b4fa",
|
"icon_accent": "#89b4fa",
|
||||||
"element_foreground": "#1e1e2e",
|
"element_foreground": "#11111b",
|
||||||
"element_background": "#89b4fa",
|
"element_background": "#89b4fa",
|
||||||
"element_hover": "#89b4fae6",
|
"element_hover": "#b4befe",
|
||||||
"element_active": "#7ba2e1",
|
"element_active": "#7ba2e1",
|
||||||
"element_selected": "#6d90c8",
|
"element_selected": "#6d90c9",
|
||||||
"element_disabled": "#89b4fa4d",
|
"element_disabled": "#89b4fa4d",
|
||||||
"secondary_foreground": "#89b4fa",
|
"secondary_foreground": "#6d90c9",
|
||||||
"secondary_background": "#313244",
|
"secondary_background": "#181825",
|
||||||
"secondary_hover": "#89b4fa1a",
|
"secondary_hover": "#89b4fa33",
|
||||||
"secondary_active": "#45475a",
|
"secondary_active": "#11111b",
|
||||||
"secondary_selected": "#45475a",
|
"secondary_selected": "#11111b",
|
||||||
"secondary_disabled": "#89b4fa4d",
|
"secondary_disabled": "#89b4fa4d",
|
||||||
"danger_foreground": "#1e1e2e",
|
"danger_foreground": "#11111b",
|
||||||
"danger_background": "#f38ba8",
|
"danger_background": "#f38ba8",
|
||||||
"danger_hover": "#f38ba8e6",
|
"danger_hover": "#eba0ac",
|
||||||
"danger_active": "#db7d97",
|
"danger_active": "#db7d98",
|
||||||
"danger_selected": "#c36f86",
|
"danger_selected": "#c46f88",
|
||||||
"danger_disabled": "#f38ba84d",
|
"danger_disabled": "#f38ba84d",
|
||||||
"warning_foreground": "#1e1e2e",
|
"warning_foreground": "#11111b",
|
||||||
"warning_background": "#f9e2af",
|
"warning_background": "#f9e2af",
|
||||||
"warning_hover": "#f9e2afe6",
|
"warning_hover": "#fab387",
|
||||||
"warning_active": "#e0cb9e",
|
"warning_active": "#e0cb9e",
|
||||||
"warning_selected": "#c7b48d",
|
"warning_selected": "#c8b48d",
|
||||||
"warning_disabled": "#f9e2af4d",
|
"warning_disabled": "#f9e2af4d",
|
||||||
"ghost_element_background": "#00000000",
|
"ghost_element_background": "#00000000",
|
||||||
"ghost_element_background_alt": "#313244",
|
"ghost_element_background_alt": "#313244",
|
||||||
"ghost_element_hover": "#cdd6f41a",
|
"ghost_element_hover": "#cdd6f433",
|
||||||
"ghost_element_active": "#45475a",
|
"ghost_element_active": "#45475a",
|
||||||
"ghost_element_selected": "#45475a",
|
"ghost_element_selected": "#45475a",
|
||||||
"ghost_element_disabled": "#cdd6f50d",
|
"ghost_element_disabled": "#cdd6f40d",
|
||||||
"tab_inactive_background": "#313244",
|
"tab_inactive_background": "#181825",
|
||||||
"tab_hover_background": "#45475a",
|
"tab_inactive_foreground": "#bac2de",
|
||||||
"tab_active_background": "#585b70",
|
"tab_active_background": "#1e1e2e",
|
||||||
"scrollbar_thumb_background": "#cdd6f533",
|
"tab_active_foreground": "#cdd6f4",
|
||||||
"scrollbar_thumb_hover_background": "#cdd6f54d",
|
"tab_hover_foreground": "#b4befe",
|
||||||
|
"scrollbar_thumb_background": "#cdd6f433",
|
||||||
|
"scrollbar_thumb_hover_background": "#cdd6f580",
|
||||||
"scrollbar_thumb_border": "#00000000",
|
"scrollbar_thumb_border": "#00000000",
|
||||||
"scrollbar_track_background": "#00000000",
|
"scrollbar_track_background": "#00000000",
|
||||||
"scrollbar_track_border": "#45475a",
|
"scrollbar_track_border": "#45475a",
|
||||||
"drop_target_background": "#89b4fa1a",
|
"drop_target_background": "#89b4fa1a",
|
||||||
"cursor": "#89dceb",
|
"cursor": "#f5e0dc",
|
||||||
"selection": "#89dceb40"
|
"selection": "#9399b240"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"id": "flexoki",
|
"id": "flexoki",
|
||||||
"name": "Flexoki",
|
"name": "Flexoki",
|
||||||
"author": "Steph Ango",
|
"author": "Stephan Ango",
|
||||||
"url": "https://stephango.com/flexoki",
|
"url": "https://stephango.com/flexoki",
|
||||||
"light": {
|
"light": {
|
||||||
"background": "#FFFCF0",
|
"background": "#FFFCF0",
|
||||||
@@ -9,64 +9,66 @@
|
|||||||
"elevated_surface_background": "#E6E4D9",
|
"elevated_surface_background": "#E6E4D9",
|
||||||
"panel_background": "#FFFCF0",
|
"panel_background": "#FFFCF0",
|
||||||
"overlay": "#100F0F1a",
|
"overlay": "#100F0F1a",
|
||||||
"title_bar": "#00000000",
|
"title_bar": "#F2F0E5",
|
||||||
"title_bar_inactive": "#FFFCF0",
|
"title_bar_inactive": "#E6E4D9",
|
||||||
"window_border": "#CECDC3",
|
"window_border": "#B7B5AC",
|
||||||
"border": "#CECDC3",
|
"border": "#CECDC3",
|
||||||
"border_variant": "#DAD8CE",
|
"border_variant": "#DAD8CE",
|
||||||
"border_focused": "#24837B",
|
"border_focused": "#205EA6",
|
||||||
"border_selected": "#24837B",
|
"border_selected": "#205EA6",
|
||||||
"border_transparent": "#00000000",
|
"border_transparent": "#00000000",
|
||||||
"border_disabled": "#E6E4D9",
|
"border_disabled": "#E6E4D9",
|
||||||
"ring": "#24837B",
|
"ring": "#205EA6",
|
||||||
"text": "#100F0F",
|
"text": "#100F0F",
|
||||||
"text_muted": "#6F6E69",
|
"text_muted": "#6F6E69",
|
||||||
"text_placeholder": "#878580",
|
"text_placeholder": "#9F9D96",
|
||||||
"text_accent": "#24837B",
|
"text_accent": "#205EA6",
|
||||||
"icon": "#100F0F",
|
"icon": "#6F6E69",
|
||||||
"icon_muted": "#6F6E69",
|
"icon_muted": "#9F9D96",
|
||||||
"icon_accent": "#24837B",
|
"icon_accent": "#205EA6",
|
||||||
"element_foreground": "#DDF1E4",
|
"element_foreground": "#FFFCF0",
|
||||||
"element_background": "#24837B",
|
"element_background": "#205EA6",
|
||||||
"element_hover": "#24837Be5",
|
"element_hover": "#1A4F8C",
|
||||||
"element_active": "#20756E",
|
"element_active": "#163B66",
|
||||||
"element_selected": "#1C6861",
|
"element_selected": "#133051",
|
||||||
"element_disabled": "#24837B4c",
|
"element_disabled": "#205EA64d",
|
||||||
"secondary_foreground": "#24837B",
|
"secondary_foreground": "#163B66",
|
||||||
"secondary_background": "#E6E4D9",
|
"secondary_background": "#F2F0E5",
|
||||||
"secondary_hover": "#24837B1a",
|
"secondary_hover": "#205EA61a",
|
||||||
"secondary_active": "#DAD8CE",
|
"secondary_active": "#E6E4D9",
|
||||||
"secondary_selected": "#DAD8CE",
|
"secondary_selected": "#E6E4D9",
|
||||||
"secondary_disabled": "#24837B4c",
|
"secondary_disabled": "#205EA64d",
|
||||||
"danger_foreground": "#FFE1D5",
|
"danger_foreground": "#FFFCF0",
|
||||||
"danger_background": "#AF3029",
|
"danger_background": "#D14D41",
|
||||||
"danger_hover": "#AF3029e5",
|
"danger_hover": "#C03E35",
|
||||||
"danger_active": "#9E2B25",
|
"danger_active": "#AF3029",
|
||||||
"danger_selected": "#8D2620",
|
"danger_selected": "#942822",
|
||||||
"danger_disabled": "#AF30294c",
|
"danger_disabled": "#D14D414d",
|
||||||
"warning_foreground": "#FFE7CE",
|
"warning_foreground": "#100F0F",
|
||||||
"warning_background": "#BC5215",
|
"warning_background": "#D0A215",
|
||||||
"warning_hover": "#BC5215e5",
|
"warning_hover": "#BE9207",
|
||||||
"warning_active": "#A94913",
|
"warning_active": "#AD8301",
|
||||||
"warning_selected": "#964011",
|
"warning_selected": "#8E6B01",
|
||||||
"warning_disabled": "#BC52154c",
|
"warning_disabled": "#D0A2154d",
|
||||||
"ghost_element_background": "#00000000",
|
"ghost_element_background": "#00000000",
|
||||||
"ghost_element_background_alt": "#E6E4D9",
|
"ghost_element_background_alt": "#E6E4D9",
|
||||||
"ghost_element_hover": "#100F0F1a",
|
"ghost_element_hover": "#100F0F1a",
|
||||||
"ghost_element_active": "#DAD8CE",
|
"ghost_element_active": "#DAD8CE",
|
||||||
"ghost_element_selected": "#DAD8CE",
|
"ghost_element_selected": "#DAD8CE",
|
||||||
"ghost_element_disabled": "#100F0F0d",
|
"ghost_element_disabled": "#100F0F0d",
|
||||||
"tab_inactive_background": "#E6E4D9",
|
"tab_inactive_background": "#F2F0E5",
|
||||||
"tab_hover_background": "#DAD8CE",
|
"tab_inactive_foreground": "#6F6E69",
|
||||||
"tab_active_background": "#CECDC3",
|
"tab_active_background": "#FFFCF0",
|
||||||
|
"tab_active_foreground": "#100F0F",
|
||||||
|
"tab_hover_foreground": "#205EA6",
|
||||||
"scrollbar_thumb_background": "#100F0F33",
|
"scrollbar_thumb_background": "#100F0F33",
|
||||||
"scrollbar_thumb_hover_background": "#100F0F4d",
|
"scrollbar_thumb_hover_background": "#100F0F4d",
|
||||||
"scrollbar_thumb_border": "#00000000",
|
"scrollbar_thumb_border": "#00000000",
|
||||||
"scrollbar_track_background": "#00000000",
|
"scrollbar_track_background": "#00000000",
|
||||||
"scrollbar_track_border": "#DAD8CE",
|
"scrollbar_track_border": "#DAD8CE",
|
||||||
"drop_target_background": "#24837B1a",
|
"drop_target_background": "#205EA61a",
|
||||||
"cursor": "#205EA6",
|
"cursor": "#205EA6",
|
||||||
"selection": "#24837B40"
|
"selection": "#205EA640"
|
||||||
},
|
},
|
||||||
"dark": {
|
"dark": {
|
||||||
"background": "#100F0F",
|
"background": "#100F0F",
|
||||||
@@ -74,63 +76,65 @@
|
|||||||
"elevated_surface_background": "#282726",
|
"elevated_surface_background": "#282726",
|
||||||
"panel_background": "#100F0F",
|
"panel_background": "#100F0F",
|
||||||
"overlay": "#FFFCF01a",
|
"overlay": "#FFFCF01a",
|
||||||
"title_bar": "#00000000",
|
"title_bar": "#1C1B1A",
|
||||||
"title_bar_inactive": "#100F0F",
|
"title_bar_inactive": "#282726",
|
||||||
"window_border": "#403E3C",
|
"window_border": "#575653",
|
||||||
"border": "#403E3C",
|
"border": "#403E3C",
|
||||||
"border_variant": "#343331",
|
"border_variant": "#343331",
|
||||||
"border_focused": "#3AA99F",
|
"border_focused": "#4385BE",
|
||||||
"border_selected": "#3AA99F",
|
"border_selected": "#4385BE",
|
||||||
"border_transparent": "#00000000",
|
"border_transparent": "#00000000",
|
||||||
"border_disabled": "#282726",
|
"border_disabled": "#282726",
|
||||||
"ring": "#3AA99F",
|
"ring": "#4385BE",
|
||||||
"text": "#FFFCF0",
|
"text": "#FFFCF0",
|
||||||
"text_muted": "#878580",
|
"text_muted": "#878580",
|
||||||
"text_placeholder": "#575653",
|
"text_placeholder": "#6F6E69",
|
||||||
"text_accent": "#3AA99F",
|
"text_accent": "#4385BE",
|
||||||
"icon": "#FFFCF0",
|
"icon": "#878580",
|
||||||
"icon_muted": "#878580",
|
"icon_muted": "#6F6E69",
|
||||||
"icon_accent": "#3AA99F",
|
"icon_accent": "#4385BE",
|
||||||
"element_foreground": "#101F1D",
|
"element_foreground": "#100F0F",
|
||||||
"element_background": "#3AA99F",
|
"element_background": "#4385BE",
|
||||||
"element_hover": "#3AA99Fe5",
|
"element_hover": "#3171B2",
|
||||||
"element_active": "#34988F",
|
"element_active": "#205EA6",
|
||||||
"element_selected": "#2F877F",
|
"element_selected": "#1A4F8C",
|
||||||
"element_disabled": "#3AA99F4c",
|
"element_disabled": "#4385BE4d",
|
||||||
"secondary_foreground": "#3AA99F",
|
"secondary_foreground": "#205EA6",
|
||||||
"secondary_background": "#282726",
|
"secondary_background": "#1C1B1A",
|
||||||
"secondary_hover": "#3AA99F1a",
|
"secondary_hover": "#4385BE1a",
|
||||||
"secondary_active": "#343331",
|
"secondary_active": "#282726",
|
||||||
"secondary_selected": "#343331",
|
"secondary_selected": "#282726",
|
||||||
"secondary_disabled": "#3AA99F4c",
|
"secondary_disabled": "#4385BE4d",
|
||||||
"danger_foreground": "#261312",
|
"danger_foreground": "#100F0F",
|
||||||
"danger_background": "#D14D41",
|
"danger_background": "#E8705F",
|
||||||
"danger_hover": "#D14D41e5",
|
"danger_hover": "#D14D41",
|
||||||
"danger_active": "#BC453A",
|
"danger_active": "#C03E35",
|
||||||
"danger_selected": "#A73D33",
|
"danger_selected": "#AF3029",
|
||||||
"danger_disabled": "#D14D414c",
|
"danger_disabled": "#E8705F4d",
|
||||||
"warning_foreground": "#27180E",
|
"warning_foreground": "#100F0F",
|
||||||
"warning_background": "#DA702C",
|
"warning_background": "#DFB431",
|
||||||
"warning_hover": "#DA702Ce5",
|
"warning_hover": "#D0A215",
|
||||||
"warning_active": "#C46527",
|
"warning_active": "#BE9207",
|
||||||
"warning_selected": "#AF5A22",
|
"warning_selected": "#AD8301",
|
||||||
"warning_disabled": "#DA702C4c",
|
"warning_disabled": "#DFB4314d",
|
||||||
"ghost_element_background": "#00000000",
|
"ghost_element_background": "#00000000",
|
||||||
"ghost_element_background_alt": "#282726",
|
"ghost_element_background_alt": "#282726",
|
||||||
"ghost_element_hover": "#FFFCF01a",
|
"ghost_element_hover": "#FFFCF01a",
|
||||||
"ghost_element_active": "#343331",
|
"ghost_element_active": "#343331",
|
||||||
"ghost_element_selected": "#343331",
|
"ghost_element_selected": "#343331",
|
||||||
"ghost_element_disabled": "#FFFCF00d",
|
"ghost_element_disabled": "#FFFCF00d",
|
||||||
"tab_inactive_background": "#282726",
|
"tab_inactive_background": "#1C1B1A",
|
||||||
"tab_hover_background": "#343331",
|
"tab_inactive_foreground": "#878580",
|
||||||
"tab_active_background": "#403E3C",
|
"tab_active_background": "#100F0F",
|
||||||
|
"tab_active_foreground": "#FFFCF0",
|
||||||
|
"tab_hover_foreground": "#4385BE",
|
||||||
"scrollbar_thumb_background": "#FFFCF033",
|
"scrollbar_thumb_background": "#FFFCF033",
|
||||||
"scrollbar_thumb_hover_background": "#FFFCF04d",
|
"scrollbar_thumb_hover_background": "#FFFCF04d",
|
||||||
"scrollbar_thumb_border": "#00000000",
|
"scrollbar_thumb_border": "#00000000",
|
||||||
"scrollbar_track_background": "#00000000",
|
"scrollbar_track_background": "#00000000",
|
||||||
"scrollbar_track_border": "#343331",
|
"scrollbar_track_border": "#343331",
|
||||||
"drop_target_background": "#3AA99F1a",
|
"drop_target_background": "#4385BE1a",
|
||||||
"cursor": "#4385BE",
|
"cursor": "#4385BE",
|
||||||
"selection": "#3AA99F40"
|
"selection": "#4385BE40"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,130 +7,134 @@
|
|||||||
"background": "#faf4ed",
|
"background": "#faf4ed",
|
||||||
"surface_background": "#fffaf3",
|
"surface_background": "#fffaf3",
|
||||||
"elevated_surface_background": "#f2e9e1",
|
"elevated_surface_background": "#f2e9e1",
|
||||||
"panel_background": "#fffaf3",
|
"panel_background": "#faf4ed",
|
||||||
"overlay": "#5752791a",
|
"overlay": "#5752791a",
|
||||||
"title_bar": "#00000000",
|
"title_bar": "#fffaf3",
|
||||||
"title_bar_inactive": "#faf4ed",
|
"title_bar_inactive": "#f2e9e1",
|
||||||
"window_border": "#cecacd",
|
"window_border": "#cecacd",
|
||||||
"border": "#cecacd",
|
"border": "#dfdad9",
|
||||||
"border_variant": "#dfdad9",
|
"border_variant": "#f4ede8",
|
||||||
"border_focused": "#286983",
|
"border_focused": "#907aa9",
|
||||||
"border_selected": "#286983",
|
"border_selected": "#907aa9",
|
||||||
"border_transparent": "#00000000",
|
"border_transparent": "#00000000",
|
||||||
"border_disabled": "#f4ede8",
|
"border_disabled": "#f2e9e1",
|
||||||
"ring": "#286983",
|
"ring": "#907aa9",
|
||||||
"text": "#575279",
|
"text": "#575279",
|
||||||
"text_muted": "#797593",
|
"text_muted": "#797593",
|
||||||
"text_placeholder": "#9893a5",
|
"text_placeholder": "#9893a5",
|
||||||
"text_accent": "#907aa9",
|
"text_accent": "#907aa9",
|
||||||
"icon": "#575279",
|
"icon": "#797593",
|
||||||
"icon_muted": "#797593",
|
"icon_muted": "#9893a5",
|
||||||
"icon_accent": "#907aa9",
|
"icon_accent": "#907aa9",
|
||||||
"element_foreground": "#faf4ed",
|
"element_foreground": "#faf4ed",
|
||||||
"element_background": "#286983",
|
"element_background": "#907aa9",
|
||||||
"element_hover": "#286983e6",
|
"element_hover": "#907aa9e6",
|
||||||
"element_active": "#245f76",
|
"element_active": "#826b95",
|
||||||
"element_selected": "#205569",
|
"element_selected": "#745c81",
|
||||||
"element_disabled": "#2869834d",
|
"element_disabled": "#907aa94d",
|
||||||
"secondary_foreground": "#286983",
|
"secondary_foreground": "#745c81",
|
||||||
"secondary_background": "#f4ede8",
|
"secondary_background": "#fffaf3",
|
||||||
"secondary_hover": "#2869831a",
|
"secondary_hover": "#907aa91a",
|
||||||
"secondary_active": "#dfdad9",
|
"secondary_active": "#f2e9e1",
|
||||||
"secondary_selected": "#dfdad9",
|
"secondary_selected": "#f2e9e1",
|
||||||
"secondary_disabled": "#2869834d",
|
"secondary_disabled": "#907aa94d",
|
||||||
"danger_foreground": "#faf4ed",
|
"danger_foreground": "#faf4ed",
|
||||||
"danger_background": "#b4637a",
|
"danger_background": "#b4637a",
|
||||||
"danger_hover": "#b4637ae6",
|
"danger_hover": "#a7586e",
|
||||||
"danger_active": "#a2596e",
|
"danger_active": "#9a4d62",
|
||||||
"danger_selected": "#904f62",
|
"danger_selected": "#8d4256",
|
||||||
"danger_disabled": "#b4637a4d",
|
"danger_disabled": "#b4637a4d",
|
||||||
"warning_foreground": "#faf4ed",
|
"warning_foreground": "#575279",
|
||||||
"warning_background": "#ea9d34",
|
"warning_background": "#ea9d34",
|
||||||
"warning_hover": "#ea9d34e6",
|
"warning_hover": "#d98e2f",
|
||||||
"warning_active": "#d38d2f",
|
"warning_active": "#c87f2a",
|
||||||
"warning_selected": "#bc7d2a",
|
"warning_selected": "#b77025",
|
||||||
"warning_disabled": "#ea9d344d",
|
"warning_disabled": "#ea9d344d",
|
||||||
"ghost_element_background": "#00000000",
|
"ghost_element_background": "#00000000",
|
||||||
"ghost_element_background_alt": "#f4ede8",
|
"ghost_element_background_alt": "#f2e9e1",
|
||||||
"ghost_element_hover": "#5752791a",
|
"ghost_element_hover": "#5752791a",
|
||||||
"ghost_element_active": "#dfdad9",
|
"ghost_element_active": "#dfdad9",
|
||||||
"ghost_element_selected": "#dfdad9",
|
"ghost_element_selected": "#dfdad9",
|
||||||
"ghost_element_disabled": "#5752790d",
|
"ghost_element_disabled": "#5752790d",
|
||||||
"tab_inactive_background": "#f4ede8",
|
"tab_inactive_background": "#fffaf3",
|
||||||
"tab_hover_background": "#dfdad9",
|
"tab_inactive_foreground": "#797593",
|
||||||
"tab_active_background": "#cecacd",
|
"tab_active_background": "#faf4ed",
|
||||||
|
"tab_active_foreground": "#575279",
|
||||||
|
"tab_hover_foreground": "#907aa9",
|
||||||
"scrollbar_thumb_background": "#57527933",
|
"scrollbar_thumb_background": "#57527933",
|
||||||
"scrollbar_thumb_hover_background": "#5752794d",
|
"scrollbar_thumb_hover_background": "#5752794d",
|
||||||
"scrollbar_thumb_border": "#00000000",
|
"scrollbar_thumb_border": "#00000000",
|
||||||
"scrollbar_track_background": "#00000000",
|
"scrollbar_track_background": "#00000000",
|
||||||
"scrollbar_track_border": "#dfdad9",
|
"scrollbar_track_border": "#dfdad9",
|
||||||
"drop_target_background": "#2869831a",
|
"drop_target_background": "#907aa91a",
|
||||||
"cursor": "#56949f",
|
"cursor": "#907aa9",
|
||||||
"selection": "#56949f40"
|
"selection": "#907aa940"
|
||||||
},
|
},
|
||||||
"dark": {
|
"dark": {
|
||||||
"background": "#faf4ed",
|
"background": "#faf4ed",
|
||||||
"surface_background": "#fffaf3",
|
"surface_background": "#fffaf3",
|
||||||
"elevated_surface_background": "#f2e9e1",
|
"elevated_surface_background": "#f2e9e1",
|
||||||
"panel_background": "#fffaf3",
|
"panel_background": "#faf4ed",
|
||||||
"overlay": "#5752791a",
|
"overlay": "#5752791a",
|
||||||
"title_bar": "#00000000",
|
"title_bar": "#fffaf3",
|
||||||
"title_bar_inactive": "#faf4ed",
|
"title_bar_inactive": "#f2e9e1",
|
||||||
"window_border": "#cecacd",
|
"window_border": "#cecacd",
|
||||||
"border": "#cecacd",
|
"border": "#dfdad9",
|
||||||
"border_variant": "#dfdad9",
|
"border_variant": "#f4ede8",
|
||||||
"border_focused": "#286983",
|
"border_focused": "#907aa9",
|
||||||
"border_selected": "#286983",
|
"border_selected": "#907aa9",
|
||||||
"border_transparent": "#00000000",
|
"border_transparent": "#00000000",
|
||||||
"border_disabled": "#f4ede8",
|
"border_disabled": "#f2e9e1",
|
||||||
"ring": "#286983",
|
"ring": "#907aa9",
|
||||||
"text": "#575279",
|
"text": "#575279",
|
||||||
"text_muted": "#797593",
|
"text_muted": "#797593",
|
||||||
"text_placeholder": "#9893a5",
|
"text_placeholder": "#9893a5",
|
||||||
"text_accent": "#907aa9",
|
"text_accent": "#907aa9",
|
||||||
"icon": "#575279",
|
"icon": "#797593",
|
||||||
"icon_muted": "#797593",
|
"icon_muted": "#9893a5",
|
||||||
"icon_accent": "#907aa9",
|
"icon_accent": "#907aa9",
|
||||||
"element_foreground": "#faf4ed",
|
"element_foreground": "#faf4ed",
|
||||||
"element_background": "#286983",
|
"element_background": "#907aa9",
|
||||||
"element_hover": "#286983e6",
|
"element_hover": "#907aa9e6",
|
||||||
"element_active": "#245f76",
|
"element_active": "#826b95",
|
||||||
"element_selected": "#205569",
|
"element_selected": "#745c81",
|
||||||
"element_disabled": "#2869834d",
|
"element_disabled": "#907aa94d",
|
||||||
"secondary_foreground": "#286983",
|
"secondary_foreground": "#745c81",
|
||||||
"secondary_background": "#f4ede8",
|
"secondary_background": "#fffaf3",
|
||||||
"secondary_hover": "#2869831a",
|
"secondary_hover": "#907aa91a",
|
||||||
"secondary_active": "#dfdad9",
|
"secondary_active": "#f2e9e1",
|
||||||
"secondary_selected": "#dfdad9",
|
"secondary_selected": "#f2e9e1",
|
||||||
"secondary_disabled": "#2869834d",
|
"secondary_disabled": "#907aa94d",
|
||||||
"danger_foreground": "#faf4ed",
|
"danger_foreground": "#faf4ed",
|
||||||
"danger_background": "#b4637a",
|
"danger_background": "#b4637a",
|
||||||
"danger_hover": "#b4637ae6",
|
"danger_hover": "#a7586e",
|
||||||
"danger_active": "#a2596e",
|
"danger_active": "#9a4d62",
|
||||||
"danger_selected": "#904f62",
|
"danger_selected": "#8d4256",
|
||||||
"danger_disabled": "#b4637a4d",
|
"danger_disabled": "#b4637a4d",
|
||||||
"warning_foreground": "#faf4ed",
|
"warning_foreground": "#575279",
|
||||||
"warning_background": "#ea9d34",
|
"warning_background": "#ea9d34",
|
||||||
"warning_hover": "#ea9d34e6",
|
"warning_hover": "#d98e2f",
|
||||||
"warning_active": "#d38d2f",
|
"warning_active": "#c87f2a",
|
||||||
"warning_selected": "#bc7d2a",
|
"warning_selected": "#b77025",
|
||||||
"warning_disabled": "#ea9d344d",
|
"warning_disabled": "#ea9d344d",
|
||||||
"ghost_element_background": "#00000000",
|
"ghost_element_background": "#00000000",
|
||||||
"ghost_element_background_alt": "#f4ede8",
|
"ghost_element_background_alt": "#f2e9e1",
|
||||||
"ghost_element_hover": "#5752791a",
|
"ghost_element_hover": "#5752791a",
|
||||||
"ghost_element_active": "#dfdad9",
|
"ghost_element_active": "#dfdad9",
|
||||||
"ghost_element_selected": "#dfdad9",
|
"ghost_element_selected": "#dfdad9",
|
||||||
"ghost_element_disabled": "#5752790d",
|
"ghost_element_disabled": "#5752790d",
|
||||||
"tab_inactive_background": "#f4ede8",
|
"tab_inactive_background": "#fffaf3",
|
||||||
"tab_hover_background": "#dfdad9",
|
"tab_inactive_foreground": "#797593",
|
||||||
"tab_active_background": "#cecacd",
|
"tab_active_background": "#faf4ed",
|
||||||
|
"tab_active_foreground": "#575279",
|
||||||
|
"tab_hover_foreground": "#907aa9",
|
||||||
"scrollbar_thumb_background": "#57527933",
|
"scrollbar_thumb_background": "#57527933",
|
||||||
"scrollbar_thumb_hover_background": "#5752794d",
|
"scrollbar_thumb_hover_background": "#5752794d",
|
||||||
"scrollbar_thumb_border": "#00000000",
|
"scrollbar_thumb_border": "#00000000",
|
||||||
"scrollbar_track_background": "#00000000",
|
"scrollbar_track_background": "#00000000",
|
||||||
"scrollbar_track_border": "#dfdad9",
|
"scrollbar_track_border": "#dfdad9",
|
||||||
"drop_target_background": "#2869831a",
|
"drop_target_background": "#907aa91a",
|
||||||
"cursor": "#56949f",
|
"cursor": "#907aa9",
|
||||||
"selection": "#56949f40"
|
"selection": "#907aa940"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,130 +7,134 @@
|
|||||||
"background": "#232136",
|
"background": "#232136",
|
||||||
"surface_background": "#2a273f",
|
"surface_background": "#2a273f",
|
||||||
"elevated_surface_background": "#393552",
|
"elevated_surface_background": "#393552",
|
||||||
"panel_background": "#2a273f",
|
"panel_background": "#232136",
|
||||||
"overlay": "#e0def41a",
|
"overlay": "#e0def41a",
|
||||||
"title_bar": "#00000000",
|
"title_bar": "#2a273f",
|
||||||
"title_bar_inactive": "#232136",
|
"title_bar_inactive": "#393552",
|
||||||
"window_border": "#56526e",
|
"window_border": "#56526e",
|
||||||
"border": "#56526e",
|
"border": "#44415a",
|
||||||
"border_variant": "#44415a",
|
"border_variant": "#393552",
|
||||||
"border_focused": "#3e8fb0",
|
"border_focused": "#c4a7e7",
|
||||||
"border_selected": "#3e8fb0",
|
"border_selected": "#c4a7e7",
|
||||||
"border_transparent": "#00000000",
|
"border_transparent": "#00000000",
|
||||||
"border_disabled": "#2a283e",
|
"border_disabled": "#393552",
|
||||||
"ring": "#3e8fb0",
|
"ring": "#c4a7e7",
|
||||||
"text": "#e0def4",
|
"text": "#e0def4",
|
||||||
"text_muted": "#908caa",
|
"text_muted": "#908caa",
|
||||||
"text_placeholder": "#6e6a86",
|
"text_placeholder": "#6e6a86",
|
||||||
"text_accent": "#c4a7e7",
|
"text_accent": "#c4a7e7",
|
||||||
"icon": "#e0def4",
|
"icon": "#908caa",
|
||||||
"icon_muted": "#908caa",
|
"icon_muted": "#6e6a86",
|
||||||
"icon_accent": "#c4a7e7",
|
"icon_accent": "#c4a7e7",
|
||||||
"element_foreground": "#232136",
|
"element_foreground": "#232136",
|
||||||
"element_background": "#3e8fb0",
|
"element_background": "#c4a7e7",
|
||||||
"element_hover": "#3e8fb0e6",
|
"element_hover": "#c4a7e7e6",
|
||||||
"element_active": "#38809d",
|
"element_active": "#b296d6",
|
||||||
"element_selected": "#32718a",
|
"element_selected": "#a085c5",
|
||||||
"element_disabled": "#3e8fb04d",
|
"element_disabled": "#c4a7e74d",
|
||||||
"secondary_foreground": "#3e8fb0",
|
"secondary_foreground": "#a085c5",
|
||||||
"secondary_background": "#2a283e",
|
"secondary_background": "#393552",
|
||||||
"secondary_hover": "#3e8fb01a",
|
"secondary_hover": "#c4a7e71a",
|
||||||
"secondary_active": "#44415a",
|
"secondary_active": "#44415a",
|
||||||
"secondary_selected": "#44415a",
|
"secondary_selected": "#44415a",
|
||||||
"secondary_disabled": "#3e8fb04d",
|
"secondary_disabled": "#c4a7e74d",
|
||||||
"danger_foreground": "#232136",
|
"danger_foreground": "#232136",
|
||||||
"danger_background": "#eb6f92",
|
"danger_background": "#eb6f92",
|
||||||
"danger_hover": "#eb6f92e6",
|
"danger_hover": "#e55a82",
|
||||||
"danger_active": "#d46483",
|
"danger_active": "#df4572",
|
||||||
"danger_selected": "#bd5974",
|
"danger_selected": "#d93062",
|
||||||
"danger_disabled": "#eb6f924d",
|
"danger_disabled": "#eb6f924d",
|
||||||
"warning_foreground": "#232136",
|
"warning_foreground": "#232136",
|
||||||
"warning_background": "#f6c177",
|
"warning_background": "#f6c177",
|
||||||
"warning_hover": "#f6c177e6",
|
"warning_hover": "#f4b35e",
|
||||||
"warning_active": "#ddae6b",
|
"warning_active": "#f2a545",
|
||||||
"warning_selected": "#c49b5f",
|
"warning_selected": "#f0972c",
|
||||||
"warning_disabled": "#f6c1774d",
|
"warning_disabled": "#f6c1774d",
|
||||||
"ghost_element_background": "#00000000",
|
"ghost_element_background": "#00000000",
|
||||||
"ghost_element_background_alt": "#2a283e",
|
"ghost_element_background_alt": "#393552",
|
||||||
"ghost_element_hover": "#e0def41a",
|
"ghost_element_hover": "#e0def41a",
|
||||||
"ghost_element_active": "#44415a",
|
"ghost_element_active": "#44415a",
|
||||||
"ghost_element_selected": "#44415a",
|
"ghost_element_selected": "#44415a",
|
||||||
"ghost_element_disabled": "#e0def40d",
|
"ghost_element_disabled": "#e0def40d",
|
||||||
"tab_inactive_background": "#2a283e",
|
"tab_inactive_background": "#2a273f",
|
||||||
"tab_hover_background": "#44415a",
|
"tab_inactive_foreground": "#908caa",
|
||||||
"tab_active_background": "#56526e",
|
"tab_active_background": "#232136",
|
||||||
|
"tab_active_foreground": "#e0def4",
|
||||||
|
"tab_hover_foreground": "#c4a7e7",
|
||||||
"scrollbar_thumb_background": "#e0def433",
|
"scrollbar_thumb_background": "#e0def433",
|
||||||
"scrollbar_thumb_hover_background": "#e0def44d",
|
"scrollbar_thumb_hover_background": "#e0def44d",
|
||||||
"scrollbar_thumb_border": "#00000000",
|
"scrollbar_thumb_border": "#00000000",
|
||||||
"scrollbar_track_background": "#00000000",
|
"scrollbar_track_background": "#00000000",
|
||||||
"scrollbar_track_border": "#44415a",
|
"scrollbar_track_border": "#44415a",
|
||||||
"drop_target_background": "#3e8fb01a",
|
"drop_target_background": "#c4a7e71a",
|
||||||
"cursor": "#9ccfd8",
|
"cursor": "#c4a7e7",
|
||||||
"selection": "#9ccfd840"
|
"selection": "#c4a7e740"
|
||||||
},
|
},
|
||||||
"dark": {
|
"dark": {
|
||||||
"background": "#232136",
|
"background": "#232136",
|
||||||
"surface_background": "#2a273f",
|
"surface_background": "#2a273f",
|
||||||
"elevated_surface_background": "#393552",
|
"elevated_surface_background": "#393552",
|
||||||
"panel_background": "#2a273f",
|
"panel_background": "#232136",
|
||||||
"overlay": "#e0def41a",
|
"overlay": "#e0def41a",
|
||||||
"title_bar": "#00000000",
|
"title_bar": "#2a273f",
|
||||||
"title_bar_inactive": "#232136",
|
"title_bar_inactive": "#393552",
|
||||||
"window_border": "#56526e",
|
"window_border": "#56526e",
|
||||||
"border": "#56526e",
|
"border": "#44415a",
|
||||||
"border_variant": "#44415a",
|
"border_variant": "#393552",
|
||||||
"border_focused": "#3e8fb0",
|
"border_focused": "#c4a7e7",
|
||||||
"border_selected": "#3e8fb0",
|
"border_selected": "#c4a7e7",
|
||||||
"border_transparent": "#00000000",
|
"border_transparent": "#00000000",
|
||||||
"border_disabled": "#2a283e",
|
"border_disabled": "#393552",
|
||||||
"ring": "#3e8fb0",
|
"ring": "#c4a7e7",
|
||||||
"text": "#e0def4",
|
"text": "#e0def4",
|
||||||
"text_muted": "#908caa",
|
"text_muted": "#908caa",
|
||||||
"text_placeholder": "#6e6a86",
|
"text_placeholder": "#6e6a86",
|
||||||
"text_accent": "#c4a7e7",
|
"text_accent": "#c4a7e7",
|
||||||
"icon": "#e0def4",
|
"icon": "#908caa",
|
||||||
"icon_muted": "#908caa",
|
"icon_muted": "#6e6a86",
|
||||||
"icon_accent": "#c4a7e7",
|
"icon_accent": "#c4a7e7",
|
||||||
"element_foreground": "#232136",
|
"element_foreground": "#232136",
|
||||||
"element_background": "#3e8fb0",
|
"element_background": "#c4a7e7",
|
||||||
"element_hover": "#3e8fb0e6",
|
"element_hover": "#c4a7e7e6",
|
||||||
"element_active": "#38809d",
|
"element_active": "#b296d6",
|
||||||
"element_selected": "#32718a",
|
"element_selected": "#a085c5",
|
||||||
"element_disabled": "#3e8fb04d",
|
"element_disabled": "#c4a7e74d",
|
||||||
"secondary_foreground": "#3e8fb0",
|
"secondary_foreground": "#a085c5",
|
||||||
"secondary_background": "#2a283e",
|
"secondary_background": "#393552",
|
||||||
"secondary_hover": "#3e8fb01a",
|
"secondary_hover": "#c4a7e71a",
|
||||||
"secondary_active": "#44415a",
|
"secondary_active": "#44415a",
|
||||||
"secondary_selected": "#44415a",
|
"secondary_selected": "#44415a",
|
||||||
"secondary_disabled": "#3e8fb04d",
|
"secondary_disabled": "#c4a7e74d",
|
||||||
"danger_foreground": "#232136",
|
"danger_foreground": "#232136",
|
||||||
"danger_background": "#eb6f92",
|
"danger_background": "#eb6f92",
|
||||||
"danger_hover": "#eb6f92e6",
|
"danger_hover": "#e55a82",
|
||||||
"danger_active": "#d46483",
|
"danger_active": "#df4572",
|
||||||
"danger_selected": "#bd5974",
|
"danger_selected": "#d93062",
|
||||||
"danger_disabled": "#eb6f924d",
|
"danger_disabled": "#eb6f924d",
|
||||||
"warning_foreground": "#232136",
|
"warning_foreground": "#232136",
|
||||||
"warning_background": "#f6c177",
|
"warning_background": "#f6c177",
|
||||||
"warning_hover": "#f6c177e6",
|
"warning_hover": "#f4b35e",
|
||||||
"warning_active": "#ddae6b",
|
"warning_active": "#f2a545",
|
||||||
"warning_selected": "#c49b5f",
|
"warning_selected": "#f0972c",
|
||||||
"warning_disabled": "#f6c1774d",
|
"warning_disabled": "#f6c1774d",
|
||||||
"ghost_element_background": "#00000000",
|
"ghost_element_background": "#00000000",
|
||||||
"ghost_element_background_alt": "#2a283e",
|
"ghost_element_background_alt": "#393552",
|
||||||
"ghost_element_hover": "#e0def41a",
|
"ghost_element_hover": "#e0def41a",
|
||||||
"ghost_element_active": "#44415a",
|
"ghost_element_active": "#44415a",
|
||||||
"ghost_element_selected": "#44415a",
|
"ghost_element_selected": "#44415a",
|
||||||
"ghost_element_disabled": "#e0def40d",
|
"ghost_element_disabled": "#e0def40d",
|
||||||
"tab_inactive_background": "#2a283e",
|
"tab_inactive_background": "#2a273f",
|
||||||
"tab_hover_background": "#44415a",
|
"tab_inactive_foreground": "#908caa",
|
||||||
"tab_active_background": "#56526e",
|
"tab_active_background": "#232136",
|
||||||
|
"tab_active_foreground": "#e0def4",
|
||||||
|
"tab_hover_foreground": "#c4a7e7",
|
||||||
"scrollbar_thumb_background": "#e0def433",
|
"scrollbar_thumb_background": "#e0def433",
|
||||||
"scrollbar_thumb_hover_background": "#e0def44d",
|
"scrollbar_thumb_hover_background": "#e0def44d",
|
||||||
"scrollbar_thumb_border": "#00000000",
|
"scrollbar_thumb_border": "#00000000",
|
||||||
"scrollbar_track_background": "#00000000",
|
"scrollbar_track_background": "#00000000",
|
||||||
"scrollbar_track_border": "#44415a",
|
"scrollbar_track_border": "#44415a",
|
||||||
"drop_target_background": "#3e8fb01a",
|
"drop_target_background": "#c4a7e71a",
|
||||||
"cursor": "#9ccfd8",
|
"cursor": "#c4a7e7",
|
||||||
"selection": "#9ccfd840"
|
"selection": "#c4a7e740"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,130 +7,134 @@
|
|||||||
"background": "#191724",
|
"background": "#191724",
|
||||||
"surface_background": "#1f1d2e",
|
"surface_background": "#1f1d2e",
|
||||||
"elevated_surface_background": "#26233a",
|
"elevated_surface_background": "#26233a",
|
||||||
"panel_background": "#1f1d2e",
|
"panel_background": "#191724",
|
||||||
"overlay": "#e0def41a",
|
"overlay": "#e0def41a",
|
||||||
"title_bar": "#00000000",
|
"title_bar": "#1f1d2e",
|
||||||
"title_bar_inactive": "#191724",
|
"title_bar_inactive": "#26233a",
|
||||||
"window_border": "#524f67",
|
"window_border": "#524f67",
|
||||||
"border": "#524f67",
|
"border": "#403d52",
|
||||||
"border_variant": "#403d52",
|
"border_variant": "#26233a",
|
||||||
"border_focused": "#31748f",
|
"border_focused": "#c4a7e7",
|
||||||
"border_selected": "#31748f",
|
"border_selected": "#c4a7e7",
|
||||||
"border_transparent": "#00000000",
|
"border_transparent": "#00000000",
|
||||||
"border_disabled": "#21202e",
|
"border_disabled": "#26233a",
|
||||||
"ring": "#31748f",
|
"ring": "#c4a7e7",
|
||||||
"text": "#e0def4",
|
"text": "#e0def4",
|
||||||
"text_muted": "#908caa",
|
"text_muted": "#908caa",
|
||||||
"text_placeholder": "#6e6a86",
|
"text_placeholder": "#6e6a86",
|
||||||
"text_accent": "#c4a7e7",
|
"text_accent": "#c4a7e7",
|
||||||
"icon": "#e0def4",
|
"icon": "#908caa",
|
||||||
"icon_muted": "#908caa",
|
"icon_muted": "#6e6a86",
|
||||||
"icon_accent": "#c4a7e7",
|
"icon_accent": "#c4a7e7",
|
||||||
"element_foreground": "#191724",
|
"element_foreground": "#191724",
|
||||||
"element_background": "#31748f",
|
"element_background": "#c4a7e7",
|
||||||
"element_hover": "#31748fe6",
|
"element_hover": "#c4a7e7e6",
|
||||||
"element_active": "#2c6980",
|
"element_active": "#b296d6",
|
||||||
"element_selected": "#275e71",
|
"element_selected": "#a085c5",
|
||||||
"element_disabled": "#31748f4d",
|
"element_disabled": "#c4a7e74d",
|
||||||
"secondary_foreground": "#31748f",
|
"secondary_foreground": "#a085c5",
|
||||||
"secondary_background": "#21202e",
|
"secondary_background": "#26233a",
|
||||||
"secondary_hover": "#31748f1a",
|
"secondary_hover": "#c4a7e71a",
|
||||||
"secondary_active": "#403d52",
|
"secondary_active": "#403d52",
|
||||||
"secondary_selected": "#403d52",
|
"secondary_selected": "#403d52",
|
||||||
"secondary_disabled": "#31748f4d",
|
"secondary_disabled": "#c4a7e74d",
|
||||||
"danger_foreground": "#191724",
|
"danger_foreground": "#191724",
|
||||||
"danger_background": "#eb6f92",
|
"danger_background": "#eb6f92",
|
||||||
"danger_hover": "#eb6f92e6",
|
"danger_hover": "#e55a82",
|
||||||
"danger_active": "#d46483",
|
"danger_active": "#df4572",
|
||||||
"danger_selected": "#bd5974",
|
"danger_selected": "#d93062",
|
||||||
"danger_disabled": "#eb6f924d",
|
"danger_disabled": "#eb6f924d",
|
||||||
"warning_foreground": "#191724",
|
"warning_foreground": "#191724",
|
||||||
"warning_background": "#f6c177",
|
"warning_background": "#f6c177",
|
||||||
"warning_hover": "#f6c177e6",
|
"warning_hover": "#f4b35e",
|
||||||
"warning_active": "#ddae6b",
|
"warning_active": "#f2a545",
|
||||||
"warning_selected": "#c49b5f",
|
"warning_selected": "#f0972c",
|
||||||
"warning_disabled": "#f6c1774d",
|
"warning_disabled": "#f6c1774d",
|
||||||
"ghost_element_background": "#00000000",
|
"ghost_element_background": "#00000000",
|
||||||
"ghost_element_background_alt": "#21202e",
|
"ghost_element_background_alt": "#26233a",
|
||||||
"ghost_element_hover": "#e0def41a",
|
"ghost_element_hover": "#e0def41a",
|
||||||
"ghost_element_active": "#403d52",
|
"ghost_element_active": "#403d52",
|
||||||
"ghost_element_selected": "#403d52",
|
"ghost_element_selected": "#403d52",
|
||||||
"ghost_element_disabled": "#e0def40d",
|
"ghost_element_disabled": "#e0def40d",
|
||||||
"tab_inactive_background": "#21202e",
|
"tab_inactive_background": "#1f1d2e",
|
||||||
"tab_hover_background": "#403d52",
|
"tab_inactive_foreground": "#908caa",
|
||||||
"tab_active_background": "#524f67",
|
"tab_active_background": "#191724",
|
||||||
|
"tab_active_foreground": "#e0def4",
|
||||||
|
"tab_hover_foreground": "#c4a7e7",
|
||||||
"scrollbar_thumb_background": "#e0def433",
|
"scrollbar_thumb_background": "#e0def433",
|
||||||
"scrollbar_thumb_hover_background": "#e0def44d",
|
"scrollbar_thumb_hover_background": "#e0def44d",
|
||||||
"scrollbar_thumb_border": "#00000000",
|
"scrollbar_thumb_border": "#00000000",
|
||||||
"scrollbar_track_background": "#00000000",
|
"scrollbar_track_background": "#00000000",
|
||||||
"scrollbar_track_border": "#403d52",
|
"scrollbar_track_border": "#403d52",
|
||||||
"drop_target_background": "#31748f1a",
|
"drop_target_background": "#c4a7e71a",
|
||||||
"cursor": "#9ccfd8",
|
"cursor": "#c4a7e7",
|
||||||
"selection": "#9ccfd840"
|
"selection": "#c4a7e740"
|
||||||
},
|
},
|
||||||
"dark": {
|
"dark": {
|
||||||
"background": "#191724",
|
"background": "#191724",
|
||||||
"surface_background": "#1f1d2e",
|
"surface_background": "#1f1d2e",
|
||||||
"elevated_surface_background": "#26233a",
|
"elevated_surface_background": "#26233a",
|
||||||
"panel_background": "#1f1d2e",
|
"panel_background": "#191724",
|
||||||
"overlay": "#e0def41a",
|
"overlay": "#e0def41a",
|
||||||
"title_bar": "#00000000",
|
"title_bar": "#1f1d2e",
|
||||||
"title_bar_inactive": "#191724",
|
"title_bar_inactive": "#26233a",
|
||||||
"window_border": "#524f67",
|
"window_border": "#524f67",
|
||||||
"border": "#524f67",
|
"border": "#403d52",
|
||||||
"border_variant": "#403d52",
|
"border_variant": "#26233a",
|
||||||
"border_focused": "#31748f",
|
"border_focused": "#c4a7e7",
|
||||||
"border_selected": "#31748f",
|
"border_selected": "#c4a7e7",
|
||||||
"border_transparent": "#00000000",
|
"border_transparent": "#00000000",
|
||||||
"border_disabled": "#21202e",
|
"border_disabled": "#26233a",
|
||||||
"ring": "#31748f",
|
"ring": "#c4a7e7",
|
||||||
"text": "#e0def4",
|
"text": "#e0def4",
|
||||||
"text_muted": "#908caa",
|
"text_muted": "#908caa",
|
||||||
"text_placeholder": "#6e6a86",
|
"text_placeholder": "#6e6a86",
|
||||||
"text_accent": "#c4a7e7",
|
"text_accent": "#c4a7e7",
|
||||||
"icon": "#e0def4",
|
"icon": "#908caa",
|
||||||
"icon_muted": "#908caa",
|
"icon_muted": "#6e6a86",
|
||||||
"icon_accent": "#c4a7e7",
|
"icon_accent": "#c4a7e7",
|
||||||
"element_foreground": "#191724",
|
"element_foreground": "#191724",
|
||||||
"element_background": "#31748f",
|
"element_background": "#c4a7e7",
|
||||||
"element_hover": "#31748fe6",
|
"element_hover": "#c4a7e7e6",
|
||||||
"element_active": "#2c6980",
|
"element_active": "#b296d6",
|
||||||
"element_selected": "#275e71",
|
"element_selected": "#a085c5",
|
||||||
"element_disabled": "#31748f4d",
|
"element_disabled": "#c4a7e74d",
|
||||||
"secondary_foreground": "#31748f",
|
"secondary_foreground": "#a085c5",
|
||||||
"secondary_background": "#21202e",
|
"secondary_background": "#26233a",
|
||||||
"secondary_hover": "#31748f1a",
|
"secondary_hover": "#c4a7e71a",
|
||||||
"secondary_active": "#403d52",
|
"secondary_active": "#403d52",
|
||||||
"secondary_selected": "#403d52",
|
"secondary_selected": "#403d52",
|
||||||
"secondary_disabled": "#31748f4d",
|
"secondary_disabled": "#c4a7e74d",
|
||||||
"danger_foreground": "#191724",
|
"danger_foreground": "#191724",
|
||||||
"danger_background": "#eb6f92",
|
"danger_background": "#eb6f92",
|
||||||
"danger_hover": "#eb6f92e6",
|
"danger_hover": "#e55a82",
|
||||||
"danger_active": "#d46483",
|
"danger_active": "#df4572",
|
||||||
"danger_selected": "#bd5974",
|
"danger_selected": "#d93062",
|
||||||
"danger_disabled": "#eb6f924d",
|
"danger_disabled": "#eb6f924d",
|
||||||
"warning_foreground": "#191724",
|
"warning_foreground": "#191724",
|
||||||
"warning_background": "#f6c177",
|
"warning_background": "#f6c177",
|
||||||
"warning_hover": "#f6c177e6",
|
"warning_hover": "#f4b35e",
|
||||||
"warning_active": "#ddae6b",
|
"warning_active": "#f2a545",
|
||||||
"warning_selected": "#c49b5f",
|
"warning_selected": "#f0972c",
|
||||||
"warning_disabled": "#f6c1774d",
|
"warning_disabled": "#f6c1774d",
|
||||||
"ghost_element_background": "#00000000",
|
"ghost_element_background": "#00000000",
|
||||||
"ghost_element_background_alt": "#21202e",
|
"ghost_element_background_alt": "#26233a",
|
||||||
"ghost_element_hover": "#e0def41a",
|
"ghost_element_hover": "#e0def41a",
|
||||||
"ghost_element_active": "#403d52",
|
"ghost_element_active": "#403d52",
|
||||||
"ghost_element_selected": "#403d52",
|
"ghost_element_selected": "#403d52",
|
||||||
"ghost_element_disabled": "#e0def40d",
|
"ghost_element_disabled": "#e0def40d",
|
||||||
"tab_inactive_background": "#21202e",
|
"tab_inactive_background": "#1f1d2e",
|
||||||
"tab_hover_background": "#403d52",
|
"tab_inactive_foreground": "#908caa",
|
||||||
"tab_active_background": "#524f67",
|
"tab_active_background": "#191724",
|
||||||
|
"tab_active_foreground": "#e0def4",
|
||||||
|
"tab_hover_foreground": "#c4a7e7",
|
||||||
"scrollbar_thumb_background": "#e0def433",
|
"scrollbar_thumb_background": "#e0def433",
|
||||||
"scrollbar_thumb_hover_background": "#e0def44d",
|
"scrollbar_thumb_hover_background": "#e0def44d",
|
||||||
"scrollbar_thumb_border": "#00000000",
|
"scrollbar_thumb_border": "#00000000",
|
||||||
"scrollbar_track_background": "#00000000",
|
"scrollbar_track_background": "#00000000",
|
||||||
"scrollbar_track_border": "#403d52",
|
"scrollbar_track_border": "#403d52",
|
||||||
"drop_target_background": "#31748f1a",
|
"drop_target_background": "#c4a7e71a",
|
||||||
"cursor": "#9ccfd8",
|
"cursor": "#c4a7e7",
|
||||||
"selection": "#9ccfd840"
|
"selection": "#c4a7e740"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ use std::path::{Path, PathBuf};
|
|||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use anyhow::{anyhow, Context as AnyhowContext, Error};
|
use anyhow::{Context as AnyhowContext, Error, anyhow};
|
||||||
use gpui::http_client::{AsyncBody, HttpClient};
|
use gpui::http_client::{AsyncBody, HttpClient};
|
||||||
use gpui::{
|
use gpui::{
|
||||||
App, AppContext, AsyncApp, BackgroundExecutor, Context, Entity, Global, Subscription, Task,
|
App, AppContext, AsyncApp, BackgroundExecutor, Context, Entity, Global, Subscription, Task,
|
||||||
@@ -11,7 +11,7 @@ use gpui::{
|
|||||||
};
|
};
|
||||||
use semver::Version;
|
use semver::Version;
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use smallvec::{smallvec, SmallVec};
|
use smallvec::{SmallVec, smallvec};
|
||||||
use smol::fs::File;
|
use smol::fs::File;
|
||||||
use smol::io::AsyncReadExt;
|
use smol::io::AsyncReadExt;
|
||||||
use smol::process::Command;
|
use smol::process::Command;
|
||||||
@@ -20,11 +20,11 @@ const GITHUB_API_URL: &str = "https://api.github.com";
|
|||||||
const COOP_UPDATE_EXPLANATION: &str = "COOP_UPDATE_EXPLANATION";
|
const COOP_UPDATE_EXPLANATION: &str = "COOP_UPDATE_EXPLANATION";
|
||||||
|
|
||||||
fn get_github_repo_owner() -> String {
|
fn get_github_repo_owner() -> String {
|
||||||
std::env::var("COOP_GITHUB_REPO_OWNER").unwrap_or_else(|_| "your-username".to_string())
|
std::env::var("COOP_GITHUB_REPO_OWNER").unwrap_or_else(|_| "reyakov".to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn get_github_repo_name() -> String {
|
fn get_github_repo_name() -> String {
|
||||||
std::env::var("COOP_GITHUB_REPO_NAME").unwrap_or_else(|_| "your-repo".to_string())
|
std::env::var("COOP_GITHUB_REPO_NAME").unwrap_or_else(|_| "coop".to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn is_flatpak_installation() -> bool {
|
fn is_flatpak_installation() -> bool {
|
||||||
|
|||||||
@@ -1,20 +1,20 @@
|
|||||||
use std::cmp::Reverse;
|
use std::cmp::Reverse;
|
||||||
use std::collections::{HashMap, HashSet};
|
use std::collections::{HashMap, HashSet};
|
||||||
use std::hash::{DefaultHasher, Hash, Hasher};
|
use std::hash::{DefaultHasher, Hash, Hasher};
|
||||||
use std::sync::atomic::{AtomicBool, Ordering};
|
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use anyhow::{anyhow, Context as AnyhowContext, Error};
|
use anyhow::{Context as AnyhowContext, Error, anyhow};
|
||||||
use common::EventUtils;
|
use common::EventUtils;
|
||||||
use fuzzy_matcher::skim::SkimMatcherV2;
|
|
||||||
use fuzzy_matcher::FuzzyMatcher;
|
use fuzzy_matcher::FuzzyMatcher;
|
||||||
|
use fuzzy_matcher::skim::SkimMatcherV2;
|
||||||
use gpui::{
|
use gpui::{
|
||||||
App, AppContext, Context, Entity, EventEmitter, Global, Subscription, Task, WeakEntity, Window,
|
App, AppContext, Context, Entity, EventEmitter, Global, Subscription, Task, WeakEntity, Window,
|
||||||
};
|
};
|
||||||
use nostr_sdk::prelude::*;
|
use nostr_sdk::prelude::*;
|
||||||
use smallvec::{smallvec, SmallVec};
|
use smallvec::{SmallVec, smallvec};
|
||||||
use state::{NostrRegistry, RelayState, DEVICE_GIFTWRAP, TIMEOUT, USER_GIFTWRAP};
|
use state::{DEVICE_GIFTWRAP, NostrRegistry, RelayState, TIMEOUT, USER_GIFTWRAP};
|
||||||
|
|
||||||
mod message;
|
mod message;
|
||||||
mod room;
|
mod room;
|
||||||
@@ -50,11 +50,32 @@ enum Signal {
|
|||||||
Eose,
|
Eose,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Inbox state.
|
||||||
|
#[derive(Debug, Clone, Default, PartialEq, Eq, PartialOrd, Ord)]
|
||||||
|
pub enum InboxState {
|
||||||
|
#[default]
|
||||||
|
Idle,
|
||||||
|
Checking,
|
||||||
|
RelayNotAvailable,
|
||||||
|
RelayConfigured(Box<Event>),
|
||||||
|
Subscribing,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl InboxState {
|
||||||
|
pub fn not_configured(&self) -> bool {
|
||||||
|
matches!(self, InboxState::RelayNotAvailable)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn subscribing(&self) -> bool {
|
||||||
|
matches!(self, InboxState::Subscribing)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Chat Registry
|
/// Chat Registry
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub struct ChatRegistry {
|
pub struct ChatRegistry {
|
||||||
/// Relay state for messaging relay list
|
/// Relay state for messaging relay list
|
||||||
messaging_relay_list: Entity<RelayState>,
|
state: Entity<InboxState>,
|
||||||
|
|
||||||
/// Collection of all chat rooms
|
/// Collection of all chat rooms
|
||||||
rooms: Vec<Entity<Room>>,
|
rooms: Vec<Entity<Room>>,
|
||||||
@@ -84,7 +105,7 @@ impl ChatRegistry {
|
|||||||
|
|
||||||
/// Create a new chat registry instance
|
/// Create a new chat registry instance
|
||||||
fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
|
fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||||
let messaging_relay_list = cx.new(|_| RelayState::default());
|
let state = cx.new(|_| InboxState::default());
|
||||||
let nostr = NostrRegistry::global(cx);
|
let nostr = NostrRegistry::global(cx);
|
||||||
|
|
||||||
let mut subscriptions = smallvec![];
|
let mut subscriptions = smallvec![];
|
||||||
@@ -92,40 +113,41 @@ impl ChatRegistry {
|
|||||||
subscriptions.push(
|
subscriptions.push(
|
||||||
// Observe the nip65 state and load chat rooms on every state change
|
// Observe the nip65 state and load chat rooms on every state change
|
||||||
cx.observe(&nostr, |this, state, cx| {
|
cx.observe(&nostr, |this, state, cx| {
|
||||||
match state.read(cx).relay_list_state() {
|
match state.read(cx).relay_list_state {
|
||||||
RelayState::Idle => {
|
RelayState::Idle => {
|
||||||
this.reset(cx);
|
this.reset(cx);
|
||||||
}
|
}
|
||||||
RelayState::Configured => {
|
RelayState::Configured => {
|
||||||
|
this.get_contact_list(cx);
|
||||||
this.ensure_messaging_relays(cx);
|
this.ensure_messaging_relays(cx);
|
||||||
}
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Load rooms on every state change
|
||||||
|
this.get_rooms(cx);
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
subscriptions.push(
|
subscriptions.push(
|
||||||
// Observe the nip17 state and load chat rooms on every state change
|
// Observe the nip17 state and load chat rooms on every state change
|
||||||
cx.observe(&messaging_relay_list, |this, state, cx| {
|
cx.observe(&state, |this, state, cx| {
|
||||||
match state.read(cx) {
|
if let InboxState::RelayConfigured(event) = state.read(cx) {
|
||||||
RelayState::Configured => {
|
let relay_urls: Vec<_> = nip17::extract_relay_list(event).cloned().collect();
|
||||||
this.get_messages(cx);
|
this.get_messages(relay_urls, cx);
|
||||||
}
|
|
||||||
_ => {
|
|
||||||
this.get_rooms(cx);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Run at the end of current cycle
|
// Run at the end of the current cycle
|
||||||
cx.defer_in(window, |this, _window, cx| {
|
cx.defer_in(window, |this, _window, cx| {
|
||||||
|
this.get_rooms(cx);
|
||||||
this.handle_notifications(cx);
|
this.handle_notifications(cx);
|
||||||
this.tracking(cx);
|
this.tracking(cx);
|
||||||
});
|
});
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
messaging_relay_list,
|
state,
|
||||||
rooms: vec![],
|
rooms: vec![],
|
||||||
tracking_flag: Arc::new(AtomicBool::new(false)),
|
tracking_flag: Arc::new(AtomicBool::new(false)),
|
||||||
tasks: smallvec![],
|
tasks: smallvec![],
|
||||||
@@ -170,10 +192,8 @@ impl ChatRegistry {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
log::info!("Received gift wrap event: {:?}", event);
|
|
||||||
|
|
||||||
// Extract the rumor from the gift wrap event
|
// Extract the rumor from the gift wrap event
|
||||||
match Self::extract_rumor(&client, &device_signer, event.as_ref()).await {
|
match extract_rumor(&client, &device_signer, event.as_ref()).await {
|
||||||
Ok(rumor) => match rumor.created_at >= initialized_at {
|
Ok(rumor) => match rumor.created_at >= initialized_at {
|
||||||
true => {
|
true => {
|
||||||
let new_message = NewMessage::new(event.id, rumor);
|
let new_message = NewMessage::new(event.id, rumor);
|
||||||
@@ -227,7 +247,7 @@ impl ChatRegistry {
|
|||||||
let status = self.tracking_flag.clone();
|
let status = self.tracking_flag.clone();
|
||||||
|
|
||||||
self.tasks.push(cx.background_spawn(async move {
|
self.tasks.push(cx.background_spawn(async move {
|
||||||
let loop_duration = Duration::from_secs(10);
|
let loop_duration = Duration::from_secs(15);
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
if status.load(Ordering::Acquire) {
|
if status.load(Ordering::Acquire) {
|
||||||
@@ -238,17 +258,59 @@ impl ChatRegistry {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
fn ensure_messaging_relays(&mut self, cx: &mut Context<Self>) {
|
/// Get contact list from relays
|
||||||
let state = self.messaging_relay_list.downgrade();
|
pub fn get_contact_list(&mut self, cx: &mut Context<Self>) {
|
||||||
|
let nostr = NostrRegistry::global(cx);
|
||||||
|
let client = nostr.read(cx).client();
|
||||||
|
let signer = nostr.read(cx).signer();
|
||||||
|
|
||||||
|
let Some(public_key) = signer.public_key() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let write_relays = nostr.read(cx).write_relays(&public_key, cx);
|
||||||
|
|
||||||
|
let task: Task<Result<(), Error>> = cx.background_spawn(async move {
|
||||||
|
let id = SubscriptionId::new("contact-list");
|
||||||
|
let opts = SubscribeAutoCloseOptions::default()
|
||||||
|
.exit_policy(ReqExitPolicy::ExitOnEOSE)
|
||||||
|
.timeout(Some(Duration::from_secs(TIMEOUT)));
|
||||||
|
|
||||||
|
// Get user's write relays
|
||||||
|
let urls = write_relays.await;
|
||||||
|
|
||||||
|
// Construct filter for inbox relays
|
||||||
|
let filter = Filter::new()
|
||||||
|
.kind(Kind::ContactList)
|
||||||
|
.author(public_key)
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
// Construct target for subscription
|
||||||
|
let target: HashMap<&RelayUrl, Filter> =
|
||||||
|
urls.iter().map(|relay| (relay, filter.clone())).collect();
|
||||||
|
|
||||||
|
// Subscribe
|
||||||
|
client.subscribe(target).close_on(opts).with_id(id).await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
});
|
||||||
|
|
||||||
|
self.tasks.push(task);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ensure messaging relays are set up for the current user.
|
||||||
|
pub fn ensure_messaging_relays(&mut self, cx: &mut Context<Self>) {
|
||||||
let task = self.verify_relays(cx);
|
let task = self.verify_relays(cx);
|
||||||
|
|
||||||
self.tasks.push(cx.spawn(async move |_this, cx| {
|
// Set state to checking
|
||||||
|
self.set_state(InboxState::Checking, cx);
|
||||||
|
|
||||||
|
self.tasks.push(cx.spawn(async move |this, cx| {
|
||||||
let result = task.await?;
|
let result = task.await?;
|
||||||
|
|
||||||
// Update state
|
// Update state
|
||||||
state.update(cx, |this, cx| {
|
this.update(cx, |this, cx| {
|
||||||
*this = result;
|
this.set_state(result, cx);
|
||||||
cx.notify();
|
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -256,12 +318,14 @@ impl ChatRegistry {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Verify messaging relay list for current user
|
// Verify messaging relay list for current user
|
||||||
fn verify_relays(&mut self, cx: &mut Context<Self>) -> Task<Result<RelayState, Error>> {
|
fn verify_relays(&mut self, cx: &mut Context<Self>) -> Task<Result<InboxState, Error>> {
|
||||||
let nostr = NostrRegistry::global(cx);
|
let nostr = NostrRegistry::global(cx);
|
||||||
let client = nostr.read(cx).client();
|
let client = nostr.read(cx).client();
|
||||||
|
|
||||||
let signer = nostr.read(cx).signer();
|
let signer = nostr.read(cx).signer();
|
||||||
let public_key = signer.public_key().unwrap();
|
|
||||||
|
let Some(public_key) = signer.public_key() else {
|
||||||
|
return Task::ready(Err(anyhow!("User not found")));
|
||||||
|
};
|
||||||
|
|
||||||
let write_relays = nostr.read(cx).write_relays(&public_key, cx);
|
let write_relays = nostr.read(cx).write_relays(&public_key, cx);
|
||||||
|
|
||||||
@@ -287,8 +351,7 @@ impl ChatRegistry {
|
|||||||
while let Some((_url, res)) = stream.next().await {
|
while let Some((_url, res)) = stream.next().await {
|
||||||
match res {
|
match res {
|
||||||
Ok(event) => {
|
Ok(event) => {
|
||||||
log::info!("Received relay list event: {event:?}");
|
return Ok(InboxState::RelayConfigured(Box::new(event)));
|
||||||
return Ok(RelayState::Configured);
|
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
log::error!("Failed to receive relay list event: {e}");
|
log::error!("Failed to receive relay list event: {e}");
|
||||||
@@ -296,41 +359,54 @@ impl ChatRegistry {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(RelayState::NotConfigured)
|
Ok(InboxState::RelayNotAvailable)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get all messages for current user
|
/// Get all messages for current user
|
||||||
fn get_messages(&mut self, cx: &mut Context<Self>) {
|
fn get_messages<I>(&mut self, relay_urls: I, cx: &mut Context<Self>)
|
||||||
let task = self.subscribe_to_giftwrap_events(cx);
|
where
|
||||||
|
I: IntoIterator<Item = RelayUrl>,
|
||||||
|
{
|
||||||
|
let task = self.subscribe(relay_urls, cx);
|
||||||
|
|
||||||
self.tasks.push(cx.spawn(async move |_this, _cx| {
|
self.tasks.push(cx.spawn(async move |this, cx| {
|
||||||
task.await?;
|
task.await?;
|
||||||
|
|
||||||
// Update state
|
// Update state
|
||||||
|
this.update(cx, |this, cx| {
|
||||||
|
this.set_state(InboxState::Subscribing, cx);
|
||||||
|
})?;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Continuously get gift wrap events for the current user in their messaging relays
|
/// Continuously get gift wrap events for the current user in their messaging relays
|
||||||
fn subscribe_to_giftwrap_events(&mut self, cx: &mut Context<Self>) -> Task<Result<(), Error>> {
|
fn subscribe<I>(&mut self, urls: I, cx: &mut Context<Self>) -> Task<Result<(), Error>>
|
||||||
|
where
|
||||||
|
I: IntoIterator<Item = RelayUrl>,
|
||||||
|
{
|
||||||
let nostr = NostrRegistry::global(cx);
|
let nostr = NostrRegistry::global(cx);
|
||||||
let client = nostr.read(cx).client();
|
let client = nostr.read(cx).client();
|
||||||
|
|
||||||
let signer = nostr.read(cx).signer();
|
let signer = nostr.read(cx).signer();
|
||||||
let public_key = signer.public_key().unwrap();
|
let urls = urls.into_iter().collect::<Vec<_>>();
|
||||||
|
|
||||||
let messaging_relays = nostr.read(cx).messaging_relays(&public_key, cx);
|
|
||||||
|
|
||||||
cx.background_spawn(async move {
|
cx.background_spawn(async move {
|
||||||
let urls = messaging_relays.await;
|
let public_key = signer.get_public_key().await?;
|
||||||
let filter = Filter::new().kind(Kind::GiftWrap).pubkey(public_key);
|
let filter = Filter::new().kind(Kind::GiftWrap).pubkey(public_key);
|
||||||
let id = SubscriptionId::new(USER_GIFTWRAP);
|
let id = SubscriptionId::new(USER_GIFTWRAP);
|
||||||
|
|
||||||
|
// Ensure relay connections
|
||||||
|
for url in urls.iter() {
|
||||||
|
client.add_relay(url).and_connect().await?;
|
||||||
|
}
|
||||||
|
|
||||||
// Construct target for subscription
|
// Construct target for subscription
|
||||||
let target: HashMap<&RelayUrl, Filter> =
|
let target: HashMap<RelayUrl, Filter> = urls
|
||||||
urls.iter().map(|relay| (relay, filter.clone())).collect();
|
.into_iter()
|
||||||
|
.map(|relay| (relay, filter.clone()))
|
||||||
|
.collect();
|
||||||
|
|
||||||
let output = client.subscribe(target).with_id(id).await?;
|
let output = client.subscribe(target).with_id(id).await?;
|
||||||
|
|
||||||
@@ -343,9 +419,17 @@ impl ChatRegistry {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Set the state of the inbox
|
||||||
|
fn set_state(&mut self, state: InboxState, cx: &mut Context<Self>) {
|
||||||
|
self.state.update(cx, |this, cx| {
|
||||||
|
*this = state;
|
||||||
|
cx.notify();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/// Get the relay state
|
/// Get the relay state
|
||||||
pub fn relay_state(&self, cx: &App) -> RelayState {
|
pub fn state(&self, cx: &App) -> InboxState {
|
||||||
self.messaging_relay_list.read(cx).clone()
|
self.state.read(cx).clone()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get the loading status of the chat registry
|
/// Get the loading status of the chat registry
|
||||||
@@ -491,16 +575,21 @@ impl ChatRegistry {
|
|||||||
pub fn get_rooms(&mut self, cx: &mut Context<Self>) {
|
pub fn get_rooms(&mut self, cx: &mut Context<Self>) {
|
||||||
let task = self.get_rooms_from_database(cx);
|
let task = self.get_rooms_from_database(cx);
|
||||||
|
|
||||||
cx.spawn(async move |this, cx| {
|
self.tasks.push(cx.spawn(async move |this, cx| {
|
||||||
let rooms = task.await.ok()?;
|
match task.await {
|
||||||
|
Ok(rooms) => {
|
||||||
this.update(cx, move |this, cx| {
|
this.update(cx, |this, cx| {
|
||||||
this.extend_rooms(rooms, cx);
|
this.extend_rooms(rooms, cx);
|
||||||
this.sort(cx);
|
this.sort(cx);
|
||||||
})
|
})?;
|
||||||
.ok()
|
}
|
||||||
})
|
Err(e) => {
|
||||||
.detach();
|
log::error!("Failed to load rooms: {}", e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Create a task to load rooms from the database
|
/// Create a task to load rooms from the database
|
||||||
@@ -513,7 +602,11 @@ impl ChatRegistry {
|
|||||||
let public_key = signer.get_public_key().await?;
|
let public_key = signer.get_public_key().await?;
|
||||||
|
|
||||||
// Get contacts
|
// Get contacts
|
||||||
let contacts = client.database().contacts_public_keys(public_key).await?;
|
let contacts = client
|
||||||
|
.database()
|
||||||
|
.contacts_public_keys(public_key)
|
||||||
|
.await
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
// Construct authored filter
|
// Construct authored filter
|
||||||
let authored_filter = Filter::new()
|
let authored_filter = Filter::new()
|
||||||
@@ -588,6 +681,7 @@ impl ChatRegistry {
|
|||||||
room.update(cx, |this, cx| {
|
room.update(cx, |this, cx| {
|
||||||
this.push_message(message, cx);
|
this.push_message(message, cx);
|
||||||
});
|
});
|
||||||
|
self.sort(cx);
|
||||||
}
|
}
|
||||||
None => {
|
None => {
|
||||||
// Push the new room to the front of the list
|
// Push the new room to the front of the list
|
||||||
@@ -597,8 +691,7 @@ impl ChatRegistry {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Trigger a refresh of the opened chat rooms by their IDs
|
/// Trigger a refresh of the opened chat rooms by their IDs
|
||||||
pub fn refresh_rooms(&mut self, ids: Option<Vec<u64>>, cx: &mut Context<Self>) {
|
pub fn refresh_rooms(&mut self, ids: &[u64], cx: &mut Context<Self>) {
|
||||||
if let Some(ids) = ids {
|
|
||||||
for room in self.rooms.iter() {
|
for room in self.rooms.iter() {
|
||||||
if ids.contains(&room.read(cx).id) {
|
if ids.contains(&room.read(cx).id) {
|
||||||
room.update(cx, |this, cx| {
|
room.update(cx, |this, cx| {
|
||||||
@@ -616,21 +709,23 @@ impl ChatRegistry {
|
|||||||
gift_wrap: &Event,
|
gift_wrap: &Event,
|
||||||
) -> Result<UnsignedEvent, Error> {
|
) -> Result<UnsignedEvent, Error> {
|
||||||
// Try to get cached rumor first
|
// Try to get cached rumor first
|
||||||
if let Ok(event) = Self::get_rumor(client, gift_wrap.id).await {
|
if let Ok(event) = get_rumor(client, gift_wrap.id).await {
|
||||||
return Ok(event);
|
return Ok(event);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try to unwrap with the available signer
|
// Try to unwrap with the available signer
|
||||||
let unwrapped = Self::try_unwrap(client, device_signer, gift_wrap).await?;
|
let unwrapped = try_unwrap(client, device_signer, gift_wrap).await?;
|
||||||
let mut rumor_unsigned = unwrapped.rumor;
|
let mut rumor = unwrapped.rumor;
|
||||||
|
|
||||||
// Generate event id for the rumor if it doesn't have one
|
// Generate event id for the rumor if it doesn't have one
|
||||||
rumor_unsigned.ensure_id();
|
rumor.ensure_id();
|
||||||
|
|
||||||
// Cache the rumor
|
// Cache the rumor
|
||||||
Self::set_rumor(client, gift_wrap.id, &rumor_unsigned).await?;
|
if let Err(e) = set_rumor(client, gift_wrap.id, &rumor).await {
|
||||||
|
log::error!("Failed to cache rumor: {e:?}");
|
||||||
|
}
|
||||||
|
|
||||||
Ok(rumor_unsigned)
|
Ok(rumor)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Helper method to try unwrapping with different signers
|
/// Helper method to try unwrapping with different signers
|
||||||
@@ -641,14 +736,14 @@ impl ChatRegistry {
|
|||||||
) -> Result<UnwrappedGift, Error> {
|
) -> Result<UnwrappedGift, Error> {
|
||||||
// Try with the device signer first
|
// Try with the device signer first
|
||||||
if let Some(signer) = device_signer {
|
if let Some(signer) = device_signer {
|
||||||
if let Ok(unwrapped) = Self::try_unwrap_with(gift_wrap, signer).await {
|
if let Ok(unwrapped) = try_unwrap_with(gift_wrap, signer).await {
|
||||||
return Ok(unwrapped);
|
return Ok(unwrapped);
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
// Try with the user's signer
|
// Try with the user's signer
|
||||||
let user_signer = client.signer().context("Signer not found")?;
|
let user_signer = client.signer().context("Signer not found")?;
|
||||||
let unwrapped = UnwrappedGift::from_gift_wrap(user_signer, gift_wrap).await?;
|
let unwrapped = try_unwrap_with(gift_wrap, user_signer).await?;
|
||||||
|
|
||||||
Ok(unwrapped)
|
Ok(unwrapped)
|
||||||
}
|
}
|
||||||
@@ -681,7 +776,7 @@ impl ChatRegistry {
|
|||||||
async fn set_rumor(client: &Client, id: EventId, rumor: &UnsignedEvent) -> Result<(), Error> {
|
async fn set_rumor(client: &Client, id: EventId, rumor: &UnsignedEvent) -> Result<(), Error> {
|
||||||
let rumor_id = rumor.id.context("Rumor is missing an event id")?;
|
let rumor_id = rumor.id.context("Rumor is missing an event id")?;
|
||||||
let author = rumor.pubkey;
|
let author = rumor.pubkey;
|
||||||
let conversation = Self::conversation_id(rumor);
|
let conversation = conversation_id(rumor);
|
||||||
|
|
||||||
let mut tags = rumor.tags.clone().to_vec();
|
let mut tags = rumor.tags.clone().to_vec();
|
||||||
|
|
||||||
@@ -751,4 +846,3 @@ impl ChatRegistry {
|
|||||||
|
|
||||||
hasher.finish()
|
hasher.finish()
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
use std::hash::Hash;
|
use std::hash::Hash;
|
||||||
|
use std::ops::Range;
|
||||||
|
|
||||||
use common::EventUtils;
|
use common::{EventUtils, NostrParser};
|
||||||
use nostr_sdk::prelude::*;
|
use nostr_sdk::prelude::*;
|
||||||
|
|
||||||
/// New message.
|
/// New message.
|
||||||
@@ -91,6 +92,18 @@ impl PartialOrd for Message {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct Mention {
|
||||||
|
pub public_key: PublicKey,
|
||||||
|
pub range: Range<usize>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Mention {
|
||||||
|
pub fn new(public_key: PublicKey, range: Range<usize>) -> Self {
|
||||||
|
Self { public_key, range }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Rendered message.
|
/// Rendered message.
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct RenderedMessage {
|
pub struct RenderedMessage {
|
||||||
@@ -102,7 +115,7 @@ pub struct RenderedMessage {
|
|||||||
/// Message created time as unix timestamp
|
/// Message created time as unix timestamp
|
||||||
pub created_at: Timestamp,
|
pub created_at: Timestamp,
|
||||||
/// List of mentioned public keys in the message
|
/// List of mentioned public keys in the message
|
||||||
pub mentions: Vec<PublicKey>,
|
pub mentions: Vec<Mention>,
|
||||||
/// List of event of the message this message is a reply to
|
/// List of event of the message this message is a reply to
|
||||||
pub replies_to: Vec<EventId>,
|
pub replies_to: Vec<EventId>,
|
||||||
}
|
}
|
||||||
@@ -184,20 +197,17 @@ impl Hash for RenderedMessage {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Extracts all mentions (public keys) from a content string.
|
/// Extracts all mentions (public keys) from a content string.
|
||||||
fn extract_mentions(content: &str) -> Vec<PublicKey> {
|
fn extract_mentions(content: &str) -> Vec<Mention> {
|
||||||
let parser = NostrParser::new();
|
let parser = NostrParser::new();
|
||||||
let tokens = parser.parse(content);
|
let tokens = parser.parse(content);
|
||||||
|
|
||||||
tokens
|
tokens
|
||||||
.filter_map(|token| match token {
|
.filter_map(|token| match token.value {
|
||||||
Token::Nostr(nip21) => match nip21 {
|
Nip21::Pubkey(public_key) => Some(Mention::new(public_key, token.range)),
|
||||||
Nip21::Pubkey(pubkey) => Some(pubkey),
|
Nip21::Profile(profile) => Some(Mention::new(profile.public_key, token.range)),
|
||||||
Nip21::Profile(profile) => Some(profile.public_key),
|
|
||||||
_ => None,
|
|
||||||
},
|
|
||||||
_ => None,
|
_ => None,
|
||||||
})
|
})
|
||||||
.collect::<Vec<_>>()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Extracts all reply (ids) from the event tags.
|
/// Extracts all reply (ids) from the event tags.
|
||||||
|
|||||||
@@ -1,17 +1,21 @@
|
|||||||
use std::cmp::Ordering;
|
use std::cmp::Ordering;
|
||||||
|
use std::collections::HashMap;
|
||||||
use std::hash::{Hash, Hasher};
|
use std::hash::{Hash, Hasher};
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use anyhow::{Context as AnyhowContext, Error};
|
use anyhow::Error;
|
||||||
use common::EventUtils;
|
use common::EventUtils;
|
||||||
use gpui::{App, AppContext, Context, EventEmitter, SharedString, Task};
|
use gpui::{App, AppContext, Context, EventEmitter, SharedString, Task};
|
||||||
use itertools::Itertools;
|
use itertools::Itertools;
|
||||||
use nostr_sdk::prelude::*;
|
use nostr_sdk::prelude::*;
|
||||||
use person::{Person, PersonRegistry};
|
use person::{Person, PersonRegistry};
|
||||||
use settings::{RoomConfig, SignerKind};
|
use settings::{RoomConfig, SignerKind};
|
||||||
use state::{NostrRegistry, TIMEOUT};
|
use state::{NostrRegistry, BOOTSTRAP_RELAYS, TIMEOUT};
|
||||||
|
|
||||||
use crate::{ChatRegistry, NewMessage};
|
use crate::NewMessage;
|
||||||
|
|
||||||
|
const NO_DEKEY: &str = "User hasn't set up a decoupled encryption key yet.";
|
||||||
|
const USER_NO_DEKEY: &str = "You haven't set up a decoupled encryption key or it's not available.";
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct SendReport {
|
pub struct SendReport {
|
||||||
@@ -149,7 +153,7 @@ impl From<&UnsignedEvent> for Room {
|
|||||||
subject,
|
subject,
|
||||||
members,
|
members,
|
||||||
kind: RoomKind::default(),
|
kind: RoomKind::default(),
|
||||||
config: RoomConfig::default(),
|
config: RoomConfig::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -222,6 +226,23 @@ impl Room {
|
|||||||
cx.notify();
|
cx.notify();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Updates the signer kind config for the room
|
||||||
|
pub fn set_signer_kind(&mut self, kind: &SignerKind, cx: &mut Context<Self>) {
|
||||||
|
self.config.set_signer_kind(kind);
|
||||||
|
cx.notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Updates the backup config for the room
|
||||||
|
pub fn set_backup(&mut self, cx: &mut Context<Self>) {
|
||||||
|
self.config.toggle_backup();
|
||||||
|
cx.notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the config of the room
|
||||||
|
pub fn config(&self) -> &RoomConfig {
|
||||||
|
&self.config
|
||||||
|
}
|
||||||
|
|
||||||
/// Returns the members of the room
|
/// Returns the members of the room
|
||||||
pub fn members(&self) -> Vec<PublicKey> {
|
pub fn members(&self) -> Vec<PublicKey> {
|
||||||
self.members.clone()
|
self.members.clone()
|
||||||
@@ -296,10 +317,6 @@ impl Room {
|
|||||||
|
|
||||||
if new_message {
|
if new_message {
|
||||||
self.set_created_at(created_at, cx);
|
self.set_created_at(created_at, cx);
|
||||||
// Sort chats after emitting a new message
|
|
||||||
ChatRegistry::global(cx).update(cx, |this, cx| {
|
|
||||||
this.sort(cx);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -309,45 +326,49 @@ impl Room {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Get gossip relays for each member
|
/// Get gossip relays for each member
|
||||||
pub fn early_connect(&self, cx: &App) -> Task<Result<(), Error>> {
|
pub fn connect(&self, cx: &App) -> Task<Result<(), Error>> {
|
||||||
let nostr = NostrRegistry::global(cx);
|
let nostr = NostrRegistry::global(cx);
|
||||||
let client = nostr.read(cx).client();
|
let client = nostr.read(cx).client();
|
||||||
|
|
||||||
let members = self.members();
|
let signer = nostr.read(cx).signer();
|
||||||
let subscription_id = SubscriptionId::new(format!("room-{}", self.id));
|
let sender = signer.public_key();
|
||||||
|
|
||||||
|
// Get room's id
|
||||||
|
let id = self.id;
|
||||||
|
|
||||||
|
// Get all members, excluding the sender
|
||||||
|
let members: Vec<PublicKey> = self
|
||||||
|
.members
|
||||||
|
.iter()
|
||||||
|
.filter(|public_key| Some(**public_key) != sender)
|
||||||
|
.copied()
|
||||||
|
.collect();
|
||||||
|
|
||||||
cx.background_spawn(async move {
|
cx.background_spawn(async move {
|
||||||
let signer = client.signer().context("Signer not found")?;
|
let id = SubscriptionId::new(format!("room-{id}"));
|
||||||
let public_key = signer.get_public_key().await?;
|
let opts = SubscribeAutoCloseOptions::default()
|
||||||
|
.exit_policy(ReqExitPolicy::ExitOnEOSE)
|
||||||
|
.timeout(Some(Duration::from_secs(TIMEOUT)));
|
||||||
|
|
||||||
for member in members.into_iter() {
|
// Construct filters for each member
|
||||||
if member == public_key {
|
let filters: Vec<Filter> = members
|
||||||
continue;
|
.into_iter()
|
||||||
};
|
.map(|public_key| {
|
||||||
|
Filter::new()
|
||||||
|
.author(public_key)
|
||||||
|
.kind(Kind::RelayList)
|
||||||
|
.limit(1)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
// Construct a filter for messaging relays
|
// Construct target for subscription
|
||||||
let inbox = Filter::new()
|
let target: HashMap<&str, Vec<Filter>> = BOOTSTRAP_RELAYS
|
||||||
.kind(Kind::InboxRelays)
|
.into_iter()
|
||||||
.author(member)
|
.map(|relay| (relay, filters.clone()))
|
||||||
.limit(1);
|
.collect();
|
||||||
|
|
||||||
// Construct a filter for announcement
|
// Subscribe to the target
|
||||||
let announcement = Filter::new()
|
client.subscribe(target).close_on(opts).with_id(id).await?;
|
||||||
.kind(Kind::Custom(10044))
|
|
||||||
.author(member)
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
// Subscribe to get member's gossip relays
|
|
||||||
client
|
|
||||||
.subscribe(vec![inbox, announcement])
|
|
||||||
.with_id(subscription_id.clone())
|
|
||||||
.close_on(
|
|
||||||
SubscribeAutoCloseOptions::default()
|
|
||||||
.timeout(Some(Duration::from_secs(TIMEOUT)))
|
|
||||||
.exit_policy(ReqExitPolicy::ExitOnEOSE),
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
})
|
})
|
||||||
@@ -393,7 +414,7 @@ impl Room {
|
|||||||
// Get current user's public key
|
// Get current user's public key
|
||||||
let sender = nostr.read(cx).signer().public_key()?;
|
let sender = nostr.read(cx).signer().public_key()?;
|
||||||
|
|
||||||
// Get all members
|
// Get all members, excluding the sender
|
||||||
let members: Vec<Person> = self
|
let members: Vec<Person> = self
|
||||||
.members
|
.members
|
||||||
.iter()
|
.iter()
|
||||||
@@ -418,11 +439,6 @@ impl Room {
|
|||||||
|
|
||||||
// Add all receiver tags
|
// Add all receiver tags
|
||||||
for member in members.into_iter() {
|
for member in members.into_iter() {
|
||||||
// Skip current user
|
|
||||||
if member.public_key() == sender {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
tags.push(Tag::from_standardized_without_cell(
|
tags.push(Tag::from_standardized_without_cell(
|
||||||
TagStandard::PublicKey {
|
TagStandard::PublicKey {
|
||||||
public_key: member.public_key(),
|
public_key: member.public_key(),
|
||||||
@@ -445,61 +461,59 @@ impl Room {
|
|||||||
|
|
||||||
/// Send rumor event to all members's messaging relays
|
/// Send rumor event to all members's messaging relays
|
||||||
pub fn send(&self, rumor: UnsignedEvent, cx: &App) -> Option<Task<Vec<SendReport>>> {
|
pub fn send(&self, rumor: UnsignedEvent, cx: &App) -> Option<Task<Vec<SendReport>>> {
|
||||||
|
let config = self.config.clone();
|
||||||
let persons = PersonRegistry::global(cx);
|
let persons = PersonRegistry::global(cx);
|
||||||
let nostr = NostrRegistry::global(cx);
|
let nostr = NostrRegistry::global(cx);
|
||||||
let client = nostr.read(cx).client();
|
let client = nostr.read(cx).client();
|
||||||
let signer = nostr.read(cx).signer();
|
let signer = nostr.read(cx).signer();
|
||||||
|
|
||||||
// Get room's config
|
|
||||||
let config = self.config.clone();
|
|
||||||
|
|
||||||
// Get current user's public key
|
// Get current user's public key
|
||||||
let sender = nostr.read(cx).signer().public_key()?;
|
let public_key = nostr.read(cx).signer().public_key()?;
|
||||||
|
let sender = persons.read(cx).get(&public_key, cx);
|
||||||
|
|
||||||
// Get all members (excluding sender)
|
// Get all members (excluding sender)
|
||||||
let members: Vec<Person> = self
|
let members: Vec<Person> = self
|
||||||
.members
|
.members
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|public_key| public_key != &&sender)
|
.filter(|public_key| public_key != &&sender.public_key())
|
||||||
.map(|member| persons.read(cx).get(member, cx))
|
.map(|member| persons.read(cx).get(member, cx))
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
Some(cx.background_spawn(async move {
|
Some(cx.background_spawn(async move {
|
||||||
let signer_kind = config.signer_kind();
|
let signer_kind = config.signer_kind();
|
||||||
|
let backup = config.backup();
|
||||||
|
|
||||||
let user_signer = signer.get().await;
|
let user_signer = signer.get().await;
|
||||||
let encryption_signer = signer.get_encryption_signer().await;
|
let encryption_signer = signer.get_encryption_signer().await;
|
||||||
|
|
||||||
|
let mut sents = 0;
|
||||||
let mut reports = Vec::new();
|
let mut reports = Vec::new();
|
||||||
|
|
||||||
|
// Process each member
|
||||||
for member in members {
|
for member in members {
|
||||||
let relays = member.messaging_relays();
|
let relays = member.messaging_relays();
|
||||||
let announcement = member.announcement();
|
let announcement = member.announcement();
|
||||||
|
let public_key = member.public_key();
|
||||||
|
|
||||||
// Skip if member has no messaging relays
|
|
||||||
if relays.is_empty() {
|
if relays.is_empty() {
|
||||||
reports.push(SendReport::new(member.public_key()).error("No messaging relays"));
|
reports.push(SendReport::new(public_key).error("No messaging relays"));
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ensure relay connections
|
// Handle encryption signer requirements
|
||||||
for url in relays.iter() {
|
if signer_kind.encryption() {
|
||||||
client
|
if announcement.is_none() {
|
||||||
.add_relay(url)
|
reports.push(SendReport::new(public_key).error(NO_DEKEY));
|
||||||
.and_connect()
|
|
||||||
.capabilities(RelayCapabilities::GOSSIP)
|
|
||||||
.await
|
|
||||||
.ok();
|
|
||||||
}
|
|
||||||
|
|
||||||
// When forced to use encryption signer, skip if receiver has no announcement
|
|
||||||
if signer_kind.encryption() && announcement.is_none() {
|
|
||||||
reports
|
|
||||||
.push(SendReport::new(member.public_key()).error("Encryption not found"));
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
if encryption_signer.is_none() {
|
||||||
|
reports.push(SendReport::new(sender.public_key()).error(USER_NO_DEKEY));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Determine receiver and signer based on signer kind
|
// Determine receiver and signer
|
||||||
let (receiver, signer_to_use) = match signer_kind {
|
let (receiver, signer) = match signer_kind {
|
||||||
SignerKind::Auto => {
|
SignerKind::Auto => {
|
||||||
if let Some(announcement) = announcement {
|
if let Some(announcement) = announcement {
|
||||||
if let Some(enc_signer) = encryption_signer.as_ref() {
|
if let Some(enc_signer) = encryption_signer.as_ref() {
|
||||||
@@ -512,26 +526,63 @@ impl Room {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
SignerKind::Encryption => {
|
SignerKind::Encryption => {
|
||||||
let Some(encryption_signer) = encryption_signer.as_ref() else {
|
// Safe to unwrap due to earlier checks
|
||||||
reports.push(
|
(
|
||||||
SendReport::new(member.public_key()).error("Encryption not found"),
|
announcement.unwrap().public_key(),
|
||||||
);
|
encryption_signer.as_ref().unwrap().clone(),
|
||||||
continue;
|
)
|
||||||
};
|
|
||||||
let Some(announcement) = announcement else {
|
|
||||||
reports.push(
|
|
||||||
SendReport::new(member.public_key())
|
|
||||||
.error("Announcement not found"),
|
|
||||||
);
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
(announcement.public_key(), encryption_signer.clone())
|
|
||||||
}
|
}
|
||||||
SignerKind::User => (member.public_key(), user_signer.clone()),
|
SignerKind::User => (member.public_key(), user_signer.clone()),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Create and send gift-wrapped event
|
match send_gift_wrap(&client, &signer, &receiver, &rumor, relays, public_key).await
|
||||||
match EventBuilder::gift_wrap(&signer_to_use, &receiver, rumor.clone(), []).await {
|
{
|
||||||
|
Ok((report, _)) => {
|
||||||
|
reports.push(report);
|
||||||
|
sents += 1;
|
||||||
|
}
|
||||||
|
Err(report) => {
|
||||||
|
reports.push(report);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send backup to current user if needed
|
||||||
|
if backup && sents >= 1 {
|
||||||
|
let relays = sender.messaging_relays();
|
||||||
|
let public_key = sender.public_key();
|
||||||
|
let signer = encryption_signer.as_ref().unwrap_or(&user_signer);
|
||||||
|
|
||||||
|
match send_gift_wrap(&client, signer, &public_key, &rumor, relays, public_key).await
|
||||||
|
{
|
||||||
|
Ok((report, _)) => reports.push(report),
|
||||||
|
Err(report) => reports.push(report),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
reports
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to send a gift-wrapped event
|
||||||
|
async fn send_gift_wrap<T>(
|
||||||
|
client: &Client,
|
||||||
|
signer: &T,
|
||||||
|
receiver: &PublicKey,
|
||||||
|
rumor: &UnsignedEvent,
|
||||||
|
relays: &[RelayUrl],
|
||||||
|
public_key: PublicKey,
|
||||||
|
) -> Result<(SendReport, bool), SendReport>
|
||||||
|
where
|
||||||
|
T: NostrSigner + 'static,
|
||||||
|
{
|
||||||
|
// Ensure relay connections
|
||||||
|
for url in relays {
|
||||||
|
client.add_relay(url).and_connect().await.ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
match EventBuilder::gift_wrap(signer, receiver, rumor.clone(), []).await {
|
||||||
Ok(event) => {
|
Ok(event) => {
|
||||||
match client
|
match client
|
||||||
.send_event(&event)
|
.send_event(&event)
|
||||||
@@ -539,245 +590,15 @@ impl Room {
|
|||||||
.ack_policy(AckPolicy::none())
|
.ack_policy(AckPolicy::none())
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
Ok(output) => {
|
Ok(output) => Ok((
|
||||||
reports.push(
|
SendReport::new(public_key)
|
||||||
SendReport::new(member.public_key())
|
|
||||||
.gift_wrap_id(event.id)
|
.gift_wrap_id(event.id)
|
||||||
.output(output),
|
.output(output),
|
||||||
);
|
true,
|
||||||
}
|
)),
|
||||||
Err(e) => {
|
Err(e) => Err(SendReport::new(public_key).error(e.to_string())),
|
||||||
reports.push(
|
|
||||||
SendReport::new(member.public_key()).error(e.to_string()),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
Err(e) => Err(SendReport::new(public_key).error(e.to_string())),
|
||||||
Err(e) => {
|
|
||||||
reports.push(SendReport::new(member.public_key()).error(e.to_string()));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
reports
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
/*
|
|
||||||
* /// Create a new unsigned message event
|
|
||||||
pub fn create_message(
|
|
||||||
&self,
|
|
||||||
content: &str,
|
|
||||||
replies: Vec<EventId>,
|
|
||||||
cx: &App,
|
|
||||||
) -> Task<Result<UnsignedEvent, Error>> {
|
|
||||||
let nostr = NostrRegistry::global(cx);
|
|
||||||
let client = nostr.read(cx).client();
|
|
||||||
|
|
||||||
let subject = self.subject.clone();
|
|
||||||
let content = content.to_string();
|
|
||||||
|
|
||||||
let mut member_and_relay_hints = HashMap::new();
|
|
||||||
|
|
||||||
// Populate the hashmap with member and relay hint tasks
|
|
||||||
for member in self.members.iter() {
|
|
||||||
let hint = nostr.read(cx).relay_hint(member, cx);
|
|
||||||
member_and_relay_hints.insert(member.to_owned(), hint);
|
|
||||||
}
|
|
||||||
|
|
||||||
cx.background_spawn(async move {
|
|
||||||
let signer = client.signer().context("Signer not found")?;
|
|
||||||
let public_key = signer.get_public_key().await?;
|
|
||||||
|
|
||||||
// List of event tags for each receiver
|
|
||||||
let mut tags = vec![];
|
|
||||||
|
|
||||||
for (member, task) in member_and_relay_hints.into_iter() {
|
|
||||||
// Skip current user
|
|
||||||
if member == public_key {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get relay hint if available
|
|
||||||
let relay_url = task.await;
|
|
||||||
|
|
||||||
// Construct a public key tag with relay hint
|
|
||||||
let tag = TagStandard::PublicKey {
|
|
||||||
public_key: member,
|
|
||||||
relay_url,
|
|
||||||
alias: None,
|
|
||||||
uppercase: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
tags.push(Tag::from_standardized_without_cell(tag));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add subject tag if present
|
|
||||||
if let Some(value) = subject {
|
|
||||||
tags.push(Tag::from_standardized_without_cell(TagStandard::Subject(
|
|
||||||
value.to_string(),
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add all reply tags
|
|
||||||
for id in replies {
|
|
||||||
tags.push(Tag::event(id))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Construct a direct message event
|
|
||||||
//
|
|
||||||
// WARNING: never sign and send this event to relays
|
|
||||||
let mut event = EventBuilder::new(Kind::PrivateDirectMessage, content)
|
|
||||||
.tags(tags)
|
|
||||||
.build(public_key);
|
|
||||||
|
|
||||||
// Ensure the event ID has been generated
|
|
||||||
event.ensure_id();
|
|
||||||
|
|
||||||
Ok(event)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Create a task to send a message to all room members
|
|
||||||
pub fn send_message(
|
|
||||||
&self,
|
|
||||||
rumor: &UnsignedEvent,
|
|
||||||
cx: &App,
|
|
||||||
) -> Task<Result<Vec<SendReport>, Error>> {
|
|
||||||
let nostr = NostrRegistry::global(cx);
|
|
||||||
let client = nostr.read(cx).client();
|
|
||||||
|
|
||||||
let mut members = self.members();
|
|
||||||
let rumor = rumor.to_owned();
|
|
||||||
|
|
||||||
cx.background_spawn(async move {
|
|
||||||
let signer = client.signer().context("Signer not found")?;
|
|
||||||
let current_user = signer.get_public_key().await?;
|
|
||||||
|
|
||||||
// Remove the current user's public key from the list of receivers
|
|
||||||
// the current user will be handled separately
|
|
||||||
members.retain(|this| this != ¤t_user);
|
|
||||||
|
|
||||||
// Collect the send reports
|
|
||||||
let mut reports: Vec<SendReport> = vec![];
|
|
||||||
|
|
||||||
for receiver in members.into_iter() {
|
|
||||||
// Construct the gift wrap event
|
|
||||||
let event =
|
|
||||||
EventBuilder::gift_wrap(signer, &receiver, rumor.clone(), vec![]).await?;
|
|
||||||
|
|
||||||
// Send the gift wrap event to the messaging relays
|
|
||||||
match client.send_event(&event).to_nip17().await {
|
|
||||||
Ok(output) => {
|
|
||||||
let id = output.id().to_owned();
|
|
||||||
let auth = output.failed.iter().any(|(_, s)| s.starts_with("auth-"));
|
|
||||||
let report = SendReport::new(receiver).status(output);
|
|
||||||
let tracker = tracker().read().await;
|
|
||||||
|
|
||||||
if auth {
|
|
||||||
// Wait for authenticated and resent event successfully
|
|
||||||
for attempt in 0..=SEND_RETRY {
|
|
||||||
// Check if event was successfully resent
|
|
||||||
if tracker.is_sent_by_coop(&id) {
|
|
||||||
let output = Output::new(id);
|
|
||||||
let report = SendReport::new(receiver).status(output);
|
|
||||||
reports.push(report);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if retry limit exceeded
|
|
||||||
if attempt == SEND_RETRY {
|
|
||||||
reports.push(report);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
smol::Timer::after(Duration::from_millis(1200)).await;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
reports.push(report);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
reports.push(SendReport::new(receiver).error(e.to_string()));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Construct the gift-wrapped event
|
|
||||||
let event =
|
|
||||||
EventBuilder::gift_wrap(signer, ¤t_user, rumor.clone(), vec![]).await?;
|
|
||||||
|
|
||||||
// Only send a backup message to current user if sent successfully to others
|
|
||||||
if reports.iter().all(|r| r.is_sent_success()) {
|
|
||||||
// Send the event to the messaging relays
|
|
||||||
match client.send_event(&event).to_nip17().await {
|
|
||||||
Ok(output) => {
|
|
||||||
reports.push(SendReport::new(current_user).status(output));
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
reports.push(SendReport::new(current_user).error(e.to_string()));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
reports.push(SendReport::new(current_user).on_hold(event));
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(reports)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Create a task to resend a failed message
|
|
||||||
pub fn resend_message(
|
|
||||||
&self,
|
|
||||||
reports: Vec<SendReport>,
|
|
||||||
cx: &App,
|
|
||||||
) -> Task<Result<Vec<SendReport>, Error>> {
|
|
||||||
let nostr = NostrRegistry::global(cx);
|
|
||||||
let client = nostr.read(cx).client();
|
|
||||||
|
|
||||||
cx.background_spawn(async move {
|
|
||||||
let mut resend_reports = vec![];
|
|
||||||
|
|
||||||
for report in reports.into_iter() {
|
|
||||||
let receiver = report.receiver;
|
|
||||||
|
|
||||||
// Process failed events
|
|
||||||
if let Some(output) = report.status {
|
|
||||||
let id = output.id();
|
|
||||||
let urls: Vec<&RelayUrl> = output.failed.keys().collect();
|
|
||||||
|
|
||||||
if let Some(event) = client.database().event_by_id(id).await? {
|
|
||||||
for url in urls.into_iter() {
|
|
||||||
let relay = client.relay(url).await?.context("Relay not found")?;
|
|
||||||
let id = relay.send_event(&event).await?;
|
|
||||||
|
|
||||||
let resent: Output<EventId> = Output {
|
|
||||||
val: id,
|
|
||||||
success: HashSet::from([url.to_owned()]),
|
|
||||||
failed: HashMap::new(),
|
|
||||||
};
|
|
||||||
|
|
||||||
resend_reports.push(SendReport::new(receiver).status(resent));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Process the on hold event if it exists
|
|
||||||
if let Some(event) = report.on_hold {
|
|
||||||
// Send the event to the messaging relays
|
|
||||||
match client.send_event(&event).await {
|
|
||||||
Ok(output) => {
|
|
||||||
resend_reports.push(SendReport::new(receiver).status(output));
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
resend_reports.push(SendReport::new(receiver).error(e.to_string()));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(resend_reports)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
*/
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -28,3 +28,5 @@ serde_json.workspace = true
|
|||||||
|
|
||||||
once_cell = "1.19.0"
|
once_cell = "1.19.0"
|
||||||
regex = "1"
|
regex = "1"
|
||||||
|
linkify = "0.10.0"
|
||||||
|
pulldown-cmark = "0.13.1"
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
use gpui::Action;
|
use gpui::Action;
|
||||||
use nostr_sdk::prelude::*;
|
use nostr_sdk::prelude::*;
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
|
use settings::SignerKind;
|
||||||
|
|
||||||
#[derive(Action, Clone, PartialEq, Eq, Deserialize)]
|
#[derive(Action, Clone, PartialEq, Eq, Deserialize)]
|
||||||
#[action(namespace = chat, no_json)]
|
#[action(namespace = chat, no_json)]
|
||||||
pub enum Command {
|
pub enum Command {
|
||||||
Insert(&'static str),
|
Insert(&'static str),
|
||||||
ChangeSubject(&'static str),
|
ChangeSubject(&'static str),
|
||||||
|
ChangeSigner(SignerKind),
|
||||||
|
ToggleBackup,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Action, Clone, PartialEq, Eq, Deserialize)]
|
#[derive(Action, Clone, PartialEq, Eq, Deserialize)]
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
use std::collections::{BTreeMap, BTreeSet, HashSet};
|
use std::collections::{BTreeMap, BTreeSet, HashSet};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
pub use actions::*;
|
pub use actions::*;
|
||||||
use anyhow::{Context as AnyhowContext, Error};
|
use anyhow::{Context as AnyhowContext, Error};
|
||||||
@@ -7,21 +8,19 @@ use chat::{Message, RenderedMessage, Room, RoomEvent, SendReport};
|
|||||||
use common::RenderedTimestamp;
|
use common::RenderedTimestamp;
|
||||||
use gpui::prelude::FluentBuilder;
|
use gpui::prelude::FluentBuilder;
|
||||||
use gpui::{
|
use gpui::{
|
||||||
deferred, div, img, list, px, red, relative, rems, svg, white, AnyElement, App, AppContext,
|
AnyElement, App, AppContext, ClipboardItem, Context, Entity, EventEmitter, FocusHandle,
|
||||||
ClipboardItem, Context, Entity, EventEmitter, FocusHandle, Focusable, InteractiveElement,
|
Focusable, InteractiveElement, IntoElement, ListAlignment, ListOffset, ListState, MouseButton,
|
||||||
IntoElement, ListAlignment, ListOffset, ListState, MouseButton, ObjectFit, ParentElement,
|
ObjectFit, ParentElement, PathPromptOptions, Render, SharedString, StatefulInteractiveElement,
|
||||||
PathPromptOptions, Render, SharedString, StatefulInteractiveElement, Styled, StyledImage,
|
Styled, StyledImage, Subscription, Task, WeakEntity, Window, deferred, div, img, list, px, red,
|
||||||
Subscription, Task, WeakEntity, Window,
|
relative, svg, white,
|
||||||
};
|
};
|
||||||
use gpui_tokio::Tokio;
|
|
||||||
use itertools::Itertools;
|
use itertools::Itertools;
|
||||||
use nostr_sdk::prelude::*;
|
use nostr_sdk::prelude::*;
|
||||||
use person::{Person, PersonRegistry};
|
use person::{Person, PersonRegistry};
|
||||||
use settings::AppSettings;
|
use settings::{AppSettings, SignerKind};
|
||||||
use smallvec::{smallvec, SmallVec};
|
use smallvec::{SmallVec, smallvec};
|
||||||
use smol::fs;
|
|
||||||
use smol::lock::RwLock;
|
use smol::lock::RwLock;
|
||||||
use state::{nostr_upload, NostrRegistry};
|
use state::{NostrRegistry, upload};
|
||||||
use theme::ActiveTheme;
|
use theme::ActiveTheme;
|
||||||
use ui::avatar::Avatar;
|
use ui::avatar::Avatar;
|
||||||
use ui::button::{Button, ButtonVariants};
|
use ui::button::{Button, ButtonVariants};
|
||||||
@@ -32,8 +31,8 @@ use ui::menu::{ContextMenuExt, DropdownMenu};
|
|||||||
use ui::notification::Notification;
|
use ui::notification::Notification;
|
||||||
use ui::scroll::Scrollbar;
|
use ui::scroll::Scrollbar;
|
||||||
use ui::{
|
use ui::{
|
||||||
h_flex, v_flex, Disableable, Icon, IconName, InteractiveElementExt, Sizable, StyledExt,
|
Disableable, Icon, IconName, InteractiveElementExt, Sizable, StyledExt, WindowExtension,
|
||||||
WindowExtension,
|
h_flex, v_flex,
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::text::RenderedText;
|
use crate::text::RenderedText;
|
||||||
@@ -41,6 +40,14 @@ use crate::text::RenderedText;
|
|||||||
mod actions;
|
mod actions;
|
||||||
mod text;
|
mod text;
|
||||||
|
|
||||||
|
const ANNOUNCEMENT: &str =
|
||||||
|
"This conversation is private. Only members can see each other's messages.";
|
||||||
|
const NO_INBOX: &str = "has not set up messaging relays. \
|
||||||
|
They will not receive messages you send.";
|
||||||
|
const NO_ANNOUNCEMENT: &str = "has not set up an encryption key. \
|
||||||
|
You cannot send messages encrypted with an encryption key to them yet. \
|
||||||
|
Coop automatically uses your identity to encrypt messages.";
|
||||||
|
|
||||||
pub fn init(room: WeakEntity<Room>, window: &mut Window, cx: &mut App) -> Entity<ChatPanel> {
|
pub fn init(room: WeakEntity<Room>, window: &mut Window, cx: &mut App) -> Entity<ChatPanel> {
|
||||||
cx.new(|cx| ChatPanel::new(room, window, cx))
|
cx.new(|cx| ChatPanel::new(room, window, cx))
|
||||||
}
|
}
|
||||||
@@ -131,7 +138,6 @@ impl ChatPanel {
|
|||||||
cx.defer_in(window, |this, window, cx| {
|
cx.defer_in(window, |this, window, cx| {
|
||||||
this.connect(window, cx);
|
this.connect(window, cx);
|
||||||
this.handle_notifications(cx);
|
this.handle_notifications(cx);
|
||||||
|
|
||||||
this.subscribe_room_events(window, cx);
|
this.subscribe_room_events(window, cx);
|
||||||
this.get_messages(window, cx);
|
this.get_messages(window, cx);
|
||||||
});
|
});
|
||||||
@@ -154,6 +160,49 @@ impl ChatPanel {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Get all necessary data for each member
|
||||||
|
fn connect(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||||
|
let Ok((members, connect)) = self
|
||||||
|
.room
|
||||||
|
.read_with(cx, |this, cx| (this.members(), this.connect(cx)))
|
||||||
|
else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Run the connect task in background
|
||||||
|
self.tasks.push(connect);
|
||||||
|
|
||||||
|
// Spawn another task to verify after 3 seconds
|
||||||
|
self.tasks.push(cx.spawn_in(window, async move |this, cx| {
|
||||||
|
cx.background_executor().timer(Duration::from_secs(3)).await;
|
||||||
|
|
||||||
|
// Verify the connection
|
||||||
|
this.update_in(cx, |this, _window, cx| {
|
||||||
|
let persons = PersonRegistry::global(cx);
|
||||||
|
|
||||||
|
for member in members.into_iter() {
|
||||||
|
let profile = persons.read(cx).get(&member, cx);
|
||||||
|
|
||||||
|
if profile.announcement().is_none() {
|
||||||
|
let content = format!("{} {}", profile.name(), NO_ANNOUNCEMENT);
|
||||||
|
let message = Message::warning(content);
|
||||||
|
|
||||||
|
this.insert_message(message, true, cx);
|
||||||
|
}
|
||||||
|
|
||||||
|
if profile.messaging_relays().is_empty() {
|
||||||
|
let content = format!("{} {}", profile.name(), NO_INBOX);
|
||||||
|
let message = Message::warning(content);
|
||||||
|
|
||||||
|
this.insert_message(message, true, cx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
/// Handle nostr notifications
|
/// Handle nostr notifications
|
||||||
fn handle_notifications(&mut self, cx: &mut Context<Self>) {
|
fn handle_notifications(&mut self, cx: &mut Context<Self>) {
|
||||||
let nostr = NostrRegistry::global(cx);
|
let nostr = NostrRegistry::global(cx);
|
||||||
@@ -224,15 +273,6 @@ impl ChatPanel {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get all necessary data for each member
|
|
||||||
fn connect(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
|
|
||||||
let Ok(connect) = self.room.read_with(cx, |this, cx| this.early_connect(cx)) else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
|
|
||||||
self.tasks.push(cx.background_spawn(connect));
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Load all messages belonging to this room
|
/// Load all messages belonging to this room
|
||||||
fn get_messages(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
|
fn get_messages(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
|
||||||
let Ok(get_messages) = self.room.read_with(cx, |this, cx| this.get_messages(cx)) else {
|
let Ok(get_messages) = self.room.read_with(cx, |this, cx| this.get_messages(cx)) else {
|
||||||
@@ -329,16 +369,19 @@ impl ChatPanel {
|
|||||||
// This can't fail, because we already ensured that the ID is set
|
// This can't fail, because we already ensured that the ID is set
|
||||||
let id = rumor.id.unwrap();
|
let id = rumor.id.unwrap();
|
||||||
|
|
||||||
|
// Upgrade room reference
|
||||||
let Some(room) = self.room.upgrade() else {
|
let Some(room) = self.room.upgrade() else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Get the send message task
|
||||||
let Some(task) = room.read(cx).send(rumor, cx) else {
|
let Some(task) = room.read(cx).send(rumor, cx) else {
|
||||||
window.push_notification("Failed to send message", cx);
|
window.push_notification("Failed to send message", cx);
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
|
||||||
self.tasks.push(cx.spawn_in(window, async move |this, cx| {
|
self.tasks.push(cx.spawn_in(window, async move |this, cx| {
|
||||||
|
// Send and get reports
|
||||||
let outputs = task.await;
|
let outputs = task.await;
|
||||||
|
|
||||||
// Add sent IDs to the list
|
// Add sent IDs to the list
|
||||||
@@ -485,12 +528,10 @@ impl ChatPanel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn upload(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
fn upload(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||||
let nostr = NostrRegistry::global(cx);
|
// Get the user's configured blossom server
|
||||||
let client = nostr.read(cx).client();
|
let server = AppSettings::get_file_server(cx);
|
||||||
|
|
||||||
// Get the user's configured NIP96 server
|
|
||||||
let nip96_server = AppSettings::get_file_server(cx);
|
|
||||||
|
|
||||||
|
// Ask user for file upload
|
||||||
let path = cx.prompt_for_paths(PathPromptOptions {
|
let path = cx.prompt_for_paths(PathPromptOptions {
|
||||||
files: true,
|
files: true,
|
||||||
directories: false,
|
directories: false,
|
||||||
@@ -499,34 +540,27 @@ impl ChatPanel {
|
|||||||
});
|
});
|
||||||
|
|
||||||
self.tasks.push(cx.spawn_in(window, async move |this, cx| {
|
self.tasks.push(cx.spawn_in(window, async move |this, cx| {
|
||||||
|
this.update(cx, |this, cx| {
|
||||||
|
this.set_uploading(true, cx);
|
||||||
|
})?;
|
||||||
|
|
||||||
let mut paths = path.await??.context("Not found")?;
|
let mut paths = path.await??.context("Not found")?;
|
||||||
let path = paths.pop().context("No path")?;
|
let path = paths.pop().context("No path")?;
|
||||||
|
|
||||||
let upload = Tokio::spawn(cx, async move {
|
// Upload via blossom client
|
||||||
let file = fs::read(path).await.ok()?;
|
match upload(server, path, cx).await {
|
||||||
let url = nostr_upload(&client, &nip96_server, file).await.ok()?;
|
Ok(url) => {
|
||||||
|
|
||||||
Some(url)
|
|
||||||
});
|
|
||||||
|
|
||||||
if let Ok(task) = upload.await {
|
|
||||||
this.update(cx, |this, cx| {
|
|
||||||
this.set_uploading(true, cx);
|
|
||||||
})
|
|
||||||
.ok();
|
|
||||||
|
|
||||||
this.update_in(cx, |this, _window, cx| {
|
this.update_in(cx, |this, _window, cx| {
|
||||||
match task {
|
|
||||||
Some(url) => {
|
|
||||||
this.add_attachment(url, cx);
|
this.add_attachment(url, cx);
|
||||||
this.set_uploading(false, cx);
|
this.set_uploading(false, cx);
|
||||||
|
})?;
|
||||||
}
|
}
|
||||||
None => {
|
Err(e) => {
|
||||||
|
this.update_in(cx, |this, window, cx| {
|
||||||
this.set_uploading(false, cx);
|
this.set_uploading(false, cx);
|
||||||
|
window.push_notification(Notification::error(e.to_string()), cx);
|
||||||
|
})?;
|
||||||
}
|
}
|
||||||
};
|
|
||||||
})
|
|
||||||
.ok();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -559,10 +593,48 @@ impl ChatPanel {
|
|||||||
persons.read(cx).get(public_key, cx)
|
persons.read(cx).get(public_key, cx)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn render_announcement(&self, ix: usize, cx: &Context<Self>) -> AnyElement {
|
fn on_command(&mut self, command: &Command, window: &mut Window, cx: &mut Context<Self>) {
|
||||||
const MSG: &str =
|
match command {
|
||||||
"This conversation is private. Only members can see each other's messages.";
|
Command::Insert(content) => {
|
||||||
|
self.send_message(content, window, cx);
|
||||||
|
}
|
||||||
|
Command::ChangeSubject(subject) => {
|
||||||
|
if self
|
||||||
|
.room
|
||||||
|
.update(cx, |this, cx| {
|
||||||
|
this.set_subject(*subject, cx);
|
||||||
|
})
|
||||||
|
.is_err()
|
||||||
|
{
|
||||||
|
window.push_notification(Notification::error("Failed to change subject"), cx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Command::ChangeSigner(kind) => {
|
||||||
|
if self
|
||||||
|
.room
|
||||||
|
.update(cx, |this, cx| {
|
||||||
|
this.set_signer_kind(kind, cx);
|
||||||
|
})
|
||||||
|
.is_err()
|
||||||
|
{
|
||||||
|
window.push_notification(Notification::error("Failed to change signer"), cx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Command::ToggleBackup => {
|
||||||
|
if self
|
||||||
|
.room
|
||||||
|
.update(cx, |this, cx| {
|
||||||
|
this.set_backup(cx);
|
||||||
|
})
|
||||||
|
.is_err()
|
||||||
|
{
|
||||||
|
window.push_notification(Notification::error("Failed to toggle backup"), cx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_announcement(&self, ix: usize, cx: &Context<Self>) -> AnyElement {
|
||||||
v_flex()
|
v_flex()
|
||||||
.id(ix)
|
.id(ix)
|
||||||
.h_40()
|
.h_40()
|
||||||
@@ -581,34 +653,39 @@ impl ChatPanel {
|
|||||||
.size_12()
|
.size_12()
|
||||||
.text_color(cx.theme().ghost_element_active),
|
.text_color(cx.theme().ghost_element_active),
|
||||||
)
|
)
|
||||||
.child(SharedString::from(MSG))
|
.child(SharedString::from(ANNOUNCEMENT))
|
||||||
.into_any_element()
|
.into_any_element()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn render_warning(&self, ix: usize, content: SharedString, cx: &Context<Self>) -> AnyElement {
|
fn render_warning(&self, ix: usize, content: SharedString, cx: &Context<Self>) -> AnyElement {
|
||||||
div()
|
div()
|
||||||
.id(ix)
|
.id(ix)
|
||||||
.relative()
|
|
||||||
.w_full()
|
.w_full()
|
||||||
.py_1()
|
.py_2()
|
||||||
.px_3()
|
.px_3()
|
||||||
.bg(cx.theme().warning_background)
|
|
||||||
.child(
|
.child(
|
||||||
h_flex()
|
h_flex()
|
||||||
|
.w_full()
|
||||||
.gap_3()
|
.gap_3()
|
||||||
.text_sm()
|
.text_sm()
|
||||||
|
.child(
|
||||||
|
h_flex()
|
||||||
|
.flex_shrink_0()
|
||||||
|
.size_8()
|
||||||
|
.justify_center()
|
||||||
|
.rounded_full()
|
||||||
|
.bg(cx.theme().warning_background)
|
||||||
.text_color(cx.theme().warning_foreground)
|
.text_color(cx.theme().warning_foreground)
|
||||||
.child(Avatar::new("brand/system.png").size(rems(2.)))
|
.child(Icon::new(IconName::Warning).small()),
|
||||||
.child(content),
|
|
||||||
)
|
)
|
||||||
.child(
|
.child(
|
||||||
div()
|
div()
|
||||||
.absolute()
|
.flex_1()
|
||||||
.left_0()
|
.w_full()
|
||||||
.top_0()
|
.flex_initial()
|
||||||
.w(px(2.))
|
.overflow_hidden()
|
||||||
.h_full()
|
.child(content),
|
||||||
.bg(cx.theme().warning_active),
|
),
|
||||||
)
|
)
|
||||||
.into_any_element()
|
.into_any_element()
|
||||||
}
|
}
|
||||||
@@ -622,10 +699,13 @@ impl ChatPanel {
|
|||||||
if let Some(message) = self.messages.iter().nth(ix) {
|
if let Some(message) = self.messages.iter().nth(ix) {
|
||||||
match message {
|
match message {
|
||||||
Message::User(rendered) => {
|
Message::User(rendered) => {
|
||||||
|
let persons = PersonRegistry::global(cx);
|
||||||
let text = self
|
let text = self
|
||||||
.rendered_texts_by_id
|
.rendered_texts_by_id
|
||||||
.entry(rendered.id)
|
.entry(rendered.id)
|
||||||
.or_insert_with(|| RenderedText::new(&rendered.content, cx))
|
.or_insert_with(|| {
|
||||||
|
RenderedText::new(&rendered.content, &rendered.mentions, &persons, cx)
|
||||||
|
})
|
||||||
.element(ix.into(), window, cx);
|
.element(ix.into(), window, cx);
|
||||||
|
|
||||||
self.render_text_message(ix, rendered, text, cx)
|
self.render_text_message(ix, rendered, text, cx)
|
||||||
@@ -681,7 +761,7 @@ impl ChatPanel {
|
|||||||
this.child(
|
this.child(
|
||||||
div()
|
div()
|
||||||
.id(SharedString::from(format!("{ix}-avatar")))
|
.id(SharedString::from(format!("{ix}-avatar")))
|
||||||
.child(Avatar::new(author.avatar()).size(rems(2.)))
|
.child(Avatar::new(author.avatar()))
|
||||||
.context_menu(move |this, _window, _cx| {
|
.context_menu(move |this, _window, _cx| {
|
||||||
let view = Box::new(OpenPublicKey(public_key));
|
let view = Box::new(OpenPublicKey(public_key));
|
||||||
let copy = Box::new(CopyPublicKey(public_key));
|
let copy = Box::new(CopyPublicKey(public_key));
|
||||||
@@ -799,7 +879,7 @@ impl ChatPanel {
|
|||||||
window.open_modal(cx, move |this, _window, cx| {
|
window.open_modal(cx, move |this, _window, cx| {
|
||||||
this.show_close(true)
|
this.show_close(true)
|
||||||
.title(SharedString::from("Sent Reports"))
|
.title(SharedString::from("Sent Reports"))
|
||||||
.child(v_flex().pb_4().gap_4().children({
|
.child(v_flex().pb_2().gap_4().children({
|
||||||
let mut items = Vec::with_capacity(reports.len());
|
let mut items = Vec::with_capacity(reports.len());
|
||||||
|
|
||||||
for report in reports.iter() {
|
for report in reports.iter() {
|
||||||
@@ -817,7 +897,7 @@ impl ChatPanel {
|
|||||||
h_flex()
|
h_flex()
|
||||||
.id(SharedString::from(id.to_hex()))
|
.id(SharedString::from(id.to_hex()))
|
||||||
.gap_0p5()
|
.gap_0p5()
|
||||||
.text_color(cx.theme().danger_foreground)
|
.text_color(cx.theme().danger_active)
|
||||||
.text_xs()
|
.text_xs()
|
||||||
.italic()
|
.italic()
|
||||||
.child(Icon::new(IconName::Info).xsmall())
|
.child(Icon::new(IconName::Info).xsmall())
|
||||||
@@ -863,7 +943,7 @@ impl ChatPanel {
|
|||||||
h_flex()
|
h_flex()
|
||||||
.gap_1()
|
.gap_1()
|
||||||
.font_semibold()
|
.font_semibold()
|
||||||
.child(Avatar::new(avatar).size(rems(1.25)))
|
.child(Avatar::new(avatar).small())
|
||||||
.child(name.clone()),
|
.child(name.clone()),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -872,13 +952,13 @@ impl ChatPanel {
|
|||||||
h_flex()
|
h_flex()
|
||||||
.flex_wrap()
|
.flex_wrap()
|
||||||
.justify_center()
|
.justify_center()
|
||||||
.p_2()
|
.p_1()
|
||||||
.h_20()
|
.h_16()
|
||||||
.w_full()
|
.w_full()
|
||||||
.text_sm()
|
.text_sm()
|
||||||
.rounded(cx.theme().radius)
|
.rounded(cx.theme().radius)
|
||||||
.bg(cx.theme().danger_background)
|
.bg(cx.theme().warning_background)
|
||||||
.text_color(cx.theme().danger_foreground)
|
.text_color(cx.theme().warning_foreground)
|
||||||
.child(div().flex_1().w_full().text_center().child(error)),
|
.child(div().flex_1().w_full().text_center().child(error)),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
@@ -894,11 +974,10 @@ impl ChatPanel {
|
|||||||
items.push(
|
items.push(
|
||||||
v_flex()
|
v_flex()
|
||||||
.gap_0p5()
|
.gap_0p5()
|
||||||
.py_1()
|
.p_1()
|
||||||
.px_2()
|
|
||||||
.w_full()
|
.w_full()
|
||||||
.rounded(cx.theme().radius)
|
.rounded(cx.theme().radius)
|
||||||
.bg(cx.theme().elevated_surface_background)
|
.bg(cx.theme().danger_background)
|
||||||
.child(
|
.child(
|
||||||
div()
|
div()
|
||||||
.text_xs()
|
.text_xs()
|
||||||
@@ -908,7 +987,7 @@ impl ChatPanel {
|
|||||||
)
|
)
|
||||||
.child(
|
.child(
|
||||||
div()
|
div()
|
||||||
.text_sm()
|
.text_xs()
|
||||||
.text_color(cx.theme().danger_foreground)
|
.text_color(cx.theme().danger_foreground)
|
||||||
.line_height(relative(1.25))
|
.line_height(relative(1.25))
|
||||||
.child(SharedString::from(msg.to_string())),
|
.child(SharedString::from(msg.to_string())),
|
||||||
@@ -925,8 +1004,7 @@ impl ChatPanel {
|
|||||||
items.push(
|
items.push(
|
||||||
v_flex()
|
v_flex()
|
||||||
.gap_0p5()
|
.gap_0p5()
|
||||||
.py_1()
|
.p_1()
|
||||||
.px_2()
|
|
||||||
.w_full()
|
.w_full()
|
||||||
.rounded(cx.theme().radius)
|
.rounded(cx.theme().radius)
|
||||||
.bg(cx.theme().elevated_surface_background)
|
.bg(cx.theme().elevated_surface_background)
|
||||||
@@ -939,8 +1017,7 @@ impl ChatPanel {
|
|||||||
)
|
)
|
||||||
.child(
|
.child(
|
||||||
div()
|
div()
|
||||||
.text_sm()
|
.text_xs()
|
||||||
.text_color(cx.theme().secondary_foreground)
|
|
||||||
.line_height(relative(1.25))
|
.line_height(relative(1.25))
|
||||||
.child(SharedString::from("Successfully")),
|
.child(SharedString::from("Successfully")),
|
||||||
),
|
),
|
||||||
@@ -1133,23 +1210,66 @@ impl ChatPanel {
|
|||||||
items
|
items
|
||||||
}
|
}
|
||||||
|
|
||||||
fn on_command(&mut self, command: &Command, window: &mut Window, cx: &mut Context<Self>) {
|
fn render_config_menu(&self, _window: &mut Window, cx: &Context<Self>) -> impl IntoElement {
|
||||||
match command {
|
let (backup, signer_kind) = self
|
||||||
Command::Insert(content) => {
|
|
||||||
self.send_message(content, window, cx);
|
|
||||||
}
|
|
||||||
Command::ChangeSubject(subject) => {
|
|
||||||
if self
|
|
||||||
.room
|
.room
|
||||||
.update(cx, |this, cx| {
|
.read_with(cx, |this, _cx| {
|
||||||
this.set_subject(*subject, cx);
|
(this.config().backup(), this.config().signer_kind().clone())
|
||||||
|
})
|
||||||
|
.ok()
|
||||||
|
.unwrap_or((true, SignerKind::default()));
|
||||||
|
|
||||||
|
Button::new("encryption")
|
||||||
|
.icon(IconName::Settings2)
|
||||||
|
.tooltip("Configuration")
|
||||||
|
.ghost()
|
||||||
|
.large()
|
||||||
|
.dropdown_menu(move |this, _window, _cx| {
|
||||||
|
let auto = matches!(signer_kind, SignerKind::Auto);
|
||||||
|
let encryption = matches!(signer_kind, SignerKind::Encryption);
|
||||||
|
let user = matches!(signer_kind, SignerKind::User);
|
||||||
|
|
||||||
|
this.label("Signer")
|
||||||
|
.menu_with_check_and_disabled(
|
||||||
|
"Auto",
|
||||||
|
auto,
|
||||||
|
Box::new(Command::ChangeSigner(SignerKind::Auto)),
|
||||||
|
auto,
|
||||||
|
)
|
||||||
|
.menu_with_check_and_disabled(
|
||||||
|
"Decoupled Encryption Key",
|
||||||
|
encryption,
|
||||||
|
Box::new(Command::ChangeSigner(SignerKind::Encryption)),
|
||||||
|
encryption,
|
||||||
|
)
|
||||||
|
.menu_with_check_and_disabled(
|
||||||
|
"User Identity",
|
||||||
|
user,
|
||||||
|
Box::new(Command::ChangeSigner(SignerKind::User)),
|
||||||
|
user,
|
||||||
|
)
|
||||||
|
.separator()
|
||||||
|
.label("Backup")
|
||||||
|
.menu_with_check("Backup messages", backup, Box::new(Command::ToggleBackup))
|
||||||
})
|
})
|
||||||
.is_err()
|
|
||||||
{
|
|
||||||
window.push_notification(Notification::error("Failed to change subject"), cx);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn render_emoji_menu(&self, _window: &Window, _cx: &Context<Self>) -> impl IntoElement {
|
||||||
|
Button::new("emoji")
|
||||||
|
.icon(IconName::Emoji)
|
||||||
|
.ghost()
|
||||||
|
.large()
|
||||||
|
.dropdown_menu_with_anchor(gpui::Corner::BottomLeft, move |this, _window, _cx| {
|
||||||
|
this.horizontal()
|
||||||
|
.menu("👍", Box::new(Command::Insert("👍")))
|
||||||
|
.menu("👎", Box::new(Command::Insert("👎")))
|
||||||
|
.menu("😄", Box::new(Command::Insert("😄")))
|
||||||
|
.menu("🎉", Box::new(Command::Insert("🎉")))
|
||||||
|
.menu("😕", Box::new(Command::Insert("😕")))
|
||||||
|
.menu("❤️", Box::new(Command::Insert("❤️")))
|
||||||
|
.menu("🚀", Box::new(Command::Insert("🚀")))
|
||||||
|
.menu("👀", Box::new(Command::Insert("👀")))
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1166,7 +1286,7 @@ impl Panel for ChatPanel {
|
|||||||
|
|
||||||
h_flex()
|
h_flex()
|
||||||
.gap_1p5()
|
.gap_1p5()
|
||||||
.child(Avatar::new(url).size(rems(1.25)))
|
.child(Avatar::new(url).small())
|
||||||
.child(label)
|
.child(label)
|
||||||
.into_any_element()
|
.into_any_element()
|
||||||
})
|
})
|
||||||
@@ -1188,9 +1308,9 @@ impl Render for ChatPanel {
|
|||||||
.on_action(cx.listener(Self::on_command))
|
.on_action(cx.listener(Self::on_command))
|
||||||
.size_full()
|
.size_full()
|
||||||
.child(
|
.child(
|
||||||
div()
|
v_flex()
|
||||||
.flex_1()
|
.flex_1()
|
||||||
.size_full()
|
.relative()
|
||||||
.child(
|
.child(
|
||||||
list(
|
list(
|
||||||
self.list_state.clone(),
|
self.list_state.clone(),
|
||||||
@@ -1228,33 +1348,15 @@ impl Render for ChatPanel {
|
|||||||
.child(
|
.child(
|
||||||
TextInput::new(&self.input)
|
TextInput::new(&self.input)
|
||||||
.appearance(false)
|
.appearance(false)
|
||||||
.flex_1()
|
.text_sm()
|
||||||
.text_sm(),
|
.flex_1(),
|
||||||
)
|
)
|
||||||
.child(
|
.child(
|
||||||
h_flex()
|
h_flex()
|
||||||
.pl_1()
|
.pl_1()
|
||||||
.gap_1()
|
.gap_1()
|
||||||
.child(
|
.child(self.render_emoji_menu(window, cx))
|
||||||
Button::new("emoji")
|
.child(self.render_config_menu(window, cx))
|
||||||
.icon(IconName::Emoji)
|
|
||||||
.ghost()
|
|
||||||
.large()
|
|
||||||
.dropdown_menu_with_anchor(
|
|
||||||
gpui::Corner::BottomLeft,
|
|
||||||
move |this, _window, _cx| {
|
|
||||||
this.horizontal()
|
|
||||||
.menu("👍", Box::new(Command::Insert("👍")))
|
|
||||||
.menu("👎", Box::new(Command::Insert("👎")))
|
|
||||||
.menu("😄", Box::new(Command::Insert("😄")))
|
|
||||||
.menu("🎉", Box::new(Command::Insert("🎉")))
|
|
||||||
.menu("😕", Box::new(Command::Insert("😕")))
|
|
||||||
.menu("❤️", Box::new(Command::Insert("❤️")))
|
|
||||||
.menu("🚀", Box::new(Command::Insert("🚀")))
|
|
||||||
.menu("👀", Box::new(Command::Insert("👀")))
|
|
||||||
},
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.child(
|
.child(
|
||||||
Button::new("send")
|
Button::new("send")
|
||||||
.icon(IconName::PaperPlaneFill)
|
.icon(IconName::PaperPlaneFill)
|
||||||
|
|||||||
@@ -1,29 +1,29 @@
|
|||||||
use std::ops::Range;
|
use std::ops::Range;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use chat::Mention;
|
||||||
|
use common::RangeExt;
|
||||||
use gpui::{
|
use gpui::{
|
||||||
AnyElement, App, ElementId, HighlightStyle, InteractiveText, IntoElement, SharedString,
|
AnyElement, App, ElementId, Entity, FontStyle, FontWeight, HighlightStyle, InteractiveText,
|
||||||
StyledText, UnderlineStyle, Window,
|
IntoElement, SharedString, StrikethroughStyle, StyledText, UnderlineStyle, Window,
|
||||||
};
|
};
|
||||||
use nostr_sdk::prelude::*;
|
|
||||||
use once_cell::sync::Lazy;
|
|
||||||
use person::PersonRegistry;
|
use person::PersonRegistry;
|
||||||
use regex::Regex;
|
|
||||||
use theme::ActiveTheme;
|
use theme::ActiveTheme;
|
||||||
|
|
||||||
use crate::actions::OpenPublicKey;
|
#[allow(clippy::enum_variant_names)]
|
||||||
|
#[allow(dead_code)]
|
||||||
static URL_REGEX: Lazy<Regex> = Lazy::new(|| {
|
|
||||||
Regex::new(r"(?i)(?:^|\s)(?:https?://)?(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,}(?::\d+)?(?:/[^\s]*)?(?:\s|$)").unwrap()
|
|
||||||
});
|
|
||||||
|
|
||||||
static NOSTR_URI_REGEX: Lazy<Regex> =
|
|
||||||
Lazy::new(|| Regex::new(r"nostr:(npub|note|nprofile|nevent|naddr)[a-zA-Z0-9]+").unwrap());
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub enum Highlight {
|
pub enum Highlight {
|
||||||
Link,
|
Code,
|
||||||
Nostr,
|
InlineCode(bool),
|
||||||
|
Highlight(HighlightStyle),
|
||||||
|
Mention,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<HighlightStyle> for Highlight {
|
||||||
|
fn from(style: HighlightStyle) -> Self {
|
||||||
|
Self::Highlight(style)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
@@ -35,7 +35,12 @@ pub struct RenderedText {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl RenderedText {
|
impl RenderedText {
|
||||||
pub fn new(content: &str, cx: &App) -> Self {
|
pub fn new(
|
||||||
|
content: &str,
|
||||||
|
mentions: &[Mention],
|
||||||
|
persons: &Entity<PersonRegistry>,
|
||||||
|
cx: &App,
|
||||||
|
) -> Self {
|
||||||
let mut text = String::new();
|
let mut text = String::new();
|
||||||
let mut highlights = Vec::new();
|
let mut highlights = Vec::new();
|
||||||
let mut link_ranges = Vec::new();
|
let mut link_ranges = Vec::new();
|
||||||
@@ -43,10 +48,12 @@ impl RenderedText {
|
|||||||
|
|
||||||
render_plain_text_mut(
|
render_plain_text_mut(
|
||||||
content,
|
content,
|
||||||
|
mentions,
|
||||||
&mut text,
|
&mut text,
|
||||||
&mut highlights,
|
&mut highlights,
|
||||||
&mut link_ranges,
|
&mut link_ranges,
|
||||||
&mut link_urls,
|
&mut link_urls,
|
||||||
|
persons,
|
||||||
cx,
|
cx,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -61,7 +68,7 @@ impl RenderedText {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn element(&self, id: ElementId, window: &Window, cx: &App) -> AnyElement {
|
pub fn element(&self, id: ElementId, window: &Window, cx: &App) -> AnyElement {
|
||||||
let link_color = cx.theme().text_accent;
|
let code_background = cx.theme().elevated_surface_background;
|
||||||
|
|
||||||
InteractiveText::new(
|
InteractiveText::new(
|
||||||
id,
|
id,
|
||||||
@@ -71,15 +78,35 @@ impl RenderedText {
|
|||||||
(
|
(
|
||||||
range.clone(),
|
range.clone(),
|
||||||
match highlight {
|
match highlight {
|
||||||
Highlight::Link => HighlightStyle {
|
Highlight::Code => HighlightStyle {
|
||||||
color: Some(link_color),
|
background_color: Some(code_background),
|
||||||
underline: Some(UnderlineStyle::default()),
|
|
||||||
..Default::default()
|
..Default::default()
|
||||||
},
|
},
|
||||||
Highlight::Nostr => HighlightStyle {
|
Highlight::InlineCode(link) => {
|
||||||
color: Some(link_color),
|
if *link {
|
||||||
|
HighlightStyle {
|
||||||
|
background_color: Some(code_background),
|
||||||
|
underline: Some(UnderlineStyle {
|
||||||
|
thickness: 1.0.into(),
|
||||||
|
..Default::default()
|
||||||
|
}),
|
||||||
|
..Default::default()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
HighlightStyle {
|
||||||
|
background_color: Some(code_background),
|
||||||
|
..Default::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Highlight::Mention => HighlightStyle {
|
||||||
|
underline: Some(UnderlineStyle {
|
||||||
|
thickness: 1.0.into(),
|
||||||
|
..Default::default()
|
||||||
|
}),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
},
|
},
|
||||||
|
Highlight::Highlight(highlight) => *highlight,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}),
|
}),
|
||||||
@@ -87,22 +114,10 @@ impl RenderedText {
|
|||||||
)
|
)
|
||||||
.on_click(self.link_ranges.clone(), {
|
.on_click(self.link_ranges.clone(), {
|
||||||
let link_urls = self.link_urls.clone();
|
let link_urls = self.link_urls.clone();
|
||||||
move |ix, window, cx| {
|
move |ix, _, cx| {
|
||||||
let token = link_urls[ix].as_str();
|
let url = &link_urls[ix];
|
||||||
|
if url.starts_with("http") {
|
||||||
if let Some(clean_url) = token.strip_prefix("nostr:") {
|
cx.open_url(url);
|
||||||
if let Ok(public_key) = PublicKey::parse(clean_url) {
|
|
||||||
window.dispatch_action(Box::new(OpenPublicKey(public_key)), cx);
|
|
||||||
}
|
|
||||||
} else if is_url(token) {
|
|
||||||
let url = if token.starts_with("http") {
|
|
||||||
token.to_string()
|
|
||||||
} else {
|
|
||||||
format!("https://{token}")
|
|
||||||
};
|
|
||||||
cx.open_url(&url);
|
|
||||||
} else {
|
|
||||||
log::warn!("Unrecognized token {token}")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -110,214 +125,273 @@ impl RenderedText {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
fn render_plain_text_mut(
|
fn render_plain_text_mut(
|
||||||
content: &str,
|
block: &str,
|
||||||
|
mut mentions: &[Mention],
|
||||||
text: &mut String,
|
text: &mut String,
|
||||||
highlights: &mut Vec<(Range<usize>, Highlight)>,
|
highlights: &mut Vec<(Range<usize>, Highlight)>,
|
||||||
link_ranges: &mut Vec<Range<usize>>,
|
link_ranges: &mut Vec<Range<usize>>,
|
||||||
link_urls: &mut Vec<String>,
|
link_urls: &mut Vec<String>,
|
||||||
|
persons: &Entity<PersonRegistry>,
|
||||||
cx: &App,
|
cx: &App,
|
||||||
) {
|
) {
|
||||||
// Copy the content directly
|
use pulldown_cmark::{Event, Options, Parser, Tag, TagEnd};
|
||||||
text.push_str(content);
|
|
||||||
|
|
||||||
// Collect all URLs
|
let mut bold_depth = 0;
|
||||||
let mut url_matches: Vec<(Range<usize>, String)> = Vec::new();
|
let mut italic_depth = 0;
|
||||||
|
let mut strikethrough_depth = 0;
|
||||||
|
let mut link_url = None;
|
||||||
|
let mut list_stack = Vec::new();
|
||||||
|
|
||||||
for link in URL_REGEX.find_iter(content) {
|
let mut options = Options::all();
|
||||||
let range = link.start()..link.end();
|
options.remove(pulldown_cmark::Options::ENABLE_DEFINITION_LIST);
|
||||||
let url = link.as_str().to_string();
|
|
||||||
|
|
||||||
url_matches.push((range, url));
|
for (event, source_range) in Parser::new_ext(block, options).into_offset_iter() {
|
||||||
|
let prev_len = text.len();
|
||||||
|
|
||||||
|
match event {
|
||||||
|
Event::Text(t) => {
|
||||||
|
// Process text with mention replacements
|
||||||
|
let t_str = t.as_ref();
|
||||||
|
let mut last_processed = 0;
|
||||||
|
|
||||||
|
while let Some(mention) = mentions.first() {
|
||||||
|
if !source_range.contains_inclusive(&mention.range) {
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Collect all nostr entities with nostr: prefix
|
// Calculate positions within the current text
|
||||||
let mut nostr_matches: Vec<(Range<usize>, String)> = Vec::new();
|
let mention_start_in_text = mention.range.start - source_range.start;
|
||||||
|
let mention_end_in_text = mention.range.end - source_range.start;
|
||||||
|
|
||||||
for nostr_match in NOSTR_URI_REGEX.find_iter(content) {
|
// Add text before this mention
|
||||||
let range = nostr_match.start()..nostr_match.end();
|
if mention_start_in_text > last_processed {
|
||||||
let nostr_uri = nostr_match.as_str().to_string();
|
let before_mention = &t_str[last_processed..mention_start_in_text];
|
||||||
|
process_text_segment(
|
||||||
// Check if this nostr URI overlaps with any already processed URL
|
before_mention,
|
||||||
if !url_matches
|
prev_len + last_processed,
|
||||||
.iter()
|
bold_depth,
|
||||||
.any(|(url_range, _)| url_range.start < range.end && range.start < url_range.end)
|
italic_depth,
|
||||||
{
|
strikethrough_depth,
|
||||||
nostr_matches.push((range, nostr_uri));
|
link_url.clone(),
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Combine all matches for processing from end to start
|
|
||||||
let mut all_matches = Vec::new();
|
|
||||||
all_matches.extend(url_matches);
|
|
||||||
all_matches.extend(nostr_matches);
|
|
||||||
|
|
||||||
// Sort by position (end to start) to avoid changing positions when replacing text
|
|
||||||
all_matches.sort_by(|(range_a, _), (range_b, _)| range_b.start.cmp(&range_a.start));
|
|
||||||
|
|
||||||
// Process all matches
|
|
||||||
for (range, entity) in all_matches {
|
|
||||||
// Handle URL token
|
|
||||||
if is_url(&entity) {
|
|
||||||
highlights.push((range.clone(), Highlight::Link));
|
|
||||||
link_ranges.push(range);
|
|
||||||
link_urls.push(entity);
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
|
|
||||||
if let Ok(nip21) = Nip21::parse(&entity) {
|
|
||||||
match nip21 {
|
|
||||||
Nip21::Pubkey(public_key) => {
|
|
||||||
render_pubkey(
|
|
||||||
public_key,
|
|
||||||
text,
|
text,
|
||||||
&range,
|
|
||||||
highlights,
|
|
||||||
link_ranges,
|
|
||||||
link_urls,
|
|
||||||
cx,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
Nip21::Profile(nip19_profile) => {
|
|
||||||
render_pubkey(
|
|
||||||
nip19_profile.public_key,
|
|
||||||
text,
|
|
||||||
&range,
|
|
||||||
highlights,
|
|
||||||
link_ranges,
|
|
||||||
link_urls,
|
|
||||||
cx,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
Nip21::EventId(event_id) => {
|
|
||||||
render_bech32(
|
|
||||||
event_id.to_bech32().unwrap(),
|
|
||||||
text,
|
|
||||||
&range,
|
|
||||||
highlights,
|
highlights,
|
||||||
link_ranges,
|
link_ranges,
|
||||||
link_urls,
|
link_urls,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
Nip21::Event(nip19_event) => {
|
|
||||||
render_bech32(
|
// Process the mention replacement
|
||||||
nip19_event.to_bech32().unwrap(),
|
let profile = persons.read(cx).get(&mention.public_key, cx);
|
||||||
text,
|
let replacement_text = format!("@{}", profile.name());
|
||||||
&range,
|
|
||||||
highlights,
|
let replacement_start = text.len();
|
||||||
link_ranges,
|
text.push_str(&replacement_text);
|
||||||
link_urls,
|
let replacement_end = text.len();
|
||||||
);
|
|
||||||
|
highlights.push((replacement_start..replacement_end, Highlight::Mention));
|
||||||
|
|
||||||
|
last_processed = mention_end_in_text;
|
||||||
|
mentions = &mentions[1..];
|
||||||
}
|
}
|
||||||
Nip21::Coordinate(nip19_coordinate) => {
|
|
||||||
render_bech32(
|
// Add any remaining text after the last mention
|
||||||
nip19_coordinate.to_bech32().unwrap(),
|
if last_processed < t_str.len() {
|
||||||
|
let remaining_text = &t_str[last_processed..];
|
||||||
|
process_text_segment(
|
||||||
|
remaining_text,
|
||||||
|
prev_len + last_processed,
|
||||||
|
bold_depth,
|
||||||
|
italic_depth,
|
||||||
|
strikethrough_depth,
|
||||||
|
link_url.clone(),
|
||||||
text,
|
text,
|
||||||
&range,
|
|
||||||
highlights,
|
highlights,
|
||||||
link_ranges,
|
link_ranges,
|
||||||
link_urls,
|
link_urls,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
Event::Code(t) => {
|
||||||
}
|
text.push_str(t.as_ref());
|
||||||
|
let is_link = link_url.is_some();
|
||||||
|
|
||||||
|
if let Some(link_url) = link_url.clone() {
|
||||||
|
link_ranges.push(prev_len..text.len());
|
||||||
|
link_urls.push(link_url);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Check if a string is a URL
|
highlights.push((prev_len..text.len(), Highlight::InlineCode(is_link)))
|
||||||
fn is_url(s: &str) -> bool {
|
|
||||||
URL_REGEX.is_match(s)
|
|
||||||
}
|
}
|
||||||
|
Event::Start(tag) => match tag {
|
||||||
/// Format a bech32 entity with ellipsis and last 4 characters
|
Tag::Paragraph => new_paragraph(text, &mut list_stack),
|
||||||
fn format_shortened_entity(entity: &str) -> String {
|
Tag::Heading { .. } => {
|
||||||
let prefix_end = entity.find('1').unwrap_or(0);
|
new_paragraph(text, &mut list_stack);
|
||||||
|
bold_depth += 1;
|
||||||
if prefix_end > 0 && entity.len() > prefix_end + 5 {
|
}
|
||||||
let prefix = &entity[0..=prefix_end]; // Include the '1'
|
Tag::CodeBlock(_kind) => {
|
||||||
let suffix = &entity[entity.len() - 4..]; // Last 4 chars
|
new_paragraph(text, &mut list_stack);
|
||||||
|
}
|
||||||
format!("{prefix}...{suffix}")
|
Tag::Emphasis => italic_depth += 1,
|
||||||
|
Tag::Strong => bold_depth += 1,
|
||||||
|
Tag::Strikethrough => strikethrough_depth += 1,
|
||||||
|
Tag::Link { dest_url, .. } => link_url = Some(dest_url.to_string()),
|
||||||
|
Tag::List(number) => {
|
||||||
|
list_stack.push((number, false));
|
||||||
|
}
|
||||||
|
Tag::Item => {
|
||||||
|
let len = list_stack.len();
|
||||||
|
if let Some((list_number, has_content)) = list_stack.last_mut() {
|
||||||
|
*has_content = false;
|
||||||
|
if !text.is_empty() && !text.ends_with('\n') {
|
||||||
|
text.push('\n');
|
||||||
|
}
|
||||||
|
for _ in 0..len - 1 {
|
||||||
|
text.push_str(" ");
|
||||||
|
}
|
||||||
|
if let Some(number) = list_number {
|
||||||
|
text.push_str(&format!("{}. ", number));
|
||||||
|
*number += 1;
|
||||||
|
*has_content = false;
|
||||||
} else {
|
} else {
|
||||||
entity.to_string()
|
text.push_str("- ");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
},
|
||||||
|
Event::End(tag) => match tag {
|
||||||
|
TagEnd::Heading(_) => bold_depth -= 1,
|
||||||
|
TagEnd::Emphasis => italic_depth -= 1,
|
||||||
|
TagEnd::Strong => bold_depth -= 1,
|
||||||
|
TagEnd::Strikethrough => strikethrough_depth -= 1,
|
||||||
|
TagEnd::Link => link_url = None,
|
||||||
|
TagEnd::List(_) => drop(list_stack.pop()),
|
||||||
|
_ => {}
|
||||||
|
},
|
||||||
|
Event::HardBreak => text.push('\n'),
|
||||||
|
Event::SoftBreak => text.push('\n'),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn render_pubkey(
|
#[allow(clippy::too_many_arguments)]
|
||||||
public_key: PublicKey,
|
fn process_text_segment(
|
||||||
|
segment: &str,
|
||||||
|
segment_start: usize,
|
||||||
|
bold_depth: i32,
|
||||||
|
italic_depth: i32,
|
||||||
|
strikethrough_depth: i32,
|
||||||
|
link_url: Option<String>,
|
||||||
text: &mut String,
|
text: &mut String,
|
||||||
range: &Range<usize>,
|
|
||||||
highlights: &mut Vec<(Range<usize>, Highlight)>,
|
|
||||||
link_ranges: &mut Vec<Range<usize>>,
|
|
||||||
link_urls: &mut Vec<String>,
|
|
||||||
cx: &App,
|
|
||||||
) {
|
|
||||||
let persons = PersonRegistry::global(cx);
|
|
||||||
let profile = persons.read(cx).get(&public_key, cx);
|
|
||||||
let display_name = format!("@{}", profile.name());
|
|
||||||
|
|
||||||
text.replace_range(range.clone(), &display_name);
|
|
||||||
|
|
||||||
let new_length = display_name.len();
|
|
||||||
let length_diff = new_length as isize - (range.end - range.start) as isize;
|
|
||||||
let new_range = range.start..(range.start + new_length);
|
|
||||||
|
|
||||||
highlights.push((new_range.clone(), Highlight::Nostr));
|
|
||||||
link_ranges.push(new_range);
|
|
||||||
link_urls.push(format!("nostr:{}", profile.public_key().to_hex()));
|
|
||||||
|
|
||||||
if length_diff != 0 {
|
|
||||||
adjust_ranges(highlights, link_ranges, range.end, length_diff);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn render_bech32(
|
|
||||||
bech32: String,
|
|
||||||
text: &mut String,
|
|
||||||
range: &Range<usize>,
|
|
||||||
highlights: &mut Vec<(Range<usize>, Highlight)>,
|
highlights: &mut Vec<(Range<usize>, Highlight)>,
|
||||||
link_ranges: &mut Vec<Range<usize>>,
|
link_ranges: &mut Vec<Range<usize>>,
|
||||||
link_urls: &mut Vec<String>,
|
link_urls: &mut Vec<String>,
|
||||||
) {
|
) {
|
||||||
let njump_url = format!("https://njump.me/{bech32}");
|
// Build the style for this segment
|
||||||
let shortened_entity = format_shortened_entity(&bech32);
|
let mut style = HighlightStyle::default();
|
||||||
let display_text = format!("https://njump.me/{shortened_entity}");
|
if bold_depth > 0 {
|
||||||
|
style.font_weight = Some(FontWeight::BOLD);
|
||||||
|
}
|
||||||
|
if italic_depth > 0 {
|
||||||
|
style.font_style = Some(FontStyle::Italic);
|
||||||
|
}
|
||||||
|
if strikethrough_depth > 0 {
|
||||||
|
style.strikethrough = Some(StrikethroughStyle {
|
||||||
|
thickness: 1.0.into(),
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
text.replace_range(range.clone(), &display_text);
|
// Add the text
|
||||||
|
text.push_str(segment);
|
||||||
|
let text_end = text.len();
|
||||||
|
|
||||||
let new_length = display_text.len();
|
if let Some(link_url) = link_url {
|
||||||
let length_diff = new_length as isize - (range.end - range.start) as isize;
|
// Handle as a markdown link
|
||||||
let new_range = range.start..(range.start + new_length);
|
link_ranges.push(segment_start..text_end);
|
||||||
|
link_urls.push(link_url);
|
||||||
|
style.underline = Some(UnderlineStyle {
|
||||||
|
thickness: 1.0.into(),
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
|
||||||
highlights.push((new_range.clone(), Highlight::Link));
|
// Add highlight for the entire linked segment
|
||||||
link_ranges.push(new_range);
|
if style != HighlightStyle::default() {
|
||||||
link_urls.push(njump_url);
|
highlights.push((segment_start..text_end, Highlight::Highlight(style)));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Handle link detection within the segment
|
||||||
|
let mut finder = linkify::LinkFinder::new();
|
||||||
|
finder.kinds(&[linkify::LinkKind::Url]);
|
||||||
|
let mut last_link_pos = 0;
|
||||||
|
|
||||||
if length_diff != 0 {
|
for link in finder.links(segment) {
|
||||||
adjust_ranges(highlights, link_ranges, range.end, length_diff);
|
let start = link.start();
|
||||||
|
let end = link.end();
|
||||||
|
|
||||||
|
// Add non-link text before this link
|
||||||
|
if start > last_link_pos {
|
||||||
|
let non_link_start = segment_start + last_link_pos;
|
||||||
|
let non_link_end = segment_start + start;
|
||||||
|
|
||||||
|
if style != HighlightStyle::default() {
|
||||||
|
highlights.push((non_link_start..non_link_end, Highlight::Highlight(style)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper function to adjust ranges when text length changes
|
// Add the link
|
||||||
fn adjust_ranges(
|
let range = (segment_start + start)..(segment_start + end);
|
||||||
highlights: &mut [(Range<usize>, Highlight)],
|
link_ranges.push(range.clone());
|
||||||
link_ranges: &mut [Range<usize>],
|
link_urls.push(link.as_str().to_string());
|
||||||
position: usize,
|
|
||||||
length_diff: isize,
|
// Apply link styling (underline + existing style)
|
||||||
) {
|
let mut link_style = style;
|
||||||
// Adjust highlight ranges
|
link_style.underline = Some(UnderlineStyle {
|
||||||
for (range, _) in highlights.iter_mut() {
|
thickness: 1.0.into(),
|
||||||
if range.start > position {
|
..Default::default()
|
||||||
range.start = (range.start as isize + length_diff) as usize;
|
});
|
||||||
range.end = (range.end as isize + length_diff) as usize;
|
|
||||||
|
highlights.push((range, Highlight::Highlight(link_style)));
|
||||||
|
|
||||||
|
last_link_pos = end;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add any remaining text after the last link
|
||||||
|
if last_link_pos < segment.len() {
|
||||||
|
let remaining_start = segment_start + last_link_pos;
|
||||||
|
let remaining_end = segment_start + segment.len();
|
||||||
|
|
||||||
|
if style != HighlightStyle::default() {
|
||||||
|
highlights.push((remaining_start..remaining_end, Highlight::Highlight(style)));
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Adjust link ranges
|
fn new_paragraph(text: &mut String, list_stack: &mut [(Option<u64>, bool)]) {
|
||||||
for range in link_ranges.iter_mut() {
|
let mut is_subsequent_paragraph_of_list = false;
|
||||||
if range.start > position {
|
if let Some((_, has_content)) = list_stack.last_mut() {
|
||||||
range.start = (range.start as isize + length_diff) as usize;
|
if *has_content {
|
||||||
range.end = (range.end as isize + length_diff) as usize;
|
is_subsequent_paragraph_of_list = true;
|
||||||
|
} else {
|
||||||
|
*has_content = true;
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if !text.is_empty() {
|
||||||
|
if !text.ends_with('\n') {
|
||||||
|
text.push('\n');
|
||||||
|
}
|
||||||
|
text.push('\n');
|
||||||
|
}
|
||||||
|
for _ in 0..list_stack.len().saturating_sub(1) {
|
||||||
|
text.push_str(" ");
|
||||||
|
}
|
||||||
|
if is_subsequent_paragraph_of_list {
|
||||||
|
text.push_str(" ");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,3 +19,4 @@ log.workspace = true
|
|||||||
|
|
||||||
dirs = "5.0"
|
dirs = "5.0"
|
||||||
qrcode = "0.14.1"
|
qrcode = "0.14.1"
|
||||||
|
bech32 = "0.11.1"
|
||||||
|
|||||||
@@ -13,38 +13,6 @@ const MINUTES_IN_HOUR: i64 = 60;
|
|||||||
const HOURS_IN_DAY: i64 = 24;
|
const HOURS_IN_DAY: i64 = 24;
|
||||||
const DAYS_IN_MONTH: i64 = 30;
|
const DAYS_IN_MONTH: i64 = 30;
|
||||||
|
|
||||||
pub trait RenderedProfile {
|
|
||||||
fn avatar(&self) -> SharedString;
|
|
||||||
fn display_name(&self) -> SharedString;
|
|
||||||
}
|
|
||||||
|
|
||||||
impl RenderedProfile for Profile {
|
|
||||||
fn avatar(&self) -> SharedString {
|
|
||||||
self.metadata()
|
|
||||||
.picture
|
|
||||||
.as_ref()
|
|
||||||
.filter(|picture| !picture.is_empty())
|
|
||||||
.map(|picture| picture.into())
|
|
||||||
.unwrap_or_else(|| "brand/avatar.png".into())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn display_name(&self) -> SharedString {
|
|
||||||
if let Some(display_name) = self.metadata().display_name.as_ref() {
|
|
||||||
if !display_name.is_empty() {
|
|
||||||
return SharedString::from(display_name);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(name) = self.metadata().name.as_ref() {
|
|
||||||
if !name.is_empty() {
|
|
||||||
return SharedString::from(name);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
SharedString::from(shorten_pubkey(self.public_key(), 4))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub trait RenderedTimestamp {
|
pub trait RenderedTimestamp {
|
||||||
fn to_human_time(&self) -> SharedString;
|
fn to_human_time(&self) -> SharedString;
|
||||||
fn to_ago(&self) -> SharedString;
|
fn to_ago(&self) -> SharedString;
|
||||||
@@ -126,13 +94,3 @@ impl<T: AsRef<str>> TextUtils for T {
|
|||||||
)))
|
)))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn shorten_pubkey(public_key: PublicKey, len: usize) -> String {
|
|
||||||
let Ok(pubkey) = public_key.to_bech32();
|
|
||||||
|
|
||||||
format!(
|
|
||||||
"{}:{}",
|
|
||||||
&pubkey[0..(len + 1)],
|
|
||||||
&pubkey[pubkey.len() - len..]
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
pub use debounced_delay::*;
|
pub use debounced_delay::*;
|
||||||
pub use display::*;
|
pub use display::*;
|
||||||
pub use event::*;
|
pub use event::*;
|
||||||
|
pub use parser::*;
|
||||||
pub use paths::*;
|
pub use paths::*;
|
||||||
|
pub use range::*;
|
||||||
|
|
||||||
mod debounced_delay;
|
mod debounced_delay;
|
||||||
mod display;
|
mod display;
|
||||||
mod event;
|
mod event;
|
||||||
|
mod parser;
|
||||||
mod paths;
|
mod paths;
|
||||||
|
mod range;
|
||||||
|
|||||||
210
crates/common/src/parser.rs
Normal file
@@ -0,0 +1,210 @@
|
|||||||
|
use std::ops::Range;
|
||||||
|
|
||||||
|
use nostr::prelude::*;
|
||||||
|
|
||||||
|
const BECH32_SEPARATOR: u8 = b'1';
|
||||||
|
const SCHEME_WITH_COLON: &str = "nostr:";
|
||||||
|
|
||||||
|
/// Nostr parsed token with its range in the original text
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct Token {
|
||||||
|
/// The parsed NIP-21 URI
|
||||||
|
///
|
||||||
|
/// <https://github.com/nostr-protocol/nips/blob/master/21.md>
|
||||||
|
pub value: Nip21,
|
||||||
|
/// The range of this token in the original text
|
||||||
|
pub range: Range<usize>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy)]
|
||||||
|
struct Match {
|
||||||
|
start: usize,
|
||||||
|
end: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Nostr parser
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||||
|
pub struct NostrParser;
|
||||||
|
|
||||||
|
impl Default for NostrParser {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl NostrParser {
|
||||||
|
/// Create new parser
|
||||||
|
pub const fn new() -> Self {
|
||||||
|
Self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse text
|
||||||
|
pub fn parse<'a>(&self, text: &'a str) -> NostrParserIter<'a> {
|
||||||
|
NostrParserIter::new(text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct FindMatches<'a> {
|
||||||
|
bytes: &'a [u8],
|
||||||
|
pos: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> FindMatches<'a> {
|
||||||
|
fn new(text: &'a str) -> Self {
|
||||||
|
Self {
|
||||||
|
bytes: text.as_bytes(),
|
||||||
|
pos: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn try_parse_nostr_uri(&mut self) -> Option<Match> {
|
||||||
|
let start = self.pos;
|
||||||
|
let bytes = self.bytes;
|
||||||
|
let len = bytes.len();
|
||||||
|
|
||||||
|
// Check if we have "nostr:" prefix
|
||||||
|
if len - start < SCHEME_WITH_COLON.len() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for "nostr:" prefix (case-insensitive)
|
||||||
|
let scheme_prefix = &bytes[start..start + SCHEME_WITH_COLON.len()];
|
||||||
|
if !scheme_prefix.eq_ignore_ascii_case(SCHEME_WITH_COLON.as_bytes()) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip the scheme
|
||||||
|
let pos = start + SCHEME_WITH_COLON.len();
|
||||||
|
|
||||||
|
// Parse bech32 entity
|
||||||
|
let mut has_separator = false;
|
||||||
|
let mut end = pos;
|
||||||
|
|
||||||
|
while end < len {
|
||||||
|
let byte = bytes[end];
|
||||||
|
|
||||||
|
// Check for bech32 separator
|
||||||
|
if byte == BECH32_SEPARATOR && !has_separator {
|
||||||
|
has_separator = true;
|
||||||
|
end += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if character is valid for bech32
|
||||||
|
if !byte.is_ascii_alphanumeric() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
end += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Must have at least one character after separator
|
||||||
|
if !has_separator || end <= pos + 1 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update position
|
||||||
|
self.pos = end;
|
||||||
|
|
||||||
|
Some(Match { start, end })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Iterator for FindMatches<'_> {
|
||||||
|
type Item = Match;
|
||||||
|
|
||||||
|
fn next(&mut self) -> Option<Self::Item> {
|
||||||
|
while self.pos < self.bytes.len() {
|
||||||
|
// Try to parse nostr URI
|
||||||
|
if let Some(mat) = self.try_parse_nostr_uri() {
|
||||||
|
return Some(mat);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip one character if no match found
|
||||||
|
self.pos += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum HandleMatch {
|
||||||
|
Token(Token),
|
||||||
|
Recursion,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct NostrParserIter<'a> {
|
||||||
|
/// The original text
|
||||||
|
text: &'a str,
|
||||||
|
/// Matches found
|
||||||
|
matches: FindMatches<'a>,
|
||||||
|
/// A pending match
|
||||||
|
pending_match: Option<Match>,
|
||||||
|
/// Last match end index
|
||||||
|
last_match_end: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> NostrParserIter<'a> {
|
||||||
|
fn new(text: &'a str) -> Self {
|
||||||
|
Self {
|
||||||
|
text,
|
||||||
|
matches: FindMatches::new(text),
|
||||||
|
pending_match: None,
|
||||||
|
last_match_end: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handle_match(&mut self, mat: Match) -> HandleMatch {
|
||||||
|
// Update last match end
|
||||||
|
self.last_match_end = mat.end;
|
||||||
|
|
||||||
|
// Extract the matched string
|
||||||
|
let data: &str = &self.text[mat.start..mat.end];
|
||||||
|
|
||||||
|
// Parse NIP-21 URI
|
||||||
|
match Nip21::parse(data) {
|
||||||
|
Ok(uri) => HandleMatch::Token(Token {
|
||||||
|
value: uri,
|
||||||
|
range: mat.start..mat.end,
|
||||||
|
}),
|
||||||
|
// If the nostr URI parsing is invalid, skip it
|
||||||
|
Err(_) => HandleMatch::Recursion,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> Iterator for NostrParserIter<'a> {
|
||||||
|
type Item = Token;
|
||||||
|
|
||||||
|
fn next(&mut self) -> Option<Self::Item> {
|
||||||
|
// Handle a pending match
|
||||||
|
if let Some(pending_match) = self.pending_match.take() {
|
||||||
|
return match self.handle_match(pending_match) {
|
||||||
|
HandleMatch::Token(token) => Some(token),
|
||||||
|
HandleMatch::Recursion => self.next(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
match self.matches.next() {
|
||||||
|
Some(mat) => {
|
||||||
|
// Skip any text before this match
|
||||||
|
if mat.start > self.last_match_end {
|
||||||
|
// Update pending match
|
||||||
|
// This will be handled at next iteration, in `handle_match` method.
|
||||||
|
self.pending_match = Some(mat);
|
||||||
|
|
||||||
|
// Skip the text before the match
|
||||||
|
self.last_match_end = mat.start;
|
||||||
|
return self.next();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle match
|
||||||
|
match self.handle_match(mat) {
|
||||||
|
HandleMatch::Token(token) => Some(token),
|
||||||
|
HandleMatch::Recursion => self.next(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
45
crates/common/src/range.rs
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
use std::cmp::{self};
|
||||||
|
use std::ops::{Range, RangeInclusive};
|
||||||
|
|
||||||
|
pub trait RangeExt<T> {
|
||||||
|
fn sorted(&self) -> Self;
|
||||||
|
fn to_inclusive(&self) -> RangeInclusive<T>;
|
||||||
|
fn overlaps(&self, other: &Range<T>) -> bool;
|
||||||
|
fn contains_inclusive(&self, other: &Range<T>) -> bool;
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T: Ord + Clone> RangeExt<T> for Range<T> {
|
||||||
|
fn sorted(&self) -> Self {
|
||||||
|
cmp::min(&self.start, &self.end).clone()..cmp::max(&self.start, &self.end).clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn to_inclusive(&self) -> RangeInclusive<T> {
|
||||||
|
self.start.clone()..=self.end.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn overlaps(&self, other: &Range<T>) -> bool {
|
||||||
|
self.start < other.end && other.start < self.end
|
||||||
|
}
|
||||||
|
|
||||||
|
fn contains_inclusive(&self, other: &Range<T>) -> bool {
|
||||||
|
self.start <= other.start && other.end <= self.end
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T: Ord + Clone> RangeExt<T> for RangeInclusive<T> {
|
||||||
|
fn sorted(&self) -> Self {
|
||||||
|
cmp::min(self.start(), self.end()).clone()..=cmp::max(self.start(), self.end()).clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn to_inclusive(&self) -> RangeInclusive<T> {
|
||||||
|
self.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn overlaps(&self, other: &Range<T>) -> bool {
|
||||||
|
self.start() < &other.end && &other.start <= self.end()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn contains_inclusive(&self, other: &Range<T>) -> bool {
|
||||||
|
self.start() <= &other.start && &other.end <= self.end()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,7 +14,7 @@ product-name = "Coop"
|
|||||||
description = "Chat Freely, Stay Private on Nostr"
|
description = "Chat Freely, Stay Private on Nostr"
|
||||||
identifier = "su.reya.coop"
|
identifier = "su.reya.coop"
|
||||||
category = "SocialNetworking"
|
category = "SocialNetworking"
|
||||||
version = "0.3.0"
|
version = "1.0.0-beta"
|
||||||
out-dir = "../../dist"
|
out-dir = "../../dist"
|
||||||
before-packaging-command = "cargo build --release"
|
before-packaging-command = "cargo build --release"
|
||||||
resources = ["Cargo.toml", "src"]
|
resources = ["Cargo.toml", "src"]
|
||||||
@@ -34,7 +34,6 @@ theme = { path = "../theme" }
|
|||||||
common = { path = "../common" }
|
common = { path = "../common" }
|
||||||
state = { path = "../state" }
|
state = { path = "../state" }
|
||||||
device = { path = "../device" }
|
device = { path = "../device" }
|
||||||
key_store = { path = "../key_store" }
|
|
||||||
chat = { path = "../chat" }
|
chat = { path = "../chat" }
|
||||||
chat_ui = { path = "../chat_ui" }
|
chat_ui = { path = "../chat_ui" }
|
||||||
settings = { path = "../settings" }
|
settings = { path = "../settings" }
|
||||||
@@ -65,4 +64,8 @@ oneshot.workspace = true
|
|||||||
webbrowser.workspace = true
|
webbrowser.workspace = true
|
||||||
|
|
||||||
indexset = "0.12.3"
|
indexset = "0.12.3"
|
||||||
tracing-subscriber = { version = "0.3.18", features = ["fmt"] }
|
tracing-subscriber = { version = "0.3.18", features = ["fmt", "env-filter"] }
|
||||||
|
|
||||||
|
[target.'cfg(target_os = "macos")'.dependencies]
|
||||||
|
# Temporary workaround https://github.com/zed-industries/zed/issues/47168
|
||||||
|
core-text = "=21.0.0"
|
||||||
|
|||||||
@@ -1,94 +0,0 @@
|
|||||||
use std::sync::Mutex;
|
|
||||||
|
|
||||||
use gpui::{actions, App};
|
|
||||||
use key_store::{KeyItem, KeyStore};
|
|
||||||
use nostr_connect::prelude::*;
|
|
||||||
use state::NostrRegistry;
|
|
||||||
|
|
||||||
// Sidebar actions
|
|
||||||
actions!(sidebar, [Reload, RelayStatus]);
|
|
||||||
|
|
||||||
// User actions
|
|
||||||
actions!(
|
|
||||||
coop,
|
|
||||||
[
|
|
||||||
KeyringPopup,
|
|
||||||
DarkMode,
|
|
||||||
ViewProfile,
|
|
||||||
ViewRelays,
|
|
||||||
Themes,
|
|
||||||
Settings,
|
|
||||||
Logout,
|
|
||||||
Quit
|
|
||||||
]
|
|
||||||
);
|
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub struct CoopAuthUrlHandler;
|
|
||||||
|
|
||||||
impl AuthUrlHandler for CoopAuthUrlHandler {
|
|
||||||
#[allow(mismatched_lifetime_syntaxes)]
|
|
||||||
fn on_auth_url(&self, auth_url: Url) -> BoxedFuture<Result<()>> {
|
|
||||||
Box::pin(async move {
|
|
||||||
log::info!("Received Auth URL: {auth_url}");
|
|
||||||
webbrowser::open(auth_url.as_str())?;
|
|
||||||
Ok(())
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn load_embedded_fonts(cx: &App) {
|
|
||||||
let asset_source = cx.asset_source();
|
|
||||||
let font_paths = asset_source.list("fonts").unwrap();
|
|
||||||
let embedded_fonts = Mutex::new(Vec::new());
|
|
||||||
let executor = cx.background_executor();
|
|
||||||
|
|
||||||
cx.foreground_executor().block_on(executor.scoped(|scope| {
|
|
||||||
for font_path in &font_paths {
|
|
||||||
if !font_path.ends_with(".ttf") {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
scope.spawn(async {
|
|
||||||
let font_bytes = asset_source.load(font_path).unwrap().unwrap();
|
|
||||||
embedded_fonts.lock().unwrap().push(font_bytes);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
|
|
||||||
cx.text_system()
|
|
||||||
.add_fonts(embedded_fonts.into_inner().unwrap())
|
|
||||||
.unwrap();
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn reset(cx: &mut App) {
|
|
||||||
let backend = KeyStore::global(cx).read(cx).backend();
|
|
||||||
let client = NostrRegistry::global(cx).read(cx).client();
|
|
||||||
|
|
||||||
cx.spawn(async move |cx| {
|
|
||||||
// Remove the signer
|
|
||||||
client.unset_signer().await;
|
|
||||||
|
|
||||||
// Delete user's credentials
|
|
||||||
backend
|
|
||||||
.delete_credentials(&KeyItem::User.to_string(), cx)
|
|
||||||
.await
|
|
||||||
.ok();
|
|
||||||
|
|
||||||
// Remove bunker's credentials if available
|
|
||||||
backend
|
|
||||||
.delete_credentials(&KeyItem::Bunker.to_string(), cx)
|
|
||||||
.await
|
|
||||||
.ok();
|
|
||||||
|
|
||||||
cx.update(|cx| {
|
|
||||||
cx.restart();
|
|
||||||
});
|
|
||||||
})
|
|
||||||
.detach();
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn quit(_: &Quit, cx: &mut App) {
|
|
||||||
log::info!("Gracefully quitting the application . . .");
|
|
||||||
cx.quit();
|
|
||||||
}
|
|
||||||
256
crates/coop/src/dialogs/accounts.rs
Normal file
@@ -0,0 +1,256 @@
|
|||||||
|
use anyhow::Error;
|
||||||
|
use gpui::prelude::FluentBuilder;
|
||||||
|
use gpui::{
|
||||||
|
div, px, App, AppContext, Context, Entity, InteractiveElement, IntoElement, ParentElement,
|
||||||
|
Render, SharedString, StatefulInteractiveElement, Styled, Subscription, Task, Window,
|
||||||
|
};
|
||||||
|
use nostr_sdk::prelude::*;
|
||||||
|
use person::PersonRegistry;
|
||||||
|
use state::{NostrRegistry, SignerEvent};
|
||||||
|
use theme::ActiveTheme;
|
||||||
|
use ui::avatar::Avatar;
|
||||||
|
use ui::button::{Button, ButtonVariants};
|
||||||
|
use ui::indicator::Indicator;
|
||||||
|
use ui::{h_flex, v_flex, Disableable, Icon, IconName, Sizable, WindowExtension};
|
||||||
|
|
||||||
|
use crate::dialogs::connect::ConnectSigner;
|
||||||
|
use crate::dialogs::import::ImportKey;
|
||||||
|
|
||||||
|
pub fn init(window: &mut Window, cx: &mut App) -> Entity<AccountSelector> {
|
||||||
|
cx.new(|cx| AccountSelector::new(window, cx))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Account selector
|
||||||
|
pub struct AccountSelector {
|
||||||
|
/// Public key currently being chosen for login
|
||||||
|
logging_in: Entity<Option<PublicKey>>,
|
||||||
|
|
||||||
|
/// The error message displayed when an error occurs.
|
||||||
|
error: Entity<Option<SharedString>>,
|
||||||
|
|
||||||
|
/// Async tasks
|
||||||
|
tasks: Vec<Task<Result<(), Error>>>,
|
||||||
|
|
||||||
|
/// Subscription to the signer events
|
||||||
|
_subscription: Option<Subscription>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AccountSelector {
|
||||||
|
pub fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||||
|
let logging_in = cx.new(|_| None);
|
||||||
|
let error = cx.new(|_| None);
|
||||||
|
|
||||||
|
// Subscribe to the signer events
|
||||||
|
let nostr = NostrRegistry::global(cx);
|
||||||
|
let subscription = cx.subscribe_in(&nostr, window, |this, _state, event, window, cx| {
|
||||||
|
match event {
|
||||||
|
SignerEvent::Set => {
|
||||||
|
window.close_all_modals(cx);
|
||||||
|
window.refresh();
|
||||||
|
}
|
||||||
|
SignerEvent::Error(e) => {
|
||||||
|
this.set_error(e.to_string(), cx);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
Self {
|
||||||
|
logging_in,
|
||||||
|
error,
|
||||||
|
tasks: vec![],
|
||||||
|
_subscription: Some(subscription),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn logging_in(&self, public_key: &PublicKey, cx: &App) -> bool {
|
||||||
|
self.logging_in.read(cx) == &Some(*public_key)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_logging_in(&mut self, public_key: PublicKey, cx: &mut Context<Self>) {
|
||||||
|
self.logging_in.update(cx, |this, cx| {
|
||||||
|
*this = Some(public_key);
|
||||||
|
cx.notify();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_error<T>(&mut self, error: T, cx: &mut Context<Self>)
|
||||||
|
where
|
||||||
|
T: Into<SharedString>,
|
||||||
|
{
|
||||||
|
self.error.update(cx, |this, cx| {
|
||||||
|
*this = Some(error.into());
|
||||||
|
cx.notify();
|
||||||
|
});
|
||||||
|
|
||||||
|
self.logging_in.update(cx, |this, cx| {
|
||||||
|
*this = None;
|
||||||
|
cx.notify();
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn login(&mut self, public_key: PublicKey, window: &mut Window, cx: &mut Context<Self>) {
|
||||||
|
let nostr = NostrRegistry::global(cx);
|
||||||
|
let task = nostr.read(cx).get_signer(&public_key, cx);
|
||||||
|
|
||||||
|
// Mark the public key as being logged in
|
||||||
|
self.set_logging_in(public_key, cx);
|
||||||
|
|
||||||
|
self.tasks.push(cx.spawn_in(window, async move |this, cx| {
|
||||||
|
match task.await {
|
||||||
|
Ok(signer) => {
|
||||||
|
nostr.update(cx, |this, cx| {
|
||||||
|
this.set_signer(signer, cx);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
this.update(cx, |this, cx| {
|
||||||
|
this.set_error(e.to_string(), cx);
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
Ok(())
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn remove(&mut self, public_key: PublicKey, cx: &mut Context<Self>) {
|
||||||
|
let nostr = NostrRegistry::global(cx);
|
||||||
|
|
||||||
|
nostr.update(cx, |this, cx| {
|
||||||
|
this.remove_signer(&public_key, cx);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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| {
|
||||||
|
this.width(px(460.))
|
||||||
|
.title("Import a Secret Key or Bunker Connection")
|
||||||
|
.show_close(true)
|
||||||
|
.pb_2()
|
||||||
|
.child(import.clone())
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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| {
|
||||||
|
this.width(px(460.))
|
||||||
|
.title("Scan QR Code to Connect")
|
||||||
|
.show_close(true)
|
||||||
|
.pb_2()
|
||||||
|
.child(connect.clone())
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Render for AccountSelector {
|
||||||
|
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||||
|
let persons = PersonRegistry::global(cx);
|
||||||
|
let nostr = NostrRegistry::global(cx);
|
||||||
|
let npubs = nostr.read(cx).npubs();
|
||||||
|
let loading = self.logging_in.read(cx).is_some();
|
||||||
|
|
||||||
|
v_flex()
|
||||||
|
.size_full()
|
||||||
|
.gap_2()
|
||||||
|
.when_some(self.error.read(cx).as_ref(), |this, error| {
|
||||||
|
this.child(
|
||||||
|
div()
|
||||||
|
.italic()
|
||||||
|
.text_xs()
|
||||||
|
.text_center()
|
||||||
|
.text_color(cx.theme().danger_active)
|
||||||
|
.child(error.clone()),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.children({
|
||||||
|
let mut items = vec![];
|
||||||
|
|
||||||
|
for (ix, public_key) in npubs.read(cx).iter().enumerate() {
|
||||||
|
let profile = persons.read(cx).get(public_key, cx);
|
||||||
|
let logging_in = self.logging_in(public_key, cx);
|
||||||
|
|
||||||
|
items.push(
|
||||||
|
h_flex()
|
||||||
|
.id(ix)
|
||||||
|
.group("")
|
||||||
|
.px_2()
|
||||||
|
.h_10()
|
||||||
|
.justify_between()
|
||||||
|
.w_full()
|
||||||
|
.rounded(cx.theme().radius)
|
||||||
|
.bg(cx.theme().ghost_element_background)
|
||||||
|
.hover(|this| this.bg(cx.theme().ghost_element_hover))
|
||||||
|
.child(
|
||||||
|
h_flex()
|
||||||
|
.gap_2()
|
||||||
|
.child(Avatar::new(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(
|
||||||
|
h_flex()
|
||||||
|
.gap_1()
|
||||||
|
.invisible()
|
||||||
|
.group_hover("", |this| this.visible())
|
||||||
|
.child(
|
||||||
|
Button::new(format!("del-{ix}"))
|
||||||
|
.icon(IconName::Close)
|
||||||
|
.ghost()
|
||||||
|
.small()
|
||||||
|
.disabled(logging_in)
|
||||||
|
.on_click(cx.listener({
|
||||||
|
let public_key = *public_key;
|
||||||
|
move |this, _ev, _window, cx| {
|
||||||
|
cx.stop_propagation();
|
||||||
|
this.remove(public_key, cx);
|
||||||
|
}
|
||||||
|
})),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.when(!logging_in, |this| {
|
||||||
|
let public_key = *public_key;
|
||||||
|
this.on_click(cx.listener(move |this, _ev, window, cx| {
|
||||||
|
this.login(public_key, window, cx);
|
||||||
|
}))
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
items
|
||||||
|
})
|
||||||
|
.child(div().w_full().h_px().bg(cx.theme().border_variant))
|
||||||
|
.child(
|
||||||
|
h_flex()
|
||||||
|
.gap_1()
|
||||||
|
.justify_end()
|
||||||
|
.w_full()
|
||||||
|
.child(
|
||||||
|
Button::new("input")
|
||||||
|
.icon(Icon::new(IconName::Usb))
|
||||||
|
.label("Import")
|
||||||
|
.ghost()
|
||||||
|
.small()
|
||||||
|
.disabled(loading)
|
||||||
|
.on_click(cx.listener(move |this, _ev, window, cx| {
|
||||||
|
this.open_import(window, cx);
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
Button::new("qr")
|
||||||
|
.icon(Icon::new(IconName::Scan))
|
||||||
|
.label("Scan QR to connect")
|
||||||
|
.ghost()
|
||||||
|
.small()
|
||||||
|
.disabled(loading)
|
||||||
|
.on_click(cx.listener(move |this, _ev, window, cx| {
|
||||||
|
this.open_connect(window, cx);
|
||||||
|
})),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
115
crates/coop/src/dialogs/connect.rs
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
use std::sync::Arc;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use common::TextUtils;
|
||||||
|
use gpui::prelude::FluentBuilder;
|
||||||
|
use gpui::{
|
||||||
|
div, img, px, AppContext, Context, Entity, Image, IntoElement, ParentElement, Render,
|
||||||
|
SharedString, Styled, Subscription, Window,
|
||||||
|
};
|
||||||
|
use nostr_connect::prelude::*;
|
||||||
|
use state::{
|
||||||
|
CoopAuthUrlHandler, NostrRegistry, SignerEvent, CLIENT_NAME, NOSTR_CONNECT_RELAY,
|
||||||
|
NOSTR_CONNECT_TIMEOUT,
|
||||||
|
};
|
||||||
|
use theme::ActiveTheme;
|
||||||
|
use ui::v_flex;
|
||||||
|
|
||||||
|
pub struct ConnectSigner {
|
||||||
|
/// QR Code
|
||||||
|
qr_code: Option<Arc<Image>>,
|
||||||
|
|
||||||
|
/// Error message
|
||||||
|
error: Entity<Option<SharedString>>,
|
||||||
|
|
||||||
|
/// Subscription to the signer event
|
||||||
|
_subscription: Option<Subscription>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ConnectSigner {
|
||||||
|
pub fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||||
|
let error = cx.new(|_| None);
|
||||||
|
|
||||||
|
let nostr = NostrRegistry::global(cx);
|
||||||
|
let app_keys = nostr.read(cx).app_keys.clone();
|
||||||
|
|
||||||
|
let timeout = Duration::from_secs(NOSTR_CONNECT_TIMEOUT);
|
||||||
|
let relay = RelayUrl::parse(NOSTR_CONNECT_RELAY).unwrap();
|
||||||
|
|
||||||
|
// Generate the nostr connect uri
|
||||||
|
let uri = NostrConnectUri::client(app_keys.public_key(), vec![relay], CLIENT_NAME);
|
||||||
|
|
||||||
|
// Generate the nostr connect
|
||||||
|
let mut signer = NostrConnect::new(uri.clone(), app_keys.clone(), timeout, None).unwrap();
|
||||||
|
|
||||||
|
// Handle the auth request
|
||||||
|
signer.auth_url_handler(CoopAuthUrlHandler);
|
||||||
|
|
||||||
|
// Generate a QR code for quick connection
|
||||||
|
let qr_code = uri.to_string().to_qr();
|
||||||
|
|
||||||
|
// Set signer in the background
|
||||||
|
nostr.update(cx, |this, cx| {
|
||||||
|
this.add_nip46_signer(&signer, cx);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Subscribe to the signer event
|
||||||
|
let subscription = cx.subscribe_in(&nostr, window, |this, _state, event, _window, cx| {
|
||||||
|
if let SignerEvent::Error(e) = event {
|
||||||
|
this.set_error(e, cx);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
Self {
|
||||||
|
qr_code,
|
||||||
|
error,
|
||||||
|
_subscription: Some(subscription),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_error<S>(&mut self, message: S, cx: &mut Context<Self>)
|
||||||
|
where
|
||||||
|
S: Into<SharedString>,
|
||||||
|
{
|
||||||
|
self.error.update(cx, |this, cx| {
|
||||||
|
*this = Some(message.into());
|
||||||
|
cx.notify();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Render for ConnectSigner {
|
||||||
|
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||||
|
const MSG: &str = "Scan with any Nostr Connect-compatible app to connect";
|
||||||
|
|
||||||
|
v_flex()
|
||||||
|
.size_full()
|
||||||
|
.items_center()
|
||||||
|
.justify_center()
|
||||||
|
.p_4()
|
||||||
|
.when_some(self.qr_code.as_ref(), |this, qr| {
|
||||||
|
this.child(
|
||||||
|
img(qr.clone())
|
||||||
|
.size(px(256.))
|
||||||
|
.rounded(cx.theme().radius_lg)
|
||||||
|
.border_1()
|
||||||
|
.border_color(cx.theme().border),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.when_some(self.error.read(cx).as_ref(), |this, error| {
|
||||||
|
this.child(
|
||||||
|
div()
|
||||||
|
.text_xs()
|
||||||
|
.text_center()
|
||||||
|
.text_color(cx.theme().danger_active)
|
||||||
|
.child(error.clone()),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.text_xs()
|
||||||
|
.text_color(cx.theme().text_muted)
|
||||||
|
.child(SharedString::from(MSG)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
301
crates/coop/src/dialogs/import.rs
Normal file
@@ -0,0 +1,301 @@
|
|||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use anyhow::{anyhow, Error};
|
||||||
|
use gpui::prelude::FluentBuilder;
|
||||||
|
use gpui::{
|
||||||
|
div, AppContext, Context, Entity, IntoElement, ParentElement, Render, SharedString, Styled,
|
||||||
|
Subscription, Task, Window,
|
||||||
|
};
|
||||||
|
use nostr_connect::prelude::*;
|
||||||
|
use smallvec::{smallvec, SmallVec};
|
||||||
|
use state::{CoopAuthUrlHandler, NostrRegistry, SignerEvent};
|
||||||
|
use theme::ActiveTheme;
|
||||||
|
use ui::button::{Button, ButtonVariants};
|
||||||
|
use ui::input::{InputEvent, InputState, TextInput};
|
||||||
|
use ui::{v_flex, Disableable};
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct ImportKey {
|
||||||
|
/// Secret key input
|
||||||
|
key_input: Entity<InputState>,
|
||||||
|
|
||||||
|
/// Password input (if required)
|
||||||
|
pass_input: Entity<InputState>,
|
||||||
|
|
||||||
|
/// Error message
|
||||||
|
error: Entity<Option<SharedString>>,
|
||||||
|
|
||||||
|
/// Countdown timer for nostr connect
|
||||||
|
countdown: Entity<Option<u64>>,
|
||||||
|
|
||||||
|
/// Whether the user is currently loading
|
||||||
|
loading: bool,
|
||||||
|
|
||||||
|
/// Async tasks
|
||||||
|
tasks: Vec<Task<Result<(), Error>>>,
|
||||||
|
|
||||||
|
/// Event subscriptions
|
||||||
|
_subscriptions: SmallVec<[Subscription; 2]>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ImportKey {
|
||||||
|
pub fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||||
|
let nostr = NostrRegistry::global(cx);
|
||||||
|
let key_input = cx.new(|cx| InputState::new(window, cx).masked(true));
|
||||||
|
let pass_input = cx.new(|cx| InputState::new(window, cx).masked(true));
|
||||||
|
let error = cx.new(|_| None);
|
||||||
|
let countdown = cx.new(|_| None);
|
||||||
|
|
||||||
|
let mut subscriptions = smallvec![];
|
||||||
|
|
||||||
|
subscriptions.push(
|
||||||
|
// Subscribe to key input events and process login when the user presses enter
|
||||||
|
cx.subscribe_in(&key_input, window, |this, _input, event, window, cx| {
|
||||||
|
if let InputEvent::PressEnter { .. } = event {
|
||||||
|
this.login(window, cx);
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
subscriptions.push(
|
||||||
|
// Subscribe to the nostr signer event
|
||||||
|
cx.subscribe_in(&nostr, window, |this, _state, event, _window, cx| {
|
||||||
|
if let SignerEvent::Error(e) = event {
|
||||||
|
this.set_error(e, cx);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
Self {
|
||||||
|
key_input,
|
||||||
|
pass_input,
|
||||||
|
error,
|
||||||
|
countdown,
|
||||||
|
loading: false,
|
||||||
|
tasks: vec![],
|
||||||
|
_subscriptions: subscriptions,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn login(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||||
|
if self.loading {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
// Prevent duplicate login requests
|
||||||
|
self.set_loading(true, cx);
|
||||||
|
|
||||||
|
let value = self.key_input.read(cx).value();
|
||||||
|
let password = self.pass_input.read(cx).value();
|
||||||
|
|
||||||
|
if value.starts_with("bunker://") {
|
||||||
|
self.bunker(&value, window, cx);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if value.starts_with("ncryptsec1") {
|
||||||
|
self.ncryptsec(value, password, window, cx);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Ok(secret) = SecretKey::parse(&value) {
|
||||||
|
let keys = Keys::new(secret);
|
||||||
|
let nostr = NostrRegistry::global(cx);
|
||||||
|
|
||||||
|
// Update the signer
|
||||||
|
nostr.update(cx, |this, cx| {
|
||||||
|
this.add_key_signer(&keys, cx);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
self.set_error("Invalid key", cx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn bunker(&mut self, content: &str, window: &mut Window, cx: &mut Context<Self>) {
|
||||||
|
let Ok(uri) = NostrConnectUri::parse(content) else {
|
||||||
|
self.set_error("Bunker is not valid", cx);
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let nostr = NostrRegistry::global(cx);
|
||||||
|
let app_keys = nostr.read(cx).app_keys.clone();
|
||||||
|
let timeout = Duration::from_secs(30);
|
||||||
|
|
||||||
|
// Construct the nostr connect signer
|
||||||
|
let mut signer = NostrConnect::new(uri, app_keys.clone(), timeout, None).unwrap();
|
||||||
|
|
||||||
|
// Handle auth url with the default browser
|
||||||
|
signer.auth_url_handler(CoopAuthUrlHandler);
|
||||||
|
|
||||||
|
// Set signer in the background
|
||||||
|
nostr.update(cx, |this, cx| {
|
||||||
|
this.add_nip46_signer(&signer, cx);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Start countdown
|
||||||
|
self.tasks.push(cx.spawn_in(window, async move |this, cx| {
|
||||||
|
for i in (0..=30).rev() {
|
||||||
|
if i == 0 {
|
||||||
|
this.update(cx, |this, cx| {
|
||||||
|
this.set_countdown(None, cx);
|
||||||
|
})?;
|
||||||
|
} else {
|
||||||
|
this.update(cx, |this, cx| {
|
||||||
|
this.set_countdown(Some(i), cx);
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
cx.background_executor().timer(Duration::from_secs(1)).await;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ncryptsec<S>(&mut self, content: S, pwd: S, window: &mut Window, cx: &mut Context<Self>)
|
||||||
|
where
|
||||||
|
S: Into<String>,
|
||||||
|
{
|
||||||
|
let nostr = NostrRegistry::global(cx);
|
||||||
|
let content: String = content.into();
|
||||||
|
let password: String = pwd.into();
|
||||||
|
|
||||||
|
if password.is_empty() {
|
||||||
|
self.set_error("Password is required", cx);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let Ok(enc) = EncryptedSecretKey::from_bech32(&content) else {
|
||||||
|
self.set_error("Secret Key is invalid", cx);
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Decrypt in the background to ensure it doesn't block the UI
|
||||||
|
let task = cx.background_spawn(async move {
|
||||||
|
if let Ok(content) = enc.decrypt(&password) {
|
||||||
|
Ok(Keys::new(content))
|
||||||
|
} else {
|
||||||
|
Err(anyhow!("Invalid password"))
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
self.tasks.push(cx.spawn_in(window, async move |this, cx| {
|
||||||
|
match task.await {
|
||||||
|
Ok(keys) => {
|
||||||
|
nostr.update(cx, |this, cx| {
|
||||||
|
this.add_key_signer(&keys, cx);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
this.update(cx, |this, cx| {
|
||||||
|
this.set_error(e.to_string(), cx);
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_error<S>(&mut self, message: S, cx: &mut Context<Self>)
|
||||||
|
where
|
||||||
|
S: Into<SharedString>,
|
||||||
|
{
|
||||||
|
// Reset the log in state
|
||||||
|
self.set_loading(false, cx);
|
||||||
|
|
||||||
|
// Reset the countdown
|
||||||
|
self.set_countdown(None, cx);
|
||||||
|
|
||||||
|
// Update error message
|
||||||
|
self.error.update(cx, |this, cx| {
|
||||||
|
*this = Some(message.into());
|
||||||
|
cx.notify();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Clear the error message after 3 secs
|
||||||
|
self.tasks.push(cx.spawn(async move |this, cx| {
|
||||||
|
cx.background_executor().timer(Duration::from_secs(3)).await;
|
||||||
|
|
||||||
|
this.update(cx, |this, cx| {
|
||||||
|
this.error.update(cx, |this, cx| {
|
||||||
|
*this = None;
|
||||||
|
cx.notify();
|
||||||
|
});
|
||||||
|
})?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_loading(&mut self, status: bool, cx: &mut Context<Self>) {
|
||||||
|
self.loading = status;
|
||||||
|
cx.notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_countdown(&mut self, i: Option<u64>, cx: &mut Context<Self>) {
|
||||||
|
self.countdown.update(cx, |this, cx| {
|
||||||
|
*this = i;
|
||||||
|
cx.notify();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Render for ImportKey {
|
||||||
|
fn render(&mut self, _window: &mut gpui::Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||||
|
v_flex()
|
||||||
|
.size_full()
|
||||||
|
.p_4()
|
||||||
|
.gap_2()
|
||||||
|
.text_sm()
|
||||||
|
.child(
|
||||||
|
v_flex()
|
||||||
|
.gap_1()
|
||||||
|
.text_sm()
|
||||||
|
.text_color(cx.theme().text_muted)
|
||||||
|
.child("nsec or bunker://")
|
||||||
|
.child(TextInput::new(&self.key_input)),
|
||||||
|
)
|
||||||
|
.when(
|
||||||
|
self.key_input.read(cx).value().starts_with("ncryptsec1"),
|
||||||
|
|this| {
|
||||||
|
this.child(
|
||||||
|
v_flex()
|
||||||
|
.gap_1()
|
||||||
|
.text_sm()
|
||||||
|
.text_color(cx.theme().text_muted)
|
||||||
|
.child("Password:")
|
||||||
|
.child(TextInput::new(&self.pass_input)),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
Button::new("login")
|
||||||
|
.label("Continue")
|
||||||
|
.primary()
|
||||||
|
.loading(self.loading)
|
||||||
|
.disabled(self.loading)
|
||||||
|
.on_click(cx.listener(move |this, _, window, cx| {
|
||||||
|
this.login(window, cx);
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
.when_some(self.countdown.read(cx).as_ref(), |this, i| {
|
||||||
|
this.child(
|
||||||
|
div()
|
||||||
|
.text_xs()
|
||||||
|
.text_center()
|
||||||
|
.text_color(cx.theme().text_muted)
|
||||||
|
.child(SharedString::from(format!(
|
||||||
|
"Approve connection request from your signer in {} seconds",
|
||||||
|
i
|
||||||
|
))),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.when_some(self.error.read(cx).as_ref(), |this, error| {
|
||||||
|
this.child(
|
||||||
|
div()
|
||||||
|
.text_xs()
|
||||||
|
.text_center()
|
||||||
|
.text_color(cx.theme().danger_active)
|
||||||
|
.child(error.clone()),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1 +1,6 @@
|
|||||||
|
pub mod accounts;
|
||||||
pub mod screening;
|
pub mod screening;
|
||||||
|
pub mod settings;
|
||||||
|
|
||||||
|
mod connect;
|
||||||
|
mod import;
|
||||||
|
|||||||
@@ -2,14 +2,14 @@ use std::collections::HashMap;
|
|||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use anyhow::{Context as AnyhowContext, Error};
|
use anyhow::{Context as AnyhowContext, Error};
|
||||||
use common::{shorten_pubkey, RenderedTimestamp};
|
use common::RenderedTimestamp;
|
||||||
use gpui::prelude::FluentBuilder;
|
use gpui::prelude::FluentBuilder;
|
||||||
use gpui::{
|
use gpui::{
|
||||||
div, px, relative, rems, uniform_list, App, AppContext, Context, Div, Entity,
|
div, px, relative, uniform_list, App, AppContext, Context, Div, Entity, InteractiveElement,
|
||||||
InteractiveElement, IntoElement, ParentElement, Render, SharedString, Styled, Task, Window,
|
IntoElement, ParentElement, Render, SharedString, Styled, Subscription, Task, Window,
|
||||||
};
|
};
|
||||||
use nostr_sdk::prelude::*;
|
use nostr_sdk::prelude::*;
|
||||||
use person::{Person, PersonRegistry};
|
use person::{shorten_pubkey, Person, PersonRegistry};
|
||||||
use smallvec::{smallvec, SmallVec};
|
use smallvec::{smallvec, SmallVec};
|
||||||
use state::{NostrAddress, NostrRegistry, BOOTSTRAP_RELAYS, TIMEOUT};
|
use state::{NostrAddress, NostrRegistry, BOOTSTRAP_RELAYS, TIMEOUT};
|
||||||
use theme::ActiveTheme;
|
use theme::ActiveTheme;
|
||||||
@@ -41,10 +41,20 @@ pub struct Screening {
|
|||||||
|
|
||||||
/// Async tasks
|
/// Async tasks
|
||||||
tasks: SmallVec<[Task<()>; 3]>,
|
tasks: SmallVec<[Task<()>; 3]>,
|
||||||
|
|
||||||
|
/// Subscriptions
|
||||||
|
_subscriptions: SmallVec<[Subscription; 1]>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Screening {
|
impl Screening {
|
||||||
pub fn new(public_key: PublicKey, window: &mut Window, cx: &mut Context<Self>) -> Self {
|
pub fn new(public_key: PublicKey, window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||||
|
let mut subscriptions = smallvec![];
|
||||||
|
|
||||||
|
subscriptions.push(cx.on_release_in(window, move |this, window, cx| {
|
||||||
|
this.tasks.clear();
|
||||||
|
window.close_all_modals(cx);
|
||||||
|
}));
|
||||||
|
|
||||||
cx.defer_in(window, move |this, _window, cx| {
|
cx.defer_in(window, move |this, _window, cx| {
|
||||||
this.check_contact(cx);
|
this.check_contact(cx);
|
||||||
this.check_wot(cx);
|
this.check_wot(cx);
|
||||||
@@ -59,6 +69,7 @@ impl Screening {
|
|||||||
last_active: None,
|
last_active: None,
|
||||||
mutual_contacts: vec![],
|
mutual_contacts: vec![],
|
||||||
tasks: smallvec![],
|
tasks: smallvec![],
|
||||||
|
_subscriptions: subscriptions,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -137,10 +148,10 @@ impl Screening {
|
|||||||
let mut activity: Option<Timestamp> = None;
|
let mut activity: Option<Timestamp> = None;
|
||||||
|
|
||||||
// Construct target for subscription
|
// Construct target for subscription
|
||||||
let target = BOOTSTRAP_RELAYS
|
let target: HashMap<&str, Vec<Filter>> = BOOTSTRAP_RELAYS
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|relay| (relay, vec![filter.clone()]))
|
.map(|relay| (relay, vec![filter.clone()]))
|
||||||
.collect::<HashMap<_, _>>();
|
.collect();
|
||||||
|
|
||||||
if let Ok(mut stream) = client
|
if let Ok(mut stream) = client
|
||||||
.stream_events(target)
|
.stream_events(target)
|
||||||
@@ -243,7 +254,7 @@ impl Screening {
|
|||||||
let total = contacts.len();
|
let total = contacts.len();
|
||||||
|
|
||||||
this.title(SharedString::from("Mutual contacts")).child(
|
this.title(SharedString::from("Mutual contacts")).child(
|
||||||
v_flex().gap_1().pb_4().child(
|
v_flex().gap_1().pb_2().child(
|
||||||
uniform_list("contacts", total, move |range, _window, cx| {
|
uniform_list("contacts", total, move |range, _window, cx| {
|
||||||
let persons = PersonRegistry::global(cx);
|
let persons = PersonRegistry::global(cx);
|
||||||
let mut items = Vec::with_capacity(total);
|
let mut items = Vec::with_capacity(total);
|
||||||
@@ -263,7 +274,7 @@ impl Screening {
|
|||||||
.rounded(cx.theme().radius)
|
.rounded(cx.theme().radius)
|
||||||
.text_sm()
|
.text_sm()
|
||||||
.hover(|this| this.bg(cx.theme().elevated_surface_background))
|
.hover(|this| this.bg(cx.theme().elevated_surface_background))
|
||||||
.child(Avatar::new(profile.avatar()).size(rems(1.75)))
|
.child(Avatar::new(profile.avatar()).small())
|
||||||
.child(profile.name()),
|
.child(profile.name()),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -279,11 +290,21 @@ impl Screening {
|
|||||||
|
|
||||||
impl Render for Screening {
|
impl Render for Screening {
|
||||||
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||||
|
const CONTACT: &str = "This person is one of your contacts.";
|
||||||
|
const NOT_CONTACT: &str = "This person is not one of your contacts.";
|
||||||
|
const NO_ACTIVITY: &str = "This person hasn't had any activity.";
|
||||||
|
const RELAY_INFO: &str = "Only checked on public relays; may be inaccurate.";
|
||||||
|
const NO_MUTUAL: &str = "You don't have any mutual contacts.";
|
||||||
|
const NIP05_MATCH: &str = "The address matches the user's public key.";
|
||||||
|
const NIP05_NOT_MATCH: &str = "The address does not match the user's public key.";
|
||||||
|
const NO_NIP05: &str = "This person has not set up their friendly address";
|
||||||
|
|
||||||
let profile = self.profile(cx);
|
let profile = self.profile(cx);
|
||||||
let shorten_pubkey = shorten_pubkey(self.public_key, 8);
|
let shorten_pubkey = shorten_pubkey(self.public_key, 8);
|
||||||
|
|
||||||
let total_mutuals = self.mutual_contacts.len();
|
|
||||||
let last_active = self.last_active.map(|_| true);
|
let last_active = self.last_active.map(|_| true);
|
||||||
|
let mutuals = self.mutual_contacts.len();
|
||||||
|
let mutuals_str = format!("You have {} mutual contacts with this person.", mutuals);
|
||||||
|
|
||||||
v_flex()
|
v_flex()
|
||||||
.gap_4()
|
.gap_4()
|
||||||
@@ -293,7 +314,7 @@ impl Render for Screening {
|
|||||||
.items_center()
|
.items_center()
|
||||||
.justify_center()
|
.justify_center()
|
||||||
.text_center()
|
.text_center()
|
||||||
.child(Avatar::new(profile.avatar()).size(rems(4.)))
|
.child(Avatar::new(profile.avatar()).large())
|
||||||
.child(
|
.child(
|
||||||
div()
|
div()
|
||||||
.font_semibold()
|
.font_semibold()
|
||||||
@@ -335,8 +356,9 @@ impl Render for Screening {
|
|||||||
.child(
|
.child(
|
||||||
Button::new("report")
|
Button::new("report")
|
||||||
.tooltip("Report as a scam or impostor")
|
.tooltip("Report as a scam or impostor")
|
||||||
.icon(IconName::Boom)
|
.icon(IconName::Warning)
|
||||||
.danger()
|
.small()
|
||||||
|
.warning()
|
||||||
.rounded()
|
.rounded()
|
||||||
.on_click(cx.listener(move |this, _e, window, cx| {
|
.on_click(cx.listener(move |this, _e, window, cx| {
|
||||||
this.report(window, cx);
|
this.report(window, cx);
|
||||||
@@ -363,9 +385,9 @@ impl Render for Screening {
|
|||||||
.text_color(cx.theme().text_muted)
|
.text_color(cx.theme().text_muted)
|
||||||
.child({
|
.child({
|
||||||
if self.followed {
|
if self.followed {
|
||||||
SharedString::from("This person is one of your contacts.")
|
SharedString::from(CONTACT)
|
||||||
} else {
|
} else {
|
||||||
SharedString::from("This person is not one of your contacts.")
|
SharedString::from(NOT_CONTACT)
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
@@ -390,7 +412,7 @@ impl Render for Screening {
|
|||||||
.xsmall()
|
.xsmall()
|
||||||
.ghost()
|
.ghost()
|
||||||
.rounded()
|
.rounded()
|
||||||
.tooltip("This may be inaccurate if the user only publishes to their private relays."),
|
.tooltip(RELAY_INFO),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.child(
|
.child(
|
||||||
@@ -399,13 +421,13 @@ impl Render for Screening {
|
|||||||
.line_clamp(1)
|
.line_clamp(1)
|
||||||
.text_color(cx.theme().text_muted)
|
.text_color(cx.theme().text_muted)
|
||||||
.map(|this| {
|
.map(|this| {
|
||||||
if let Some(date) = self.last_active {
|
if let Some(t) = self.last_active {
|
||||||
this.child(SharedString::from(format!(
|
this.child(SharedString::from(format!(
|
||||||
"Last active: {}.",
|
"Last active: {}.",
|
||||||
date.to_human_time()
|
t.to_human_time()
|
||||||
)))
|
)))
|
||||||
} else {
|
} else {
|
||||||
this.child(SharedString::from("This person hasn't had any activity."))
|
this.child(SharedString::from(NO_ACTIVITY))
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
@@ -423,7 +445,9 @@ impl Render for Screening {
|
|||||||
if let Some(addr) = self.address(cx) {
|
if let Some(addr) = self.address(cx) {
|
||||||
SharedString::from(format!("{} validation", addr))
|
SharedString::from(format!("{} validation", addr))
|
||||||
} else {
|
} else {
|
||||||
SharedString::from("Friendly Address (NIP-05) validation")
|
SharedString::from(
|
||||||
|
"Friendly Address (NIP-05) validation",
|
||||||
|
)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.child(
|
.child(
|
||||||
@@ -433,12 +457,12 @@ impl Render for Screening {
|
|||||||
.child({
|
.child({
|
||||||
if self.address(cx).is_some() {
|
if self.address(cx).is_some() {
|
||||||
if self.verified {
|
if self.verified {
|
||||||
SharedString::from("The address matches the user's public key.")
|
SharedString::from(NIP05_MATCH)
|
||||||
} else {
|
} else {
|
||||||
SharedString::from("The address does not match the user's public key.")
|
SharedString::from(NIP05_NOT_MATCH)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
SharedString::from("This person has not set up their friendly address")
|
SharedString::from(NO_NIP05)
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
@@ -448,7 +472,7 @@ impl Render for Screening {
|
|||||||
h_flex()
|
h_flex()
|
||||||
.items_start()
|
.items_start()
|
||||||
.gap_2()
|
.gap_2()
|
||||||
.child(status_badge(Some(total_mutuals > 0), cx))
|
.child(status_badge(Some(mutuals > 0), cx))
|
||||||
.child(
|
.child(
|
||||||
v_flex()
|
v_flex()
|
||||||
.text_sm()
|
.text_sm()
|
||||||
@@ -474,13 +498,10 @@ impl Render for Screening {
|
|||||||
.line_clamp(1)
|
.line_clamp(1)
|
||||||
.text_color(cx.theme().text_muted)
|
.text_color(cx.theme().text_muted)
|
||||||
.child({
|
.child({
|
||||||
if total_mutuals > 0 {
|
if mutuals > 0 {
|
||||||
SharedString::from(format!(
|
SharedString::from(mutuals_str)
|
||||||
"You have {} mutual contacts with this person.",
|
|
||||||
total_mutuals
|
|
||||||
))
|
|
||||||
} else {
|
} else {
|
||||||
SharedString::from("You don't have any mutual contacts with this person.")
|
SharedString::from(NO_MUTUAL)
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
|
|||||||
242
crates/coop/src/dialogs/settings.rs
Normal file
@@ -0,0 +1,242 @@
|
|||||||
|
use gpui::http_client::Url;
|
||||||
|
use gpui::{
|
||||||
|
div, px, App, AppContext, Context, Entity, IntoElement, ParentElement, Render, SharedString,
|
||||||
|
Styled, Window,
|
||||||
|
};
|
||||||
|
use settings::{AppSettings, AuthMode};
|
||||||
|
use theme::{ActiveTheme, ThemeMode};
|
||||||
|
use ui::button::{Button, ButtonVariants};
|
||||||
|
use ui::group_box::{GroupBox, GroupBoxVariants};
|
||||||
|
use ui::input::{InputState, TextInput};
|
||||||
|
use ui::menu::{DropdownMenu, PopupMenuItem};
|
||||||
|
use ui::notification::Notification;
|
||||||
|
use ui::switch::Switch;
|
||||||
|
use ui::{h_flex, v_flex, IconName, Sizable, WindowExtension};
|
||||||
|
|
||||||
|
pub fn init(window: &mut Window, cx: &mut App) -> Entity<Preferences> {
|
||||||
|
cx.new(|cx| Preferences::new(window, cx))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct Preferences {
|
||||||
|
file_input: Entity<InputState>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Preferences {
|
||||||
|
pub fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||||
|
let server = AppSettings::get_file_server(cx);
|
||||||
|
let file_input = cx.new(|cx| {
|
||||||
|
InputState::new(window, cx)
|
||||||
|
.default_value(server.to_string())
|
||||||
|
.placeholder("https://myblossom.com")
|
||||||
|
});
|
||||||
|
|
||||||
|
Self { file_input }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn update_file_server(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||||
|
let value = self.file_input.read(cx).value();
|
||||||
|
|
||||||
|
match Url::parse(&value) {
|
||||||
|
Ok(url) => {
|
||||||
|
AppSettings::update_file_server(url, cx);
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
window.push_notification(Notification::error(e.to_string()), cx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Render for Preferences {
|
||||||
|
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||||
|
const SCREENING: &str =
|
||||||
|
"When opening a request, a popup will appear to help you identify the sender.";
|
||||||
|
const AVATAR: &str =
|
||||||
|
"Hide all avatar pictures to improve performance and protect your privacy.";
|
||||||
|
const MODE: &str =
|
||||||
|
"Choose whether to use the selected light or dark theme, or to follow the OS.";
|
||||||
|
const AUTH: &str = "Choose the authentication behavior for relays.";
|
||||||
|
const RESET: &str = "Reset the theme to the default one.";
|
||||||
|
|
||||||
|
let screening = AppSettings::get_screening(cx);
|
||||||
|
let hide_avatar = AppSettings::get_hide_avatar(cx);
|
||||||
|
let auth_mode = AppSettings::get_auth_mode(cx);
|
||||||
|
let theme_mode = AppSettings::get_theme_mode(cx);
|
||||||
|
|
||||||
|
v_flex()
|
||||||
|
.gap_4()
|
||||||
|
.child(
|
||||||
|
GroupBox::new()
|
||||||
|
.id("general")
|
||||||
|
.title("General")
|
||||||
|
.fill()
|
||||||
|
.child(
|
||||||
|
Switch::new("screening")
|
||||||
|
.label("Screening")
|
||||||
|
.description(SCREENING)
|
||||||
|
.checked(screening)
|
||||||
|
.on_click(move |_, _window, cx| {
|
||||||
|
AppSettings::update_screening(!screening, cx);
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
Switch::new("avatar")
|
||||||
|
.label("Hide user avatar")
|
||||||
|
.description(AVATAR)
|
||||||
|
.checked(hide_avatar)
|
||||||
|
.on_click(move |_, _window, cx| {
|
||||||
|
AppSettings::update_hide_avatar(!hide_avatar, cx);
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
h_flex()
|
||||||
|
.gap_3()
|
||||||
|
.justify_between()
|
||||||
|
.child(
|
||||||
|
v_flex()
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.text_sm()
|
||||||
|
.child(SharedString::from("Relay authentication")),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.text_xs()
|
||||||
|
.text_color(cx.theme().text_muted)
|
||||||
|
.child(SharedString::from(AUTH)),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
Button::new("auth")
|
||||||
|
.label(auth_mode.to_string())
|
||||||
|
.ghost_alt()
|
||||||
|
.small()
|
||||||
|
.dropdown_menu(|this, _window, _cx| {
|
||||||
|
this.min_w(px(256.))
|
||||||
|
.item(
|
||||||
|
PopupMenuItem::new("Auto authentication").on_click(
|
||||||
|
|_ev, _window, cx| {
|
||||||
|
AppSettings::update_auth_mode(
|
||||||
|
AuthMode::Auto,
|
||||||
|
cx,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.item(PopupMenuItem::new("Ask every time").on_click(
|
||||||
|
|_ev, _window, cx| {
|
||||||
|
AppSettings::update_auth_mode(
|
||||||
|
AuthMode::Manual,
|
||||||
|
cx,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
))
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
GroupBox::new()
|
||||||
|
.id("appearance")
|
||||||
|
.title("Appearance")
|
||||||
|
.fill()
|
||||||
|
.child(
|
||||||
|
h_flex()
|
||||||
|
.gap_3()
|
||||||
|
.justify_between()
|
||||||
|
.child(
|
||||||
|
v_flex()
|
||||||
|
.child(div().text_sm().child(SharedString::from("Mode")))
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.text_xs()
|
||||||
|
.text_color(cx.theme().text_muted)
|
||||||
|
.child(SharedString::from(MODE)),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
Button::new("theme-mode")
|
||||||
|
.label(theme_mode.name())
|
||||||
|
.ghost_alt()
|
||||||
|
.small()
|
||||||
|
.dropdown_menu(|this, _window, _cx| {
|
||||||
|
this.min_w(px(256.))
|
||||||
|
.item(PopupMenuItem::new("Light").on_click(
|
||||||
|
|_ev, _window, cx| {
|
||||||
|
AppSettings::update_theme_mode(
|
||||||
|
ThemeMode::Light,
|
||||||
|
cx,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
))
|
||||||
|
.item(PopupMenuItem::new("Dark").on_click(
|
||||||
|
|_ev, _window, cx| {
|
||||||
|
AppSettings::update_theme_mode(
|
||||||
|
ThemeMode::Dark,
|
||||||
|
cx,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
))
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
h_flex()
|
||||||
|
.gap_3()
|
||||||
|
.justify_between()
|
||||||
|
.child(
|
||||||
|
v_flex()
|
||||||
|
.child(div().text_sm().child(SharedString::from("Reset theme")))
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.text_xs()
|
||||||
|
.text_color(cx.theme().text_muted)
|
||||||
|
.child(SharedString::from(RESET)),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
Button::new("reset")
|
||||||
|
.label("Reset")
|
||||||
|
.ghost_alt()
|
||||||
|
.small()
|
||||||
|
.on_click(move |_ev, window, cx| {
|
||||||
|
AppSettings::global(cx).update(cx, |this, cx| {
|
||||||
|
this.reset_theme(window, cx);
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
GroupBox::new()
|
||||||
|
.id("media")
|
||||||
|
.title("Media Upload Service")
|
||||||
|
.fill()
|
||||||
|
.child(
|
||||||
|
v_flex()
|
||||||
|
.gap_0p5()
|
||||||
|
.child(
|
||||||
|
h_flex()
|
||||||
|
.gap_1()
|
||||||
|
.child(TextInput::new(&self.file_input).text_xs().small())
|
||||||
|
.child(
|
||||||
|
Button::new("update-file-server")
|
||||||
|
.icon(IconName::Check)
|
||||||
|
.ghost()
|
||||||
|
.size_8()
|
||||||
|
.on_click(cx.listener(move |this, _ev, window, cx| {
|
||||||
|
this.update_file_server(window, cx)
|
||||||
|
})),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.text_size(px(10.))
|
||||||
|
.italic()
|
||||||
|
.text_color(cx.theme().text_placeholder)
|
||||||
|
.child(SharedString::from("Only support blossom service")),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -79,19 +79,14 @@ fn main() {
|
|||||||
// Initialize theme registry
|
// Initialize theme registry
|
||||||
theme::init(cx);
|
theme::init(cx);
|
||||||
|
|
||||||
// Initialize backend for keys storage
|
// Initialize settings
|
||||||
key_store::init(cx);
|
settings::init(window, cx);
|
||||||
|
|
||||||
// Initialize the nostr client
|
// Initialize the nostr client
|
||||||
state::init(window, cx);
|
state::init(window, cx);
|
||||||
|
|
||||||
// Initialize device signer
|
// Initialize person registry
|
||||||
//
|
person::init(cx);
|
||||||
// NIP-4e: https://github.com/nostr-protocol/nips/blob/per-device-keys/4e.md
|
|
||||||
device::init(window, cx);
|
|
||||||
|
|
||||||
// Initialize settings
|
|
||||||
settings::init(cx);
|
|
||||||
|
|
||||||
// Initialize relay auth registry
|
// Initialize relay auth registry
|
||||||
relay_auth::init(window, cx);
|
relay_auth::init(window, cx);
|
||||||
@@ -99,8 +94,10 @@ fn main() {
|
|||||||
// Initialize app registry
|
// Initialize app registry
|
||||||
chat::init(window, cx);
|
chat::init(window, cx);
|
||||||
|
|
||||||
// Initialize person registry
|
// Initialize device signer
|
||||||
person::init(cx);
|
//
|
||||||
|
// NIP-4e: https://github.com/nostr-protocol/nips/blob/per-device-keys/4e.md
|
||||||
|
device::init(window, cx);
|
||||||
|
|
||||||
// Initialize auto update
|
// Initialize auto update
|
||||||
auto_update::init(window, cx);
|
auto_update::init(window, cx);
|
||||||
|
|||||||
207
crates/coop/src/panels/backup.rs
Normal file
@@ -0,0 +1,207 @@
|
|||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use anyhow::Error;
|
||||||
|
use gpui::{
|
||||||
|
div, AnyElement, App, AppContext, ClipboardItem, Context, Entity, EventEmitter, FocusHandle,
|
||||||
|
Focusable, IntoElement, ParentElement, Render, SharedString, Styled, Task, Window,
|
||||||
|
};
|
||||||
|
use nostr_sdk::prelude::*;
|
||||||
|
use state::KEYRING;
|
||||||
|
use theme::ActiveTheme;
|
||||||
|
use ui::button::{Button, ButtonVariants};
|
||||||
|
use ui::dock_area::panel::{Panel, PanelEvent};
|
||||||
|
use ui::input::{InputState, TextInput};
|
||||||
|
use ui::{divider, v_flex, IconName, Sizable, StyledExt};
|
||||||
|
|
||||||
|
const MSG: &str = "Store your account keys in a safe location. \
|
||||||
|
You can restore your account or move to another client anytime you want.";
|
||||||
|
|
||||||
|
pub fn init(window: &mut Window, cx: &mut App) -> Entity<BackupPanel> {
|
||||||
|
cx.new(|cx| BackupPanel::new(window, cx))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct BackupPanel {
|
||||||
|
name: SharedString,
|
||||||
|
focus_handle: FocusHandle,
|
||||||
|
|
||||||
|
/// Public key input
|
||||||
|
npub_input: Entity<InputState>,
|
||||||
|
|
||||||
|
/// Secret key input
|
||||||
|
nsec_input: Entity<InputState>,
|
||||||
|
|
||||||
|
/// Copied status
|
||||||
|
copied: bool,
|
||||||
|
|
||||||
|
/// Background tasks
|
||||||
|
tasks: Vec<Task<Result<(), Error>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl BackupPanel {
|
||||||
|
pub fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||||
|
let npub_input = cx.new(|cx| InputState::new(window, cx).disabled(true));
|
||||||
|
let nsec_input = cx.new(|cx| InputState::new(window, cx).disabled(true).masked(true));
|
||||||
|
|
||||||
|
// Run at the end of current cycle
|
||||||
|
cx.defer_in(window, |this, window, cx| {
|
||||||
|
this.load(window, cx);
|
||||||
|
});
|
||||||
|
|
||||||
|
Self {
|
||||||
|
name: "Backup".into(),
|
||||||
|
focus_handle: cx.focus_handle(),
|
||||||
|
npub_input,
|
||||||
|
nsec_input,
|
||||||
|
copied: false,
|
||||||
|
tasks: vec![],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn load(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||||
|
let keyring = cx.read_credentials(KEYRING);
|
||||||
|
|
||||||
|
self.tasks.push(cx.spawn_in(window, async move |this, cx| {
|
||||||
|
if let Some((_, secret)) = keyring.await? {
|
||||||
|
let secret = SecretKey::from_slice(&secret)?;
|
||||||
|
let keys = Keys::new(secret);
|
||||||
|
|
||||||
|
this.update_in(cx, |this, window, cx| {
|
||||||
|
this.npub_input.update(cx, |this, cx| {
|
||||||
|
this.set_value(keys.public_key().to_bech32().unwrap(), window, cx);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.nsec_input.update(cx, |this, cx| {
|
||||||
|
this.set_value(keys.secret_key().to_bech32().unwrap(), window, cx);
|
||||||
|
});
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn copy_secret(&mut self, cx: &mut Context<Self>) {
|
||||||
|
let value = self.nsec_input.read(cx).value();
|
||||||
|
let item = ClipboardItem::new_string(value.to_string());
|
||||||
|
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
cx.write_to_primary(item);
|
||||||
|
|
||||||
|
#[cfg(not(target_os = "linux"))]
|
||||||
|
cx.write_to_clipboard(item);
|
||||||
|
|
||||||
|
// Set the copied status to true
|
||||||
|
self.set_copied(true, cx);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_copied(&mut self, status: bool, cx: &mut Context<Self>) {
|
||||||
|
self.copied = status;
|
||||||
|
cx.notify();
|
||||||
|
|
||||||
|
self.tasks.push(cx.spawn(async move |this, cx| {
|
||||||
|
cx.background_executor().timer(Duration::from_secs(2)).await;
|
||||||
|
|
||||||
|
// Clear the error message after a delay
|
||||||
|
this.update(cx, |this, cx| {
|
||||||
|
this.set_copied(false, cx);
|
||||||
|
})?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Panel for BackupPanel {
|
||||||
|
fn panel_id(&self) -> SharedString {
|
||||||
|
self.name.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn title(&self, _cx: &App) -> AnyElement {
|
||||||
|
self.name.clone().into_any_element()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl EventEmitter<PanelEvent> for BackupPanel {}
|
||||||
|
|
||||||
|
impl Focusable for BackupPanel {
|
||||||
|
fn focus_handle(&self, _: &App) -> gpui::FocusHandle {
|
||||||
|
self.focus_handle.clone()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Render for BackupPanel {
|
||||||
|
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||||
|
v_flex()
|
||||||
|
.p_3()
|
||||||
|
.gap_3()
|
||||||
|
.w_full()
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.text_xs()
|
||||||
|
.text_color(cx.theme().text_muted)
|
||||||
|
.child(SharedString::from(MSG)),
|
||||||
|
)
|
||||||
|
.child(divider(cx))
|
||||||
|
.child(
|
||||||
|
v_flex()
|
||||||
|
.gap_2()
|
||||||
|
.flex_1()
|
||||||
|
.w_full()
|
||||||
|
.text_sm()
|
||||||
|
.child(
|
||||||
|
v_flex()
|
||||||
|
.gap_1p5()
|
||||||
|
.w_full()
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.text_xs()
|
||||||
|
.font_semibold()
|
||||||
|
.text_color(cx.theme().text_muted)
|
||||||
|
.child(SharedString::from("Public Key:")),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
TextInput::new(&self.npub_input)
|
||||||
|
.small()
|
||||||
|
.bordered(false)
|
||||||
|
.disabled(true),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
v_flex()
|
||||||
|
.gap_1p5()
|
||||||
|
.w_full()
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.text_xs()
|
||||||
|
.font_semibold()
|
||||||
|
.text_color(cx.theme().text_muted)
|
||||||
|
.child(SharedString::from("Secret Key:")),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
TextInput::new(&self.nsec_input)
|
||||||
|
.small()
|
||||||
|
.bordered(false)
|
||||||
|
.disabled(true),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
Button::new("copy")
|
||||||
|
.icon(IconName::Copy)
|
||||||
|
.label({
|
||||||
|
if self.copied {
|
||||||
|
"Copied"
|
||||||
|
} else {
|
||||||
|
"Copy secret key"
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.primary()
|
||||||
|
.small()
|
||||||
|
.font_semibold()
|
||||||
|
.on_click(cx.listener(move |this, _ev, _window, cx| {
|
||||||
|
this.copy_secret(cx);
|
||||||
|
})),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,127 +0,0 @@
|
|||||||
use std::sync::Arc;
|
|
||||||
|
|
||||||
use common::TextUtils;
|
|
||||||
use gpui::prelude::FluentBuilder;
|
|
||||||
use gpui::{
|
|
||||||
div, img, px, relative, AnyElement, App, AppContext, Context, Entity, EventEmitter,
|
|
||||||
FocusHandle, Focusable, Image, IntoElement, ParentElement, Render, SharedString, Styled, Task,
|
|
||||||
Window,
|
|
||||||
};
|
|
||||||
use smallvec::{smallvec, SmallVec};
|
|
||||||
use state::NostrRegistry;
|
|
||||||
use theme::ActiveTheme;
|
|
||||||
use ui::dock_area::panel::{Panel, PanelEvent};
|
|
||||||
use ui::dock_area::ClosePanel;
|
|
||||||
use ui::notification::Notification;
|
|
||||||
use ui::{v_flex, StyledExt, WindowExtension};
|
|
||||||
|
|
||||||
pub fn init(window: &mut Window, cx: &mut App) -> Entity<ConnectPanel> {
|
|
||||||
cx.new(|cx| ConnectPanel::new(window, cx))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct ConnectPanel {
|
|
||||||
name: SharedString,
|
|
||||||
focus_handle: FocusHandle,
|
|
||||||
|
|
||||||
/// QR Code
|
|
||||||
qr_code: Option<Arc<Image>>,
|
|
||||||
|
|
||||||
/// Background tasks
|
|
||||||
_tasks: SmallVec<[Task<()>; 1]>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ConnectPanel {
|
|
||||||
fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
|
|
||||||
let nostr = NostrRegistry::global(cx);
|
|
||||||
let weak_state = nostr.downgrade();
|
|
||||||
let (signer, uri) = nostr.read(cx).client_connect(None);
|
|
||||||
|
|
||||||
// Generate a QR code for quick connection
|
|
||||||
let qr_code = uri.to_string().to_qr();
|
|
||||||
|
|
||||||
let mut tasks = smallvec![];
|
|
||||||
|
|
||||||
tasks.push(
|
|
||||||
// Wait for nostr connect
|
|
||||||
cx.spawn_in(window, async move |_this, cx| {
|
|
||||||
let result = signer.bunker_uri().await;
|
|
||||||
|
|
||||||
weak_state
|
|
||||||
.update_in(cx, |this, window, cx| {
|
|
||||||
match result {
|
|
||||||
Ok(uri) => {
|
|
||||||
this.persist_bunker(uri, cx);
|
|
||||||
this.set_signer(signer, true, cx);
|
|
||||||
// Close the current panel after setting the signer
|
|
||||||
window.dispatch_action(Box::new(ClosePanel), cx);
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
window.push_notification(Notification::error(e.to_string()), cx);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
})
|
|
||||||
.ok();
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
Self {
|
|
||||||
name: "Nostr Connect".into(),
|
|
||||||
focus_handle: cx.focus_handle(),
|
|
||||||
qr_code,
|
|
||||||
_tasks: tasks,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Panel for ConnectPanel {
|
|
||||||
fn panel_id(&self) -> SharedString {
|
|
||||||
self.name.clone()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn title(&self, _cx: &App) -> AnyElement {
|
|
||||||
self.name.clone().into_any_element()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl EventEmitter<PanelEvent> for ConnectPanel {}
|
|
||||||
|
|
||||||
impl Focusable for ConnectPanel {
|
|
||||||
fn focus_handle(&self, _: &App) -> gpui::FocusHandle {
|
|
||||||
self.focus_handle.clone()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Render for ConnectPanel {
|
|
||||||
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
|
||||||
v_flex()
|
|
||||||
.size_full()
|
|
||||||
.items_center()
|
|
||||||
.justify_center()
|
|
||||||
.p_2()
|
|
||||||
.gap_10()
|
|
||||||
.child(
|
|
||||||
v_flex()
|
|
||||||
.justify_center()
|
|
||||||
.items_center()
|
|
||||||
.text_center()
|
|
||||||
.child(
|
|
||||||
div()
|
|
||||||
.font_semibold()
|
|
||||||
.line_height(relative(1.25))
|
|
||||||
.child(SharedString::from("Continue with Nostr Connect")),
|
|
||||||
)
|
|
||||||
.child(div().text_sm().text_color(cx.theme().text_muted).child(
|
|
||||||
SharedString::from("Use Nostr Connect apps to scan the code"),
|
|
||||||
)),
|
|
||||||
)
|
|
||||||
.when_some(self.qr_code.as_ref(), |this, qr| {
|
|
||||||
this.child(
|
|
||||||
img(qr.clone())
|
|
||||||
.size(px(256.))
|
|
||||||
.rounded(cx.theme().radius_lg)
|
|
||||||
.border_1()
|
|
||||||
.border_color(cx.theme().border),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
369
crates/coop/src/panels/contact_list.rs
Normal file
@@ -0,0 +1,369 @@
|
|||||||
|
use std::collections::HashSet;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use anyhow::{Context as AnyhowContext, Error};
|
||||||
|
use gpui::prelude::FluentBuilder;
|
||||||
|
use gpui::{
|
||||||
|
div, rems, AnyElement, App, AppContext, Context, Entity, EventEmitter, FocusHandle, Focusable,
|
||||||
|
InteractiveElement, IntoElement, ParentElement, Render, SharedString, Styled, Subscription,
|
||||||
|
Task, TextAlign, Window,
|
||||||
|
};
|
||||||
|
use nostr_sdk::prelude::*;
|
||||||
|
use person::PersonRegistry;
|
||||||
|
use smallvec::{smallvec, SmallVec};
|
||||||
|
use state::NostrRegistry;
|
||||||
|
use theme::ActiveTheme;
|
||||||
|
use ui::avatar::Avatar;
|
||||||
|
use ui::button::{Button, ButtonVariants};
|
||||||
|
use ui::dock_area::panel::{Panel, PanelEvent};
|
||||||
|
use ui::input::{InputEvent, InputState, TextInput};
|
||||||
|
use ui::{h_flex, v_flex, Disableable, IconName, Sizable, StyledExt, WindowExtension};
|
||||||
|
|
||||||
|
pub fn init(window: &mut Window, cx: &mut App) -> Entity<ContactListPanel> {
|
||||||
|
cx.new(|cx| ContactListPanel::new(window, cx))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct ContactListPanel {
|
||||||
|
name: SharedString,
|
||||||
|
focus_handle: FocusHandle,
|
||||||
|
|
||||||
|
/// Npub input
|
||||||
|
input: Entity<InputState>,
|
||||||
|
|
||||||
|
/// Whether the panel is updating
|
||||||
|
updating: bool,
|
||||||
|
|
||||||
|
/// Error message
|
||||||
|
error: Option<SharedString>,
|
||||||
|
|
||||||
|
/// All contacts
|
||||||
|
contacts: HashSet<PublicKey>,
|
||||||
|
|
||||||
|
/// Event subscriptions
|
||||||
|
_subscriptions: SmallVec<[Subscription; 1]>,
|
||||||
|
|
||||||
|
/// Background tasks
|
||||||
|
tasks: Vec<Task<Result<(), Error>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ContactListPanel {
|
||||||
|
pub fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||||
|
let input = cx.new(|cx| InputState::new(window, cx).placeholder("npub1..."));
|
||||||
|
let mut subscriptions = smallvec![];
|
||||||
|
|
||||||
|
subscriptions.push(
|
||||||
|
// Subscribe to user's input events
|
||||||
|
cx.subscribe_in(&input, window, move |this, _input, event, window, cx| {
|
||||||
|
if let InputEvent::PressEnter { .. } = event {
|
||||||
|
this.add(window, cx);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Run at the end of current cycle
|
||||||
|
cx.defer_in(window, |this, window, cx| {
|
||||||
|
this.load(window, cx);
|
||||||
|
});
|
||||||
|
|
||||||
|
Self {
|
||||||
|
name: "Contact List".into(),
|
||||||
|
focus_handle: cx.focus_handle(),
|
||||||
|
input,
|
||||||
|
updating: false,
|
||||||
|
contacts: HashSet::new(),
|
||||||
|
error: None,
|
||||||
|
_subscriptions: subscriptions,
|
||||||
|
tasks: vec![],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn load(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||||
|
let nostr = NostrRegistry::global(cx);
|
||||||
|
let client = nostr.read(cx).client();
|
||||||
|
|
||||||
|
let task: Task<Result<HashSet<PublicKey>, Error>> = cx.background_spawn(async move {
|
||||||
|
let signer = client.signer().context("Signer not found")?;
|
||||||
|
let public_key = signer.get_public_key().await?;
|
||||||
|
let contact_list = client.database().contacts_public_keys(public_key).await?;
|
||||||
|
|
||||||
|
Ok(contact_list)
|
||||||
|
});
|
||||||
|
|
||||||
|
self.tasks.push(cx.spawn_in(window, async move |this, cx| {
|
||||||
|
let public_keys = task.await?;
|
||||||
|
|
||||||
|
// Update state
|
||||||
|
this.update(cx, |this, cx| {
|
||||||
|
this.contacts.extend(public_keys);
|
||||||
|
cx.notify();
|
||||||
|
})?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn add(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||||
|
let value = self.input.read(cx).value().to_string();
|
||||||
|
|
||||||
|
if let Ok(public_key) = PublicKey::parse(&value) {
|
||||||
|
if self.contacts.insert(public_key) {
|
||||||
|
self.input.update(cx, |this, cx| {
|
||||||
|
this.set_value("", window, cx);
|
||||||
|
});
|
||||||
|
cx.notify();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
self.set_error("Public Key is invalid", window, cx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn remove(&mut self, public_key: &PublicKey, cx: &mut Context<Self>) {
|
||||||
|
self.contacts.remove(public_key);
|
||||||
|
cx.notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_error<E>(&mut self, error: E, window: &mut Window, cx: &mut Context<Self>)
|
||||||
|
where
|
||||||
|
E: Into<SharedString>,
|
||||||
|
{
|
||||||
|
self.error = Some(error.into());
|
||||||
|
cx.notify();
|
||||||
|
|
||||||
|
self.tasks.push(cx.spawn_in(window, async move |this, cx| {
|
||||||
|
cx.background_executor().timer(Duration::from_secs(2)).await;
|
||||||
|
|
||||||
|
// Clear the error message after a delay
|
||||||
|
this.update(cx, |this, cx| {
|
||||||
|
this.error = None;
|
||||||
|
cx.notify();
|
||||||
|
})?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_updating(&mut self, updating: bool, cx: &mut Context<Self>) {
|
||||||
|
self.updating = updating;
|
||||||
|
cx.notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn update(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||||
|
if self.contacts.is_empty() {
|
||||||
|
self.set_error("You need to add at least 1 contact", window, cx);
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let nostr = NostrRegistry::global(cx);
|
||||||
|
let client = nostr.read(cx).client();
|
||||||
|
let signer = nostr.read(cx).signer();
|
||||||
|
|
||||||
|
let Some(public_key) = signer.public_key() else {
|
||||||
|
window.push_notification("Public Key not found", cx);
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get user's write relays
|
||||||
|
let write_relays = nostr.read(cx).write_relays(&public_key, cx);
|
||||||
|
|
||||||
|
// Get contacts
|
||||||
|
let contacts: Vec<Contact> = self
|
||||||
|
.contacts
|
||||||
|
.iter()
|
||||||
|
.map(|public_key| Contact::new(*public_key))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
// Set updating state
|
||||||
|
self.set_updating(true, cx);
|
||||||
|
|
||||||
|
let task: Task<Result<(), Error>> = cx.background_spawn(async move {
|
||||||
|
let urls = write_relays.await;
|
||||||
|
|
||||||
|
// Construct contact list event builder
|
||||||
|
let builder = EventBuilder::contact_list(contacts);
|
||||||
|
let event = client.sign_event_builder(builder).await?;
|
||||||
|
|
||||||
|
// Set contact list
|
||||||
|
client.send_event(&event).to(urls).await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
});
|
||||||
|
|
||||||
|
self.tasks.push(cx.spawn_in(window, async move |this, cx| {
|
||||||
|
match task.await {
|
||||||
|
Ok(_) => {
|
||||||
|
this.update_in(cx, |this, window, cx| {
|
||||||
|
this.set_updating(false, cx);
|
||||||
|
this.load(window, cx);
|
||||||
|
|
||||||
|
window.push_notification("Update successful", cx);
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
this.update_in(cx, |this, window, cx| {
|
||||||
|
this.set_updating(false, cx);
|
||||||
|
this.set_error(e.to_string(), window, cx);
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_list_items(&mut self, cx: &mut Context<Self>) -> Vec<impl IntoElement> {
|
||||||
|
let persons = PersonRegistry::global(cx);
|
||||||
|
let mut items = Vec::new();
|
||||||
|
|
||||||
|
for (ix, public_key) in self.contacts.iter().enumerate() {
|
||||||
|
let profile = persons.read(cx).get(public_key, cx);
|
||||||
|
|
||||||
|
items.push(
|
||||||
|
h_flex()
|
||||||
|
.id(ix)
|
||||||
|
.group("")
|
||||||
|
.flex_1()
|
||||||
|
.w_full()
|
||||||
|
.h_8()
|
||||||
|
.px_2()
|
||||||
|
.justify_between()
|
||||||
|
.rounded(cx.theme().radius)
|
||||||
|
.bg(cx.theme().secondary_background)
|
||||||
|
.text_color(cx.theme().secondary_foreground)
|
||||||
|
.child(
|
||||||
|
h_flex()
|
||||||
|
.gap_2()
|
||||||
|
.text_sm()
|
||||||
|
.child(Avatar::new(profile.avatar()).small())
|
||||||
|
.child(profile.name()),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
Button::new("remove_{ix}")
|
||||||
|
.icon(IconName::Close)
|
||||||
|
.xsmall()
|
||||||
|
.ghost()
|
||||||
|
.invisible()
|
||||||
|
.group_hover("", |this| this.visible())
|
||||||
|
.on_click({
|
||||||
|
let public_key = public_key.to_owned();
|
||||||
|
cx.listener(move |this, _ev, _window, cx| {
|
||||||
|
this.remove(&public_key, cx);
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
items
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_empty(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||||
|
h_flex()
|
||||||
|
.h_20()
|
||||||
|
.justify_center()
|
||||||
|
.border_2()
|
||||||
|
.border_dashed()
|
||||||
|
.border_color(cx.theme().border)
|
||||||
|
.rounded(cx.theme().radius_lg)
|
||||||
|
.text_sm()
|
||||||
|
.text_align(TextAlign::Center)
|
||||||
|
.child(SharedString::from("Please add some relays."))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Panel for ContactListPanel {
|
||||||
|
fn panel_id(&self) -> SharedString {
|
||||||
|
self.name.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn title(&self, _cx: &App) -> AnyElement {
|
||||||
|
self.name.clone().into_any_element()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl EventEmitter<PanelEvent> for ContactListPanel {}
|
||||||
|
|
||||||
|
impl Focusable for ContactListPanel {
|
||||||
|
fn focus_handle(&self, _: &App) -> gpui::FocusHandle {
|
||||||
|
self.focus_handle.clone()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Render for ContactListPanel {
|
||||||
|
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||||
|
v_flex().p_3().gap_3().w_full().child(
|
||||||
|
v_flex()
|
||||||
|
.gap_2()
|
||||||
|
.flex_1()
|
||||||
|
.w_full()
|
||||||
|
.text_sm()
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.text_xs()
|
||||||
|
.font_semibold()
|
||||||
|
.text_color(cx.theme().text_muted)
|
||||||
|
.child(SharedString::from("New contact:")),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
v_flex()
|
||||||
|
.gap_1()
|
||||||
|
.child(
|
||||||
|
h_flex()
|
||||||
|
.gap_1()
|
||||||
|
.w_full()
|
||||||
|
.child(
|
||||||
|
TextInput::new(&self.input)
|
||||||
|
.small()
|
||||||
|
.bordered(false)
|
||||||
|
.cleanable(),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
Button::new("add")
|
||||||
|
.icon(IconName::Plus)
|
||||||
|
.tooltip("Add contact")
|
||||||
|
.ghost()
|
||||||
|
.size(rems(2.))
|
||||||
|
.on_click(cx.listener(move |this, _, window, cx| {
|
||||||
|
this.add(window, cx);
|
||||||
|
})),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.when_some(self.error.as_ref(), |this, error| {
|
||||||
|
this.child(
|
||||||
|
div()
|
||||||
|
.italic()
|
||||||
|
.text_xs()
|
||||||
|
.text_color(cx.theme().danger_active)
|
||||||
|
.child(error.clone()),
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.map(|this| {
|
||||||
|
if self.contacts.is_empty() {
|
||||||
|
this.child(self.render_empty(window, cx))
|
||||||
|
} else {
|
||||||
|
this.child(
|
||||||
|
v_flex()
|
||||||
|
.gap_1()
|
||||||
|
.flex_1()
|
||||||
|
.w_full()
|
||||||
|
.children(self.render_list_items(cx)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.child(
|
||||||
|
Button::new("submit")
|
||||||
|
.icon(IconName::CheckCircle)
|
||||||
|
.label("Update")
|
||||||
|
.primary()
|
||||||
|
.small()
|
||||||
|
.font_semibold()
|
||||||
|
.loading(self.updating)
|
||||||
|
.disabled(self.updating)
|
||||||
|
.on_click(cx.listener(move |this, _ev, window, cx| {
|
||||||
|
this.update(window, cx);
|
||||||
|
})),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
use chat::ChatRegistry;
|
use chat::{ChatRegistry, InboxState};
|
||||||
use gpui::prelude::FluentBuilder;
|
use gpui::prelude::FluentBuilder;
|
||||||
use gpui::{
|
use gpui::{
|
||||||
div, relative, svg, AnyElement, App, AppContext, Context, Entity, EventEmitter, FocusHandle,
|
div, svg, AnyElement, App, AppContext, Context, Entity, EventEmitter, FocusHandle, Focusable,
|
||||||
Focusable, IntoElement, ParentElement, Render, SharedString, Styled, Window,
|
IntoElement, ParentElement, Render, SharedString, Styled, Window,
|
||||||
};
|
};
|
||||||
use state::{NostrRegistry, RelayState};
|
use state::{NostrRegistry, RelayState};
|
||||||
use theme::ActiveTheme;
|
use theme::ActiveTheme;
|
||||||
@@ -11,7 +11,7 @@ use ui::dock_area::dock::DockPlacement;
|
|||||||
use ui::dock_area::panel::{Panel, PanelEvent};
|
use ui::dock_area::panel::{Panel, PanelEvent};
|
||||||
use ui::{h_flex, v_flex, Icon, IconName, Sizable, StyledExt};
|
use ui::{h_flex, v_flex, Icon, IconName, Sizable, StyledExt};
|
||||||
|
|
||||||
use crate::panels::{connect, import, messaging_relays, profile, relay_list};
|
use crate::panels::{messaging_relays, profile, relay_list};
|
||||||
use crate::workspace::Workspace;
|
use crate::workspace::Workspace;
|
||||||
|
|
||||||
pub fn init(window: &mut Window, cx: &mut App) -> Entity<GreeterPanel> {
|
pub fn init(window: &mut Window, cx: &mut App) -> Entity<GreeterPanel> {
|
||||||
@@ -40,7 +40,7 @@ impl GreeterPanel {
|
|||||||
cx.update(|window, cx| {
|
cx.update(|window, cx| {
|
||||||
Workspace::add_panel(
|
Workspace::add_panel(
|
||||||
profile::init(public_key, window, cx),
|
profile::init(public_key, window, cx),
|
||||||
DockPlacement::Center,
|
DockPlacement::Right,
|
||||||
window,
|
window,
|
||||||
cx,
|
cx,
|
||||||
);
|
);
|
||||||
@@ -83,15 +83,13 @@ impl Render for GreeterPanel {
|
|||||||
const DESCRIPTION: &str = "Chat Freely, Stay Private on Nostr.";
|
const DESCRIPTION: &str = "Chat Freely, Stay Private on Nostr.";
|
||||||
|
|
||||||
let chat = ChatRegistry::global(cx);
|
let chat = ChatRegistry::global(cx);
|
||||||
let nip17_state = chat.read(cx).relay_state(cx);
|
let nip17 = chat.read(cx).state(cx);
|
||||||
|
|
||||||
let nostr = NostrRegistry::global(cx);
|
let nostr = NostrRegistry::global(cx);
|
||||||
let nip65_state = nostr.read(cx).relay_list_state();
|
let nip65 = nostr.read(cx).relay_list_state.clone();
|
||||||
let signer = nostr.read(cx).signer();
|
|
||||||
let owned = signer.owned();
|
|
||||||
|
|
||||||
let required_actions =
|
let required_actions =
|
||||||
nip65_state == RelayState::NotConfigured || nip17_state == RelayState::NotConfigured;
|
nip65 == RelayState::NotConfigured || nip17 == InboxState::RelayNotAvailable;
|
||||||
|
|
||||||
h_flex()
|
h_flex()
|
||||||
.size_full()
|
.size_full()
|
||||||
@@ -121,14 +119,13 @@ impl Render for GreeterPanel {
|
|||||||
.child(
|
.child(
|
||||||
div()
|
div()
|
||||||
.font_semibold()
|
.font_semibold()
|
||||||
.line_height(relative(1.25))
|
.text_color(cx.theme().text)
|
||||||
.child(SharedString::from(TITLE)),
|
.child(SharedString::from(TITLE)),
|
||||||
)
|
)
|
||||||
.child(
|
.child(
|
||||||
div()
|
div()
|
||||||
.text_sm()
|
.text_xs()
|
||||||
.text_color(cx.theme().text_muted)
|
.text_color(cx.theme().text_muted)
|
||||||
.line_height(relative(1.25))
|
|
||||||
.child(SharedString::from(DESCRIPTION)),
|
.child(SharedString::from(DESCRIPTION)),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -140,9 +137,9 @@ impl Render for GreeterPanel {
|
|||||||
.w_full()
|
.w_full()
|
||||||
.child(
|
.child(
|
||||||
h_flex()
|
h_flex()
|
||||||
.gap_1()
|
.gap_2()
|
||||||
.w_full()
|
.w_full()
|
||||||
.text_sm()
|
.text_xs()
|
||||||
.font_semibold()
|
.font_semibold()
|
||||||
.text_color(cx.theme().text_muted)
|
.text_color(cx.theme().text_muted)
|
||||||
.child(SharedString::from("Required Actions"))
|
.child(SharedString::from("Required Actions"))
|
||||||
@@ -152,7 +149,7 @@ impl Render for GreeterPanel {
|
|||||||
v_flex()
|
v_flex()
|
||||||
.gap_2()
|
.gap_2()
|
||||||
.w_full()
|
.w_full()
|
||||||
.when(nip65_state.not_configured(), |this| {
|
.when(nip65.not_configured(), |this| {
|
||||||
this.child(
|
this.child(
|
||||||
Button::new("relaylist")
|
Button::new("relaylist")
|
||||||
.icon(Icon::new(IconName::Relay))
|
.icon(Icon::new(IconName::Relay))
|
||||||
@@ -170,7 +167,7 @@ impl Render for GreeterPanel {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
.when(nip17_state.not_configured(), |this| {
|
.when(nip17.not_configured(), |this| {
|
||||||
this.child(
|
this.child(
|
||||||
Button::new("import")
|
Button::new("import")
|
||||||
.icon(Icon::new(IconName::Relay))
|
.icon(Icon::new(IconName::Relay))
|
||||||
@@ -191,69 +188,15 @@ impl Render for GreeterPanel {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
.when(!owned, |this| {
|
|
||||||
this.child(
|
|
||||||
v_flex()
|
|
||||||
.gap_2()
|
|
||||||
.w_full()
|
|
||||||
.child(
|
|
||||||
h_flex()
|
|
||||||
.gap_1()
|
|
||||||
.w_full()
|
|
||||||
.text_sm()
|
|
||||||
.font_semibold()
|
|
||||||
.text_color(cx.theme().text_muted)
|
|
||||||
.child(SharedString::from("Use your own identity"))
|
|
||||||
.child(div().flex_1().h_px().bg(cx.theme().border)),
|
|
||||||
)
|
|
||||||
.child(
|
|
||||||
v_flex()
|
|
||||||
.gap_2()
|
|
||||||
.w_full()
|
|
||||||
.child(
|
|
||||||
Button::new("connect")
|
|
||||||
.icon(Icon::new(IconName::Door))
|
|
||||||
.label("Connect account via Nostr Connect")
|
|
||||||
.ghost()
|
|
||||||
.small()
|
|
||||||
.justify_start()
|
|
||||||
.on_click(move |_ev, window, cx| {
|
|
||||||
Workspace::add_panel(
|
|
||||||
connect::init(window, cx),
|
|
||||||
DockPlacement::Center,
|
|
||||||
window,
|
|
||||||
cx,
|
|
||||||
);
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.child(
|
|
||||||
Button::new("import")
|
|
||||||
.icon(Icon::new(IconName::Usb))
|
|
||||||
.label("Import a secret key or bunker")
|
|
||||||
.ghost()
|
|
||||||
.small()
|
|
||||||
.justify_start()
|
|
||||||
.on_click(move |_ev, window, cx| {
|
|
||||||
Workspace::add_panel(
|
|
||||||
import::init(window, cx),
|
|
||||||
DockPlacement::Center,
|
|
||||||
window,
|
|
||||||
cx,
|
|
||||||
);
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
.child(
|
.child(
|
||||||
v_flex()
|
v_flex()
|
||||||
.gap_2()
|
.gap_2()
|
||||||
.w_full()
|
.w_full()
|
||||||
.child(
|
.child(
|
||||||
h_flex()
|
h_flex()
|
||||||
.gap_1()
|
.gap_2()
|
||||||
.w_full()
|
.w_full()
|
||||||
.text_sm()
|
.text_xs()
|
||||||
.font_semibold()
|
.font_semibold()
|
||||||
.text_color(cx.theme().text_muted)
|
.text_color(cx.theme().text_muted)
|
||||||
.child(SharedString::from("Get Started"))
|
.child(SharedString::from("Get Started"))
|
||||||
@@ -263,14 +206,6 @@ impl Render for GreeterPanel {
|
|||||||
v_flex()
|
v_flex()
|
||||||
.gap_2()
|
.gap_2()
|
||||||
.w_full()
|
.w_full()
|
||||||
.child(
|
|
||||||
Button::new("backup")
|
|
||||||
.icon(Icon::new(IconName::Shield))
|
|
||||||
.label("Backup account")
|
|
||||||
.ghost()
|
|
||||||
.small()
|
|
||||||
.justify_start(),
|
|
||||||
)
|
|
||||||
.child(
|
.child(
|
||||||
Button::new("profile")
|
Button::new("profile")
|
||||||
.icon(Icon::new(IconName::Profile))
|
.icon(Icon::new(IconName::Profile))
|
||||||
|
|||||||
@@ -1,371 +0,0 @@
|
|||||||
use std::time::Duration;
|
|
||||||
|
|
||||||
use anyhow::anyhow;
|
|
||||||
use gpui::prelude::FluentBuilder;
|
|
||||||
use gpui::{
|
|
||||||
div, relative, AnyElement, App, AppContext, Context, Entity, EventEmitter, FocusHandle,
|
|
||||||
Focusable, IntoElement, ParentElement, Render, SharedString, Styled, Subscription, Window,
|
|
||||||
};
|
|
||||||
use nostr_connect::prelude::*;
|
|
||||||
use smallvec::{smallvec, SmallVec};
|
|
||||||
use state::{CoopAuthUrlHandler, NostrRegistry};
|
|
||||||
use theme::ActiveTheme;
|
|
||||||
use ui::button::{Button, ButtonVariants};
|
|
||||||
use ui::dock_area::panel::{Panel, PanelEvent};
|
|
||||||
use ui::dock_area::ClosePanel;
|
|
||||||
use ui::input::{InputEvent, InputState, TextInput};
|
|
||||||
use ui::notification::Notification;
|
|
||||||
use ui::{v_flex, Disableable, StyledExt, WindowExtension};
|
|
||||||
|
|
||||||
pub fn init(window: &mut Window, cx: &mut App) -> Entity<ImportPanel> {
|
|
||||||
cx.new(|cx| ImportPanel::new(window, cx))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug)]
|
|
||||||
pub struct ImportPanel {
|
|
||||||
name: SharedString,
|
|
||||||
focus_handle: FocusHandle,
|
|
||||||
|
|
||||||
/// Secret key input
|
|
||||||
key_input: Entity<InputState>,
|
|
||||||
|
|
||||||
/// Password input (if required)
|
|
||||||
pass_input: Entity<InputState>,
|
|
||||||
|
|
||||||
/// Error message
|
|
||||||
error: Entity<Option<SharedString>>,
|
|
||||||
|
|
||||||
/// Countdown timer for nostr connect
|
|
||||||
countdown: Entity<Option<u64>>,
|
|
||||||
|
|
||||||
/// Whether the user is currently logging in
|
|
||||||
logging_in: bool,
|
|
||||||
|
|
||||||
/// Event subscriptions
|
|
||||||
_subscriptions: SmallVec<[Subscription; 1]>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ImportPanel {
|
|
||||||
fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
|
|
||||||
let key_input = cx.new(|cx| InputState::new(window, cx).masked(true));
|
|
||||||
let pass_input = cx.new(|cx| InputState::new(window, cx).masked(true));
|
|
||||||
|
|
||||||
let error = cx.new(|_| None);
|
|
||||||
let countdown = cx.new(|_| None);
|
|
||||||
|
|
||||||
let mut subscriptions = smallvec![];
|
|
||||||
|
|
||||||
subscriptions.push(
|
|
||||||
// Subscribe to key input events and process login when the user presses enter
|
|
||||||
cx.subscribe_in(&key_input, window, |this, _input, event, window, cx| {
|
|
||||||
if let InputEvent::PressEnter { .. } = event {
|
|
||||||
this.login(window, cx);
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
Self {
|
|
||||||
key_input,
|
|
||||||
pass_input,
|
|
||||||
error,
|
|
||||||
countdown,
|
|
||||||
name: "Import".into(),
|
|
||||||
focus_handle: cx.focus_handle(),
|
|
||||||
logging_in: false,
|
|
||||||
_subscriptions: subscriptions,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn login(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
|
||||||
if self.logging_in {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
// Prevent duplicate login requests
|
|
||||||
self.set_logging_in(true, cx);
|
|
||||||
|
|
||||||
let value = self.key_input.read(cx).value();
|
|
||||||
let password = self.pass_input.read(cx).value();
|
|
||||||
|
|
||||||
if value.starts_with("bunker://") {
|
|
||||||
self.login_with_bunker(&value, window, cx);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if value.starts_with("ncryptsec1") {
|
|
||||||
self.login_with_password(&value, &password, window, cx);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Ok(secret) = SecretKey::parse(&value) {
|
|
||||||
let keys = Keys::new(secret);
|
|
||||||
let nostr = NostrRegistry::global(cx);
|
|
||||||
// Update the signer
|
|
||||||
nostr.update(cx, |this, cx| {
|
|
||||||
this.set_signer(keys, true, cx);
|
|
||||||
});
|
|
||||||
// Close the current panel after setting the signer
|
|
||||||
window.dispatch_action(Box::new(ClosePanel), cx);
|
|
||||||
} else {
|
|
||||||
self.set_error("Invalid", cx);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn login_with_bunker(&mut self, content: &str, window: &mut Window, cx: &mut Context<Self>) {
|
|
||||||
let Ok(uri) = NostrConnectUri::parse(content) else {
|
|
||||||
self.set_error("Bunker is not valid", cx);
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
|
|
||||||
let nostr = NostrRegistry::global(cx);
|
|
||||||
let weak_state = nostr.downgrade();
|
|
||||||
|
|
||||||
let app_keys = nostr.read(cx).app_keys();
|
|
||||||
let timeout = Duration::from_secs(30);
|
|
||||||
let mut signer = NostrConnect::new(uri, app_keys.clone(), timeout, None).unwrap();
|
|
||||||
|
|
||||||
// Handle auth url with the default browser
|
|
||||||
signer.auth_url_handler(CoopAuthUrlHandler);
|
|
||||||
|
|
||||||
// Start countdown
|
|
||||||
cx.spawn_in(window, async move |this, cx| {
|
|
||||||
for i in (0..=30).rev() {
|
|
||||||
if i == 0 {
|
|
||||||
this.update(cx, |this, cx| {
|
|
||||||
this.set_countdown(None, cx);
|
|
||||||
})
|
|
||||||
.ok();
|
|
||||||
} else {
|
|
||||||
this.update(cx, |this, cx| {
|
|
||||||
this.set_countdown(Some(i), cx);
|
|
||||||
})
|
|
||||||
.ok();
|
|
||||||
}
|
|
||||||
cx.background_executor().timer(Duration::from_secs(1)).await;
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.detach();
|
|
||||||
|
|
||||||
// Handle connection
|
|
||||||
cx.spawn_in(window, async move |_this, cx| {
|
|
||||||
let result = signer.bunker_uri().await;
|
|
||||||
|
|
||||||
weak_state
|
|
||||||
.update_in(cx, |this, window, cx| {
|
|
||||||
match result {
|
|
||||||
Ok(uri) => {
|
|
||||||
this.persist_bunker(uri, cx);
|
|
||||||
this.set_signer(signer, true, cx);
|
|
||||||
// Close the current panel after setting the signer
|
|
||||||
window.dispatch_action(Box::new(ClosePanel), cx);
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
window.push_notification(Notification::error(e.to_string()), cx);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
})
|
|
||||||
.ok();
|
|
||||||
})
|
|
||||||
.detach();
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn login_with_password(
|
|
||||||
&mut self,
|
|
||||||
content: &str,
|
|
||||||
pwd: &str,
|
|
||||||
window: &mut Window,
|
|
||||||
cx: &mut Context<Self>,
|
|
||||||
) {
|
|
||||||
if pwd.is_empty() {
|
|
||||||
self.set_error("Password is required", cx);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let Ok(enc) = EncryptedSecretKey::from_bech32(content) else {
|
|
||||||
self.set_error("Secret Key is invalid", cx);
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
|
|
||||||
let password = pwd.to_owned();
|
|
||||||
|
|
||||||
// Decrypt in the background to ensure it doesn't block the UI
|
|
||||||
let task = cx.background_spawn(async move {
|
|
||||||
if let Ok(content) = enc.decrypt(&password) {
|
|
||||||
Ok(Keys::new(content))
|
|
||||||
} else {
|
|
||||||
Err(anyhow!("Invalid password"))
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
cx.spawn_in(window, async move |this, cx| {
|
|
||||||
let result = task.await;
|
|
||||||
|
|
||||||
this.update_in(cx, |this, window, cx| {
|
|
||||||
match result {
|
|
||||||
Ok(keys) => {
|
|
||||||
let nostr = NostrRegistry::global(cx);
|
|
||||||
// Update the signer
|
|
||||||
nostr.update(cx, |this, cx| {
|
|
||||||
this.set_signer(keys, true, cx);
|
|
||||||
});
|
|
||||||
// Close the current panel after setting the signer
|
|
||||||
window.dispatch_action(Box::new(ClosePanel), cx);
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
this.set_error(e.to_string(), cx);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
})
|
|
||||||
.ok();
|
|
||||||
})
|
|
||||||
.detach();
|
|
||||||
}
|
|
||||||
|
|
||||||
fn set_error<S>(&mut self, message: S, cx: &mut Context<Self>)
|
|
||||||
where
|
|
||||||
S: Into<SharedString>,
|
|
||||||
{
|
|
||||||
// Reset the log in state
|
|
||||||
self.set_logging_in(false, cx);
|
|
||||||
|
|
||||||
// Reset the countdown
|
|
||||||
self.set_countdown(None, cx);
|
|
||||||
|
|
||||||
// Update error message
|
|
||||||
self.error.update(cx, |this, cx| {
|
|
||||||
*this = Some(message.into());
|
|
||||||
cx.notify();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Clear the error message after 3 secs
|
|
||||||
cx.spawn(async move |this, cx| {
|
|
||||||
cx.background_executor().timer(Duration::from_secs(3)).await;
|
|
||||||
|
|
||||||
this.update(cx, |this, cx| {
|
|
||||||
this.error.update(cx, |this, cx| {
|
|
||||||
*this = None;
|
|
||||||
cx.notify();
|
|
||||||
});
|
|
||||||
})
|
|
||||||
.ok();
|
|
||||||
})
|
|
||||||
.detach();
|
|
||||||
}
|
|
||||||
|
|
||||||
fn set_logging_in(&mut self, status: bool, cx: &mut Context<Self>) {
|
|
||||||
self.logging_in = status;
|
|
||||||
cx.notify();
|
|
||||||
}
|
|
||||||
|
|
||||||
fn set_countdown(&mut self, i: Option<u64>, cx: &mut Context<Self>) {
|
|
||||||
self.countdown.update(cx, |this, cx| {
|
|
||||||
*this = i;
|
|
||||||
cx.notify();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Panel for ImportPanel {
|
|
||||||
fn panel_id(&self) -> SharedString {
|
|
||||||
self.name.clone()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn title(&self, _cx: &App) -> AnyElement {
|
|
||||||
self.name.clone().into_any_element()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl EventEmitter<PanelEvent> for ImportPanel {}
|
|
||||||
|
|
||||||
impl Focusable for ImportPanel {
|
|
||||||
fn focus_handle(&self, _: &App) -> gpui::FocusHandle {
|
|
||||||
self.focus_handle.clone()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Render for ImportPanel {
|
|
||||||
fn render(&mut self, _window: &mut gpui::Window, cx: &mut Context<Self>) -> impl IntoElement {
|
|
||||||
const SECRET_WARN: &str = "* Coop doesn't store your secret key. \
|
|
||||||
It will be cleared when you close the app. \
|
|
||||||
To persist your identity, please connect via Nostr Connect.";
|
|
||||||
|
|
||||||
v_flex()
|
|
||||||
.size_full()
|
|
||||||
.items_center()
|
|
||||||
.justify_center()
|
|
||||||
.p_2()
|
|
||||||
.gap_10()
|
|
||||||
.child(
|
|
||||||
div()
|
|
||||||
.text_center()
|
|
||||||
.font_semibold()
|
|
||||||
.line_height(relative(1.25))
|
|
||||||
.child(SharedString::from("Import a Secret Key or Bunker")),
|
|
||||||
)
|
|
||||||
.child(
|
|
||||||
v_flex()
|
|
||||||
.w_112()
|
|
||||||
.gap_2()
|
|
||||||
.text_sm()
|
|
||||||
.child(
|
|
||||||
v_flex()
|
|
||||||
.gap_1()
|
|
||||||
.text_sm()
|
|
||||||
.text_color(cx.theme().text_muted)
|
|
||||||
.child("nsec or bunker://")
|
|
||||||
.child(TextInput::new(&self.key_input)),
|
|
||||||
)
|
|
||||||
.when(
|
|
||||||
self.key_input.read(cx).value().starts_with("ncryptsec1"),
|
|
||||||
|this| {
|
|
||||||
this.child(
|
|
||||||
v_flex()
|
|
||||||
.gap_1()
|
|
||||||
.text_sm()
|
|
||||||
.text_color(cx.theme().text_muted)
|
|
||||||
.child("Password:")
|
|
||||||
.child(TextInput::new(&self.pass_input)),
|
|
||||||
)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.child(
|
|
||||||
Button::new("login")
|
|
||||||
.label("Continue")
|
|
||||||
.primary()
|
|
||||||
.loading(self.logging_in)
|
|
||||||
.disabled(self.logging_in)
|
|
||||||
.on_click(cx.listener(move |this, _, window, cx| {
|
|
||||||
this.login(window, cx);
|
|
||||||
})),
|
|
||||||
)
|
|
||||||
.when_some(self.countdown.read(cx).as_ref(), |this, i| {
|
|
||||||
this.child(
|
|
||||||
div()
|
|
||||||
.text_xs()
|
|
||||||
.text_center()
|
|
||||||
.text_color(cx.theme().text_muted)
|
|
||||||
.child(SharedString::from(format!(
|
|
||||||
"Approve connection request from your signer in {} seconds",
|
|
||||||
i
|
|
||||||
))),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
.when_some(self.error.read(cx).as_ref(), |this, error| {
|
|
||||||
this.child(
|
|
||||||
div()
|
|
||||||
.text_xs()
|
|
||||||
.text_center()
|
|
||||||
.text_color(cx.theme().danger_foreground)
|
|
||||||
.child(error.clone()),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
.child(
|
|
||||||
div()
|
|
||||||
.mt_2()
|
|
||||||
.italic()
|
|
||||||
.text_xs()
|
|
||||||
.text_color(cx.theme().text_muted)
|
|
||||||
.child(SharedString::from(SECRET_WARN)),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -4,9 +4,9 @@ use std::time::Duration;
|
|||||||
use anyhow::{anyhow, Context as AnyhowContext, Error};
|
use anyhow::{anyhow, Context as AnyhowContext, Error};
|
||||||
use gpui::prelude::FluentBuilder;
|
use gpui::prelude::FluentBuilder;
|
||||||
use gpui::{
|
use gpui::{
|
||||||
div, relative, uniform_list, AnyElement, App, AppContext, Context, Entity, EventEmitter,
|
div, rems, AnyElement, App, AppContext, Context, Entity, EventEmitter, FocusHandle, Focusable,
|
||||||
FocusHandle, Focusable, InteractiveElement, IntoElement, ParentElement, Render, SharedString,
|
InteractiveElement, IntoElement, ParentElement, Render, SharedString, Styled, Subscription,
|
||||||
Styled, Subscription, Task, TextAlign, UniformList, Window,
|
Task, TextAlign, Window,
|
||||||
};
|
};
|
||||||
use nostr_sdk::prelude::*;
|
use nostr_sdk::prelude::*;
|
||||||
use smallvec::{smallvec, SmallVec};
|
use smallvec::{smallvec, SmallVec};
|
||||||
@@ -15,7 +15,10 @@ use theme::ActiveTheme;
|
|||||||
use ui::button::{Button, ButtonVariants};
|
use ui::button::{Button, ButtonVariants};
|
||||||
use ui::dock_area::panel::{Panel, PanelEvent};
|
use ui::dock_area::panel::{Panel, PanelEvent};
|
||||||
use ui::input::{InputEvent, InputState, TextInput};
|
use ui::input::{InputEvent, InputState, TextInput};
|
||||||
use ui::{divider, h_flex, v_flex, IconName, Sizable, StyledExt};
|
use ui::{divider, h_flex, v_flex, Disableable, IconName, Sizable, StyledExt, WindowExtension};
|
||||||
|
|
||||||
|
const MSG: &str = "Messaging Relays are relays that hosted all your messages. \
|
||||||
|
Other users will find your relays and send messages to it.";
|
||||||
|
|
||||||
pub fn init(window: &mut Window, cx: &mut App) -> Entity<MessagingRelayPanel> {
|
pub fn init(window: &mut Window, cx: &mut App) -> Entity<MessagingRelayPanel> {
|
||||||
cx.new(|cx| MessagingRelayPanel::new(window, cx))
|
cx.new(|cx| MessagingRelayPanel::new(window, cx))
|
||||||
@@ -29,44 +32,26 @@ pub struct MessagingRelayPanel {
|
|||||||
/// Relay URL input
|
/// Relay URL input
|
||||||
input: Entity<InputState>,
|
input: Entity<InputState>,
|
||||||
|
|
||||||
|
/// Whether the panel is updating
|
||||||
|
updating: bool,
|
||||||
|
|
||||||
/// Error message
|
/// Error message
|
||||||
error: Option<SharedString>,
|
error: Option<SharedString>,
|
||||||
|
|
||||||
// All relays
|
/// All relays
|
||||||
relays: HashSet<RelayUrl>,
|
relays: HashSet<RelayUrl>,
|
||||||
|
|
||||||
// Event subscriptions
|
/// Event subscriptions
|
||||||
_subscriptions: SmallVec<[Subscription; 1]>,
|
_subscriptions: SmallVec<[Subscription; 1]>,
|
||||||
|
|
||||||
// Background tasks
|
/// Background tasks
|
||||||
_tasks: SmallVec<[Task<()>; 1]>,
|
tasks: Vec<Task<Result<(), Error>>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl MessagingRelayPanel {
|
impl MessagingRelayPanel {
|
||||||
pub fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
|
pub fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||||
let input = cx.new(|cx| InputState::new(window, cx).placeholder("wss://example.com"));
|
let input = cx.new(|cx| InputState::new(window, cx).placeholder("wss://example.com"));
|
||||||
let nostr = NostrRegistry::global(cx);
|
|
||||||
let client = nostr.read(cx).client();
|
|
||||||
|
|
||||||
let mut subscriptions = smallvec![];
|
let mut subscriptions = smallvec![];
|
||||||
let mut tasks = smallvec![];
|
|
||||||
|
|
||||||
tasks.push(
|
|
||||||
// Load user's relays in the local database
|
|
||||||
cx.spawn_in(window, async move |this, cx| {
|
|
||||||
let result = cx
|
|
||||||
.background_spawn(async move { Self::load(&client).await })
|
|
||||||
.await;
|
|
||||||
|
|
||||||
if let Ok(relays) = result {
|
|
||||||
this.update(cx, |this, cx| {
|
|
||||||
this.relays.extend(relays);
|
|
||||||
cx.notify();
|
|
||||||
})
|
|
||||||
.ok();
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
subscriptions.push(
|
subscriptions.push(
|
||||||
// Subscribe to user's input events
|
// Subscribe to user's input events
|
||||||
@@ -77,18 +62,28 @@ impl MessagingRelayPanel {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Run at the end of current cycle
|
||||||
|
cx.defer_in(window, |this, window, cx| {
|
||||||
|
this.load(window, cx);
|
||||||
|
});
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
name: "Update Messaging Relays".into(),
|
name: "Update Messaging Relays".into(),
|
||||||
focus_handle: cx.focus_handle(),
|
focus_handle: cx.focus_handle(),
|
||||||
input,
|
input,
|
||||||
|
updating: false,
|
||||||
relays: HashSet::new(),
|
relays: HashSet::new(),
|
||||||
error: None,
|
error: None,
|
||||||
_subscriptions: subscriptions,
|
_subscriptions: subscriptions,
|
||||||
_tasks: tasks,
|
tasks: vec![],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn load(client: &Client) -> Result<Vec<RelayUrl>, Error> {
|
fn load(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||||
|
let nostr = NostrRegistry::global(cx);
|
||||||
|
let client = nostr.read(cx).client();
|
||||||
|
|
||||||
|
let task: Task<Result<Vec<RelayUrl>, Error>> = cx.background_spawn(async move {
|
||||||
let signer = client.signer().context("Signer not found")?;
|
let signer = client.signer().context("Signer not found")?;
|
||||||
let public_key = signer.get_public_key().await?;
|
let public_key = signer.get_public_key().await?;
|
||||||
|
|
||||||
@@ -102,6 +97,19 @@ impl MessagingRelayPanel {
|
|||||||
} else {
|
} else {
|
||||||
Err(anyhow!("Not found."))
|
Err(anyhow!("Not found."))
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
self.tasks.push(cx.spawn_in(window, async move |this, cx| {
|
||||||
|
let relays = task.await?;
|
||||||
|
|
||||||
|
// Update state
|
||||||
|
this.update(cx, |this, cx| {
|
||||||
|
this.relays.extend(relays);
|
||||||
|
cx.notify();
|
||||||
|
})?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
fn add(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
fn add(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||||
@@ -113,7 +121,7 @@ impl MessagingRelayPanel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if let Ok(url) = RelayUrl::parse(&value) {
|
if let Ok(url) = RelayUrl::parse(&value) {
|
||||||
if !self.relays.insert(url) {
|
if self.relays.insert(url) {
|
||||||
self.input.update(cx, |this, cx| {
|
self.input.update(cx, |this, cx| {
|
||||||
this.set_value("", window, cx);
|
this.set_value("", window, cx);
|
||||||
});
|
});
|
||||||
@@ -136,16 +144,22 @@ impl MessagingRelayPanel {
|
|||||||
self.error = Some(error.into());
|
self.error = Some(error.into());
|
||||||
cx.notify();
|
cx.notify();
|
||||||
|
|
||||||
cx.spawn_in(window, async move |this, cx| {
|
self.tasks.push(cx.spawn_in(window, async move |this, cx| {
|
||||||
cx.background_executor().timer(Duration::from_secs(2)).await;
|
cx.background_executor().timer(Duration::from_secs(2)).await;
|
||||||
|
|
||||||
// Clear the error message after a delay
|
// Clear the error message after a delay
|
||||||
this.update(cx, |this, cx| {
|
this.update(cx, |this, cx| {
|
||||||
this.error = None;
|
this.error = None;
|
||||||
cx.notify();
|
cx.notify();
|
||||||
})
|
})?;
|
||||||
.ok();
|
|
||||||
})
|
Ok(())
|
||||||
.detach();
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_updating(&mut self, updating: bool, cx: &mut Context<Self>) {
|
||||||
|
self.updating = updating;
|
||||||
|
cx.notify();
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn set_relays(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
pub fn set_relays(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||||
@@ -156,72 +170,78 @@ impl MessagingRelayPanel {
|
|||||||
|
|
||||||
let nostr = NostrRegistry::global(cx);
|
let nostr = NostrRegistry::global(cx);
|
||||||
let client = nostr.read(cx).client();
|
let client = nostr.read(cx).client();
|
||||||
|
let signer = nostr.read(cx).signer();
|
||||||
|
|
||||||
|
let Some(public_key) = signer.public_key() else {
|
||||||
|
window.push_notification("Public Key not found", cx);
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get user's write relays
|
||||||
|
let write_relays = nostr.read(cx).write_relays(&public_key, cx);
|
||||||
|
|
||||||
|
// Construct event tags
|
||||||
let tags: Vec<Tag> = self
|
let tags: Vec<Tag> = self
|
||||||
.relays
|
.relays
|
||||||
.iter()
|
.iter()
|
||||||
.map(|relay| Tag::relay(relay.clone()))
|
.map(|relay| Tag::relay(relay.clone()))
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
|
// Set updating state
|
||||||
|
self.set_updating(true, cx);
|
||||||
|
|
||||||
let task: Task<Result<(), Error>> = cx.background_spawn(async move {
|
let task: Task<Result<(), Error>> = cx.background_spawn(async move {
|
||||||
|
let urls = write_relays.await;
|
||||||
|
|
||||||
// Construct nip17 event builder
|
// Construct nip17 event builder
|
||||||
let builder = EventBuilder::new(Kind::InboxRelays, "").tags(tags);
|
let builder = EventBuilder::new(Kind::InboxRelays, "").tags(tags);
|
||||||
let event = client.sign_event_builder(builder).await?;
|
let event = client.sign_event_builder(builder).await?;
|
||||||
|
|
||||||
// Set messaging relays
|
// Set messaging relays
|
||||||
client.send_event(&event).to_nip65().await?;
|
client.send_event(&event).to(urls).await?;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
});
|
});
|
||||||
|
|
||||||
cx.spawn_in(window, async move |this, cx| {
|
self.tasks.push(cx.spawn_in(window, async move |this, cx| {
|
||||||
match task.await {
|
match task.await {
|
||||||
Ok(_) => {
|
Ok(_) => {
|
||||||
// TODO
|
this.update_in(cx, |this, window, cx| {
|
||||||
|
this.set_updating(false, cx);
|
||||||
|
this.load(window, cx);
|
||||||
|
|
||||||
|
window.push_notification("Update successful", cx);
|
||||||
|
})?;
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
this.update_in(cx, |this, window, cx| {
|
this.update_in(cx, |this, window, cx| {
|
||||||
|
this.set_updating(false, cx);
|
||||||
this.set_error(e.to_string(), window, cx);
|
this.set_error(e.to_string(), window, cx);
|
||||||
})
|
})?;
|
||||||
.ok();
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
})
|
|
||||||
.detach();
|
Ok(())
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
fn render_list(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> UniformList {
|
fn render_list_items(&mut self, cx: &mut Context<Self>) -> Vec<impl IntoElement> {
|
||||||
let relays = self.relays.clone();
|
|
||||||
let total = relays.len();
|
|
||||||
|
|
||||||
uniform_list(
|
|
||||||
"relays",
|
|
||||||
total,
|
|
||||||
cx.processor(move |_v, range, _window, cx| {
|
|
||||||
let mut items = Vec::new();
|
let mut items = Vec::new();
|
||||||
|
|
||||||
for ix in range {
|
for url in self.relays.iter() {
|
||||||
let Some(url) = relays.iter().nth(ix) else {
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
|
|
||||||
items.push(
|
items.push(
|
||||||
div()
|
h_flex()
|
||||||
.id(SharedString::from(url.to_string()))
|
.id(SharedString::from(url.to_string()))
|
||||||
.group("")
|
.group("")
|
||||||
|
.flex_1()
|
||||||
.w_full()
|
.w_full()
|
||||||
.h_9()
|
.h_8()
|
||||||
.py_0p5()
|
|
||||||
.child(
|
|
||||||
h_flex()
|
|
||||||
.px_2()
|
.px_2()
|
||||||
.flex()
|
|
||||||
.justify_between()
|
.justify_between()
|
||||||
.rounded(cx.theme().radius)
|
.rounded(cx.theme().radius)
|
||||||
.bg(cx.theme().elevated_surface_background)
|
.bg(cx.theme().secondary_background)
|
||||||
.child(
|
.text_color(cx.theme().secondary_foreground)
|
||||||
div().text_sm().child(SharedString::from(url.to_string())),
|
.child(div().text_sm().child(SharedString::from(url.to_string())))
|
||||||
)
|
|
||||||
.child(
|
.child(
|
||||||
Button::new("remove_{ix}")
|
Button::new("remove_{ix}")
|
||||||
.icon(IconName::Close)
|
.icon(IconName::Close)
|
||||||
@@ -236,19 +256,14 @@ impl MessagingRelayPanel {
|
|||||||
})
|
})
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
),
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
items
|
items
|
||||||
}),
|
|
||||||
)
|
|
||||||
.h_full()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn render_empty(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
fn render_empty(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||||
h_flex()
|
h_flex()
|
||||||
.mt_2()
|
|
||||||
.h_20()
|
.h_20()
|
||||||
.justify_center()
|
.justify_center()
|
||||||
.border_2()
|
.border_2()
|
||||||
@@ -282,36 +297,48 @@ impl Focusable for MessagingRelayPanel {
|
|||||||
impl Render for MessagingRelayPanel {
|
impl Render for MessagingRelayPanel {
|
||||||
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||||
v_flex()
|
v_flex()
|
||||||
.size_full()
|
.p_3()
|
||||||
.items_center()
|
.gap_3()
|
||||||
.justify_center()
|
.w_full()
|
||||||
.p_2()
|
|
||||||
.gap_10()
|
|
||||||
.child(
|
.child(
|
||||||
div()
|
div()
|
||||||
.text_center()
|
.text_xs()
|
||||||
|
.text_color(cx.theme().text_muted)
|
||||||
|
.child(SharedString::from(MSG)),
|
||||||
|
)
|
||||||
|
.child(divider(cx))
|
||||||
|
.child(
|
||||||
|
v_flex()
|
||||||
|
.gap_2()
|
||||||
|
.flex_1()
|
||||||
|
.w_full()
|
||||||
|
.text_sm()
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.text_xs()
|
||||||
.font_semibold()
|
.font_semibold()
|
||||||
.line_height(relative(1.25))
|
.text_color(cx.theme().text_muted)
|
||||||
.child(SharedString::from("Update Messaging Relays")),
|
.child(SharedString::from("Relays:")),
|
||||||
)
|
)
|
||||||
.child(
|
.child(
|
||||||
v_flex()
|
v_flex()
|
||||||
.w_112()
|
.gap_1()
|
||||||
.gap_2()
|
|
||||||
.text_sm()
|
|
||||||
.child(
|
|
||||||
v_flex()
|
|
||||||
.gap_1p5()
|
|
||||||
.child(
|
.child(
|
||||||
h_flex()
|
h_flex()
|
||||||
.gap_1()
|
.gap_1()
|
||||||
.w_full()
|
.w_full()
|
||||||
.child(TextInput::new(&self.input).small())
|
.child(
|
||||||
|
TextInput::new(&self.input)
|
||||||
|
.small()
|
||||||
|
.bordered(false)
|
||||||
|
.cleanable(),
|
||||||
|
)
|
||||||
.child(
|
.child(
|
||||||
Button::new("add")
|
Button::new("add")
|
||||||
.icon(IconName::Plus)
|
.icon(IconName::Plus)
|
||||||
.label("Add")
|
.tooltip("Add relay")
|
||||||
.ghost()
|
.ghost()
|
||||||
|
.size(rems(2.))
|
||||||
.on_click(cx.listener(move |this, _, window, cx| {
|
.on_click(cx.listener(move |this, _, window, cx| {
|
||||||
this.add(window, cx);
|
this.add(window, cx);
|
||||||
})),
|
})),
|
||||||
@@ -322,23 +349,33 @@ impl Render for MessagingRelayPanel {
|
|||||||
div()
|
div()
|
||||||
.italic()
|
.italic()
|
||||||
.text_xs()
|
.text_xs()
|
||||||
.text_color(cx.theme().danger_foreground)
|
.text_color(cx.theme().danger_active)
|
||||||
.child(error.clone()),
|
.child(error.clone()),
|
||||||
)
|
)
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.map(|this| {
|
.map(|this| {
|
||||||
if !self.relays.is_empty() {
|
if self.relays.is_empty() {
|
||||||
this.child(self.render_list(window, cx))
|
|
||||||
} else {
|
|
||||||
this.child(self.render_empty(window, cx))
|
this.child(self.render_empty(window, cx))
|
||||||
|
} else {
|
||||||
|
this.child(
|
||||||
|
v_flex()
|
||||||
|
.gap_1()
|
||||||
|
.flex_1()
|
||||||
|
.w_full()
|
||||||
|
.children(self.render_list_items(cx)),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.child(divider(cx))
|
|
||||||
.child(
|
.child(
|
||||||
Button::new("submit")
|
Button::new("submit")
|
||||||
|
.icon(IconName::CheckCircle)
|
||||||
.label("Update")
|
.label("Update")
|
||||||
.primary()
|
.primary()
|
||||||
|
.small()
|
||||||
|
.font_semibold()
|
||||||
|
.loading(self.updating)
|
||||||
|
.disabled(self.updating)
|
||||||
.on_click(cx.listener(move |this, _ev, window, cx| {
|
.on_click(cx.listener(move |this, _ev, window, cx| {
|
||||||
this.set_relays(window, cx);
|
this.set_relays(window, cx);
|
||||||
})),
|
})),
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
pub mod connect;
|
pub mod backup;
|
||||||
|
pub mod contact_list;
|
||||||
pub mod greeter;
|
pub mod greeter;
|
||||||
pub mod import;
|
|
||||||
pub mod messaging_relays;
|
pub mod messaging_relays;
|
||||||
pub mod profile;
|
pub mod profile;
|
||||||
pub mod relay_list;
|
pub mod relay_list;
|
||||||
|
|||||||
@@ -1,26 +1,23 @@
|
|||||||
use std::str::FromStr;
|
use std::str::FromStr;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use anyhow::{anyhow, Error};
|
use anyhow::{Context as AnyhowContext, Error};
|
||||||
use common::shorten_pubkey;
|
|
||||||
use gpui::{
|
use gpui::{
|
||||||
div, rems, AnyElement, App, AppContext, ClipboardItem, Context, Entity, EventEmitter,
|
div, AnyElement, App, AppContext, ClipboardItem, Context, Entity, EventEmitter, FocusHandle,
|
||||||
FocusHandle, Focusable, IntoElement, ParentElement, PathPromptOptions, Render, SharedString,
|
Focusable, IntoElement, ParentElement, PathPromptOptions, Render, SharedString, Styled, Task,
|
||||||
Styled, Task, Window,
|
Window,
|
||||||
};
|
};
|
||||||
use gpui_tokio::Tokio;
|
|
||||||
use nostr_sdk::prelude::*;
|
use nostr_sdk::prelude::*;
|
||||||
use person::{Person, PersonRegistry};
|
use person::{shorten_pubkey, Person, PersonRegistry};
|
||||||
use settings::AppSettings;
|
use settings::AppSettings;
|
||||||
use smol::fs;
|
use state::{upload, NostrRegistry};
|
||||||
use state::{nostr_upload, NostrRegistry};
|
|
||||||
use theme::ActiveTheme;
|
use theme::ActiveTheme;
|
||||||
use ui::avatar::Avatar;
|
use ui::avatar::Avatar;
|
||||||
use ui::button::{Button, ButtonVariants};
|
use ui::button::{Button, ButtonVariants};
|
||||||
use ui::dock_area::panel::{Panel, PanelEvent};
|
use ui::dock_area::panel::{Panel, PanelEvent};
|
||||||
use ui::input::{InputState, TextInput};
|
use ui::input::{InputState, TextInput};
|
||||||
use ui::notification::Notification;
|
use ui::notification::Notification;
|
||||||
use ui::{divider, h_flex, v_flex, Disableable, IconName, Sizable, StyledExt, WindowExtension};
|
use ui::{h_flex, v_flex, Disableable, IconName, Sizable, StyledExt, WindowExtension};
|
||||||
|
|
||||||
pub fn init(public_key: PublicKey, window: &mut Window, cx: &mut App) -> Entity<ProfilePanel> {
|
pub fn init(public_key: PublicKey, window: &mut Window, cx: &mut App) -> Entity<ProfilePanel> {
|
||||||
cx.new(|cx| ProfilePanel::new(public_key, window, cx))
|
cx.new(|cx| ProfilePanel::new(public_key, window, cx))
|
||||||
@@ -51,6 +48,12 @@ pub struct ProfilePanel {
|
|||||||
|
|
||||||
/// Copied states
|
/// Copied states
|
||||||
copied: bool,
|
copied: bool,
|
||||||
|
|
||||||
|
/// Updating state
|
||||||
|
updating: bool,
|
||||||
|
|
||||||
|
/// Tasks
|
||||||
|
tasks: Vec<Task<Result<(), Error>>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ProfilePanel {
|
impl ProfilePanel {
|
||||||
@@ -58,6 +61,7 @@ impl ProfilePanel {
|
|||||||
let name_input = cx.new(|cx| InputState::new(window, cx).placeholder("Alice"));
|
let name_input = cx.new(|cx| InputState::new(window, cx).placeholder("Alice"));
|
||||||
let website_input = cx.new(|cx| InputState::new(window, cx).placeholder("alice.me"));
|
let website_input = cx.new(|cx| InputState::new(window, cx).placeholder("alice.me"));
|
||||||
let avatar_input = cx.new(|cx| InputState::new(window, cx).placeholder("alice.me/a.jpg"));
|
let avatar_input = cx.new(|cx| InputState::new(window, cx).placeholder("alice.me/a.jpg"));
|
||||||
|
|
||||||
// Use multi-line input for bio
|
// Use multi-line input for bio
|
||||||
let bio_input = cx.new(|cx| {
|
let bio_input = cx.new(|cx| {
|
||||||
InputState::new(window, cx)
|
InputState::new(window, cx)
|
||||||
@@ -68,10 +72,7 @@ impl ProfilePanel {
|
|||||||
|
|
||||||
// Get user's profile and update inputs
|
// Get user's profile and update inputs
|
||||||
cx.defer_in(window, move |this, window, cx| {
|
cx.defer_in(window, move |this, window, cx| {
|
||||||
let persons = PersonRegistry::global(cx);
|
this.set_profile(window, cx);
|
||||||
let profile = persons.read(cx).get(&public_key, cx);
|
|
||||||
// Set all input's values with current profile
|
|
||||||
this.set_profile(profile, window, cx);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
@@ -84,11 +85,15 @@ impl ProfilePanel {
|
|||||||
website_input,
|
website_input,
|
||||||
uploading: false,
|
uploading: false,
|
||||||
copied: false,
|
copied: false,
|
||||||
|
updating: false,
|
||||||
|
tasks: vec![],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn set_profile(&mut self, person: Person, window: &mut Window, cx: &mut Context<Self>) {
|
fn set_profile(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||||
let metadata = person.metadata();
|
let persons = PersonRegistry::global(cx);
|
||||||
|
let profile = persons.read(cx).get(&self.public_key, cx);
|
||||||
|
let metadata = profile.metadata();
|
||||||
|
|
||||||
self.avatar_input.update(cx, |this, cx| {
|
self.avatar_input.update(cx, |this, cx| {
|
||||||
if let Some(avatar) = metadata.picture.as_ref() {
|
if let Some(avatar) = metadata.picture.as_ref() {
|
||||||
@@ -143,66 +148,56 @@ impl ProfilePanel {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn uploading(&mut self, status: bool, cx: &mut Context<Self>) {
|
fn set_uploading(&mut self, status: bool, cx: &mut Context<Self>) {
|
||||||
self.uploading = status;
|
self.uploading = status;
|
||||||
cx.notify();
|
cx.notify();
|
||||||
}
|
}
|
||||||
|
|
||||||
fn upload(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
fn upload(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||||
self.uploading(true, cx);
|
// Get the user's configured blossom server
|
||||||
|
let server = AppSettings::get_file_server(cx);
|
||||||
|
|
||||||
let nostr = NostrRegistry::global(cx);
|
// Ask user for file upload
|
||||||
let client = nostr.read(cx).client();
|
let path = cx.prompt_for_paths(PathPromptOptions {
|
||||||
|
|
||||||
// Get the user's configured NIP96 server
|
|
||||||
let nip96_server = AppSettings::get_file_server(cx);
|
|
||||||
|
|
||||||
// Open native file dialog
|
|
||||||
let paths = cx.prompt_for_paths(PathPromptOptions {
|
|
||||||
files: true,
|
files: true,
|
||||||
directories: false,
|
directories: false,
|
||||||
multiple: false,
|
multiple: false,
|
||||||
prompt: None,
|
prompt: None,
|
||||||
});
|
});
|
||||||
|
|
||||||
let task = Tokio::spawn(cx, async move {
|
self.tasks.push(cx.spawn_in(window, async move |this, cx| {
|
||||||
match paths.await {
|
this.update(cx, |this, cx| {
|
||||||
Ok(Ok(Some(mut paths))) => {
|
this.set_uploading(true, cx);
|
||||||
if let Some(path) = paths.pop() {
|
})?;
|
||||||
let file = fs::read(path).await?;
|
|
||||||
let url = nostr_upload(&client, &nip96_server, file).await?;
|
|
||||||
|
|
||||||
Ok(url)
|
let mut paths = path.await??.context("Not found")?;
|
||||||
} else {
|
let path = paths.pop().context("No path")?;
|
||||||
Err(anyhow!("Path not found"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_ => Err(anyhow!("Error")),
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
cx.spawn_in(window, async move |this, cx| {
|
|
||||||
let result = task.await;
|
|
||||||
|
|
||||||
|
// Upload via blossom client
|
||||||
|
match upload(server, path, cx).await {
|
||||||
|
Ok(url) => {
|
||||||
this.update_in(cx, |this, window, cx| {
|
this.update_in(cx, |this, window, cx| {
|
||||||
match result {
|
|
||||||
Ok(Ok(url)) => {
|
|
||||||
this.avatar_input.update(cx, |this, cx| {
|
this.avatar_input.update(cx, |this, cx| {
|
||||||
this.set_value(url.to_string(), window, cx);
|
this.set_value(url.to_string(), window, cx);
|
||||||
});
|
});
|
||||||
}
|
this.set_uploading(false, cx);
|
||||||
Ok(Err(e)) => {
|
})?;
|
||||||
window.push_notification(e.to_string(), cx);
|
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
log::warn!("Failed to upload avatar: {e}");
|
this.update_in(cx, |this, window, cx| {
|
||||||
|
this.set_uploading(false, cx);
|
||||||
|
window.push_notification(Notification::error(e.to_string()), cx);
|
||||||
|
})?;
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
this.uploading(false, cx);
|
|
||||||
})
|
Ok(())
|
||||||
.expect("Entity has been released");
|
}));
|
||||||
})
|
}
|
||||||
.detach();
|
|
||||||
|
fn set_updating(&mut self, updating: bool, cx: &mut Context<Self>) {
|
||||||
|
self.updating = updating;
|
||||||
|
cx.notify();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Set the metadata for the current user
|
/// Set the metadata for the current user
|
||||||
@@ -253,26 +248,34 @@ impl ProfilePanel {
|
|||||||
// Set the metadata
|
// Set the metadata
|
||||||
let task = self.publish(&new_metadata, cx);
|
let task = self.publish(&new_metadata, cx);
|
||||||
|
|
||||||
cx.spawn_in(window, async move |_this, cx| {
|
// Set the updating state
|
||||||
|
self.set_updating(true, cx);
|
||||||
|
|
||||||
|
self.tasks.push(cx.spawn_in(window, async move |this, cx| {
|
||||||
match task.await {
|
match task.await {
|
||||||
Ok(_) => {
|
Ok(_) => {
|
||||||
cx.update(|window, cx| {
|
this.update_in(cx, |this, window, cx| {
|
||||||
|
// Update the registry
|
||||||
persons.update(cx, |this, cx| {
|
persons.update(cx, |this, cx| {
|
||||||
this.insert(Person::new(public_key, new_metadata), cx);
|
this.insert(Person::new(public_key, new_metadata), cx);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Update current panel
|
||||||
|
this.set_updating(false, cx);
|
||||||
|
this.set_profile(window, cx);
|
||||||
|
|
||||||
window.push_notification("Profile updated successfully", cx);
|
window.push_notification("Profile updated successfully", cx);
|
||||||
})
|
})?;
|
||||||
.ok();
|
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
cx.update(|window, cx| {
|
cx.update(|window, cx| {
|
||||||
window.push_notification(Notification::error(e.to_string()), cx);
|
window.push_notification(Notification::error(e.to_string()), cx);
|
||||||
})
|
})?;
|
||||||
.ok();
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
})
|
|
||||||
.detach();
|
Ok(())
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -296,25 +299,22 @@ impl Focusable for ProfilePanel {
|
|||||||
|
|
||||||
impl Render for ProfilePanel {
|
impl Render for ProfilePanel {
|
||||||
fn render(&mut self, _window: &mut gpui::Window, cx: &mut Context<Self>) -> impl IntoElement {
|
fn render(&mut self, _window: &mut gpui::Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||||
let shorten_pkey = SharedString::from(shorten_pubkey(self.public_key, 8));
|
let avatar_input = self.avatar_input.read(cx).value();
|
||||||
|
|
||||||
// Get the avatar
|
// Get the avatar
|
||||||
let avatar_input = self.avatar_input.read(cx).value();
|
|
||||||
let avatar = if avatar_input.is_empty() {
|
let avatar = if avatar_input.is_empty() {
|
||||||
"brand/avatar.png"
|
"brand/avatar.png"
|
||||||
} else {
|
} else {
|
||||||
avatar_input.as_str()
|
avatar_input.as_str()
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Get the public key as short string
|
||||||
|
let shorten_pkey = SharedString::from(shorten_pubkey(self.public_key, 8));
|
||||||
|
|
||||||
v_flex()
|
v_flex()
|
||||||
.size_full()
|
.p_3()
|
||||||
.items_center()
|
.gap_3()
|
||||||
.justify_center()
|
.w_full()
|
||||||
.p_2()
|
|
||||||
.child(
|
|
||||||
v_flex()
|
|
||||||
.gap_2()
|
|
||||||
.w_112()
|
|
||||||
.child(
|
.child(
|
||||||
v_flex()
|
v_flex()
|
||||||
.h_40()
|
.h_40()
|
||||||
@@ -322,7 +322,7 @@ impl Render for ProfilePanel {
|
|||||||
.items_center()
|
.items_center()
|
||||||
.justify_center()
|
.justify_center()
|
||||||
.gap_4()
|
.gap_4()
|
||||||
.child(Avatar::new(avatar).size(rems(4.25)))
|
.child(Avatar::new(avatar).large())
|
||||||
.child(
|
.child(
|
||||||
Button::new("upload")
|
Button::new("upload")
|
||||||
.icon(IconName::PlusCircle)
|
.icon(IconName::PlusCircle)
|
||||||
@@ -339,37 +339,44 @@ impl Render for ProfilePanel {
|
|||||||
)
|
)
|
||||||
.child(
|
.child(
|
||||||
v_flex()
|
v_flex()
|
||||||
.gap_1()
|
.gap_1p5()
|
||||||
.text_sm()
|
|
||||||
.text_color(cx.theme().text_muted)
|
|
||||||
.child(SharedString::from("What should people call you?"))
|
|
||||||
.child(TextInput::new(&self.name_input).small()),
|
|
||||||
)
|
|
||||||
.child(
|
|
||||||
v_flex()
|
|
||||||
.gap_1()
|
|
||||||
.text_sm()
|
|
||||||
.text_color(cx.theme().text_muted)
|
|
||||||
.child(SharedString::from("A short introduction about you:"))
|
|
||||||
.child(TextInput::new(&self.bio_input).small()),
|
|
||||||
)
|
|
||||||
.child(
|
|
||||||
v_flex()
|
|
||||||
.gap_1()
|
|
||||||
.text_sm()
|
|
||||||
.text_color(cx.theme().text_muted)
|
|
||||||
.child(SharedString::from("Website:"))
|
|
||||||
.child(TextInput::new(&self.website_input).small()),
|
|
||||||
)
|
|
||||||
.child(divider(cx))
|
|
||||||
.child(
|
|
||||||
v_flex()
|
|
||||||
.gap_1()
|
|
||||||
.child(
|
.child(
|
||||||
div()
|
div()
|
||||||
.font_semibold()
|
.text_sm()
|
||||||
.text_xs()
|
.text_color(cx.theme().text_muted)
|
||||||
.text_color(cx.theme().text_placeholder)
|
.child(SharedString::from("What should people call you?")),
|
||||||
|
)
|
||||||
|
.child(TextInput::new(&self.name_input).bordered(false).small()),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
v_flex()
|
||||||
|
.gap_1p5()
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.text_sm()
|
||||||
|
.text_color(cx.theme().text_muted)
|
||||||
|
.child(SharedString::from("A short introduction about you:")),
|
||||||
|
)
|
||||||
|
.child(TextInput::new(&self.bio_input).bordered(false).small()),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
v_flex()
|
||||||
|
.gap_1p5()
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.text_sm()
|
||||||
|
.text_color(cx.theme().text_muted)
|
||||||
|
.child(SharedString::from("Website:")),
|
||||||
|
)
|
||||||
|
.child(TextInput::new(&self.website_input).bordered(false).small()),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
v_flex()
|
||||||
|
.gap_1p5()
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.text_sm()
|
||||||
|
.text_color(cx.theme().text_muted)
|
||||||
.child(SharedString::from("Public Key:")),
|
.child(SharedString::from("Public Key:")),
|
||||||
)
|
)
|
||||||
.child(
|
.child(
|
||||||
@@ -377,10 +384,11 @@ impl Render for ProfilePanel {
|
|||||||
.h_8()
|
.h_8()
|
||||||
.w_full()
|
.w_full()
|
||||||
.justify_center()
|
.justify_center()
|
||||||
.gap_2()
|
.gap_3()
|
||||||
.bg(cx.theme().surface_background)
|
|
||||||
.rounded(cx.theme().radius)
|
.rounded(cx.theme().radius)
|
||||||
|
.bg(cx.theme().secondary_background)
|
||||||
.text_sm()
|
.text_sm()
|
||||||
|
.text_color(cx.theme().secondary_foreground)
|
||||||
.child(shorten_pkey)
|
.child(shorten_pkey)
|
||||||
.child(
|
.child(
|
||||||
Button::new("copy")
|
Button::new("copy")
|
||||||
@@ -392,27 +400,25 @@ impl Render for ProfilePanel {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
.xsmall()
|
.xsmall()
|
||||||
.ghost()
|
.secondary()
|
||||||
.on_click(cx.listener(move |this, _ev, window, cx| {
|
.on_click(cx.listener(move |this, _ev, window, cx| {
|
||||||
this.copy(
|
this.copy(this.public_key.to_bech32().unwrap(), window, cx);
|
||||||
this.public_key.to_bech32().unwrap(),
|
|
||||||
window,
|
|
||||||
cx,
|
|
||||||
);
|
|
||||||
})),
|
})),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.child(divider(cx))
|
|
||||||
.child(
|
.child(
|
||||||
Button::new("submit")
|
Button::new("submit")
|
||||||
|
.icon(IconName::CheckCircle)
|
||||||
.label("Update")
|
.label("Update")
|
||||||
.primary()
|
.primary()
|
||||||
.disabled(self.uploading)
|
.small()
|
||||||
|
.font_semibold()
|
||||||
|
.loading(self.updating)
|
||||||
|
.disabled(self.updating)
|
||||||
.on_click(cx.listener(move |this, _ev, window, cx| {
|
.on_click(cx.listener(move |this, _ev, window, cx| {
|
||||||
this.update(window, cx);
|
this.update(window, cx);
|
||||||
})),
|
})),
|
||||||
),
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,23 +4,36 @@ use std::time::Duration;
|
|||||||
use anyhow::{anyhow, Context as AnyhowContext, Error};
|
use anyhow::{anyhow, Context as AnyhowContext, Error};
|
||||||
use gpui::prelude::FluentBuilder;
|
use gpui::prelude::FluentBuilder;
|
||||||
use gpui::{
|
use gpui::{
|
||||||
div, relative, uniform_list, AnyElement, App, AppContext, Context, Entity, EventEmitter,
|
div, px, rems, Action, AnyElement, App, AppContext, Context, Entity, EventEmitter, FocusHandle,
|
||||||
FocusHandle, Focusable, InteractiveElement, IntoElement, ParentElement, Render, SharedString,
|
Focusable, InteractiveElement, IntoElement, ParentElement, Render, SharedString, Styled,
|
||||||
Styled, Subscription, Task, TextAlign, UniformList, Window,
|
Subscription, Task, TextAlign, Window,
|
||||||
};
|
};
|
||||||
use nostr_sdk::prelude::*;
|
use nostr_sdk::prelude::*;
|
||||||
|
use serde::Deserialize;
|
||||||
use smallvec::{smallvec, SmallVec};
|
use smallvec::{smallvec, SmallVec};
|
||||||
use state::{NostrRegistry, BOOTSTRAP_RELAYS};
|
use state::NostrRegistry;
|
||||||
use theme::ActiveTheme;
|
use theme::ActiveTheme;
|
||||||
use ui::button::{Button, ButtonVariants};
|
use ui::button::{Button, ButtonVariants};
|
||||||
use ui::dock_area::panel::{Panel, PanelEvent};
|
use ui::dock_area::panel::{Panel, PanelEvent};
|
||||||
use ui::input::{InputEvent, InputState, TextInput};
|
use ui::input::{InputEvent, InputState, TextInput};
|
||||||
use ui::{divider, h_flex, v_flex, IconName, Sizable, StyledExt};
|
use ui::menu::DropdownMenu;
|
||||||
|
use ui::{divider, h_flex, v_flex, Disableable, IconName, Sizable, StyledExt, WindowExtension};
|
||||||
|
|
||||||
|
const MSG: &str = "Relay List (or Gossip Relays) are a set of relays \
|
||||||
|
where you will publish all your events. Others also publish events \
|
||||||
|
related to you here.";
|
||||||
|
|
||||||
pub fn init(window: &mut Window, cx: &mut App) -> Entity<RelayListPanel> {
|
pub fn init(window: &mut Window, cx: &mut App) -> Entity<RelayListPanel> {
|
||||||
cx.new(|cx| RelayListPanel::new(window, cx))
|
cx.new(|cx| RelayListPanel::new(window, cx))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Action, Clone, PartialEq, Eq, Deserialize)]
|
||||||
|
#[action(namespace = relay, no_json)]
|
||||||
|
enum SetMetadata {
|
||||||
|
Read,
|
||||||
|
Write,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub struct RelayListPanel {
|
pub struct RelayListPanel {
|
||||||
name: SharedString,
|
name: SharedString,
|
||||||
@@ -29,6 +42,9 @@ pub struct RelayListPanel {
|
|||||||
/// Relay URL input
|
/// Relay URL input
|
||||||
input: Entity<InputState>,
|
input: Entity<InputState>,
|
||||||
|
|
||||||
|
/// Whether the panel is updating
|
||||||
|
updating: bool,
|
||||||
|
|
||||||
/// Relay metadata input
|
/// Relay metadata input
|
||||||
metadata: Entity<Option<RelayMetadata>>,
|
metadata: Entity<Option<RelayMetadata>>,
|
||||||
|
|
||||||
@@ -42,7 +58,7 @@ pub struct RelayListPanel {
|
|||||||
_subscriptions: SmallVec<[Subscription; 1]>,
|
_subscriptions: SmallVec<[Subscription; 1]>,
|
||||||
|
|
||||||
// Background tasks
|
// Background tasks
|
||||||
_tasks: SmallVec<[Task<()>; 1]>,
|
tasks: Vec<Task<Result<(), Error>>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl RelayListPanel {
|
impl RelayListPanel {
|
||||||
@@ -50,28 +66,7 @@ impl RelayListPanel {
|
|||||||
let input = cx.new(|cx| InputState::new(window, cx).placeholder("wss://example.com"));
|
let input = cx.new(|cx| InputState::new(window, cx).placeholder("wss://example.com"));
|
||||||
let metadata = cx.new(|_| None);
|
let metadata = cx.new(|_| None);
|
||||||
|
|
||||||
let nostr = NostrRegistry::global(cx);
|
|
||||||
let client = nostr.read(cx).client();
|
|
||||||
|
|
||||||
let mut subscriptions = smallvec![];
|
let mut subscriptions = smallvec![];
|
||||||
let mut tasks = smallvec![];
|
|
||||||
|
|
||||||
tasks.push(
|
|
||||||
// Load user's relays in the local database
|
|
||||||
cx.spawn_in(window, async move |this, cx| {
|
|
||||||
let result = cx
|
|
||||||
.background_spawn(async move { Self::load(&client).await })
|
|
||||||
.await;
|
|
||||||
|
|
||||||
if let Ok(relays) = result {
|
|
||||||
this.update(cx, |this, cx| {
|
|
||||||
this.relays.extend(relays);
|
|
||||||
cx.notify();
|
|
||||||
})
|
|
||||||
.ok();
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
subscriptions.push(
|
subscriptions.push(
|
||||||
// Subscribe to user's input events
|
// Subscribe to user's input events
|
||||||
@@ -82,19 +77,31 @@ impl RelayListPanel {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Run at the end of current cycle
|
||||||
|
cx.defer_in(window, |this, window, cx| {
|
||||||
|
this.load(window, cx);
|
||||||
|
});
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
name: "Update Relay List".into(),
|
name: "Update Relay List".into(),
|
||||||
focus_handle: cx.focus_handle(),
|
focus_handle: cx.focus_handle(),
|
||||||
input,
|
input,
|
||||||
|
updating: false,
|
||||||
metadata,
|
metadata,
|
||||||
relays: HashSet::new(),
|
relays: HashSet::new(),
|
||||||
error: None,
|
error: None,
|
||||||
_subscriptions: subscriptions,
|
_subscriptions: subscriptions,
|
||||||
_tasks: tasks,
|
tasks: vec![],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn load(client: &Client) -> Result<Vec<(RelayUrl, Option<RelayMetadata>)>, Error> {
|
#[allow(clippy::type_complexity)]
|
||||||
|
fn load(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||||
|
let nostr = NostrRegistry::global(cx);
|
||||||
|
let client = nostr.read(cx).client();
|
||||||
|
|
||||||
|
let task: Task<Result<Vec<(RelayUrl, Option<RelayMetadata>)>, Error>> = cx
|
||||||
|
.background_spawn(async move {
|
||||||
let signer = client.signer().context("Signer not found")?;
|
let signer = client.signer().context("Signer not found")?;
|
||||||
let public_key = signer.get_public_key().await?;
|
let public_key = signer.get_public_key().await?;
|
||||||
|
|
||||||
@@ -108,6 +115,19 @@ impl RelayListPanel {
|
|||||||
} else {
|
} else {
|
||||||
Err(anyhow!("Not found."))
|
Err(anyhow!("Not found."))
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
self.tasks.push(cx.spawn_in(window, async move |this, cx| {
|
||||||
|
let relays = task.await?;
|
||||||
|
|
||||||
|
// Update state
|
||||||
|
this.update(cx, |this, cx| {
|
||||||
|
this.relays.extend(relays);
|
||||||
|
cx.notify();
|
||||||
|
})?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
fn add(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
fn add(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||||
@@ -120,7 +140,7 @@ impl RelayListPanel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if let Ok(url) = RelayUrl::parse(&value) {
|
if let Ok(url) = RelayUrl::parse(&value) {
|
||||||
if !self.relays.insert((url, metadata.to_owned())) {
|
if self.relays.insert((url, metadata.to_owned())) {
|
||||||
self.input.update(cx, |this, cx| {
|
self.input.update(cx, |this, cx| {
|
||||||
this.set_value("", window, cx);
|
this.set_value("", window, cx);
|
||||||
});
|
});
|
||||||
@@ -143,19 +163,42 @@ impl RelayListPanel {
|
|||||||
self.error = Some(error.into());
|
self.error = Some(error.into());
|
||||||
cx.notify();
|
cx.notify();
|
||||||
|
|
||||||
cx.spawn_in(window, async move |this, cx| {
|
self.tasks.push(cx.spawn_in(window, async move |this, cx| {
|
||||||
cx.background_executor().timer(Duration::from_secs(2)).await;
|
cx.background_executor().timer(Duration::from_secs(2)).await;
|
||||||
|
|
||||||
// Clear the error message after a delay
|
// Clear the error message after a delay
|
||||||
this.update(cx, |this, cx| {
|
this.update(cx, |this, cx| {
|
||||||
this.error = None;
|
this.error = None;
|
||||||
cx.notify();
|
cx.notify();
|
||||||
})
|
})?;
|
||||||
.ok();
|
|
||||||
})
|
Ok(())
|
||||||
.detach();
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn set_relays(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
fn set_updating(&mut self, updating: bool, cx: &mut Context<Self>) {
|
||||||
|
self.updating = updating;
|
||||||
|
cx.notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_metadata(&mut self, ev: &SetMetadata, _window: &mut Window, cx: &mut Context<Self>) {
|
||||||
|
match ev {
|
||||||
|
SetMetadata::Read => {
|
||||||
|
self.metadata.update(cx, |this, cx| {
|
||||||
|
*this = Some(RelayMetadata::Read);
|
||||||
|
cx.notify();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
SetMetadata::Write => {
|
||||||
|
self.metadata.update(cx, |this, cx| {
|
||||||
|
*this = Some(RelayMetadata::Write);
|
||||||
|
cx.notify();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_relays(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||||
if self.relays.is_empty() {
|
if self.relays.is_empty() {
|
||||||
self.set_error("You need to add at least 1 relay", window, cx);
|
self.set_error("You need to add at least 1 relay", window, cx);
|
||||||
return;
|
return;
|
||||||
@@ -163,79 +206,82 @@ impl RelayListPanel {
|
|||||||
|
|
||||||
let nostr = NostrRegistry::global(cx);
|
let nostr = NostrRegistry::global(cx);
|
||||||
let client = nostr.read(cx).client();
|
let client = nostr.read(cx).client();
|
||||||
|
|
||||||
|
// Get all relays
|
||||||
let relays = self.relays.clone();
|
let relays = self.relays.clone();
|
||||||
|
|
||||||
|
// Set updating state
|
||||||
|
self.set_updating(true, cx);
|
||||||
|
|
||||||
let task: Task<Result<(), Error>> = cx.background_spawn(async move {
|
let task: Task<Result<(), Error>> = cx.background_spawn(async move {
|
||||||
let builder = EventBuilder::relay_list(relays);
|
let builder = EventBuilder::relay_list(relays);
|
||||||
let event = client.sign_event_builder(builder).await?;
|
let event = client.sign_event_builder(builder).await?;
|
||||||
|
|
||||||
// Set relay list for current user
|
// Set relay list for current user
|
||||||
client.send_event(&event).to(BOOTSTRAP_RELAYS).await?;
|
client.send_event(&event).await?;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
});
|
});
|
||||||
|
|
||||||
cx.spawn_in(window, async move |this, cx| {
|
self.tasks.push(cx.spawn_in(window, async move |this, cx| {
|
||||||
match task.await {
|
match task.await {
|
||||||
Ok(_) => {
|
Ok(_) => {
|
||||||
// TODO
|
this.update_in(cx, |this, window, cx| {
|
||||||
|
this.set_updating(false, cx);
|
||||||
|
this.load(window, cx);
|
||||||
|
|
||||||
|
window.push_notification("Update successful", cx);
|
||||||
|
})?;
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
this.update_in(cx, |this, window, cx| {
|
this.update_in(cx, |this, window, cx| {
|
||||||
|
this.set_updating(false, cx);
|
||||||
this.set_error(e.to_string(), window, cx);
|
this.set_error(e.to_string(), window, cx);
|
||||||
})
|
})?;
|
||||||
.ok();
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
})
|
|
||||||
.detach();
|
Ok(())
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
fn render_list(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> UniformList {
|
fn render_list_items(&mut self, cx: &mut Context<Self>) -> Vec<impl IntoElement> {
|
||||||
let relays = self.relays.clone();
|
|
||||||
let total = relays.len();
|
|
||||||
|
|
||||||
uniform_list(
|
|
||||||
"relays",
|
|
||||||
total,
|
|
||||||
cx.processor(move |_v, range, _window, cx| {
|
|
||||||
let mut items = Vec::new();
|
let mut items = Vec::new();
|
||||||
|
|
||||||
for ix in range {
|
for (url, metadata) in self.relays.iter() {
|
||||||
let Some((url, metadata)) = relays.iter().nth(ix) else {
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
|
|
||||||
items.push(
|
items.push(
|
||||||
div()
|
h_flex()
|
||||||
.id(SharedString::from(url.to_string()))
|
.id(SharedString::from(url.to_string()))
|
||||||
.group("")
|
.group("")
|
||||||
|
.flex_1()
|
||||||
.w_full()
|
.w_full()
|
||||||
.h_9()
|
.h_8()
|
||||||
.py_0p5()
|
|
||||||
.child(
|
|
||||||
h_flex()
|
|
||||||
.px_2()
|
.px_2()
|
||||||
.flex()
|
|
||||||
.justify_between()
|
.justify_between()
|
||||||
.rounded(cx.theme().radius)
|
.rounded(cx.theme().radius)
|
||||||
.bg(cx.theme().elevated_surface_background)
|
.bg(cx.theme().secondary_background)
|
||||||
.child(
|
.text_color(cx.theme().secondary_foreground)
|
||||||
div().text_sm().child(SharedString::from(url.to_string())),
|
|
||||||
)
|
|
||||||
.child(
|
.child(
|
||||||
h_flex()
|
h_flex()
|
||||||
.gap_1()
|
.gap_1()
|
||||||
.text_xs()
|
.text_sm()
|
||||||
|
.child(SharedString::from(url.to_string()))
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.p_0p5()
|
||||||
|
.rounded_xs()
|
||||||
|
.font_semibold()
|
||||||
|
.text_size(px(8.))
|
||||||
|
.text_color(cx.theme().secondary_foreground)
|
||||||
.map(|this| {
|
.map(|this| {
|
||||||
if let Some(metadata) = metadata {
|
if let Some(metadata) = metadata {
|
||||||
this.child(SharedString::from(
|
this.child(SharedString::from(metadata.to_string()))
|
||||||
metadata.to_string(),
|
|
||||||
))
|
|
||||||
} else {
|
} else {
|
||||||
this.child(SharedString::from("Read+Write"))
|
this.child("Read and Write")
|
||||||
}
|
}
|
||||||
})
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
.child(
|
.child(
|
||||||
Button::new("remove_{ix}")
|
Button::new("remove_{ix}")
|
||||||
.icon(IconName::Close)
|
.icon(IconName::Close)
|
||||||
@@ -245,27 +291,19 @@ impl RelayListPanel {
|
|||||||
.group_hover("", |this| this.visible())
|
.group_hover("", |this| this.visible())
|
||||||
.on_click({
|
.on_click({
|
||||||
let url = url.to_owned();
|
let url = url.to_owned();
|
||||||
cx.listener(
|
cx.listener(move |this, _ev, _window, cx| {
|
||||||
move |this, _ev, _window, cx| {
|
|
||||||
this.remove(&url, cx);
|
this.remove(&url, cx);
|
||||||
},
|
})
|
||||||
)
|
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
items
|
items
|
||||||
}),
|
|
||||||
)
|
|
||||||
.h_full()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn render_empty(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
fn render_empty(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||||
h_flex()
|
h_flex()
|
||||||
.mt_2()
|
|
||||||
.h_20()
|
.h_20()
|
||||||
.justify_center()
|
.justify_center()
|
||||||
.border_2()
|
.border_2()
|
||||||
@@ -299,36 +337,67 @@ impl Focusable for RelayListPanel {
|
|||||||
impl Render for RelayListPanel {
|
impl Render for RelayListPanel {
|
||||||
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||||
v_flex()
|
v_flex()
|
||||||
.size_full()
|
.on_action(cx.listener(Self::set_metadata))
|
||||||
.items_center()
|
.p_3()
|
||||||
.justify_center()
|
.gap_3()
|
||||||
.p_2()
|
.w_full()
|
||||||
.gap_10()
|
|
||||||
.child(
|
.child(
|
||||||
div()
|
div()
|
||||||
.text_center()
|
.text_xs()
|
||||||
|
.text_color(cx.theme().text_muted)
|
||||||
|
.child(SharedString::from(MSG)),
|
||||||
|
)
|
||||||
|
.child(divider(cx))
|
||||||
|
.child(
|
||||||
|
v_flex()
|
||||||
|
.gap_2()
|
||||||
|
.flex_1()
|
||||||
|
.w_full()
|
||||||
|
.text_sm()
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.text_xs()
|
||||||
.font_semibold()
|
.font_semibold()
|
||||||
.line_height(relative(1.25))
|
.text_color(cx.theme().text_muted)
|
||||||
.child(SharedString::from("Update Relay List")),
|
.child(SharedString::from("Relays:")),
|
||||||
)
|
)
|
||||||
.child(
|
.child(
|
||||||
v_flex()
|
v_flex()
|
||||||
.w_112()
|
.gap_1()
|
||||||
.gap_2()
|
|
||||||
.text_sm()
|
|
||||||
.child(
|
|
||||||
v_flex()
|
|
||||||
.gap_1p5()
|
|
||||||
.child(
|
.child(
|
||||||
h_flex()
|
h_flex()
|
||||||
.gap_1()
|
.gap_1()
|
||||||
.w_full()
|
.w_full()
|
||||||
.child(TextInput::new(&self.input).small())
|
.child(
|
||||||
|
TextInput::new(&self.input)
|
||||||
|
.small()
|
||||||
|
.bordered(false)
|
||||||
|
.cleanable(),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
Button::new("metadata")
|
||||||
|
.map(|this| {
|
||||||
|
if let Some(metadata) = self.metadata.read(cx) {
|
||||||
|
this.label(metadata.to_string())
|
||||||
|
} else {
|
||||||
|
this.label("R & W")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.tooltip("Relay metadata")
|
||||||
|
.ghost()
|
||||||
|
.h(rems(2.))
|
||||||
|
.text_xs()
|
||||||
|
.dropdown_menu(|this, _window, _cx| {
|
||||||
|
this.menu("Read", Box::new(SetMetadata::Read))
|
||||||
|
.menu("Write", Box::new(SetMetadata::Write))
|
||||||
|
}),
|
||||||
|
)
|
||||||
.child(
|
.child(
|
||||||
Button::new("add")
|
Button::new("add")
|
||||||
.icon(IconName::Plus)
|
.icon(IconName::Plus)
|
||||||
.label("Add")
|
.tooltip("Add relay")
|
||||||
.ghost()
|
.ghost()
|
||||||
|
.size(rems(2.))
|
||||||
.on_click(cx.listener(move |this, _, window, cx| {
|
.on_click(cx.listener(move |this, _, window, cx| {
|
||||||
this.add(window, cx);
|
this.add(window, cx);
|
||||||
})),
|
})),
|
||||||
@@ -339,23 +408,33 @@ impl Render for RelayListPanel {
|
|||||||
div()
|
div()
|
||||||
.italic()
|
.italic()
|
||||||
.text_xs()
|
.text_xs()
|
||||||
.text_color(cx.theme().danger_foreground)
|
.text_color(cx.theme().danger_active)
|
||||||
.child(error.clone()),
|
.child(error.clone()),
|
||||||
)
|
)
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.map(|this| {
|
.map(|this| {
|
||||||
if !self.relays.is_empty() {
|
if self.relays.is_empty() {
|
||||||
this.child(self.render_list(window, cx))
|
|
||||||
} else {
|
|
||||||
this.child(self.render_empty(window, cx))
|
this.child(self.render_empty(window, cx))
|
||||||
|
} else {
|
||||||
|
this.child(
|
||||||
|
v_flex()
|
||||||
|
.gap_1()
|
||||||
|
.flex_1()
|
||||||
|
.w_full()
|
||||||
|
.children(self.render_list_items(cx)),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.child(divider(cx))
|
|
||||||
.child(
|
.child(
|
||||||
Button::new("submit")
|
Button::new("submit")
|
||||||
|
.icon(IconName::CheckCircle)
|
||||||
.label("Update")
|
.label("Update")
|
||||||
.primary()
|
.primary()
|
||||||
|
.small()
|
||||||
|
.font_semibold()
|
||||||
|
.loading(self.updating)
|
||||||
|
.disabled(self.updating)
|
||||||
.on_click(cx.listener(move |this, _ev, window, cx| {
|
.on_click(cx.listener(move |this, _ev, window, cx| {
|
||||||
this.set_relays(window, cx);
|
this.set_relays(window, cx);
|
||||||
})),
|
})),
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ use std::rc::Rc;
|
|||||||
use chat::RoomKind;
|
use chat::RoomKind;
|
||||||
use gpui::prelude::FluentBuilder;
|
use gpui::prelude::FluentBuilder;
|
||||||
use gpui::{
|
use gpui::{
|
||||||
div, rems, App, ClickEvent, InteractiveElement, IntoElement, ParentElement as _, RenderOnce,
|
div, App, ClickEvent, InteractiveElement, IntoElement, ParentElement as _, RenderOnce,
|
||||||
SharedString, StatefulInteractiveElement, Styled, Window,
|
SharedString, StatefulInteractiveElement, Styled, Window,
|
||||||
};
|
};
|
||||||
use nostr_sdk::prelude::*;
|
use nostr_sdk::prelude::*;
|
||||||
@@ -106,14 +106,7 @@ impl RenderOnce for RoomEntry {
|
|||||||
.rounded(cx.theme().radius)
|
.rounded(cx.theme().radius)
|
||||||
.when(!hide_avatar, |this| {
|
.when(!hide_avatar, |this| {
|
||||||
this.when_some(self.avatar, |this, avatar| {
|
this.when_some(self.avatar, |this, avatar| {
|
||||||
this.child(
|
this.child(Avatar::new(avatar).small().flex_shrink_0())
|
||||||
div()
|
|
||||||
.flex_shrink_0()
|
|
||||||
.size_6()
|
|
||||||
.rounded_full()
|
|
||||||
.overflow_hidden()
|
|
||||||
.child(Avatar::new(avatar).size(rems(1.5))),
|
|
||||||
)
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
.child(
|
.child(
|
||||||
|
|||||||
@@ -8,25 +8,22 @@ use common::{DebouncedDelay, RenderedTimestamp};
|
|||||||
use entry::RoomEntry;
|
use entry::RoomEntry;
|
||||||
use gpui::prelude::FluentBuilder;
|
use gpui::prelude::FluentBuilder;
|
||||||
use gpui::{
|
use gpui::{
|
||||||
div, uniform_list, App, AppContext, Context, Entity, EventEmitter, FocusHandle, Focusable,
|
App, AppContext, Context, Entity, EventEmitter, FocusHandle, Focusable, IntoElement,
|
||||||
IntoElement, ParentElement, Render, RetainAllImageCache, SharedString, Styled, Subscription,
|
ParentElement, Render, RetainAllImageCache, SharedString, Styled, Subscription, Task,
|
||||||
Task, UniformListScrollHandle, Window,
|
UniformListScrollHandle, Window, div, uniform_list,
|
||||||
};
|
};
|
||||||
use nostr_sdk::prelude::*;
|
use nostr_sdk::prelude::*;
|
||||||
use person::PersonRegistry;
|
use person::PersonRegistry;
|
||||||
use smallvec::{smallvec, SmallVec};
|
use smallvec::{SmallVec, smallvec};
|
||||||
use state::{NostrRegistry, FIND_DELAY};
|
use state::{FIND_DELAY, NostrRegistry};
|
||||||
use theme::{ActiveTheme, TITLEBAR_HEIGHT};
|
use theme::{ActiveTheme, SIDEBAR_WIDTH, TABBAR_HEIGHT};
|
||||||
use ui::button::{Button, ButtonVariants};
|
use ui::button::{Button, ButtonVariants};
|
||||||
use ui::divider::Divider;
|
|
||||||
use ui::dock_area::panel::{Panel, PanelEvent};
|
use ui::dock_area::panel::{Panel, PanelEvent};
|
||||||
use ui::indicator::Indicator;
|
use ui::indicator::Indicator;
|
||||||
use ui::input::{InputEvent, InputState, TextInput};
|
use ui::input::{InputEvent, InputState, TextInput};
|
||||||
use ui::notification::Notification;
|
use ui::notification::Notification;
|
||||||
use ui::scroll::Scrollbar;
|
use ui::scroll::Scrollbar;
|
||||||
use ui::{
|
use ui::{Icon, IconName, Selectable, Sizable, StyledExt, WindowExtension, h_flex, v_flex};
|
||||||
h_flex, v_flex, Disableable, Icon, IconName, Selectable, Sizable, StyledExt, WindowExtension,
|
|
||||||
};
|
|
||||||
|
|
||||||
mod entry;
|
mod entry;
|
||||||
|
|
||||||
@@ -122,12 +119,10 @@ impl Sidebar {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
InputEvent::Focus => {
|
InputEvent::Focus => {
|
||||||
this.set_input_focus(window, cx);
|
this.set_input_focus(true, window, cx);
|
||||||
this.get_contact_list(window, cx);
|
this.get_contact_list(window, cx);
|
||||||
}
|
}
|
||||||
InputEvent::Blur => {
|
_ => {}
|
||||||
this.set_input_focus(window, cx);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@@ -246,6 +241,7 @@ impl Sidebar {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Set the results of the search
|
||||||
fn set_results(&mut self, results: Vec<PublicKey>, cx: &mut Context<Self>) {
|
fn set_results(&mut self, results: Vec<PublicKey>, cx: &mut Context<Self>) {
|
||||||
self.find_results.update(cx, |this, cx| {
|
self.find_results.update(cx, |this, cx| {
|
||||||
*this = Some(results);
|
*this = Some(results);
|
||||||
@@ -253,6 +249,7 @@ impl Sidebar {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Set the finding status
|
||||||
fn set_finding(&mut self, status: bool, _window: &mut Window, cx: &mut Context<Self>) {
|
fn set_finding(&mut self, status: bool, _window: &mut Window, cx: &mut Context<Self>) {
|
||||||
// Disable the input to prevent duplicate requests
|
// Disable the input to prevent duplicate requests
|
||||||
self.find_input.update(cx, |this, cx| {
|
self.find_input.update(cx, |this, cx| {
|
||||||
@@ -264,13 +261,14 @@ impl Sidebar {
|
|||||||
cx.notify();
|
cx.notify();
|
||||||
}
|
}
|
||||||
|
|
||||||
fn set_input_focus(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
/// Set the focus status of the input element.
|
||||||
self.find_focused = !self.find_focused;
|
fn set_input_focus(&mut self, status: bool, window: &mut Window, cx: &mut Context<Self>) {
|
||||||
|
self.find_focused = status;
|
||||||
cx.notify();
|
cx.notify();
|
||||||
|
|
||||||
// Reset the find panel
|
// Focus to the input element
|
||||||
if !self.find_focused {
|
if !status {
|
||||||
self.reset(window, cx);
|
window.focus_prev(cx);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -356,7 +354,8 @@ impl Sidebar {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Set the active filter for the sidebar.
|
/// Set the active filter for the sidebar.
|
||||||
fn set_filter(&mut self, kind: RoomKind, cx: &mut Context<Self>) {
|
fn set_filter(&mut self, kind: RoomKind, window: &mut Window, cx: &mut Context<Self>) {
|
||||||
|
self.set_input_focus(false, window, cx);
|
||||||
self.filter.update(cx, |this, cx| {
|
self.filter.update(cx, |this, cx| {
|
||||||
*this = kind;
|
*this = kind;
|
||||||
cx.notify();
|
cx.notify();
|
||||||
@@ -495,12 +494,13 @@ impl Render for Sidebar {
|
|||||||
v_flex()
|
v_flex()
|
||||||
.image_cache(self.image_cache.clone())
|
.image_cache(self.image_cache.clone())
|
||||||
.size_full()
|
.size_full()
|
||||||
.relative()
|
.gap_2()
|
||||||
.child(
|
.child(
|
||||||
h_flex()
|
h_flex()
|
||||||
.h(TITLEBAR_HEIGHT)
|
.h(TABBAR_HEIGHT)
|
||||||
.border_b_1()
|
.border_b_1()
|
||||||
.border_color(cx.theme().border)
|
.border_color(cx.theme().border)
|
||||||
|
.bg(cx.theme().elevated_surface_background)
|
||||||
.child(
|
.child(
|
||||||
TextInput::new(&self.find_input)
|
TextInput::new(&self.find_input)
|
||||||
.appearance(false)
|
.appearance(false)
|
||||||
@@ -520,22 +520,17 @@ impl Render for Sidebar {
|
|||||||
)
|
)
|
||||||
.child(
|
.child(
|
||||||
h_flex()
|
h_flex()
|
||||||
.h(TITLEBAR_HEIGHT)
|
.px_2()
|
||||||
|
.gap_2()
|
||||||
.justify_center()
|
.justify_center()
|
||||||
.border_b_1()
|
|
||||||
.border_color(cx.theme().border)
|
|
||||||
.when(show_find_panel, |this| {
|
.when(show_find_panel, |this| {
|
||||||
this.child(
|
this.child(
|
||||||
Button::new("search-results")
|
Button::new("search-results")
|
||||||
.icon(IconName::Search)
|
.icon(IconName::Search)
|
||||||
.label("Search")
|
|
||||||
.tooltip("All search results")
|
.tooltip("All search results")
|
||||||
.small()
|
.small()
|
||||||
.underline()
|
.ghost_alt()
|
||||||
.ghost()
|
|
||||||
.font_semibold()
|
.font_semibold()
|
||||||
.rounded_none()
|
|
||||||
.h_full()
|
|
||||||
.flex_1()
|
.flex_1()
|
||||||
.selected(true),
|
.selected(true),
|
||||||
)
|
)
|
||||||
@@ -552,21 +547,16 @@ impl Render for Sidebar {
|
|||||||
.when(!show_find_panel, |this| this.label("Inbox"))
|
.when(!show_find_panel, |this| this.label("Inbox"))
|
||||||
.tooltip("All ongoing conversations")
|
.tooltip("All ongoing conversations")
|
||||||
.small()
|
.small()
|
||||||
.underline()
|
.ghost_alt()
|
||||||
.ghost()
|
|
||||||
.font_semibold()
|
.font_semibold()
|
||||||
.rounded_none()
|
|
||||||
.h_full()
|
|
||||||
.flex_1()
|
.flex_1()
|
||||||
.disabled(show_find_panel)
|
|
||||||
.selected(
|
.selected(
|
||||||
!show_find_panel && self.current_filter(&RoomKind::Ongoing, cx),
|
!show_find_panel && self.current_filter(&RoomKind::Ongoing, cx),
|
||||||
)
|
)
|
||||||
.on_click(cx.listener(|this, _ev, _window, cx| {
|
.on_click(cx.listener(|this, _ev, window, cx| {
|
||||||
this.set_filter(RoomKind::Ongoing, cx);
|
this.set_filter(RoomKind::Ongoing, window, cx);
|
||||||
})),
|
})),
|
||||||
)
|
)
|
||||||
.child(Divider::vertical())
|
|
||||||
.child(
|
.child(
|
||||||
Button::new("requests")
|
Button::new("requests")
|
||||||
.map(|this| {
|
.map(|this| {
|
||||||
@@ -579,27 +569,23 @@ impl Render for Sidebar {
|
|||||||
.when(!show_find_panel, |this| this.label("Requests"))
|
.when(!show_find_panel, |this| this.label("Requests"))
|
||||||
.tooltip("Incoming new conversations")
|
.tooltip("Incoming new conversations")
|
||||||
.small()
|
.small()
|
||||||
.ghost()
|
.ghost_alt()
|
||||||
.underline()
|
|
||||||
.font_semibold()
|
.font_semibold()
|
||||||
.rounded_none()
|
|
||||||
.h_full()
|
|
||||||
.flex_1()
|
.flex_1()
|
||||||
.disabled(show_find_panel)
|
|
||||||
.selected(
|
.selected(
|
||||||
!show_find_panel && !self.current_filter(&RoomKind::Ongoing, cx),
|
!show_find_panel && !self.current_filter(&RoomKind::Ongoing, cx),
|
||||||
)
|
)
|
||||||
.when(self.new_requests, |this| {
|
.when(self.new_requests, |this| {
|
||||||
this.child(div().size_1().rounded_full().bg(cx.theme().cursor))
|
this.child(div().size_1().rounded_full().bg(cx.theme().cursor))
|
||||||
})
|
})
|
||||||
.on_click(cx.listener(|this, _ev, _window, cx| {
|
.on_click(cx.listener(|this, _ev, window, cx| {
|
||||||
this.set_filter(RoomKind::default(), cx);
|
this.set_filter(RoomKind::default(), window, cx);
|
||||||
})),
|
})),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.when(!show_find_panel && !loading && total_rooms == 0, |this| {
|
.when(!show_find_panel && !loading && total_rooms == 0, |this| {
|
||||||
this.child(
|
this.child(
|
||||||
div().mt_2().px_2().child(
|
div().w(SIDEBAR_WIDTH).px_2().child(
|
||||||
v_flex()
|
v_flex()
|
||||||
.p_3()
|
.p_3()
|
||||||
.h_24()
|
.h_24()
|
||||||
@@ -627,12 +613,9 @@ impl Render for Sidebar {
|
|||||||
})
|
})
|
||||||
.child(
|
.child(
|
||||||
v_flex()
|
v_flex()
|
||||||
.h_full()
|
.size_full()
|
||||||
.px_1p5()
|
|
||||||
.mt_2()
|
|
||||||
.flex_1()
|
.flex_1()
|
||||||
.gap_1()
|
.gap_1()
|
||||||
.overflow_y_hidden()
|
|
||||||
.when(show_find_panel, |this| {
|
.when(show_find_panel, |this| {
|
||||||
this.gap_3()
|
this.gap_3()
|
||||||
.when_some(self.find_results.read(cx).as_ref(), |this, results| {
|
.when_some(self.find_results.read(cx).as_ref(), |this, results| {
|
||||||
@@ -675,7 +658,7 @@ impl Render for Sidebar {
|
|||||||
.text_xs()
|
.text_xs()
|
||||||
.font_semibold()
|
.font_semibold()
|
||||||
.text_color(cx.theme().text_muted)
|
.text_color(cx.theme().text_muted)
|
||||||
.child(Icon::new(IconName::ChevronDown))
|
.child(Icon::new(IconName::ChevronDown).small())
|
||||||
.child(SharedString::from("Suggestions")),
|
.child(SharedString::from("Suggestions")),
|
||||||
)
|
)
|
||||||
.child(
|
.child(
|
||||||
@@ -703,7 +686,8 @@ impl Render for Sidebar {
|
|||||||
)
|
)
|
||||||
.track_scroll(&self.scroll_handle)
|
.track_scroll(&self.scroll_handle)
|
||||||
.flex_1()
|
.flex_1()
|
||||||
.h_full(),
|
.h_full()
|
||||||
|
.px_2(),
|
||||||
)
|
)
|
||||||
.child(Scrollbar::vertical(&self.scroll_handle))
|
.child(Scrollbar::vertical(&self.scroll_handle))
|
||||||
}),
|
}),
|
||||||
@@ -747,7 +731,7 @@ impl Render for Sidebar {
|
|||||||
.bg(cx.theme().background.opacity(0.85))
|
.bg(cx.theme().background.opacity(0.85))
|
||||||
.border_color(cx.theme().border_disabled)
|
.border_color(cx.theme().border_disabled)
|
||||||
.border_1()
|
.border_1()
|
||||||
.when(cx.theme().shadow, |this| this.shadow_sm())
|
.when(cx.theme().shadow, |this| this.shadow_xs())
|
||||||
.rounded_full()
|
.rounded_full()
|
||||||
.text_xs()
|
.text_xs()
|
||||||
.font_semibold()
|
.font_semibold()
|
||||||
|
|||||||
@@ -1,31 +1,61 @@
|
|||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use chat::{ChatEvent, ChatRegistry};
|
use ::settings::AppSettings;
|
||||||
|
use chat::{ChatEvent, ChatRegistry, InboxState};
|
||||||
|
use device::DeviceRegistry;
|
||||||
use gpui::prelude::FluentBuilder;
|
use gpui::prelude::FluentBuilder;
|
||||||
use gpui::{
|
use gpui::{
|
||||||
div, rems, App, AppContext, Axis, Context, Entity, InteractiveElement, IntoElement,
|
Action, App, AppContext, Axis, Context, Entity, InteractiveElement, IntoElement, ParentElement,
|
||||||
ParentElement, Render, SharedString, Styled, Subscription, Window,
|
Render, SharedString, Styled, Subscription, Window, div, px,
|
||||||
};
|
};
|
||||||
use person::PersonRegistry;
|
use person::PersonRegistry;
|
||||||
use smallvec::{smallvec, SmallVec};
|
use serde::Deserialize;
|
||||||
use state::{NostrRegistry, RelayState};
|
use smallvec::{SmallVec, smallvec};
|
||||||
use theme::{ActiveTheme, SIDEBAR_WIDTH, TITLEBAR_HEIGHT};
|
use state::{NostrRegistry, RelayState, SignerEvent};
|
||||||
|
use theme::{ActiveTheme, SIDEBAR_WIDTH, Theme, ThemeRegistry};
|
||||||
use title_bar::TitleBar;
|
use title_bar::TitleBar;
|
||||||
use ui::avatar::Avatar;
|
use ui::avatar::Avatar;
|
||||||
use ui::button::{Button, ButtonVariants};
|
use ui::button::{Button, ButtonVariants};
|
||||||
use ui::dock_area::dock::DockPlacement;
|
use ui::dock_area::dock::DockPlacement;
|
||||||
use ui::dock_area::panel::{PanelStyle, PanelView};
|
use ui::dock_area::panel::PanelView;
|
||||||
use ui::dock_area::{ClosePanel, DockArea, DockItem};
|
use ui::dock_area::{ClosePanel, DockArea, DockItem};
|
||||||
use ui::menu::DropdownMenu;
|
use ui::menu::{DropdownMenu, PopupMenuItem};
|
||||||
use ui::{h_flex, v_flex, Root, Sizable, WindowExtension};
|
use ui::notification::Notification;
|
||||||
|
use ui::{IconName, Root, Sizable, WindowExtension, h_flex, v_flex};
|
||||||
|
|
||||||
use crate::panels::greeter;
|
use crate::dialogs::{accounts, settings};
|
||||||
|
use crate::panels::{backup, contact_list, greeter, messaging_relays, profile, relay_list};
|
||||||
use crate::sidebar;
|
use crate::sidebar;
|
||||||
|
|
||||||
|
const ENC_MSG: &str = "Encryption Key is a special key that used to encrypt and decrypt your messages. \
|
||||||
|
Your identity is completely decoupled from all encryption processes to protect your privacy.";
|
||||||
|
|
||||||
|
const ENC_WARN: &str = "By resetting your encryption key, you will lose access to \
|
||||||
|
all your encrypted messages before. This action cannot be undone.";
|
||||||
|
|
||||||
pub fn init(window: &mut Window, cx: &mut App) -> Entity<Workspace> {
|
pub fn init(window: &mut Window, cx: &mut App) -> Entity<Workspace> {
|
||||||
cx.new(|cx| Workspace::new(window, cx))
|
cx.new(|cx| Workspace::new(window, cx))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Action, Clone, PartialEq, Eq, Deserialize)]
|
||||||
|
#[action(namespace = workspace, no_json)]
|
||||||
|
enum Command {
|
||||||
|
ToggleTheme,
|
||||||
|
ToggleAccount,
|
||||||
|
|
||||||
|
RefreshEncryption,
|
||||||
|
RefreshRelayList,
|
||||||
|
RefreshMessagingRelays,
|
||||||
|
ResetEncryption,
|
||||||
|
|
||||||
|
ShowRelayList,
|
||||||
|
ShowMessaging,
|
||||||
|
ShowProfile,
|
||||||
|
ShowSettings,
|
||||||
|
ShowBackup,
|
||||||
|
ShowContactList,
|
||||||
|
}
|
||||||
|
|
||||||
pub struct Workspace {
|
pub struct Workspace {
|
||||||
/// App's Title Bar
|
/// App's Title Bar
|
||||||
titlebar: Entity<TitleBar>,
|
titlebar: Entity<TitleBar>,
|
||||||
@@ -34,17 +64,44 @@ pub struct Workspace {
|
|||||||
dock: Entity<DockArea>,
|
dock: Entity<DockArea>,
|
||||||
|
|
||||||
/// Event subscriptions
|
/// Event subscriptions
|
||||||
_subscriptions: SmallVec<[Subscription; 3]>,
|
_subscriptions: SmallVec<[Subscription; 4]>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Workspace {
|
impl Workspace {
|
||||||
fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
|
fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||||
|
let nostr = NostrRegistry::global(cx);
|
||||||
|
let npubs = nostr.read(cx).npubs();
|
||||||
let chat = ChatRegistry::global(cx);
|
let chat = ChatRegistry::global(cx);
|
||||||
let titlebar = cx.new(|_| TitleBar::new());
|
let titlebar = cx.new(|_| TitleBar::new());
|
||||||
let dock = cx.new(|cx| DockArea::new(window, cx).panel_style(PanelStyle::TabBar));
|
let dock = cx.new(|cx| DockArea::new(window, cx));
|
||||||
|
|
||||||
let mut subscriptions = smallvec![];
|
let mut subscriptions = smallvec![];
|
||||||
|
|
||||||
|
subscriptions.push(
|
||||||
|
// Observe system appearance and update theme
|
||||||
|
cx.observe_window_appearance(window, |_this, window, cx| {
|
||||||
|
Theme::sync_system_appearance(Some(window), cx);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
subscriptions.push(
|
||||||
|
// Observe the npubs entity
|
||||||
|
cx.observe_in(&npubs, window, move |this, npubs, window, cx| {
|
||||||
|
if !npubs.read(cx).is_empty() {
|
||||||
|
this.account_selector(window, cx);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
subscriptions.push(
|
||||||
|
// Subscribe to the signer events
|
||||||
|
cx.subscribe_in(&nostr, window, move |this, _state, event, window, cx| {
|
||||||
|
if let SignerEvent::Set = event {
|
||||||
|
this.set_center_layout(window, cx);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
subscriptions.push(
|
subscriptions.push(
|
||||||
// Observe all events emitted by the chat registry
|
// Observe all events emitted by the chat registry
|
||||||
cx.subscribe_in(&chat, window, move |this, chat, ev, window, cx| {
|
cx.subscribe_in(&chat, window, move |this, chat, ev, window, cx| {
|
||||||
@@ -84,12 +141,12 @@ impl Workspace {
|
|||||||
let ids = this.panel_ids(cx);
|
let ids = this.panel_ids(cx);
|
||||||
|
|
||||||
chat.update(cx, |this, cx| {
|
chat.update(cx, |this, cx| {
|
||||||
this.refresh_rooms(ids, cx);
|
this.refresh_rooms(&ids, cx);
|
||||||
});
|
});
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Set the default layout for app's dock
|
// Set the layout at the end of cycle
|
||||||
cx.defer_in(window, |this, window, cx| {
|
cx.defer_in(window, |this, window, cx| {
|
||||||
this.set_layout(window, cx);
|
this.set_layout(window, cx);
|
||||||
});
|
});
|
||||||
@@ -118,139 +175,551 @@ impl Workspace {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Get all panel ids
|
/// Get all panel ids
|
||||||
fn panel_ids(&self, cx: &App) -> Option<Vec<u64>> {
|
fn panel_ids(&self, cx: &App) -> Vec<u64> {
|
||||||
let ids: Vec<u64> = self
|
self.dock
|
||||||
.dock
|
|
||||||
.read(cx)
|
.read(cx)
|
||||||
.items
|
.items
|
||||||
.panel_ids(cx)
|
.panel_ids(cx)
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.filter_map(|panel| panel.parse::<u64>().ok())
|
.filter_map(|panel| panel.parse::<u64>().ok())
|
||||||
.collect();
|
.collect()
|
||||||
|
|
||||||
Some(ids)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Set the dock layout
|
/// Set the dock layout
|
||||||
fn set_layout(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
fn set_layout(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||||
let weak_dock = self.dock.downgrade();
|
|
||||||
|
|
||||||
// Sidebar
|
|
||||||
let left = DockItem::panel(Arc::new(sidebar::init(window, cx)));
|
let left = DockItem::panel(Arc::new(sidebar::init(window, cx)));
|
||||||
|
|
||||||
// Main workspace
|
// Update the dock layout with sidebar on the left
|
||||||
let center = DockItem::split_with_sizes(
|
|
||||||
Axis::Vertical,
|
|
||||||
vec![DockItem::tabs(
|
|
||||||
vec![Arc::new(greeter::init(window, cx))],
|
|
||||||
None,
|
|
||||||
&weak_dock,
|
|
||||||
window,
|
|
||||||
cx,
|
|
||||||
)],
|
|
||||||
vec![None],
|
|
||||||
&weak_dock,
|
|
||||||
window,
|
|
||||||
cx,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Update the dock layout
|
|
||||||
self.dock.update(cx, |this, cx| {
|
self.dock.update(cx, |this, cx| {
|
||||||
this.set_left_dock(left, Some(SIDEBAR_WIDTH), true, window, cx);
|
this.set_left_dock(left, Some(SIDEBAR_WIDTH), true, window, cx);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set the center dock layout
|
||||||
|
fn set_center_layout(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||||
|
let dock = self.dock.downgrade();
|
||||||
|
let greeeter = Arc::new(greeter::init(window, cx));
|
||||||
|
let tabs = DockItem::tabs(vec![greeeter], None, &dock, window, cx);
|
||||||
|
let center = DockItem::split(Axis::Vertical, vec![tabs], &dock, window, cx);
|
||||||
|
|
||||||
|
// Update the layout with center dock
|
||||||
|
self.dock.update(cx, |this, cx| {
|
||||||
this.set_center(center, window, cx);
|
this.set_center(center, window, cx);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
fn titlebar_left(&mut self, _window: &mut Window, cx: &Context<Self>) -> impl IntoElement {
|
/// Handle command events
|
||||||
|
fn on_command(&mut self, command: &Command, window: &mut Window, cx: &mut Context<Self>) {
|
||||||
|
match command {
|
||||||
|
Command::ShowSettings => {
|
||||||
|
let view = settings::init(window, cx);
|
||||||
|
|
||||||
|
window.open_modal(cx, move |this, _window, _cx| {
|
||||||
|
this.width(px(520.))
|
||||||
|
.show_close(true)
|
||||||
|
.pb_2()
|
||||||
|
.title("Preferences")
|
||||||
|
.child(view.clone())
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Command::ShowProfile => {
|
||||||
|
let nostr = NostrRegistry::global(cx);
|
||||||
|
let signer = nostr.read(cx).signer();
|
||||||
|
|
||||||
|
if let Some(public_key) = signer.public_key() {
|
||||||
|
self.dock.update(cx, |this, cx| {
|
||||||
|
this.add_panel(
|
||||||
|
Arc::new(profile::init(public_key, window, cx)),
|
||||||
|
DockPlacement::Right,
|
||||||
|
window,
|
||||||
|
cx,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Command::ShowContactList => {
|
||||||
|
self.dock.update(cx, |this, cx| {
|
||||||
|
this.add_panel(
|
||||||
|
Arc::new(contact_list::init(window, cx)),
|
||||||
|
DockPlacement::Right,
|
||||||
|
window,
|
||||||
|
cx,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Command::ShowBackup => {
|
||||||
|
self.dock.update(cx, |this, cx| {
|
||||||
|
this.add_panel(
|
||||||
|
Arc::new(backup::init(window, cx)),
|
||||||
|
DockPlacement::Right,
|
||||||
|
window,
|
||||||
|
cx,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Command::ShowMessaging => {
|
||||||
|
self.dock.update(cx, |this, cx| {
|
||||||
|
this.add_panel(
|
||||||
|
Arc::new(messaging_relays::init(window, cx)),
|
||||||
|
DockPlacement::Right,
|
||||||
|
window,
|
||||||
|
cx,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Command::ShowRelayList => {
|
||||||
|
self.dock.update(cx, |this, cx| {
|
||||||
|
this.add_panel(
|
||||||
|
Arc::new(relay_list::init(window, cx)),
|
||||||
|
DockPlacement::Right,
|
||||||
|
window,
|
||||||
|
cx,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Command::RefreshEncryption => {
|
||||||
|
let device = DeviceRegistry::global(cx);
|
||||||
|
device.update(cx, |this, cx| {
|
||||||
|
this.get_announcement(cx);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Command::RefreshRelayList => {
|
||||||
|
let nostr = NostrRegistry::global(cx);
|
||||||
|
nostr.update(cx, |this, cx| {
|
||||||
|
this.ensure_relay_list(cx);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Command::ResetEncryption => {
|
||||||
|
self.confirm_reset_encryption(window, cx);
|
||||||
|
}
|
||||||
|
Command::RefreshMessagingRelays => {
|
||||||
let chat = ChatRegistry::global(cx);
|
let chat = ChatRegistry::global(cx);
|
||||||
|
chat.update(cx, |this, cx| {
|
||||||
|
this.ensure_messaging_relays(cx);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Command::ToggleTheme => {
|
||||||
|
self.theme_selector(window, cx);
|
||||||
|
}
|
||||||
|
Command::ToggleAccount => {
|
||||||
|
self.account_selector(window, cx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn confirm_reset_encryption(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||||
|
window.open_modal(cx, |this, _window, cx| {
|
||||||
|
this.confirm()
|
||||||
|
.show_close(true)
|
||||||
|
.title("Reset Encryption Keys")
|
||||||
|
.child(
|
||||||
|
v_flex()
|
||||||
|
.gap_1()
|
||||||
|
.text_sm()
|
||||||
|
.child(SharedString::from(ENC_MSG))
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.italic()
|
||||||
|
.text_color(cx.theme().warning_active)
|
||||||
|
.child(SharedString::from(ENC_WARN)),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.on_ok(move |_ev, window, cx| {
|
||||||
|
let device = DeviceRegistry::global(cx);
|
||||||
|
let task = device.read(cx).create_encryption(cx);
|
||||||
|
|
||||||
|
window
|
||||||
|
.spawn(cx, async move |cx| {
|
||||||
|
let result = task.await;
|
||||||
|
|
||||||
|
cx.update(|window, cx| match result {
|
||||||
|
Ok(keys) => {
|
||||||
|
device.update(cx, |this, cx| {
|
||||||
|
this.set_signer(keys, cx);
|
||||||
|
this.listen_request(cx);
|
||||||
|
});
|
||||||
|
window.close_modal(cx);
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
window
|
||||||
|
.push_notification(Notification::error(e.to_string()), cx);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.ok();
|
||||||
|
})
|
||||||
|
.detach();
|
||||||
|
|
||||||
|
// false to keep modal open
|
||||||
|
false
|
||||||
|
})
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn account_selector(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||||
|
let accounts = accounts::init(window, cx);
|
||||||
|
|
||||||
|
window.open_modal(cx, move |this, _window, _cx| {
|
||||||
|
this.width(px(520.))
|
||||||
|
.title("Continue with")
|
||||||
|
.show_close(false)
|
||||||
|
.keyboard(false)
|
||||||
|
.overlay_closable(false)
|
||||||
|
.pb_2()
|
||||||
|
.child(accounts.clone())
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn theme_selector(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||||
|
window.open_modal(cx, move |this, _window, cx| {
|
||||||
|
let registry = ThemeRegistry::global(cx);
|
||||||
|
let themes = registry.read(cx).themes();
|
||||||
|
|
||||||
|
this.width(px(520.))
|
||||||
|
.show_close(true)
|
||||||
|
.title("Select theme")
|
||||||
|
.pb_2()
|
||||||
|
.child(v_flex().gap_2().w_full().children({
|
||||||
|
let mut items = vec![];
|
||||||
|
|
||||||
|
for (ix, (path, theme)) in themes.iter().enumerate() {
|
||||||
|
items.push(
|
||||||
|
h_flex()
|
||||||
|
.id(ix)
|
||||||
|
.group("")
|
||||||
|
.px_2()
|
||||||
|
.h_8()
|
||||||
|
.w_full()
|
||||||
|
.justify_between()
|
||||||
|
.rounded(cx.theme().radius)
|
||||||
|
.bg(cx.theme().ghost_element_background)
|
||||||
|
.hover(|this| this.bg(cx.theme().ghost_element_hover))
|
||||||
|
.child(
|
||||||
|
h_flex()
|
||||||
|
.gap_1p5()
|
||||||
|
.flex_1()
|
||||||
|
.text_sm()
|
||||||
|
.child(theme.name.clone())
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.text_xs()
|
||||||
|
.italic()
|
||||||
|
.text_color(cx.theme().text_muted)
|
||||||
|
.child(theme.author.clone()),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
h_flex()
|
||||||
|
.gap_1()
|
||||||
|
.invisible()
|
||||||
|
.group_hover("", |this| this.visible())
|
||||||
|
.child(
|
||||||
|
Button::new(format!("url-{ix}"))
|
||||||
|
.icon(IconName::Link)
|
||||||
|
.ghost()
|
||||||
|
.small()
|
||||||
|
.on_click({
|
||||||
|
let theme = theme.clone();
|
||||||
|
move |_ev, _window, cx| {
|
||||||
|
cx.open_url(&theme.url);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
Button::new(format!("set-{ix}"))
|
||||||
|
.icon(IconName::Check)
|
||||||
|
.primary()
|
||||||
|
.small()
|
||||||
|
.on_click({
|
||||||
|
let path = path.clone();
|
||||||
|
move |_ev, window, cx| {
|
||||||
|
let settings = AppSettings::global(cx);
|
||||||
|
let path = path.clone();
|
||||||
|
|
||||||
|
settings.update(cx, |this, cx| {
|
||||||
|
this.set_theme(path, window, cx);
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
items
|
||||||
|
}))
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn titlebar_left(&mut self, _window: &mut Window, cx: &Context<Self>) -> impl IntoElement {
|
||||||
let nostr = NostrRegistry::global(cx);
|
let nostr = NostrRegistry::global(cx);
|
||||||
let signer = nostr.read(cx).signer();
|
let signer = nostr.read(cx).signer();
|
||||||
let current_user = signer.public_key();
|
let current_user = signer.public_key();
|
||||||
|
|
||||||
h_flex()
|
h_flex()
|
||||||
.h(TITLEBAR_HEIGHT)
|
|
||||||
.flex_shrink_0()
|
.flex_shrink_0()
|
||||||
.justify_between()
|
|
||||||
.gap_2()
|
.gap_2()
|
||||||
|
.when_none(¤t_user, |this| {
|
||||||
|
this.child(
|
||||||
|
div()
|
||||||
|
.text_xs()
|
||||||
|
.text_color(cx.theme().text_muted)
|
||||||
|
.child(SharedString::from("Choose an account to continue...")),
|
||||||
|
)
|
||||||
|
})
|
||||||
.when_some(current_user.as_ref(), |this, public_key| {
|
.when_some(current_user.as_ref(), |this, public_key| {
|
||||||
let persons = PersonRegistry::global(cx);
|
let persons = PersonRegistry::global(cx);
|
||||||
let profile = persons.read(cx).get(public_key, cx);
|
let profile = persons.read(cx).get(public_key, cx);
|
||||||
|
let avatar = profile.avatar();
|
||||||
|
let name = profile.name();
|
||||||
|
|
||||||
this.child(
|
this.child(
|
||||||
Button::new("current-user")
|
Button::new("current-user")
|
||||||
.child(Avatar::new(profile.avatar()).size(rems(1.25)))
|
.child(Avatar::new(avatar.clone()).xsmall())
|
||||||
.small()
|
.small()
|
||||||
.caret()
|
.caret()
|
||||||
.compact()
|
.compact()
|
||||||
.transparent()
|
.transparent()
|
||||||
.dropdown_menu(move |this, _window, _cx| {
|
.dropdown_menu(move |this, _window, _cx| {
|
||||||
this.label(profile.name())
|
let avatar = avatar.clone();
|
||||||
|
let name = name.clone();
|
||||||
|
|
||||||
|
this.min_w(px(256.))
|
||||||
|
.item(PopupMenuItem::element(move |_window, cx| {
|
||||||
|
h_flex()
|
||||||
|
.gap_1p5()
|
||||||
|
.text_xs()
|
||||||
|
.text_color(cx.theme().text_muted)
|
||||||
|
.child(Avatar::new(avatar.clone()).xsmall())
|
||||||
|
.child(name.clone())
|
||||||
|
}))
|
||||||
.separator()
|
.separator()
|
||||||
.menu("Profile", Box::new(ClosePanel))
|
.menu_with_icon(
|
||||||
.menu("Backup", Box::new(ClosePanel))
|
"Profile",
|
||||||
.menu("Themes", Box::new(ClosePanel))
|
IconName::Profile,
|
||||||
.menu("Settings", Box::new(ClosePanel))
|
Box::new(Command::ShowProfile),
|
||||||
|
)
|
||||||
|
.menu_with_icon(
|
||||||
|
"Contact List",
|
||||||
|
IconName::Book,
|
||||||
|
Box::new(Command::ShowContactList),
|
||||||
|
)
|
||||||
|
.menu_with_icon(
|
||||||
|
"Backup",
|
||||||
|
IconName::UserKey,
|
||||||
|
Box::new(Command::ShowBackup),
|
||||||
|
)
|
||||||
|
.menu_with_icon(
|
||||||
|
"Themes",
|
||||||
|
IconName::Sun,
|
||||||
|
Box::new(Command::ToggleTheme),
|
||||||
|
)
|
||||||
|
.separator()
|
||||||
|
.menu_with_icon(
|
||||||
|
"Accounts",
|
||||||
|
IconName::Group,
|
||||||
|
Box::new(Command::ToggleAccount),
|
||||||
|
)
|
||||||
|
.menu_with_icon(
|
||||||
|
"Settings",
|
||||||
|
IconName::Settings,
|
||||||
|
Box::new(Command::ShowSettings),
|
||||||
|
)
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
.when(nostr.read(cx).creating(), |this| {
|
|
||||||
this.child(div().text_xs().text_color(cx.theme().text_muted).child(
|
|
||||||
SharedString::from("Coop is creating a new identity for you..."),
|
|
||||||
))
|
|
||||||
})
|
|
||||||
.when(!nostr.read(cx).connected(), |this| {
|
|
||||||
this.child(
|
|
||||||
div()
|
|
||||||
.text_xs()
|
|
||||||
.text_color(cx.theme().text_muted)
|
|
||||||
.child(SharedString::from("Connecting...")),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
.map(|this| match nostr.read(cx).relay_list_state() {
|
|
||||||
RelayState::Checking => this.child(
|
|
||||||
div()
|
|
||||||
.text_xs()
|
|
||||||
.text_color(cx.theme().text_muted)
|
|
||||||
.child(SharedString::from("Fetching user's relay list...")),
|
|
||||||
),
|
|
||||||
RelayState::NotConfigured => this.child(
|
|
||||||
h_flex()
|
|
||||||
.h_6()
|
|
||||||
.w_full()
|
|
||||||
.px_1()
|
|
||||||
.text_xs()
|
|
||||||
.text_color(cx.theme().warning_foreground)
|
|
||||||
.bg(cx.theme().warning_background)
|
|
||||||
.rounded_sm()
|
|
||||||
.child(SharedString::from("User hasn't configured a relay list")),
|
|
||||||
),
|
|
||||||
_ => this,
|
|
||||||
})
|
|
||||||
.map(|this| match chat.read(cx).relay_state(cx) {
|
|
||||||
RelayState::Checking => {
|
|
||||||
this.child(div().text_xs().text_color(cx.theme().text_muted).child(
|
|
||||||
SharedString::from("Fetching user's messaging relay list..."),
|
|
||||||
))
|
|
||||||
}
|
|
||||||
RelayState::NotConfigured => this.child(
|
|
||||||
h_flex()
|
|
||||||
.h_6()
|
|
||||||
.w_full()
|
|
||||||
.px_1()
|
|
||||||
.text_xs()
|
|
||||||
.text_color(cx.theme().warning_foreground)
|
|
||||||
.bg(cx.theme().warning_background)
|
|
||||||
.rounded_sm()
|
|
||||||
.child(SharedString::from(
|
|
||||||
"User hasn't configured a messaging relay list",
|
|
||||||
)),
|
|
||||||
),
|
|
||||||
_ => this,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn titlebar_right(&mut self, _window: &mut Window, _cx: &Context<Self>) -> impl IntoElement {
|
fn titlebar_right(&mut self, _window: &mut Window, cx: &Context<Self>) -> impl IntoElement {
|
||||||
h_flex().h(TITLEBAR_HEIGHT).flex_shrink_0()
|
let nostr = NostrRegistry::global(cx);
|
||||||
|
let signer = nostr.read(cx).signer();
|
||||||
|
|
||||||
|
let chat = ChatRegistry::global(cx);
|
||||||
|
let inbox_state = chat.read(cx).state(cx);
|
||||||
|
|
||||||
|
let Some(pkey) = signer.public_key() else {
|
||||||
|
return div();
|
||||||
|
};
|
||||||
|
|
||||||
|
h_flex()
|
||||||
|
.when(!cx.theme().platform.is_mac(), |this| this.pr_2())
|
||||||
|
.gap_3()
|
||||||
|
.child(
|
||||||
|
Button::new("key")
|
||||||
|
.icon(IconName::UserKey)
|
||||||
|
.tooltip("Decoupled encryption key")
|
||||||
|
.small()
|
||||||
|
.ghost()
|
||||||
|
.dropdown_menu(move |this, _window, cx| {
|
||||||
|
let device = DeviceRegistry::global(cx);
|
||||||
|
let state = device.read(cx).state();
|
||||||
|
|
||||||
|
this.min_w(px(260.))
|
||||||
|
.item(PopupMenuItem::element(move |_window, _cx| {
|
||||||
|
h_flex()
|
||||||
|
.px_1()
|
||||||
|
.w_full()
|
||||||
|
.gap_2()
|
||||||
|
.text_sm()
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.size_1p5()
|
||||||
|
.rounded_full()
|
||||||
|
.when(state.set(), |this| this.bg(gpui::green()))
|
||||||
|
.when(state.requesting(), |this| {
|
||||||
|
this.bg(gpui::yellow())
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.child(SharedString::from(state.to_string()))
|
||||||
|
}))
|
||||||
|
.separator()
|
||||||
|
.menu_with_icon(
|
||||||
|
"Reload",
|
||||||
|
IconName::Refresh,
|
||||||
|
Box::new(Command::RefreshEncryption),
|
||||||
|
)
|
||||||
|
.menu_with_icon(
|
||||||
|
"Reset",
|
||||||
|
IconName::Warning,
|
||||||
|
Box::new(Command::ResetEncryption),
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
h_flex()
|
||||||
|
.gap_2()
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.text_xs()
|
||||||
|
.text_color(cx.theme().text_muted)
|
||||||
|
.map(|this| match inbox_state {
|
||||||
|
InboxState::Checking => this.child(div().child(
|
||||||
|
SharedString::from("Fetching user's messaging relay list..."),
|
||||||
|
)),
|
||||||
|
InboxState::RelayNotAvailable => {
|
||||||
|
this.child(div().text_color(cx.theme().warning_active).child(
|
||||||
|
SharedString::from(
|
||||||
|
"User hasn't configured a messaging relay list",
|
||||||
|
),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
_ => this,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
Button::new("inbox")
|
||||||
|
.icon(IconName::Inbox)
|
||||||
|
.tooltip("Inbox")
|
||||||
|
.small()
|
||||||
|
.ghost()
|
||||||
|
.when(inbox_state.subscribing(), |this| this.indicator())
|
||||||
|
.dropdown_menu(move |this, _window, cx| {
|
||||||
|
let persons = PersonRegistry::global(cx);
|
||||||
|
let profile = persons.read(cx).get(&pkey, cx);
|
||||||
|
let urls: Vec<SharedString> = profile
|
||||||
|
.messaging_relays()
|
||||||
|
.iter()
|
||||||
|
.map(|url| SharedString::from(url.to_string()))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
// Header
|
||||||
|
let menu = this.min_w(px(260.)).label("Messaging Relays");
|
||||||
|
|
||||||
|
// Content
|
||||||
|
let menu = urls.into_iter().fold(menu, |this, url| {
|
||||||
|
this.item(PopupMenuItem::element(move |_window, _cx| {
|
||||||
|
h_flex()
|
||||||
|
.px_1()
|
||||||
|
.w_full()
|
||||||
|
.gap_2()
|
||||||
|
.text_sm()
|
||||||
|
.child(
|
||||||
|
div().size_1p5().rounded_full().bg(gpui::green()),
|
||||||
|
)
|
||||||
|
.child(url.clone())
|
||||||
|
}))
|
||||||
|
});
|
||||||
|
|
||||||
|
// Footer
|
||||||
|
menu.separator()
|
||||||
|
.menu_with_icon(
|
||||||
|
"Reload",
|
||||||
|
IconName::Refresh,
|
||||||
|
Box::new(Command::RefreshMessagingRelays),
|
||||||
|
)
|
||||||
|
.menu_with_icon(
|
||||||
|
"Update relays",
|
||||||
|
IconName::Settings,
|
||||||
|
Box::new(Command::ShowMessaging),
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
h_flex()
|
||||||
|
.gap_2()
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.text_xs()
|
||||||
|
.text_color(cx.theme().text_muted)
|
||||||
|
.map(|this| match nostr.read(cx).relay_list_state {
|
||||||
|
RelayState::Checking => this
|
||||||
|
.child(div().child(SharedString::from(
|
||||||
|
"Fetching user's relay list...",
|
||||||
|
))),
|
||||||
|
RelayState::NotConfigured => {
|
||||||
|
this.child(div().text_color(cx.theme().warning_active).child(
|
||||||
|
SharedString::from("User hasn't configured a relay list"),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
_ => this,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
Button::new("relay-list")
|
||||||
|
.icon(IconName::Relay)
|
||||||
|
.tooltip("User's relay list")
|
||||||
|
.small()
|
||||||
|
.ghost()
|
||||||
|
.when(nostr.read(cx).relay_list_state.configured(), |this| {
|
||||||
|
this.indicator()
|
||||||
|
})
|
||||||
|
.dropdown_menu(move |this, _window, cx| {
|
||||||
|
let nostr = NostrRegistry::global(cx);
|
||||||
|
let urls = nostr.read(cx).read_only_relays(&pkey, cx);
|
||||||
|
|
||||||
|
// Header
|
||||||
|
let menu = this.min_w(px(260.)).label("Relays");
|
||||||
|
|
||||||
|
// Content
|
||||||
|
let menu = urls.into_iter().fold(menu, |this, url| {
|
||||||
|
this.item(PopupMenuItem::element(move |_window, _cx| {
|
||||||
|
h_flex()
|
||||||
|
.px_1()
|
||||||
|
.w_full()
|
||||||
|
.gap_2()
|
||||||
|
.text_sm()
|
||||||
|
.child(
|
||||||
|
div().size_1p5().rounded_full().bg(gpui::green()),
|
||||||
|
)
|
||||||
|
.child(url.clone())
|
||||||
|
}))
|
||||||
|
});
|
||||||
|
|
||||||
|
// Footer
|
||||||
|
menu.separator()
|
||||||
|
.menu_with_icon(
|
||||||
|
"Reload",
|
||||||
|
IconName::Refresh,
|
||||||
|
Box::new(Command::RefreshRelayList),
|
||||||
|
)
|
||||||
|
.menu_with_icon(
|
||||||
|
"Update relay list",
|
||||||
|
IconName::Settings,
|
||||||
|
Box::new(Command::ShowRelayList),
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -270,6 +739,7 @@ impl Render for Workspace {
|
|||||||
|
|
||||||
div()
|
div()
|
||||||
.id(SharedString::from("workspace"))
|
.id(SharedString::from("workspace"))
|
||||||
|
.on_action(cx.listener(Self::on_command))
|
||||||
.relative()
|
.relative()
|
||||||
.size_full()
|
.size_full()
|
||||||
.child(
|
.child(
|
||||||
|
|||||||
@@ -7,6 +7,9 @@ publish.workspace = true
|
|||||||
[dependencies]
|
[dependencies]
|
||||||
common = { path = "../common" }
|
common = { path = "../common" }
|
||||||
state = { path = "../state" }
|
state = { path = "../state" }
|
||||||
|
person = { path = "../person" }
|
||||||
|
ui = { path = "../ui" }
|
||||||
|
theme = { path = "../theme" }
|
||||||
|
|
||||||
gpui.workspace = true
|
gpui.workspace = true
|
||||||
nostr-sdk.workspace = true
|
nostr-sdk.workspace = true
|
||||||
|
|||||||
@@ -1,17 +1,28 @@
|
|||||||
|
use std::cell::Cell;
|
||||||
use std::collections::{HashMap, HashSet};
|
use std::collections::{HashMap, HashSet};
|
||||||
|
use std::rc::Rc;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use anyhow::{anyhow, Context as AnyhowContext, Error};
|
use anyhow::{anyhow, Context as AnyhowContext, Error};
|
||||||
use gpui::{App, AppContext, Context, Entity, Global, Subscription, Task, Window};
|
use gpui::{
|
||||||
|
div, App, AppContext, Context, Entity, Global, IntoElement, ParentElement, SharedString,
|
||||||
|
Styled, Subscription, Task, Window,
|
||||||
|
};
|
||||||
use nostr_sdk::prelude::*;
|
use nostr_sdk::prelude::*;
|
||||||
|
use person::PersonRegistry;
|
||||||
use smallvec::{smallvec, SmallVec};
|
use smallvec::{smallvec, SmallVec};
|
||||||
use state::{app_name, NostrRegistry, RelayState, DEVICE_GIFTWRAP, TIMEOUT};
|
use state::{
|
||||||
|
app_name, Announcement, DeviceState, NostrRegistry, RelayState, DEVICE_GIFTWRAP, TIMEOUT,
|
||||||
mod device;
|
};
|
||||||
|
use theme::ActiveTheme;
|
||||||
pub use device::*;
|
use ui::avatar::Avatar;
|
||||||
|
use ui::button::{Button, ButtonVariants};
|
||||||
|
use ui::notification::Notification;
|
||||||
|
use ui::{h_flex, v_flex, Disableable, IconName, Sizable, WindowExtension};
|
||||||
|
|
||||||
const IDENTIFIER: &str = "coop:device";
|
const IDENTIFIER: &str = "coop:device";
|
||||||
|
const MSG: &str = "You've requested an encryption key from another device. \
|
||||||
|
Approve to allow Coop to share with it.";
|
||||||
|
|
||||||
pub fn init(window: &mut Window, cx: &mut App) {
|
pub fn init(window: &mut Window, cx: &mut App) {
|
||||||
DeviceRegistry::set_global(cx.new(|cx| DeviceRegistry::new(window, cx)), cx);
|
DeviceRegistry::set_global(cx.new(|cx| DeviceRegistry::new(window, cx)), cx);
|
||||||
@@ -29,9 +40,6 @@ pub struct DeviceRegistry {
|
|||||||
/// Device state
|
/// Device state
|
||||||
state: DeviceState,
|
state: DeviceState,
|
||||||
|
|
||||||
/// Device requests
|
|
||||||
requests: Entity<HashSet<Event>>,
|
|
||||||
|
|
||||||
/// Async tasks
|
/// Async tasks
|
||||||
tasks: Vec<Task<Result<(), Error>>>,
|
tasks: Vec<Task<Result<(), Error>>>,
|
||||||
|
|
||||||
@@ -53,44 +61,35 @@ impl DeviceRegistry {
|
|||||||
/// Create a new device registry instance
|
/// Create a new device registry instance
|
||||||
fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
|
fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||||
let nostr = NostrRegistry::global(cx);
|
let nostr = NostrRegistry::global(cx);
|
||||||
let requests = cx.new(|_| HashSet::default());
|
|
||||||
|
|
||||||
let mut subscriptions = smallvec![];
|
let mut subscriptions = smallvec![];
|
||||||
|
|
||||||
subscriptions.push(
|
subscriptions.push(
|
||||||
// Observe the NIP-65 state
|
// Observe the NIP-65 state
|
||||||
cx.observe(&nostr, |this, state, cx| {
|
cx.observe(&nostr, |this, state, cx| {
|
||||||
match state.read(cx).relay_list_state() {
|
if state.read(cx).relay_list_state == RelayState::Configured {
|
||||||
RelayState::Idle => {
|
|
||||||
this.reset(cx);
|
|
||||||
}
|
|
||||||
RelayState::Configured => {
|
|
||||||
this.get_announcement(cx);
|
this.get_announcement(cx);
|
||||||
}
|
|
||||||
_ => {}
|
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Run at the end of current cycle
|
// Run at the end of current cycle
|
||||||
cx.defer_in(window, |this, _window, cx| {
|
cx.defer_in(window, |this, window, cx| {
|
||||||
this.handle_notifications(cx);
|
this.handle_notifications(window, cx);
|
||||||
});
|
});
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
requests,
|
|
||||||
state: DeviceState::default(),
|
state: DeviceState::default(),
|
||||||
tasks: vec![],
|
tasks: vec![],
|
||||||
_subscriptions: subscriptions,
|
_subscriptions: subscriptions,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn handle_notifications(&mut self, cx: &mut Context<Self>) {
|
fn handle_notifications(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||||
let nostr = NostrRegistry::global(cx);
|
let nostr = NostrRegistry::global(cx);
|
||||||
let client = nostr.read(cx).client();
|
let client = nostr.read(cx).client();
|
||||||
let (tx, rx) = flume::bounded::<Event>(100);
|
let (tx, rx) = flume::bounded::<Event>(100);
|
||||||
|
|
||||||
cx.background_spawn(async move {
|
self.tasks.push(cx.background_spawn(async move {
|
||||||
let mut notifications = client.notifications();
|
let mut notifications = client.notifications();
|
||||||
let mut processed_events = HashSet::new();
|
let mut processed_events = HashSet::new();
|
||||||
|
|
||||||
@@ -108,34 +107,37 @@ impl DeviceRegistry {
|
|||||||
match event.kind {
|
match event.kind {
|
||||||
Kind::Custom(4454) => {
|
Kind::Custom(4454) => {
|
||||||
if verify_author(&client, event.as_ref()).await {
|
if verify_author(&client, event.as_ref()).await {
|
||||||
tx.send_async(event.into_owned()).await.ok();
|
tx.send_async(event.into_owned()).await?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Kind::Custom(4455) => {
|
Kind::Custom(4455) => {
|
||||||
if verify_author(&client, event.as_ref()).await {
|
if verify_author(&client, event.as_ref()).await {
|
||||||
tx.send_async(event.into_owned()).await.ok();
|
tx.send_async(event.into_owned()).await?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
|
||||||
.detach();
|
Ok(())
|
||||||
|
}));
|
||||||
|
|
||||||
self.tasks.push(
|
self.tasks.push(
|
||||||
// Update GPUI states
|
// Update GPUI states
|
||||||
cx.spawn(async move |this, cx| {
|
cx.spawn_in(window, async move |this, cx| {
|
||||||
while let Ok(event) = rx.recv_async().await {
|
while let Ok(event) = rx.recv_async().await {
|
||||||
match event.kind {
|
match event.kind {
|
||||||
|
// New request event
|
||||||
Kind::Custom(4454) => {
|
Kind::Custom(4454) => {
|
||||||
this.update(cx, |this, cx| {
|
this.update_in(cx, |this, window, cx| {
|
||||||
this.add_request(event, cx);
|
this.ask_for_approval(event, window, cx);
|
||||||
})?;
|
})?;
|
||||||
}
|
}
|
||||||
|
// New response event
|
||||||
Kind::Custom(4455) => {
|
Kind::Custom(4455) => {
|
||||||
this.update(cx, |this, cx| {
|
this.update(cx, |this, cx| {
|
||||||
this.parse_response(event, cx);
|
this.extract_encryption(event, cx);
|
||||||
})?;
|
})?;
|
||||||
}
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
@@ -148,8 +150,8 @@ impl DeviceRegistry {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Get the device state
|
/// Get the device state
|
||||||
pub fn state(&self) -> &DeviceState {
|
pub fn state(&self) -> DeviceState {
|
||||||
&self.state
|
self.state.clone()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Set the device state
|
/// Set the device state
|
||||||
@@ -159,7 +161,7 @@ impl DeviceRegistry {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Set the decoupled encryption key for the current user
|
/// Set the decoupled encryption key for the current user
|
||||||
fn set_signer<S>(&mut self, new: S, cx: &mut Context<Self>)
|
pub fn set_signer<S>(&mut self, new: S, cx: &mut Context<Self>)
|
||||||
where
|
where
|
||||||
S: NostrSigner + 'static,
|
S: NostrSigner + 'static,
|
||||||
{
|
{
|
||||||
@@ -181,20 +183,8 @@ impl DeviceRegistry {
|
|||||||
|
|
||||||
/// Reset the device state
|
/// Reset the device state
|
||||||
fn reset(&mut self, cx: &mut Context<Self>) {
|
fn reset(&mut self, cx: &mut Context<Self>) {
|
||||||
self.state = DeviceState::Initial;
|
self.state = DeviceState::Idle;
|
||||||
self.requests.update(cx, |this, cx| {
|
|
||||||
this.clear();
|
|
||||||
cx.notify();
|
cx.notify();
|
||||||
});
|
|
||||||
cx.notify();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Add a request for device keys
|
|
||||||
fn add_request(&mut self, request: Event, cx: &mut Context<Self>) {
|
|
||||||
self.requests.update(cx, |this, cx| {
|
|
||||||
this.insert(request);
|
|
||||||
cx.notify();
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get all messages for encryption keys
|
/// Get all messages for encryption keys
|
||||||
@@ -214,20 +204,23 @@ impl DeviceRegistry {
|
|||||||
fn subscribe_to_giftwrap_events(&mut self, cx: &mut Context<Self>) -> Task<Result<(), Error>> {
|
fn subscribe_to_giftwrap_events(&mut self, cx: &mut Context<Self>) -> Task<Result<(), Error>> {
|
||||||
let nostr = NostrRegistry::global(cx);
|
let nostr = NostrRegistry::global(cx);
|
||||||
let client = nostr.read(cx).client();
|
let client = nostr.read(cx).client();
|
||||||
|
|
||||||
let signer = nostr.read(cx).signer();
|
let signer = nostr.read(cx).signer();
|
||||||
let public_key = signer.public_key().unwrap();
|
|
||||||
|
|
||||||
let messaging_relays = nostr.read(cx).messaging_relays(&public_key, cx);
|
let Some(public_key) = signer.public_key() else {
|
||||||
|
return Task::ready(Err(anyhow!("User not found")));
|
||||||
|
};
|
||||||
|
|
||||||
|
let persons = PersonRegistry::global(cx);
|
||||||
|
let profile = persons.read(cx).get(&public_key, cx);
|
||||||
|
let relay_urls = profile.messaging_relays().clone();
|
||||||
|
|
||||||
cx.background_spawn(async move {
|
cx.background_spawn(async move {
|
||||||
let relay_urls = messaging_relays.await;
|
|
||||||
let filter = Filter::new().kind(Kind::GiftWrap).pubkey(public_key);
|
let filter = Filter::new().kind(Kind::GiftWrap).pubkey(public_key);
|
||||||
let id = SubscriptionId::new(DEVICE_GIFTWRAP);
|
let id = SubscriptionId::new(DEVICE_GIFTWRAP);
|
||||||
|
|
||||||
// Construct target for subscription
|
// Construct target for subscription
|
||||||
let target: HashMap<&RelayUrl, Filter> = relay_urls
|
let target: HashMap<RelayUrl, Filter> = relay_urls
|
||||||
.iter()
|
.into_iter()
|
||||||
.map(|relay| (relay, filter.clone()))
|
.map(|relay| (relay, filter.clone()))
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
@@ -243,13 +236,19 @@ impl DeviceRegistry {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Get device announcement for current user
|
/// Get device announcement for current user
|
||||||
fn get_announcement(&mut self, cx: &mut Context<Self>) {
|
pub fn get_announcement(&mut self, cx: &mut Context<Self>) {
|
||||||
let nostr = NostrRegistry::global(cx);
|
let nostr = NostrRegistry::global(cx);
|
||||||
let client = nostr.read(cx).client();
|
let client = nostr.read(cx).client();
|
||||||
|
|
||||||
let signer = nostr.read(cx).signer();
|
let signer = nostr.read(cx).signer();
|
||||||
let public_key = signer.public_key().unwrap();
|
|
||||||
|
|
||||||
|
let Some(public_key) = signer.public_key() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Reset state before fetching announcement
|
||||||
|
self.reset(cx);
|
||||||
|
|
||||||
|
// Get user's write relays
|
||||||
let write_relays = nostr.read(cx).write_relays(&public_key, cx);
|
let write_relays = nostr.read(cx).write_relays(&public_key, cx);
|
||||||
|
|
||||||
let task: Task<Result<Event, Error>> = cx.background_spawn(async move {
|
let task: Task<Result<Event, Error>> = cx.background_spawn(async move {
|
||||||
@@ -290,12 +289,12 @@ impl DeviceRegistry {
|
|||||||
match task.await {
|
match task.await {
|
||||||
Ok(event) => {
|
Ok(event) => {
|
||||||
this.update(cx, |this, cx| {
|
this.update(cx, |this, cx| {
|
||||||
this.init_device_signer(&event, cx);
|
this.new_signer(&event, cx);
|
||||||
})?;
|
})?;
|
||||||
}
|
}
|
||||||
Err(_) => {
|
Err(_) => {
|
||||||
this.update(cx, |this, cx| {
|
this.update(cx, |this, cx| {
|
||||||
this.announce_device(cx);
|
this.announce(cx);
|
||||||
})?;
|
})?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -304,21 +303,24 @@ impl DeviceRegistry {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Create a new device signer and announce it
|
/// Create new encryption keys
|
||||||
fn announce_device(&mut self, cx: &mut Context<Self>) {
|
pub fn create_encryption(&self, cx: &App) -> Task<Result<Keys, Error>> {
|
||||||
let nostr = NostrRegistry::global(cx);
|
let nostr = NostrRegistry::global(cx);
|
||||||
let client = nostr.read(cx).client();
|
let client = nostr.read(cx).client();
|
||||||
|
|
||||||
let signer = nostr.read(cx).signer();
|
let signer = nostr.read(cx).signer();
|
||||||
let public_key = signer.public_key().unwrap();
|
|
||||||
|
|
||||||
|
let Some(public_key) = signer.public_key() else {
|
||||||
|
return Task::ready(Err(anyhow!("User not found")));
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get user's write relays
|
||||||
let write_relays = nostr.read(cx).write_relays(&public_key, cx);
|
let write_relays = nostr.read(cx).write_relays(&public_key, cx);
|
||||||
|
|
||||||
let keys = Keys::generate();
|
let keys = Keys::generate();
|
||||||
let secret = keys.secret_key().to_secret_hex();
|
let secret = keys.secret_key().to_secret_hex();
|
||||||
let n = keys.public_key();
|
let n = keys.public_key();
|
||||||
|
|
||||||
let task: Task<Result<(), Error>> = cx.background_spawn(async move {
|
cx.background_spawn(async move {
|
||||||
let urls = write_relays.await;
|
let urls = write_relays.await;
|
||||||
|
|
||||||
// Construct an announcement event
|
// Construct an announcement event
|
||||||
@@ -335,23 +337,29 @@ impl DeviceRegistry {
|
|||||||
// Save device keys to the database
|
// Save device keys to the database
|
||||||
set_keys(&client, &secret).await?;
|
set_keys(&client, &secret).await?;
|
||||||
|
|
||||||
Ok(())
|
Ok(keys)
|
||||||
});
|
})
|
||||||
|
}
|
||||||
|
|
||||||
cx.spawn(async move |this, cx| {
|
/// Create a new device signer and announce it
|
||||||
if task.await.is_ok() {
|
fn announce(&mut self, cx: &mut Context<Self>) {
|
||||||
|
let task = self.create_encryption(cx);
|
||||||
|
|
||||||
|
self.tasks.push(cx.spawn(async move |this, cx| {
|
||||||
|
let keys = task.await?;
|
||||||
|
|
||||||
|
// Update signer
|
||||||
this.update(cx, |this, cx| {
|
this.update(cx, |this, cx| {
|
||||||
this.set_signer(keys, cx);
|
this.set_signer(keys, cx);
|
||||||
this.listen_device_request(cx);
|
this.listen_request(cx);
|
||||||
})
|
})?;
|
||||||
.ok();
|
|
||||||
}
|
Ok(())
|
||||||
})
|
}));
|
||||||
.detach();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Initialize device signer (decoupled encryption key) for the current user
|
/// Initialize device signer (decoupled encryption key) for the current user
|
||||||
fn init_device_signer(&mut self, event: &Event, cx: &mut Context<Self>) {
|
pub fn new_signer(&mut self, event: &Event, cx: &mut Context<Self>) {
|
||||||
let nostr = NostrRegistry::global(cx);
|
let nostr = NostrRegistry::global(cx);
|
||||||
let client = nostr.read(cx).client();
|
let client = nostr.read(cx).client();
|
||||||
|
|
||||||
@@ -370,36 +378,36 @@ impl DeviceRegistry {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
cx.spawn(async move |this, cx| {
|
self.tasks.push(cx.spawn(async move |this, cx| {
|
||||||
match task.await {
|
match task.await {
|
||||||
Ok(keys) => {
|
Ok(keys) => {
|
||||||
this.update(cx, |this, cx| {
|
this.update(cx, |this, cx| {
|
||||||
this.set_signer(keys, cx);
|
this.set_signer(keys, cx);
|
||||||
this.listen_device_request(cx);
|
this.listen_request(cx);
|
||||||
})
|
})?;
|
||||||
.ok();
|
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
this.update(cx, |this, cx| {
|
|
||||||
this.request_device_keys(cx);
|
|
||||||
this.listen_device_approval(cx);
|
|
||||||
})
|
|
||||||
.ok();
|
|
||||||
|
|
||||||
log::warn!("Failed to initialize device signer: {e}");
|
log::warn!("Failed to initialize device signer: {e}");
|
||||||
|
this.update(cx, |this, cx| {
|
||||||
|
this.request(cx);
|
||||||
|
this.listen_approval(cx);
|
||||||
|
})?;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
})
|
|
||||||
.detach();
|
Ok(())
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Listen for device key requests on user's write relays
|
/// Listen for device key requests on user's write relays
|
||||||
fn listen_device_request(&mut self, cx: &mut Context<Self>) {
|
pub fn listen_request(&mut self, cx: &mut Context<Self>) {
|
||||||
let nostr = NostrRegistry::global(cx);
|
let nostr = NostrRegistry::global(cx);
|
||||||
let client = nostr.read(cx).client();
|
let client = nostr.read(cx).client();
|
||||||
|
|
||||||
let signer = nostr.read(cx).signer();
|
let signer = nostr.read(cx).signer();
|
||||||
let public_key = signer.public_key().unwrap();
|
|
||||||
|
let Some(public_key) = signer.public_key() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
let write_relays = nostr.read(cx).write_relays(&public_key, cx);
|
let write_relays = nostr.read(cx).write_relays(&public_key, cx);
|
||||||
|
|
||||||
@@ -426,16 +434,18 @@ impl DeviceRegistry {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Listen for device key approvals on user's write relays
|
/// Listen for device key approvals on user's write relays
|
||||||
fn listen_device_approval(&mut self, cx: &mut Context<Self>) {
|
fn listen_approval(&mut self, cx: &mut Context<Self>) {
|
||||||
let nostr = NostrRegistry::global(cx);
|
let nostr = NostrRegistry::global(cx);
|
||||||
let client = nostr.read(cx).client();
|
let client = nostr.read(cx).client();
|
||||||
|
|
||||||
let signer = nostr.read(cx).signer();
|
let signer = nostr.read(cx).signer();
|
||||||
let public_key = signer.public_key().unwrap();
|
|
||||||
|
let Some(public_key) = signer.public_key() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
let write_relays = nostr.read(cx).write_relays(&public_key, cx);
|
let write_relays = nostr.read(cx).write_relays(&public_key, cx);
|
||||||
|
|
||||||
let task: Task<Result<(), Error>> = cx.background_spawn(async move {
|
self.tasks.push(cx.background_spawn(async move {
|
||||||
let urls = write_relays.await;
|
let urls = write_relays.await;
|
||||||
|
|
||||||
// Construct a filter for device key requests
|
// Construct a filter for device key requests
|
||||||
@@ -452,22 +462,22 @@ impl DeviceRegistry {
|
|||||||
client.subscribe(target).await?;
|
client.subscribe(target).await?;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
});
|
}));
|
||||||
|
|
||||||
task.detach();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Request encryption keys from other device
|
/// Request encryption keys from other device
|
||||||
fn request_device_keys(&mut self, cx: &mut Context<Self>) {
|
fn request(&mut self, cx: &mut Context<Self>) {
|
||||||
let nostr = NostrRegistry::global(cx);
|
let nostr = NostrRegistry::global(cx);
|
||||||
let client = nostr.read(cx).client();
|
let client = nostr.read(cx).client();
|
||||||
|
|
||||||
let signer = nostr.read(cx).signer();
|
let signer = nostr.read(cx).signer();
|
||||||
let public_key = signer.public_key().unwrap();
|
|
||||||
|
let Some(public_key) = signer.public_key() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
let write_relays = nostr.read(cx).write_relays(&public_key, cx);
|
let write_relays = nostr.read(cx).write_relays(&public_key, cx);
|
||||||
|
|
||||||
let app_keys = nostr.read(cx).app_keys().clone();
|
let app_keys = nostr.read(cx).app_keys.clone();
|
||||||
let app_pubkey = app_keys.public_key();
|
let app_pubkey = app_keys.public_key();
|
||||||
|
|
||||||
let task: Task<Result<Option<Keys>, Error>> = cx.background_spawn(async move {
|
let task: Task<Result<Option<Keys>, Error>> = cx.background_spawn(async move {
|
||||||
@@ -515,32 +525,31 @@ impl DeviceRegistry {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
cx.spawn(async move |this, cx| {
|
self.tasks.push(cx.spawn(async move |this, cx| {
|
||||||
match task.await {
|
match task.await {
|
||||||
Ok(Some(keys)) => {
|
Ok(Some(keys)) => {
|
||||||
this.update(cx, |this, cx| {
|
this.update(cx, |this, cx| {
|
||||||
this.set_signer(keys, cx);
|
this.set_signer(keys, cx);
|
||||||
})
|
})?;
|
||||||
.ok();
|
|
||||||
}
|
}
|
||||||
Ok(None) => {
|
Ok(None) => {
|
||||||
this.update(cx, |this, cx| {
|
this.update(cx, |this, cx| {
|
||||||
this.set_state(DeviceState::Requesting, cx);
|
this.set_state(DeviceState::Requesting, cx);
|
||||||
})
|
})?;
|
||||||
.ok();
|
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
log::error!("Failed to request the encryption key: {e}");
|
log::error!("Failed to request the encryption key: {e}");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
})
|
|
||||||
.detach();
|
Ok(())
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Parse the response event for device keys from other devices
|
/// Parse the response event for device keys from other devices
|
||||||
fn parse_response(&mut self, event: Event, cx: &mut Context<Self>) {
|
fn extract_encryption(&mut self, event: Event, cx: &mut Context<Self>) {
|
||||||
let nostr = NostrRegistry::global(cx);
|
let nostr = NostrRegistry::global(cx);
|
||||||
let app_keys = nostr.read(cx).app_keys().clone();
|
let app_keys = nostr.read(cx).app_keys.clone();
|
||||||
|
|
||||||
let task: Task<Result<Keys, Error>> = cx.background_spawn(async move {
|
let task: Task<Result<Keys, Error>> = cx.background_spawn(async move {
|
||||||
let root_device = event
|
let root_device = event
|
||||||
@@ -559,32 +568,32 @@ impl DeviceRegistry {
|
|||||||
Ok(keys)
|
Ok(keys)
|
||||||
});
|
});
|
||||||
|
|
||||||
cx.spawn(async move |this, cx| {
|
self.tasks.push(cx.spawn(async move |this, cx| {
|
||||||
match task.await {
|
let keys = task.await?;
|
||||||
Ok(keys) => {
|
|
||||||
|
// Update signer
|
||||||
this.update(cx, |this, cx| {
|
this.update(cx, |this, cx| {
|
||||||
this.set_signer(keys, cx);
|
this.set_signer(keys, cx);
|
||||||
})
|
})?;
|
||||||
.ok();
|
|
||||||
}
|
Ok(())
|
||||||
Err(e) => {
|
}));
|
||||||
log::error!("Error: {e}")
|
|
||||||
}
|
|
||||||
};
|
|
||||||
})
|
|
||||||
.detach();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Approve requests for device keys from other devices
|
/// Approve requests for device keys from other devices
|
||||||
#[allow(dead_code)]
|
fn approve(&mut self, event: &Event, window: &mut Window, cx: &mut Context<Self>) {
|
||||||
fn approve(&mut self, event: Event, cx: &mut Context<Self>) {
|
|
||||||
let nostr = NostrRegistry::global(cx);
|
let nostr = NostrRegistry::global(cx);
|
||||||
let client = nostr.read(cx).client();
|
let client = nostr.read(cx).client();
|
||||||
|
|
||||||
let signer = nostr.read(cx).signer();
|
let signer = nostr.read(cx).signer();
|
||||||
let public_key = signer.public_key().unwrap();
|
|
||||||
|
|
||||||
|
let Some(public_key) = signer.public_key() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get user's write relays
|
||||||
let write_relays = nostr.read(cx).write_relays(&public_key, cx);
|
let write_relays = nostr.read(cx).write_relays(&public_key, cx);
|
||||||
|
let event = event.clone();
|
||||||
|
let id: SharedString = event.id.to_hex().into();
|
||||||
|
|
||||||
let task: Task<Result<(), Error>> = cx.background_spawn(async move {
|
let task: Task<Result<(), Error>> = cx.background_spawn(async move {
|
||||||
let urls = write_relays.await;
|
let urls = write_relays.await;
|
||||||
@@ -608,12 +617,13 @@ impl DeviceRegistry {
|
|||||||
//
|
//
|
||||||
// P tag: the current device's public key
|
// P tag: the current device's public key
|
||||||
// p tag: the requester's public key
|
// p tag: the requester's public key
|
||||||
let event = client
|
let builder = EventBuilder::new(Kind::Custom(4455), payload).tags(vec![
|
||||||
.sign_event_builder(EventBuilder::new(Kind::Custom(4455), payload).tags(vec![
|
|
||||||
Tag::custom(TagKind::custom("P"), vec![keys.public_key()]),
|
Tag::custom(TagKind::custom("P"), vec![keys.public_key()]),
|
||||||
Tag::public_key(target),
|
Tag::public_key(target),
|
||||||
]))
|
]);
|
||||||
.await?;
|
|
||||||
|
// Sign the builder
|
||||||
|
let event = client.sign_event_builder(builder).await?;
|
||||||
|
|
||||||
// Send the response event to the user's relay list
|
// Send the response event to the user's relay list
|
||||||
client.send_event(&event).to(urls).await?;
|
client.send_event(&event).to(urls).await?;
|
||||||
@@ -621,7 +631,131 @@ impl DeviceRegistry {
|
|||||||
Ok(())
|
Ok(())
|
||||||
});
|
});
|
||||||
|
|
||||||
task.detach();
|
cx.spawn_in(window, async move |_this, cx| {
|
||||||
|
match task.await {
|
||||||
|
Ok(_) => {
|
||||||
|
cx.update(|window, cx| {
|
||||||
|
window.clear_notification(id, cx);
|
||||||
|
})
|
||||||
|
.ok();
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
cx.update(|window, cx| {
|
||||||
|
window.push_notification(Notification::error(e.to_string()), cx);
|
||||||
|
})
|
||||||
|
.ok();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.detach();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handle encryption request
|
||||||
|
fn ask_for_approval(&mut self, event: Event, window: &mut Window, cx: &mut Context<Self>) {
|
||||||
|
let notification = self.notification(event, cx);
|
||||||
|
|
||||||
|
cx.spawn_in(window, async move |_this, cx| {
|
||||||
|
cx.update(|window, cx| {
|
||||||
|
window.push_notification(notification, cx);
|
||||||
|
})
|
||||||
|
.ok();
|
||||||
|
})
|
||||||
|
.detach();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build a notification for the encryption request.
|
||||||
|
fn notification(&self, event: Event, cx: &Context<Self>) -> Notification {
|
||||||
|
let request = Announcement::from(&event);
|
||||||
|
let persons = PersonRegistry::global(cx);
|
||||||
|
let profile = persons.read(cx).get(&request.public_key(), cx);
|
||||||
|
|
||||||
|
let entity = cx.entity().downgrade();
|
||||||
|
let loading = Rc::new(Cell::new(false));
|
||||||
|
|
||||||
|
Notification::new()
|
||||||
|
.custom_id(SharedString::from(event.id.to_hex()))
|
||||||
|
.autohide(false)
|
||||||
|
.icon(IconName::UserKey)
|
||||||
|
.title(SharedString::from("New request"))
|
||||||
|
.content(move |_window, cx| {
|
||||||
|
v_flex()
|
||||||
|
.gap_2()
|
||||||
|
.text_sm()
|
||||||
|
.child(SharedString::from(MSG))
|
||||||
|
.child(
|
||||||
|
v_flex()
|
||||||
|
.gap_2()
|
||||||
|
.child(
|
||||||
|
v_flex()
|
||||||
|
.gap_1()
|
||||||
|
.text_sm()
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.text_xs()
|
||||||
|
.text_color(cx.theme().text_muted)
|
||||||
|
.child(SharedString::from("Requester:")),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.h_7()
|
||||||
|
.w_full()
|
||||||
|
.px_2()
|
||||||
|
.rounded(cx.theme().radius)
|
||||||
|
.bg(cx.theme().elevated_surface_background)
|
||||||
|
.child(
|
||||||
|
h_flex()
|
||||||
|
.gap_2()
|
||||||
|
.child(Avatar::new(profile.avatar()).xsmall())
|
||||||
|
.child(profile.name()),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
v_flex()
|
||||||
|
.gap_1()
|
||||||
|
.text_sm()
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.text_xs()
|
||||||
|
.text_color(cx.theme().text_muted)
|
||||||
|
.child(SharedString::from("Client:")),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.h_7()
|
||||||
|
.w_full()
|
||||||
|
.px_2()
|
||||||
|
.rounded(cx.theme().radius)
|
||||||
|
.bg(cx.theme().elevated_surface_background)
|
||||||
|
.child(request.client_name()),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.into_any_element()
|
||||||
|
})
|
||||||
|
.action(move |_window, _cx| {
|
||||||
|
let view = entity.clone();
|
||||||
|
let event = event.clone();
|
||||||
|
|
||||||
|
Button::new("approve")
|
||||||
|
.label("Approve")
|
||||||
|
.small()
|
||||||
|
.primary()
|
||||||
|
.loading(loading.get())
|
||||||
|
.disabled(loading.get())
|
||||||
|
.on_click({
|
||||||
|
let loading = Rc::clone(&loading);
|
||||||
|
move |_ev, window, cx| {
|
||||||
|
// Set loading state to true
|
||||||
|
loading.set(true);
|
||||||
|
// Process to approve the request
|
||||||
|
view.update(cx, |this, cx| {
|
||||||
|
this.approve(&event, window, cx);
|
||||||
|
})
|
||||||
|
.ok();
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -663,7 +797,8 @@ async fn get_keys(client: &Client) -> Result<Keys, Error> {
|
|||||||
|
|
||||||
let filter = Filter::new()
|
let filter = Filter::new()
|
||||||
.kind(Kind::ApplicationSpecificData)
|
.kind(Kind::ApplicationSpecificData)
|
||||||
.identifier(IDENTIFIER);
|
.identifier(IDENTIFIER)
|
||||||
|
.author(public_key);
|
||||||
|
|
||||||
if let Some(event) = client.database().query(filter).await?.first() {
|
if let Some(event) = client.database().query(filter).await?.first() {
|
||||||
let content = signer.nip44_decrypt(&public_key, &event.content).await?;
|
let content = signer.nip44_decrypt(&public_key, &event.content).await?;
|
||||||
|
|||||||
@@ -1,19 +0,0 @@
|
|||||||
[package]
|
|
||||||
name = "key_store"
|
|
||||||
version.workspace = true
|
|
||||||
edition.workspace = true
|
|
||||||
publish.workspace = true
|
|
||||||
|
|
||||||
[dependencies]
|
|
||||||
common = { path = "../common" }
|
|
||||||
|
|
||||||
gpui.workspace = true
|
|
||||||
nostr-sdk.workspace = true
|
|
||||||
|
|
||||||
anyhow.workspace = true
|
|
||||||
smallvec.workspace = true
|
|
||||||
smol.workspace = true
|
|
||||||
log.workspace = true
|
|
||||||
futures.workspace = true
|
|
||||||
serde.workspace = true
|
|
||||||
serde_json.workspace = true
|
|
||||||
@@ -1,211 +0,0 @@
|
|||||||
use std::any::Any;
|
|
||||||
use std::collections::HashMap;
|
|
||||||
use std::fmt::Display;
|
|
||||||
use std::future::Future;
|
|
||||||
use std::path::PathBuf;
|
|
||||||
use std::pin::Pin;
|
|
||||||
|
|
||||||
use anyhow::Result;
|
|
||||||
use common::config_dir;
|
|
||||||
use futures::FutureExt as _;
|
|
||||||
use gpui::AsyncApp;
|
|
||||||
use nostr_sdk::prelude::*;
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
|
||||||
pub struct Credential {
|
|
||||||
public_key: PublicKey,
|
|
||||||
secret: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Credential {
|
|
||||||
pub fn new(user: String, secret: Vec<u8>) -> Self {
|
|
||||||
Self {
|
|
||||||
public_key: PublicKey::parse(&user).unwrap(),
|
|
||||||
secret: String::from_utf8(secret).unwrap(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn public_key(&self) -> PublicKey {
|
|
||||||
self.public_key
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn secret(&self) -> &str {
|
|
||||||
&self.secret
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
|
||||||
pub enum KeyItem {
|
|
||||||
User,
|
|
||||||
Bunker,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Display for KeyItem {
|
|
||||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
||||||
match self {
|
|
||||||
Self::User => write!(f, "coop-user"),
|
|
||||||
Self::Bunker => write!(f, "coop-bunker"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<KeyItem> for String {
|
|
||||||
fn from(item: KeyItem) -> Self {
|
|
||||||
item.to_string()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub trait KeyBackend: Any + Send + Sync {
|
|
||||||
fn name(&self) -> &str;
|
|
||||||
|
|
||||||
/// Reads the credentials from the provider.
|
|
||||||
#[allow(clippy::type_complexity)]
|
|
||||||
fn read_credentials<'a>(
|
|
||||||
&'a self,
|
|
||||||
url: &'a str,
|
|
||||||
cx: &'a AsyncApp,
|
|
||||||
) -> Pin<Box<dyn Future<Output = Result<Option<(String, Vec<u8>)>>> + 'a>>;
|
|
||||||
|
|
||||||
/// Writes the credentials to the provider.
|
|
||||||
fn write_credentials<'a>(
|
|
||||||
&'a self,
|
|
||||||
url: &'a str,
|
|
||||||
username: &'a str,
|
|
||||||
password: &'a [u8],
|
|
||||||
cx: &'a AsyncApp,
|
|
||||||
) -> Pin<Box<dyn Future<Output = Result<()>> + 'a>>;
|
|
||||||
|
|
||||||
/// Deletes the credentials from the provider.
|
|
||||||
fn delete_credentials<'a>(
|
|
||||||
&'a self,
|
|
||||||
url: &'a str,
|
|
||||||
cx: &'a AsyncApp,
|
|
||||||
) -> Pin<Box<dyn Future<Output = Result<()>> + 'a>>;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A credentials provider that stores credentials in the system keychain.
|
|
||||||
pub struct KeyringProvider;
|
|
||||||
|
|
||||||
impl KeyBackend for KeyringProvider {
|
|
||||||
fn name(&self) -> &str {
|
|
||||||
"keyring"
|
|
||||||
}
|
|
||||||
|
|
||||||
fn read_credentials<'a>(
|
|
||||||
&'a self,
|
|
||||||
url: &'a str,
|
|
||||||
cx: &'a AsyncApp,
|
|
||||||
) -> Pin<Box<dyn Future<Output = Result<Option<(String, Vec<u8>)>>> + 'a>> {
|
|
||||||
async move { cx.update(|cx| cx.read_credentials(url)).await }.boxed_local()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn write_credentials<'a>(
|
|
||||||
&'a self,
|
|
||||||
url: &'a str,
|
|
||||||
username: &'a str,
|
|
||||||
password: &'a [u8],
|
|
||||||
cx: &'a AsyncApp,
|
|
||||||
) -> Pin<Box<dyn Future<Output = Result<()>> + 'a>> {
|
|
||||||
async move {
|
|
||||||
cx.update(move |cx| cx.write_credentials(url, username, password))
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
.boxed_local()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn delete_credentials<'a>(
|
|
||||||
&'a self,
|
|
||||||
url: &'a str,
|
|
||||||
cx: &'a AsyncApp,
|
|
||||||
) -> Pin<Box<dyn Future<Output = Result<()>> + 'a>> {
|
|
||||||
async move { cx.update(move |cx| cx.delete_credentials(url)).await }.boxed_local()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A credentials provider that stores credentials in a local file.
|
|
||||||
pub struct FileProvider {
|
|
||||||
path: PathBuf,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl FileProvider {
|
|
||||||
pub fn new() -> Self {
|
|
||||||
let path = config_dir().join(".keys");
|
|
||||||
|
|
||||||
if let Some(parent) = path.parent() {
|
|
||||||
let _ = std::fs::create_dir_all(parent);
|
|
||||||
}
|
|
||||||
|
|
||||||
Self { path }
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn load_credentials(&self) -> Result<HashMap<String, (String, Vec<u8>)>> {
|
|
||||||
let json = std::fs::read(&self.path)?;
|
|
||||||
let credentials: HashMap<String, (String, Vec<u8>)> = serde_json::from_slice(&json)?;
|
|
||||||
|
|
||||||
Ok(credentials)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn save_credentials(&self, credentials: &HashMap<String, (String, Vec<u8>)>) -> Result<()> {
|
|
||||||
let json = serde_json::to_string(credentials)?;
|
|
||||||
std::fs::write(&self.path, json)?;
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for FileProvider {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self::new()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl KeyBackend for FileProvider {
|
|
||||||
fn name(&self) -> &str {
|
|
||||||
"file"
|
|
||||||
}
|
|
||||||
|
|
||||||
fn read_credentials<'a>(
|
|
||||||
&'a self,
|
|
||||||
url: &'a str,
|
|
||||||
_cx: &'a AsyncApp,
|
|
||||||
) -> Pin<Box<dyn Future<Output = Result<Option<(String, Vec<u8>)>>> + 'a>> {
|
|
||||||
async move {
|
|
||||||
Ok(self
|
|
||||||
.load_credentials()
|
|
||||||
.unwrap_or_default()
|
|
||||||
.get(url)
|
|
||||||
.cloned())
|
|
||||||
}
|
|
||||||
.boxed_local()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn write_credentials<'a>(
|
|
||||||
&'a self,
|
|
||||||
url: &'a str,
|
|
||||||
username: &'a str,
|
|
||||||
password: &'a [u8],
|
|
||||||
_cx: &'a AsyncApp,
|
|
||||||
) -> Pin<Box<dyn Future<Output = Result<()>> + 'a>> {
|
|
||||||
async move {
|
|
||||||
let mut credentials = self.load_credentials().unwrap_or_default();
|
|
||||||
credentials.insert(url.to_string(), (username.to_string(), password.to_vec()));
|
|
||||||
|
|
||||||
self.save_credentials(&credentials)
|
|
||||||
}
|
|
||||||
.boxed_local()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn delete_credentials<'a>(
|
|
||||||
&'a self,
|
|
||||||
url: &'a str,
|
|
||||||
_cx: &'a AsyncApp,
|
|
||||||
) -> Pin<Box<dyn Future<Output = Result<()>> + 'a>> {
|
|
||||||
async move {
|
|
||||||
let mut credentials = self.load_credentials()?;
|
|
||||||
credentials.remove(url);
|
|
||||||
|
|
||||||
self.save_credentials(&credentials)
|
|
||||||
}
|
|
||||||
.boxed_local()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,94 +0,0 @@
|
|||||||
use std::sync::{Arc, LazyLock};
|
|
||||||
|
|
||||||
pub use backend::*;
|
|
||||||
use gpui::{App, AppContext, Context, Entity, Global, Task};
|
|
||||||
use smallvec::{smallvec, SmallVec};
|
|
||||||
|
|
||||||
mod backend;
|
|
||||||
|
|
||||||
static DISABLE_KEYRING: LazyLock<bool> =
|
|
||||||
LazyLock::new(|| std::env::var("DISABLE_KEYRING").is_ok_and(|value| !value.is_empty()));
|
|
||||||
|
|
||||||
pub fn init(cx: &mut App) {
|
|
||||||
KeyStore::set_global(cx.new(KeyStore::new), cx);
|
|
||||||
}
|
|
||||||
|
|
||||||
struct GlobalKeyStore(Entity<KeyStore>);
|
|
||||||
|
|
||||||
impl Global for GlobalKeyStore {}
|
|
||||||
|
|
||||||
pub struct KeyStore {
|
|
||||||
/// Key Store for storing credentials
|
|
||||||
pub backend: Arc<dyn KeyBackend>,
|
|
||||||
|
|
||||||
/// Whether the keystore has been initialized
|
|
||||||
pub initialized: bool,
|
|
||||||
|
|
||||||
/// Tasks for asynchronous operations
|
|
||||||
_tasks: SmallVec<[Task<()>; 1]>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl KeyStore {
|
|
||||||
/// Retrieve the global keys state
|
|
||||||
pub fn global(cx: &App) -> Entity<Self> {
|
|
||||||
cx.global::<GlobalKeyStore>().0.clone()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Set the global keys instance
|
|
||||||
pub(crate) fn set_global(state: Entity<Self>, cx: &mut App) {
|
|
||||||
cx.set_global(GlobalKeyStore(state));
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Create a new keys instance
|
|
||||||
pub(crate) fn new(cx: &mut Context<Self>) -> Self {
|
|
||||||
// Use the file system for keystore in development or when the user specifies it
|
|
||||||
let use_file_keystore = cfg!(debug_assertions) || *DISABLE_KEYRING;
|
|
||||||
|
|
||||||
// Construct the key backend
|
|
||||||
let backend: Arc<dyn KeyBackend> = if use_file_keystore {
|
|
||||||
Arc::new(FileProvider::default())
|
|
||||||
} else {
|
|
||||||
Arc::new(KeyringProvider)
|
|
||||||
};
|
|
||||||
|
|
||||||
// Only used for testing keyring availability on the user's system
|
|
||||||
let read_credential = cx.read_credentials("Coop");
|
|
||||||
let mut tasks = smallvec![];
|
|
||||||
|
|
||||||
tasks.push(
|
|
||||||
// Verify the keyring availability
|
|
||||||
cx.spawn(async move |this, cx| {
|
|
||||||
let result = read_credential.await;
|
|
||||||
|
|
||||||
this.update(cx, |this, cx| {
|
|
||||||
if let Err(e) = result {
|
|
||||||
log::error!("Keyring error: {e}");
|
|
||||||
// For Linux:
|
|
||||||
// The user has not installed secret service on their system
|
|
||||||
// Fall back to the file provider
|
|
||||||
this.backend = Arc::new(FileProvider::default());
|
|
||||||
}
|
|
||||||
this.initialized = true;
|
|
||||||
cx.notify();
|
|
||||||
})
|
|
||||||
.ok();
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
Self {
|
|
||||||
backend,
|
|
||||||
initialized: false,
|
|
||||||
_tasks: tasks,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Returns the key backend.
|
|
||||||
pub fn backend(&self) -> Arc<dyn KeyBackend> {
|
|
||||||
Arc::clone(&self.backend)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Returns true if the keystore is a file key backend.
|
|
||||||
pub fn is_using_file_keystore(&self) -> bool {
|
|
||||||
self.backend.name() == "file"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -7,7 +7,6 @@ publish.workspace = true
|
|||||||
[dependencies]
|
[dependencies]
|
||||||
common = { path = "../common" }
|
common = { path = "../common" }
|
||||||
state = { path = "../state" }
|
state = { path = "../state" }
|
||||||
device = { path = "../device" }
|
|
||||||
|
|
||||||
gpui.workspace = true
|
gpui.workspace = true
|
||||||
nostr-sdk.workspace = true
|
nostr-sdk.workspace = true
|
||||||
|
|||||||
@@ -5,11 +5,10 @@ use std::time::Duration;
|
|||||||
|
|
||||||
use anyhow::{anyhow, Error};
|
use anyhow::{anyhow, Error};
|
||||||
use common::EventUtils;
|
use common::EventUtils;
|
||||||
use device::Announcement;
|
|
||||||
use gpui::{App, AppContext, Context, Entity, Global, Task};
|
use gpui::{App, AppContext, Context, Entity, Global, Task};
|
||||||
use nostr_sdk::prelude::*;
|
use nostr_sdk::prelude::*;
|
||||||
use smallvec::{smallvec, SmallVec};
|
use smallvec::{smallvec, SmallVec};
|
||||||
use state::{NostrRegistry, BOOTSTRAP_RELAYS, TIMEOUT};
|
use state::{Announcement, NostrRegistry, BOOTSTRAP_RELAYS, TIMEOUT};
|
||||||
|
|
||||||
mod person;
|
mod person;
|
||||||
|
|
||||||
@@ -254,7 +253,7 @@ impl PersonRegistry {
|
|||||||
match self.persons.get(&public_key) {
|
match self.persons.get(&public_key) {
|
||||||
Some(this) => {
|
Some(this) => {
|
||||||
this.update(cx, |this, cx| {
|
this.update(cx, |this, cx| {
|
||||||
*this = person;
|
this.set_metadata(person.metadata());
|
||||||
cx.notify();
|
cx.notify();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -314,10 +313,10 @@ where
|
|||||||
.limit(limit);
|
.limit(limit);
|
||||||
|
|
||||||
// Construct target for subscription
|
// Construct target for subscription
|
||||||
let target = BOOTSTRAP_RELAYS
|
let target: HashMap<&str, Vec<Filter>> = BOOTSTRAP_RELAYS
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|relay| (relay, vec![filter.clone()]))
|
.map(|relay| (relay, vec![filter.clone()]))
|
||||||
.collect::<HashMap<_, _>>();
|
.collect();
|
||||||
|
|
||||||
client.subscribe(target).close_on(opts).await?;
|
client.subscribe(target).close_on(opts).await?;
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
use std::cmp::Ordering;
|
use std::cmp::Ordering;
|
||||||
use std::hash::{Hash, Hasher};
|
use std::hash::{Hash, Hasher};
|
||||||
|
|
||||||
use device::Announcement;
|
|
||||||
use gpui::SharedString;
|
use gpui::SharedString;
|
||||||
use nostr_sdk::prelude::*;
|
use nostr_sdk::prelude::*;
|
||||||
|
use state::Announcement;
|
||||||
|
|
||||||
const IMAGE_RESIZER: &str = "https://wsrv.nl";
|
const IMAGE_RESIZER: &str = "https://wsrv.nl";
|
||||||
|
|
||||||
@@ -75,6 +75,11 @@ impl Person {
|
|||||||
self.metadata.clone()
|
self.metadata.clone()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Set profile metadata
|
||||||
|
pub fn set_metadata(&mut self, metadata: Metadata) {
|
||||||
|
self.metadata = metadata;
|
||||||
|
}
|
||||||
|
|
||||||
/// Get profile encryption keys announcement
|
/// Get profile encryption keys announcement
|
||||||
pub fn announcement(&self) -> Option<Announcement> {
|
pub fn announcement(&self) -> Option<Announcement> {
|
||||||
self.announcement.clone()
|
self.announcement.clone()
|
||||||
@@ -83,7 +88,6 @@ impl Person {
|
|||||||
/// Set profile encryption keys announcement
|
/// Set profile encryption keys announcement
|
||||||
pub fn set_announcement(&mut self, announcement: Announcement) {
|
pub fn set_announcement(&mut self, announcement: Announcement) {
|
||||||
self.announcement = Some(announcement);
|
self.announcement = Some(announcement);
|
||||||
log::info!("Updated announcement for: {}", self.public_key());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get profile messaging relays
|
/// Get profile messaging relays
|
||||||
@@ -102,7 +106,6 @@ impl Person {
|
|||||||
I: IntoIterator<Item = RelayUrl>,
|
I: IntoIterator<Item = RelayUrl>,
|
||||||
{
|
{
|
||||||
self.messaging_relays = relays.into_iter().collect();
|
self.messaging_relays = relays.into_iter().collect();
|
||||||
log::info!("Updated messaging relays for: {}", self.public_key());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get profile avatar
|
/// Get profile avatar
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ pub struct RelayAuth {
|
|||||||
pending_events: HashSet<(EventId, RelayUrl)>,
|
pending_events: HashSet<(EventId, RelayUrl)>,
|
||||||
|
|
||||||
/// Tasks for asynchronous operations
|
/// Tasks for asynchronous operations
|
||||||
tasks: SmallVec<[Task<()>; 2]>,
|
_tasks: SmallVec<[Task<()>; 2]>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl RelayAuth {
|
impl RelayAuth {
|
||||||
@@ -83,26 +83,15 @@ impl RelayAuth {
|
|||||||
|
|
||||||
/// Create a new relay auth instance
|
/// Create a new relay auth instance
|
||||||
fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
|
fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||||
cx.defer_in(window, |this, window, cx| {
|
|
||||||
this.handle_notifications(window, cx);
|
|
||||||
});
|
|
||||||
|
|
||||||
Self {
|
|
||||||
pending_events: HashSet::default(),
|
|
||||||
tasks: smallvec![],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Handle nostr notifications
|
|
||||||
fn handle_notifications(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
|
||||||
let nostr = NostrRegistry::global(cx);
|
let nostr = NostrRegistry::global(cx);
|
||||||
let client = nostr.read(cx).client();
|
let client = nostr.read(cx).client();
|
||||||
|
|
||||||
|
let mut tasks = smallvec![];
|
||||||
|
|
||||||
// Channel for communication between nostr and gpui
|
// Channel for communication between nostr and gpui
|
||||||
let (tx, rx) = flume::bounded::<Signal>(256);
|
let (tx, rx) = flume::bounded::<Signal>(256);
|
||||||
|
|
||||||
self.tasks.push(cx.background_spawn(async move {
|
tasks.push(cx.background_spawn(async move {
|
||||||
log::info!("Started handling nostr notifications");
|
|
||||||
let mut notifications = client.notifications();
|
let mut notifications = client.notifications();
|
||||||
let mut challenges: HashSet<Cow<'_, str>> = HashSet::default();
|
let mut challenges: HashSet<Cow<'_, str>> = HashSet::default();
|
||||||
|
|
||||||
@@ -117,6 +106,22 @@ impl RelayAuth {
|
|||||||
tx.send_async(signal).await.ok();
|
tx.send_async(signal).await.ok();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
RelayMessage::Closed {
|
||||||
|
subscription_id,
|
||||||
|
message,
|
||||||
|
} => {
|
||||||
|
let msg = MachineReadablePrefix::parse(&message);
|
||||||
|
|
||||||
|
if let Some(MachineReadablePrefix::AuthRequired) = msg {
|
||||||
|
if let Ok(Some(relay)) = client.relay(&relay_url).await {
|
||||||
|
// Send close message to relay
|
||||||
|
relay
|
||||||
|
.send_msg(ClientMessage::Close(subscription_id))
|
||||||
|
.await
|
||||||
|
.ok();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
RelayMessage::Ok {
|
RelayMessage::Ok {
|
||||||
event_id, message, ..
|
event_id, message, ..
|
||||||
} => {
|
} => {
|
||||||
@@ -134,7 +139,7 @@ impl RelayAuth {
|
|||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
|
|
||||||
self.tasks.push(cx.spawn_in(window, async move |this, cx| {
|
tasks.push(cx.spawn_in(window, async move |this, cx| {
|
||||||
while let Ok(signal) = rx.recv_async().await {
|
while let Ok(signal) = rx.recv_async().await {
|
||||||
match signal {
|
match signal {
|
||||||
Signal::Auth(req) => {
|
Signal::Auth(req) => {
|
||||||
@@ -152,6 +157,11 @@ impl RelayAuth {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
Self {
|
||||||
|
pending_events: HashSet::default(),
|
||||||
|
_tasks: tasks,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Insert a pending event waiting for resend after authentication
|
/// Insert a pending event waiting for resend after authentication
|
||||||
@@ -162,15 +172,12 @@ impl RelayAuth {
|
|||||||
|
|
||||||
/// Get all pending events for a specific relay,
|
/// Get all pending events for a specific relay,
|
||||||
fn get_pending_events(&self, relay: &RelayUrl, _cx: &App) -> Vec<EventId> {
|
fn get_pending_events(&self, relay: &RelayUrl, _cx: &App) -> Vec<EventId> {
|
||||||
let pending_events: Vec<EventId> = self
|
self.pending_events
|
||||||
.pending_events
|
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|(_, pending_relay)| pending_relay == relay)
|
.filter(|(_, pending_relay)| pending_relay == relay)
|
||||||
.map(|(id, _relay)| id)
|
.map(|(id, _relay)| id)
|
||||||
.cloned()
|
.cloned()
|
||||||
.collect();
|
.collect()
|
||||||
|
|
||||||
pending_events
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Clear all pending events for a specific relay,
|
/// Clear all pending events for a specific relay,
|
||||||
@@ -282,10 +289,12 @@ impl RelayAuth {
|
|||||||
Ok(_) => {
|
Ok(_) => {
|
||||||
// Clear pending events for the authenticated relay
|
// Clear pending events for the authenticated relay
|
||||||
this.clear_pending_events(url, cx);
|
this.clear_pending_events(url, cx);
|
||||||
|
|
||||||
// Save the authenticated relay to automatically authenticate future requests
|
// Save the authenticated relay to automatically authenticate future requests
|
||||||
settings.update(cx, |this, cx| {
|
settings.update(cx, |this, cx| {
|
||||||
this.add_trusted_relay(url, cx);
|
this.add_trusted_relay(url, cx);
|
||||||
});
|
});
|
||||||
|
|
||||||
window.push_notification(format!("{} has been authenticated", url), cx);
|
window.push_notification(format!("{} has been authenticated", url), cx);
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
@@ -334,8 +343,8 @@ impl RelayAuth {
|
|||||||
.px_1p5()
|
.px_1p5()
|
||||||
.rounded_sm()
|
.rounded_sm()
|
||||||
.text_xs()
|
.text_xs()
|
||||||
.bg(cx.theme().warning_background)
|
.bg(cx.theme().elevated_surface_background)
|
||||||
.text_color(cx.theme().warning_foreground)
|
.text_color(cx.theme().text_accent)
|
||||||
.child(url.clone()),
|
.child(url.clone()),
|
||||||
)
|
)
|
||||||
.into_any_element()
|
.into_any_element()
|
||||||
@@ -352,11 +361,9 @@ impl RelayAuth {
|
|||||||
.disabled(loading.get())
|
.disabled(loading.get())
|
||||||
.on_click({
|
.on_click({
|
||||||
let loading = Rc::clone(&loading);
|
let loading = Rc::clone(&loading);
|
||||||
|
|
||||||
move |_ev, window, cx| {
|
move |_ev, window, cx| {
|
||||||
// Set loading state to true
|
// Set loading state to true
|
||||||
loading.set(true);
|
loading.set(true);
|
||||||
|
|
||||||
// Process to approve the request
|
// Process to approve the request
|
||||||
view.update(cx, |this, cx| {
|
view.update(cx, |this, cx| {
|
||||||
this.response(&req, window, cx);
|
this.response(&req, window, cx);
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ edition.workspace = true
|
|||||||
publish.workspace = true
|
publish.workspace = true
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
theme = { path = "../theme" }
|
||||||
common = { path = "../common" }
|
common = { path = "../common" }
|
||||||
|
|
||||||
nostr-sdk.workspace = true
|
nostr-sdk.workspace = true
|
||||||
|
|||||||
@@ -1,14 +1,17 @@
|
|||||||
use std::collections::{HashMap, HashSet};
|
use std::collections::{HashMap, HashSet};
|
||||||
|
use std::fmt::Display;
|
||||||
|
use std::rc::Rc;
|
||||||
|
|
||||||
use anyhow::{anyhow, Error};
|
use anyhow::{Error, anyhow};
|
||||||
use common::config_dir;
|
use common::config_dir;
|
||||||
use gpui::{App, AppContext, Context, Entity, Global, Subscription, Task};
|
use gpui::{App, AppContext, Context, Entity, Global, Subscription, Task, Window};
|
||||||
use nostr_sdk::prelude::*;
|
use nostr_sdk::prelude::*;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use smallvec::{smallvec, SmallVec};
|
use smallvec::{SmallVec, smallvec};
|
||||||
|
use theme::{Theme, ThemeFamily, ThemeMode};
|
||||||
|
|
||||||
pub fn init(cx: &mut App) {
|
pub fn init(window: &mut Window, cx: &mut App) {
|
||||||
AppSettings::set_global(cx.new(AppSettings::new), cx)
|
AppSettings::set_global(cx.new(|cx| AppSettings::new(window, cx)), cx)
|
||||||
}
|
}
|
||||||
|
|
||||||
macro_rules! setting_accessors {
|
macro_rules! setting_accessors {
|
||||||
@@ -33,6 +36,8 @@ macro_rules! setting_accessors {
|
|||||||
}
|
}
|
||||||
|
|
||||||
setting_accessors! {
|
setting_accessors! {
|
||||||
|
pub theme: Option<String>,
|
||||||
|
pub theme_mode: ThemeMode,
|
||||||
pub hide_avatar: bool,
|
pub hide_avatar: bool,
|
||||||
pub screening: bool,
|
pub screening: bool,
|
||||||
pub auth_mode: AuthMode,
|
pub auth_mode: AuthMode,
|
||||||
@@ -49,11 +54,20 @@ pub enum AuthMode {
|
|||||||
Manual,
|
Manual,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl Display for AuthMode {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
match self {
|
||||||
|
AuthMode::Auto => write!(f, "Auto"),
|
||||||
|
AuthMode::Manual => write!(f, "Ask every time"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Signer kind
|
/// Signer kind
|
||||||
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
|
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
|
||||||
pub enum SignerKind {
|
pub enum SignerKind {
|
||||||
Auto,
|
|
||||||
#[default]
|
#[default]
|
||||||
|
Auto,
|
||||||
User,
|
User,
|
||||||
Encryption,
|
Encryption,
|
||||||
}
|
}
|
||||||
@@ -80,18 +94,43 @@ pub struct RoomConfig {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl RoomConfig {
|
impl RoomConfig {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
backup: true,
|
||||||
|
signer_kind: SignerKind::Auto,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get backup config
|
||||||
pub fn backup(&self) -> bool {
|
pub fn backup(&self) -> bool {
|
||||||
self.backup
|
self.backup
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Set backup config
|
||||||
|
pub fn toggle_backup(&mut self) {
|
||||||
|
self.backup = !self.backup;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get signer kind config
|
||||||
pub fn signer_kind(&self) -> &SignerKind {
|
pub fn signer_kind(&self) -> &SignerKind {
|
||||||
&self.signer_kind
|
&self.signer_kind
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Set signer kind config
|
||||||
|
pub fn set_signer_kind(&mut self, kind: &SignerKind) {
|
||||||
|
self.signer_kind = kind.to_owned();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Settings
|
/// Settings
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct Settings {
|
pub struct Settings {
|
||||||
|
/// Theme
|
||||||
|
pub theme: Option<String>,
|
||||||
|
|
||||||
|
/// Theme mode
|
||||||
|
pub theme_mode: ThemeMode,
|
||||||
|
|
||||||
/// Hide user avatars
|
/// Hide user avatars
|
||||||
pub hide_avatar: bool,
|
pub hide_avatar: bool,
|
||||||
|
|
||||||
@@ -107,19 +146,21 @@ pub struct Settings {
|
|||||||
/// Configuration for each chat room
|
/// Configuration for each chat room
|
||||||
pub room_configs: HashMap<u64, RoomConfig>,
|
pub room_configs: HashMap<u64, RoomConfig>,
|
||||||
|
|
||||||
/// File server for NIP-96 media attachments
|
/// Server for blossom media attachments
|
||||||
pub file_server: Url,
|
pub file_server: Url,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for Settings {
|
impl Default for Settings {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self {
|
Self {
|
||||||
|
theme: None,
|
||||||
|
theme_mode: ThemeMode::default(),
|
||||||
hide_avatar: false,
|
hide_avatar: false,
|
||||||
screening: true,
|
screening: true,
|
||||||
auth_mode: AuthMode::default(),
|
auth_mode: AuthMode::default(),
|
||||||
trusted_relays: HashSet::default(),
|
trusted_relays: HashSet::default(),
|
||||||
room_configs: HashMap::default(),
|
room_configs: HashMap::default(),
|
||||||
file_server: Url::parse("https://nostrmedia.com").unwrap(),
|
file_server: Url::parse("https://blossom.band/").unwrap(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -154,7 +195,7 @@ impl AppSettings {
|
|||||||
cx.set_global(GlobalAppSettings(state));
|
cx.set_global(GlobalAppSettings(state));
|
||||||
}
|
}
|
||||||
|
|
||||||
fn new(cx: &mut Context<Self>) -> Self {
|
fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||||
let mut subscriptions = smallvec![];
|
let mut subscriptions = smallvec![];
|
||||||
|
|
||||||
subscriptions.push(
|
subscriptions.push(
|
||||||
@@ -164,12 +205,9 @@ impl AppSettings {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
cx.defer(|cx| {
|
// Run at the end of current cycle
|
||||||
let settings = AppSettings::global(cx);
|
cx.defer_in(window, |this, window, cx| {
|
||||||
|
this.load(window, cx);
|
||||||
settings.update(cx, |this, cx| {
|
|
||||||
this.load(cx);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
@@ -185,7 +223,7 @@ impl AppSettings {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Load settings
|
/// Load settings
|
||||||
fn load(&mut self, cx: &mut Context<Self>) {
|
fn load(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||||
let task: Task<Result<Settings, Error>> = cx.background_spawn(async move {
|
let task: Task<Result<Settings, Error>> = cx.background_spawn(async move {
|
||||||
let path = config_dir().join(".settings");
|
let path = config_dir().join(".settings");
|
||||||
|
|
||||||
@@ -196,12 +234,13 @@ impl AppSettings {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
cx.spawn(async move |this, cx| {
|
cx.spawn_in(window, async move |this, cx| {
|
||||||
let settings = task.await.unwrap_or(Settings::default());
|
let settings = task.await.unwrap_or(Settings::default());
|
||||||
|
|
||||||
// Update settings
|
// Update settings
|
||||||
this.update(cx, |this, cx| {
|
this.update_in(cx, |this, window, cx| {
|
||||||
this.set_settings(settings, cx);
|
this.set_settings(settings, cx);
|
||||||
|
this.apply_theme(window, cx);
|
||||||
})
|
})
|
||||||
.ok();
|
.ok();
|
||||||
})
|
})
|
||||||
@@ -225,6 +264,38 @@ impl AppSettings {
|
|||||||
task.detach();
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Apply theme
|
||||||
|
pub fn apply_theme(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||||
|
if let Some(name) = self.values.theme.as_ref() {
|
||||||
|
if let Ok(new_theme) = ThemeFamily::from_assets(name) {
|
||||||
|
Theme::apply_theme(Rc::new(new_theme), Some(window), cx);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Theme::apply_theme(Rc::new(ThemeFamily::default()), Some(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);
|
||||||
|
}
|
||||||
|
|
||||||
/// Check if the given relay is already authenticated
|
/// Check if the given relay is already authenticated
|
||||||
pub fn trusted_relay(&self, url: &RelayUrl, _cx: &App) -> bool {
|
pub fn trusted_relay(&self, url: &RelayUrl, _cx: &App) -> bool {
|
||||||
self.values.trusted_relays.iter().any(|relay| {
|
self.values.trusted_relays.iter().any(|relay| {
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ nostr.workspace = true
|
|||||||
nostr-sdk.workspace = true
|
nostr-sdk.workspace = true
|
||||||
nostr-lmdb.workspace = true
|
nostr-lmdb.workspace = true
|
||||||
nostr-connect.workspace = true
|
nostr-connect.workspace = true
|
||||||
|
nostr-blossom.workspace = true
|
||||||
|
|
||||||
gpui.workspace = true
|
gpui.workspace = true
|
||||||
gpui_tokio.workspace = true
|
gpui_tokio.workspace = true
|
||||||
@@ -25,4 +26,4 @@ serde_json.workspace = true
|
|||||||
rustls = "0.23"
|
rustls = "0.23"
|
||||||
petname = "2.0.2"
|
petname = "2.0.2"
|
||||||
whoami = "1.6.1"
|
whoami = "1.6.1"
|
||||||
reqwest = { version = "0.12", features = ["multipart", "stream", "json"] }
|
mime_guess = "2.0.4"
|
||||||
|
|||||||
27
crates/state/src/blossom.rs
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
use anyhow::{anyhow, Error};
|
||||||
|
use gpui::AsyncApp;
|
||||||
|
use gpui_tokio::Tokio;
|
||||||
|
use mime_guess::from_path;
|
||||||
|
use nostr_blossom::prelude::*;
|
||||||
|
use nostr_sdk::prelude::*;
|
||||||
|
|
||||||
|
pub async fn upload(server: Url, path: PathBuf, cx: &AsyncApp) -> Result<Url, Error> {
|
||||||
|
let content_type = from_path(&path).first_or_octet_stream().to_string();
|
||||||
|
let data = smol::fs::read(path).await?;
|
||||||
|
let keys = Keys::generate();
|
||||||
|
|
||||||
|
// Construct the blossom client
|
||||||
|
let client = BlossomClient::new(server);
|
||||||
|
|
||||||
|
Tokio::spawn(cx, async move {
|
||||||
|
let blob = client
|
||||||
|
.upload_blob(data, Some(content_type), None, Some(&keys))
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(blob.url)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|e| anyhow!("Upload error: {e}"))?
|
||||||
|
}
|
||||||
@@ -4,7 +4,7 @@ use std::sync::OnceLock;
|
|||||||
pub const CLIENT_NAME: &str = "Coop";
|
pub const CLIENT_NAME: &str = "Coop";
|
||||||
|
|
||||||
/// COOP's public key
|
/// COOP's public key
|
||||||
pub const COOP_PUBKEY: &str = "npub126kl5fruqan90py77gf6pvfvygefl2mu2ukew6xdx5pc5uqscwgsnkgarv";
|
pub const COOP_PUBKEY: &str = "npub1j3rz3ndl902lya6ywxvy5c983lxs8mpukqnx4pa4lt5wrykwl5ys7wpw3x";
|
||||||
|
|
||||||
/// App ID
|
/// App ID
|
||||||
pub const APP_ID: &str = "su.reya.coop";
|
pub const APP_ID: &str = "su.reya.coop";
|
||||||
@@ -13,7 +13,7 @@ pub const APP_ID: &str = "su.reya.coop";
|
|||||||
pub const KEYRING: &str = "Coop Safe Storage";
|
pub const KEYRING: &str = "Coop Safe Storage";
|
||||||
|
|
||||||
/// Default timeout for subscription
|
/// Default timeout for subscription
|
||||||
pub const TIMEOUT: u64 = 3;
|
pub const TIMEOUT: u64 = 2;
|
||||||
|
|
||||||
/// Default delay for searching
|
/// Default delay for searching
|
||||||
pub const FIND_DELAY: u64 = 600;
|
pub const FIND_DELAY: u64 = 600;
|
||||||
@@ -21,28 +21,28 @@ pub const FIND_DELAY: u64 = 600;
|
|||||||
/// Default limit for searching
|
/// Default limit for searching
|
||||||
pub const FIND_LIMIT: usize = 20;
|
pub const FIND_LIMIT: usize = 20;
|
||||||
|
|
||||||
/// Default timeout for Nostr Connect
|
|
||||||
pub const NOSTR_CONNECT_TIMEOUT: u64 = 200;
|
|
||||||
|
|
||||||
/// Default Nostr Connect relay
|
|
||||||
pub const NOSTR_CONNECT_RELAY: &str = "wss://relay.nsec.app";
|
|
||||||
|
|
||||||
/// Default subscription id for device gift wrap events
|
/// Default subscription id for device gift wrap events
|
||||||
pub const DEVICE_GIFTWRAP: &str = "device-gift-wraps";
|
pub const DEVICE_GIFTWRAP: &str = "device-gift-wraps";
|
||||||
|
|
||||||
/// Default subscription id for user gift wrap events
|
/// Default subscription id for user gift wrap events
|
||||||
pub const USER_GIFTWRAP: &str = "user-gift-wraps";
|
pub const USER_GIFTWRAP: &str = "user-gift-wraps";
|
||||||
|
|
||||||
|
/// Default timeout for Nostr Connect
|
||||||
|
pub const NOSTR_CONNECT_TIMEOUT: u64 = 60;
|
||||||
|
|
||||||
|
/// Default Nostr Connect relay
|
||||||
|
pub const NOSTR_CONNECT_RELAY: &str = "wss://relay.nip46.com";
|
||||||
|
|
||||||
/// Default vertex relays
|
/// Default vertex relays
|
||||||
pub const WOT_RELAYS: [&str; 1] = ["wss://relay.vertexlab.io"];
|
pub const WOT_RELAYS: [&str; 1] = ["wss://relay.vertexlab.io"];
|
||||||
|
|
||||||
/// Default search relays
|
/// Default search relays
|
||||||
pub const SEARCH_RELAYS: [&str; 1] = ["wss://antiprimal.net"];
|
pub const SEARCH_RELAYS: [&str; 2] = ["wss://antiprimal.net", "wss://search.nos.today"];
|
||||||
|
|
||||||
/// Default bootstrap relays
|
/// Default bootstrap relays
|
||||||
pub const BOOTSTRAP_RELAYS: [&str; 3] = [
|
pub const BOOTSTRAP_RELAYS: [&str; 3] = [
|
||||||
"wss://relay.damus.io",
|
|
||||||
"wss://relay.primal.net",
|
"wss://relay.primal.net",
|
||||||
|
"wss://indexer.coracle.social",
|
||||||
"wss://user.kindpag.es",
|
"wss://user.kindpag.es",
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -1,14 +1,40 @@
|
|||||||
|
use std::fmt::Display;
|
||||||
|
|
||||||
use gpui::SharedString;
|
use gpui::SharedString;
|
||||||
use nostr_sdk::prelude::*;
|
use nostr_sdk::prelude::*;
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Default)]
|
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Default)]
|
||||||
pub enum DeviceState {
|
pub enum DeviceState {
|
||||||
#[default]
|
#[default]
|
||||||
Initial,
|
Idle,
|
||||||
Requesting,
|
Requesting,
|
||||||
Set,
|
Set,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl Display for DeviceState {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
match self {
|
||||||
|
DeviceState::Idle => write!(f, "Idle"),
|
||||||
|
DeviceState::Requesting => write!(f, "Wait for approval"),
|
||||||
|
DeviceState::Set => write!(f, "Encryption Key is ready"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DeviceState {
|
||||||
|
pub fn idle(&self) -> bool {
|
||||||
|
matches!(self, DeviceState::Idle)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn requesting(&self) -> bool {
|
||||||
|
matches!(self, DeviceState::Requesting)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set(&self) -> bool {
|
||||||
|
matches!(self, DeviceState::Set)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Announcement
|
/// Announcement
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||||
pub struct Announcement {
|
pub struct Announcement {
|
||||||
@@ -1,17 +1,27 @@
|
|||||||
use std::collections::{HashMap, HashSet};
|
use std::collections::{HashMap, HashSet};
|
||||||
|
|
||||||
|
use gpui::SharedString;
|
||||||
use nostr_sdk::prelude::*;
|
use nostr_sdk::prelude::*;
|
||||||
|
|
||||||
/// Gossip
|
/// Gossip
|
||||||
#[derive(Debug, Clone, Default)]
|
#[derive(Debug, Clone, Default)]
|
||||||
pub struct Gossip {
|
pub struct Gossip {
|
||||||
/// Gossip relays for each public key
|
|
||||||
relays: HashMap<PublicKey, HashSet<(RelayUrl, Option<RelayMetadata>)>>,
|
relays: HashMap<PublicKey, HashSet<(RelayUrl, Option<RelayMetadata>)>>,
|
||||||
/// Messaging relays for each public key
|
|
||||||
messaging_relays: HashMap<PublicKey, HashSet<RelayUrl>>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Gossip {
|
impl Gossip {
|
||||||
|
pub fn read_only_relays(&self, public_key: &PublicKey) -> Vec<SharedString> {
|
||||||
|
self.relays
|
||||||
|
.get(public_key)
|
||||||
|
.map(|relays| {
|
||||||
|
relays
|
||||||
|
.iter()
|
||||||
|
.map(|(url, _)| url.to_string().into())
|
||||||
|
.collect()
|
||||||
|
})
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
/// Get read relays for a given public key
|
/// Get read relays for a given public key
|
||||||
pub fn read_relays(&self, public_key: &PublicKey) -> Vec<RelayUrl> {
|
pub fn read_relays(&self, public_key: &PublicKey) -> Vec<RelayUrl> {
|
||||||
self.relays
|
self.relays
|
||||||
@@ -69,39 +79,5 @@ impl Gossip {
|
|||||||
})
|
})
|
||||||
.take(3),
|
.take(3),
|
||||||
);
|
);
|
||||||
|
|
||||||
log::info!("Updating gossip relays for: {}", event.pubkey);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get messaging relays for a given public key
|
|
||||||
pub fn messaging_relays(&self, public_key: &PublicKey) -> Vec<RelayUrl> {
|
|
||||||
self.messaging_relays
|
|
||||||
.get(public_key)
|
|
||||||
.cloned()
|
|
||||||
.unwrap_or_default()
|
|
||||||
.into_iter()
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Insert messaging relays for a public key
|
|
||||||
pub fn insert_messaging_relays(&mut self, event: &Event) {
|
|
||||||
self.messaging_relays
|
|
||||||
.entry(event.pubkey)
|
|
||||||
.or_default()
|
|
||||||
.extend(
|
|
||||||
event
|
|
||||||
.tags
|
|
||||||
.iter()
|
|
||||||
.filter_map(|tag| {
|
|
||||||
if let Some(TagStandard::Relay(url)) = tag.as_standardized() {
|
|
||||||
Some(url.to_owned())
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.take(3),
|
|
||||||
);
|
|
||||||
|
|
||||||
log::info!("Updating messaging relays for: {}", event.pubkey);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,83 +0,0 @@
|
|||||||
use anyhow::anyhow;
|
|
||||||
use nostr::hashes::sha256::Hash as Sha256Hash;
|
|
||||||
use nostr::hashes::Hash;
|
|
||||||
use nostr::prelude::*;
|
|
||||||
use nostr_sdk::prelude::*;
|
|
||||||
use reqwest::{multipart, Client as ReqClient, Response};
|
|
||||||
|
|
||||||
pub(crate) fn make_multipart_form(
|
|
||||||
file_data: Vec<u8>,
|
|
||||||
mime_type: Option<&str>,
|
|
||||||
) -> Result<multipart::Form, anyhow::Error> {
|
|
||||||
let form_file_part = multipart::Part::bytes(file_data).file_name("filename");
|
|
||||||
|
|
||||||
// Set the part's MIME type, or leave it as is if mime_type is None
|
|
||||||
|
|
||||||
let part = match mime_type {
|
|
||||||
Some(mime) => form_file_part.mime_str(mime)?,
|
|
||||||
None => form_file_part,
|
|
||||||
};
|
|
||||||
|
|
||||||
Ok(multipart::Form::new().part("file", part))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) async fn upload<T>(
|
|
||||||
signer: &T,
|
|
||||||
desc: &ServerConfig,
|
|
||||||
file_data: Vec<u8>,
|
|
||||||
mime_type: Option<&str>,
|
|
||||||
) -> Result<Url, anyhow::Error>
|
|
||||||
where
|
|
||||||
T: NostrSigner,
|
|
||||||
{
|
|
||||||
let payload: Sha256Hash = Sha256Hash::hash(&file_data);
|
|
||||||
let data: HttpData = HttpData::new(desc.api_url.clone(), HttpMethod::POST).payload(payload);
|
|
||||||
let nip98_auth: String = data.to_authorization(signer).await?;
|
|
||||||
|
|
||||||
// Make form
|
|
||||||
let form: multipart::Form = make_multipart_form(file_data, mime_type)?;
|
|
||||||
|
|
||||||
// Make req client
|
|
||||||
let req_client = ReqClient::new();
|
|
||||||
|
|
||||||
// Send
|
|
||||||
let response: Response = req_client
|
|
||||||
.post(desc.api_url.clone())
|
|
||||||
.header("Authorization", nip98_auth)
|
|
||||||
.multipart(form)
|
|
||||||
.send()
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
// Parse response
|
|
||||||
let json: Value = response.json().await?;
|
|
||||||
let upload_response = nip96::UploadResponse::from_json(json.to_string())?;
|
|
||||||
|
|
||||||
if upload_response.status == UploadResponseStatus::Error {
|
|
||||||
return Err(anyhow!(upload_response.message));
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(upload_response.download_url()?.to_owned())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn nostr_upload(
|
|
||||||
client: &Client,
|
|
||||||
server: &Url,
|
|
||||||
file: Vec<u8>,
|
|
||||||
) -> Result<Url, anyhow::Error> {
|
|
||||||
let req_client = ReqClient::new();
|
|
||||||
let config_url = nip96::get_server_config_url(server)?;
|
|
||||||
|
|
||||||
// Get
|
|
||||||
let res = req_client.get(config_url.to_string()).send().await?;
|
|
||||||
let json: Value = res.json().await?;
|
|
||||||
|
|
||||||
let config = nip96::ServerConfig::from_json(json.to_string())?;
|
|
||||||
let signer = client
|
|
||||||
.signer()
|
|
||||||
.cloned()
|
|
||||||
.unwrap_or(Keys::generate().into_nostr_signer());
|
|
||||||
|
|
||||||
let url = upload(&signer, &config, file, None).await?;
|
|
||||||
|
|
||||||
Ok(url)
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
use std::borrow::Cow;
|
use std::borrow::Cow;
|
||||||
use std::result::Result;
|
use std::result::Result;
|
||||||
use std::sync::atomic::{AtomicBool, Ordering};
|
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use nostr_sdk::prelude::*;
|
use nostr_sdk::prelude::*;
|
||||||
@@ -16,11 +15,6 @@ pub struct CoopSigner {
|
|||||||
|
|
||||||
/// Specific signer for encryption purposes
|
/// Specific signer for encryption purposes
|
||||||
encryption_signer: RwLock<Option<Arc<dyn NostrSigner>>>,
|
encryption_signer: RwLock<Option<Arc<dyn NostrSigner>>>,
|
||||||
|
|
||||||
/// By default, Coop generates a new signer for new users.
|
|
||||||
///
|
|
||||||
/// This flag indicates whether the signer is user-owned or Coop-generated.
|
|
||||||
owned: AtomicBool,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl CoopSigner {
|
impl CoopSigner {
|
||||||
@@ -32,7 +26,6 @@ impl CoopSigner {
|
|||||||
signer: RwLock::new(signer.into_nostr_signer()),
|
signer: RwLock::new(signer.into_nostr_signer()),
|
||||||
signer_pkey: RwLock::new(None),
|
signer_pkey: RwLock::new(None),
|
||||||
encryption_signer: RwLock::new(None),
|
encryption_signer: RwLock::new(None),
|
||||||
owned: AtomicBool::new(false),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -47,17 +40,15 @@ impl CoopSigner {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Get public key
|
/// Get public key
|
||||||
|
///
|
||||||
|
/// Ensure to call this method after the signer has been initialized.
|
||||||
|
/// Otherwise, this method will panic.
|
||||||
pub fn public_key(&self) -> Option<PublicKey> {
|
pub fn public_key(&self) -> Option<PublicKey> {
|
||||||
self.signer_pkey.read_blocking().to_owned()
|
*self.signer_pkey.read_blocking()
|
||||||
}
|
|
||||||
|
|
||||||
/// Get the flag indicating whether the signer is user-owned.
|
|
||||||
pub fn owned(&self) -> bool {
|
|
||||||
self.owned.load(Ordering::SeqCst)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Switch the current signer to a new signer.
|
/// Switch the current signer to a new signer.
|
||||||
pub async fn switch<T>(&self, new: T, owned: bool)
|
pub async fn switch<T>(&self, new: T)
|
||||||
where
|
where
|
||||||
T: IntoNostrSigner,
|
T: IntoNostrSigner,
|
||||||
{
|
{
|
||||||
@@ -75,9 +66,6 @@ impl CoopSigner {
|
|||||||
|
|
||||||
// Reset the encryption signer
|
// Reset the encryption signer
|
||||||
*encryption_signer = None;
|
*encryption_signer = None;
|
||||||
|
|
||||||
// Update the owned flag
|
|
||||||
self.owned.store(owned, Ordering::SeqCst);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Set the encryption signer.
|
/// Set the encryption signer.
|
||||||
|
|||||||
@@ -78,8 +78,10 @@ pub struct ThemeColors {
|
|||||||
|
|
||||||
// Tab colors
|
// Tab colors
|
||||||
pub tab_inactive_background: Hsla,
|
pub tab_inactive_background: Hsla,
|
||||||
pub tab_hover_background: Hsla,
|
pub tab_inactive_foreground: Hsla,
|
||||||
pub tab_active_background: Hsla,
|
pub tab_active_background: Hsla,
|
||||||
|
pub tab_active_foreground: Hsla,
|
||||||
|
pub tab_hover_foreground: Hsla,
|
||||||
|
|
||||||
// Scrollbar colors
|
// Scrollbar colors
|
||||||
pub scrollbar_thumb_background: Hsla,
|
pub scrollbar_thumb_background: Hsla,
|
||||||
@@ -106,10 +108,10 @@ impl ThemeColors {
|
|||||||
background: neutral().light().step_1(),
|
background: neutral().light().step_1(),
|
||||||
surface_background: neutral().light().step_2(),
|
surface_background: neutral().light().step_2(),
|
||||||
elevated_surface_background: neutral().light().step_3(),
|
elevated_surface_background: neutral().light().step_3(),
|
||||||
panel_background: gpui::white(),
|
panel_background: neutral().light().step_1(),
|
||||||
overlay: neutral().light_alpha().step_3(),
|
overlay: neutral().light_alpha().step_3(),
|
||||||
title_bar: gpui::transparent_black(),
|
title_bar: neutral().light().step_2(),
|
||||||
title_bar_inactive: neutral().light().step_1(),
|
title_bar_inactive: neutral().light().step_3(),
|
||||||
window_border: hsl(240.0, 5.9, 78.0),
|
window_border: hsl(240.0, 5.9, 78.0),
|
||||||
|
|
||||||
border: neutral().light().step_6(),
|
border: neutral().light().step_6(),
|
||||||
@@ -164,9 +166,11 @@ impl ThemeColors {
|
|||||||
ghost_element_selected: neutral().light().step_5(),
|
ghost_element_selected: neutral().light().step_5(),
|
||||||
ghost_element_disabled: neutral().light_alpha().step_2(),
|
ghost_element_disabled: neutral().light_alpha().step_2(),
|
||||||
|
|
||||||
tab_inactive_background: neutral().light().step_3(),
|
tab_inactive_background: neutral().light().step_2(),
|
||||||
tab_hover_background: neutral().light().step_4(),
|
tab_inactive_foreground: neutral().light().step_11(),
|
||||||
tab_active_background: neutral().light().step_5(),
|
tab_active_background: neutral().light().step_1(),
|
||||||
|
tab_active_foreground: neutral().light().step_12(),
|
||||||
|
tab_hover_foreground: brand().light().step_9(),
|
||||||
|
|
||||||
scrollbar_thumb_background: neutral().light_alpha().step_3(),
|
scrollbar_thumb_background: neutral().light_alpha().step_3(),
|
||||||
scrollbar_thumb_hover_background: neutral().light_alpha().step_4(),
|
scrollbar_thumb_hover_background: neutral().light_alpha().step_4(),
|
||||||
@@ -246,9 +250,11 @@ impl ThemeColors {
|
|||||||
ghost_element_selected: neutral().dark().step_5(),
|
ghost_element_selected: neutral().dark().step_5(),
|
||||||
ghost_element_disabled: neutral().dark_alpha().step_2(),
|
ghost_element_disabled: neutral().dark_alpha().step_2(),
|
||||||
|
|
||||||
tab_inactive_background: neutral().dark().step_3(),
|
tab_inactive_background: neutral().dark().step_2(),
|
||||||
tab_hover_background: neutral().dark().step_4(),
|
tab_inactive_foreground: neutral().dark().step_11(),
|
||||||
tab_active_background: neutral().dark().step_5(),
|
tab_active_background: neutral().dark().step_3(),
|
||||||
|
tab_active_foreground: neutral().dark().step_12(),
|
||||||
|
tab_hover_foreground: brand().dark().step_9(),
|
||||||
|
|
||||||
scrollbar_thumb_background: neutral().dark_alpha().step_3(),
|
scrollbar_thumb_background: neutral().dark_alpha().step_3(),
|
||||||
scrollbar_thumb_hover_background: neutral().dark_alpha().step_4(),
|
scrollbar_thumb_hover_background: neutral().dark_alpha().step_4(),
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
use std::ops::{Deref, DerefMut};
|
use std::ops::{Deref, DerefMut};
|
||||||
use std::rc::Rc;
|
use std::rc::Rc;
|
||||||
|
|
||||||
use gpui::{px, App, Global, Pixels, SharedString, Window};
|
use gpui::{App, Global, Pixels, SharedString, Window, px};
|
||||||
|
|
||||||
mod colors;
|
mod colors;
|
||||||
mod platform_kind;
|
mod platform_kind;
|
||||||
@@ -29,6 +29,9 @@ pub const CLIENT_SIDE_DECORATION_BORDER: Pixels = px(1.0);
|
|||||||
/// Defines window titlebar height
|
/// Defines window titlebar height
|
||||||
pub const TITLEBAR_HEIGHT: Pixels = px(36.0);
|
pub const TITLEBAR_HEIGHT: Pixels = px(36.0);
|
||||||
|
|
||||||
|
/// Defines workspace tabbar height
|
||||||
|
pub const TABBAR_HEIGHT: Pixels = px(28.0);
|
||||||
|
|
||||||
/// Defines default sidebar width
|
/// Defines default sidebar width
|
||||||
pub const SIDEBAR_WIDTH: Pixels = px(240.);
|
pub const SIDEBAR_WIDTH: Pixels = px(240.);
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ use serde::{Deserialize, Serialize};
|
|||||||
|
|
||||||
use crate::ThemeColors;
|
use crate::ThemeColors;
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, Default, PartialEq, PartialOrd, Eq, Hash)]
|
#[derive(Debug, Clone, Copy, Default, PartialEq, PartialOrd, Eq, Hash, Deserialize, Serialize)]
|
||||||
pub enum ThemeMode {
|
pub enum ThemeMode {
|
||||||
#[default]
|
#[default]
|
||||||
Light,
|
Light,
|
||||||
@@ -18,11 +18,11 @@ impl ThemeMode {
|
|||||||
matches!(self, Self::Dark)
|
matches!(self, Self::Dark)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Return lower_case theme name: `light`, `dark`.
|
/// Return theme name: `light`, `dark`.
|
||||||
pub fn name(&self) -> &'static str {
|
pub fn name(&self) -> &'static str {
|
||||||
match self {
|
match self {
|
||||||
ThemeMode::Light => "light",
|
ThemeMode::Light => "Light",
|
||||||
ThemeMode::Dark => "dark",
|
ThemeMode::Dark => "Dark",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -153,14 +153,14 @@ impl ThemeFamily {
|
|||||||
///
|
///
|
||||||
/// # fn main() -> anyhow::Result<()> {
|
/// # fn main() -> anyhow::Result<()> {
|
||||||
/// // Assuming the file exists at `assets/themes/my-theme.json`
|
/// // Assuming the file exists at `assets/themes/my-theme.json`
|
||||||
/// let theme = ThemeFamily::from_assets("my-theme")?;
|
/// let theme = ThemeFamily::from_assets("themes/my-theme.json")?;
|
||||||
///
|
///
|
||||||
/// println!("Loaded theme: {}", theme.name);
|
/// println!("Loaded theme: {}", theme.name);
|
||||||
/// # Ok(())
|
/// # Ok(())
|
||||||
/// # }
|
/// # }
|
||||||
/// ```
|
/// ```
|
||||||
pub fn from_assets(name: &str) -> anyhow::Result<Self> {
|
pub fn from_assets(target: &str) -> anyhow::Result<Self> {
|
||||||
let path = format!("assets/themes/{}.json", name);
|
let path = format!("assets/{target}");
|
||||||
Self::from_file(path)
|
Self::from_file(path)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,12 @@
|
|||||||
use gpui::prelude::FluentBuilder;
|
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
use gpui::MouseButton;
|
use gpui::MouseButton;
|
||||||
#[cfg(not(target_os = "windows"))]
|
use gpui::prelude::FluentBuilder;
|
||||||
use gpui::Pixels;
|
|
||||||
use gpui::{
|
use gpui::{
|
||||||
div, px, AnyElement, Context, Decorations, Hsla, InteractiveElement as _, IntoElement,
|
AnyElement, Context, Decorations, Hsla, InteractiveElement as _, IntoElement, ParentElement,
|
||||||
ParentElement, Render, StatefulInteractiveElement as _, Styled, Window, WindowControlArea,
|
Pixels, Render, StatefulInteractiveElement as _, Styled, Window, WindowControlArea, px,
|
||||||
};
|
};
|
||||||
use smallvec::{smallvec, SmallVec};
|
use smallvec::{SmallVec, smallvec};
|
||||||
use theme::{ActiveTheme, PlatformKind, CLIENT_SIDE_DECORATION_ROUNDING};
|
use theme::{ActiveTheme, CLIENT_SIDE_DECORATION_ROUNDING, PlatformKind};
|
||||||
use ui::h_flex;
|
use ui::h_flex;
|
||||||
|
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
@@ -127,28 +125,18 @@ impl Render for TitleBar {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
.when(!cx.theme().platform.is_mac(), |this| this.pr_2())
|
||||||
.children(children),
|
.children(children),
|
||||||
)
|
)
|
||||||
.child(
|
.when(!window.is_fullscreen(), |this| match cx.theme().platform {
|
||||||
h_flex()
|
|
||||||
.absolute()
|
|
||||||
.top_0()
|
|
||||||
.right_0()
|
|
||||||
.pr_2()
|
|
||||||
.h(height)
|
|
||||||
.child(
|
|
||||||
div().when(!window.is_fullscreen(), |this| match cx.theme().platform {
|
|
||||||
PlatformKind::Linux => {
|
PlatformKind::Linux => {
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
if matches!(decorations, Decorations::Client { .. }) {
|
if matches!(decorations, Decorations::Client { .. }) {
|
||||||
this.child(LinuxWindowControls::new(None))
|
this.child(LinuxWindowControls::new(None))
|
||||||
.when(supported_controls.window_menu, |this| {
|
.when(supported_controls.window_menu, |this| {
|
||||||
this.on_mouse_down(
|
this.on_mouse_down(MouseButton::Right, move |ev, window, _| {
|
||||||
MouseButton::Right,
|
|
||||||
move |ev, window, _| {
|
|
||||||
window.show_window_menu(ev.position)
|
window.show_window_menu(ev.position)
|
||||||
},
|
})
|
||||||
)
|
|
||||||
})
|
})
|
||||||
.on_mouse_move(cx.listener(move |this, _ev, window, _| {
|
.on_mouse_move(cx.listener(move |this, _ev, window, _| {
|
||||||
if this.should_move {
|
if this.should_move {
|
||||||
@@ -156,11 +144,9 @@ impl Render for TitleBar {
|
|||||||
window.start_window_move();
|
window.start_window_move();
|
||||||
}
|
}
|
||||||
}))
|
}))
|
||||||
.on_mouse_down_out(cx.listener(
|
.on_mouse_down_out(cx.listener(move |this, _ev, _window, _cx| {
|
||||||
move |this, _ev, _window, _cx| {
|
|
||||||
this.should_move = false;
|
this.should_move = false;
|
||||||
},
|
}))
|
||||||
))
|
|
||||||
.on_mouse_up(
|
.on_mouse_up(
|
||||||
MouseButton::Left,
|
MouseButton::Left,
|
||||||
cx.listener(move |this, _ev, _window, _cx| {
|
cx.listener(move |this, _ev, _window, _cx| {
|
||||||
@@ -181,8 +167,6 @@ impl Render for TitleBar {
|
|||||||
}
|
}
|
||||||
PlatformKind::Windows => this.child(WindowsWindowControls::new(height)),
|
PlatformKind::Windows => this.child(WindowsWindowControls::new(height)),
|
||||||
PlatformKind::Mac => this,
|
PlatformKind::Mac => this,
|
||||||
}),
|
})
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,24 @@
|
|||||||
use gpui::prelude::FluentBuilder;
|
use gpui::prelude::FluentBuilder;
|
||||||
use gpui::{
|
use gpui::{
|
||||||
div, img, px, rems, AbsoluteLength, App, Hsla, ImageSource, Img, IntoElement, ParentElement,
|
div, img, px, AbsoluteLength, App, Div, Hsla, ImageSource, Img, InteractiveElement,
|
||||||
RenderOnce, Styled, StyledImage, Window,
|
Interactivity, IntoElement, ParentElement, RenderOnce, StyleRefinement, Styled, StyledImage,
|
||||||
|
Window,
|
||||||
};
|
};
|
||||||
use theme::ActiveTheme;
|
use theme::ActiveTheme;
|
||||||
|
|
||||||
|
use crate::{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.
|
/// An element that renders a user avatar with customizable appearance options.
|
||||||
///
|
///
|
||||||
/// # Examples
|
/// # Examples
|
||||||
@@ -18,8 +32,10 @@ use theme::ActiveTheme;
|
|||||||
/// ```
|
/// ```
|
||||||
#[derive(IntoElement)]
|
#[derive(IntoElement)]
|
||||||
pub struct Avatar {
|
pub struct Avatar {
|
||||||
|
base: Div,
|
||||||
image: Img,
|
image: Img,
|
||||||
size: Option<AbsoluteLength>,
|
style: StyleRefinement,
|
||||||
|
size: Size,
|
||||||
border_color: Option<Hsla>,
|
border_color: Option<Hsla>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -27,8 +43,10 @@ impl Avatar {
|
|||||||
/// Creates a new avatar element with the specified image source.
|
/// Creates a new avatar element with the specified image source.
|
||||||
pub fn new(src: impl Into<ImageSource>) -> Self {
|
pub fn new(src: impl Into<ImageSource>) -> Self {
|
||||||
Avatar {
|
Avatar {
|
||||||
|
base: div(),
|
||||||
image: img(src),
|
image: img(src),
|
||||||
size: None,
|
style: StyleRefinement::default(),
|
||||||
|
size: Size::Medium,
|
||||||
border_color: None,
|
border_color: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -56,14 +74,27 @@ impl Avatar {
|
|||||||
self.border_color = Some(color.into());
|
self.border_color = Some(color.into());
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Size overrides the avatar size. By default they are 1rem.
|
impl Sizable for Avatar {
|
||||||
pub fn size<L: Into<AbsoluteLength>>(mut self, size: impl Into<Option<L>>) -> Self {
|
fn with_size(mut self, size: impl Into<Size>) -> Self {
|
||||||
self.size = size.into().map(Into::into);
|
self.size = size.into();
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl Styled for Avatar {
|
||||||
|
fn style(&mut self) -> &mut StyleRefinement {
|
||||||
|
&mut self.style
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl InteractiveElement for Avatar {
|
||||||
|
fn interactivity(&mut self) -> &mut Interactivity {
|
||||||
|
self.base.interactivity()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl RenderOnce for Avatar {
|
impl RenderOnce for Avatar {
|
||||||
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
|
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||||
let border_width = if self.border_color.is_some() {
|
let border_width = if self.border_color.is_some() {
|
||||||
@@ -71,8 +102,7 @@ impl RenderOnce for Avatar {
|
|||||||
} else {
|
} else {
|
||||||
px(0.)
|
px(0.)
|
||||||
};
|
};
|
||||||
|
let image_size = avatar_size(self.size);
|
||||||
let image_size = self.size.unwrap_or_else(|| rems(1.).into());
|
|
||||||
let container_size = image_size.to_pixels(window.rem_size()) + border_width * 2.;
|
let container_size = image_size.to_pixels(window.rem_size()) + border_width * 2.;
|
||||||
|
|
||||||
div()
|
div()
|
||||||
|
|||||||
@@ -131,8 +131,8 @@ pub struct Button {
|
|||||||
|
|
||||||
rounded: bool,
|
rounded: bool,
|
||||||
compact: bool,
|
compact: bool,
|
||||||
underline: bool,
|
|
||||||
caret: bool,
|
caret: bool,
|
||||||
|
indicator: bool,
|
||||||
|
|
||||||
on_click: Option<Rc<dyn Fn(&ClickEvent, &mut Window, &mut App)>>,
|
on_click: Option<Rc<dyn Fn(&ClickEvent, &mut Window, &mut App)>>,
|
||||||
on_hover: Option<Rc<dyn Fn(&bool, &mut Window, &mut App)>>,
|
on_hover: Option<Rc<dyn Fn(&bool, &mut Window, &mut App)>>,
|
||||||
@@ -162,7 +162,7 @@ impl Button {
|
|||||||
variant: ButtonVariant::default(),
|
variant: ButtonVariant::default(),
|
||||||
disabled: false,
|
disabled: false,
|
||||||
selected: false,
|
selected: false,
|
||||||
underline: false,
|
indicator: false,
|
||||||
compact: false,
|
compact: false,
|
||||||
caret: false,
|
caret: false,
|
||||||
rounded: false,
|
rounded: false,
|
||||||
@@ -219,9 +219,9 @@ impl Button {
|
|||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Set true to show the underline indicator.
|
/// Set true to show the indicator.
|
||||||
pub fn underline(mut self) -> Self {
|
pub fn indicator(mut self) -> Self {
|
||||||
self.underline = true;
|
self.indicator = true;
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -455,6 +455,17 @@ impl RenderOnce for Button {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
.text_color(normal_style.fg)
|
.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| {
|
.when(!self.disabled && !self.selected, |this| {
|
||||||
this.bg(normal_style.bg)
|
this.bg(normal_style.bg)
|
||||||
.hover(|this| {
|
.hover(|this| {
|
||||||
@@ -470,17 +481,6 @@ impl RenderOnce for Button {
|
|||||||
let selected_style = style.selected(cx);
|
let selected_style = style.selected(cx);
|
||||||
this.bg(selected_style.bg).text_color(selected_style.fg)
|
this.bg(selected_style.bg).text_color(selected_style.fg)
|
||||||
})
|
})
|
||||||
.when(self.selected && self.underline, |this| {
|
|
||||||
this.child(
|
|
||||||
div()
|
|
||||||
.absolute()
|
|
||||||
.bottom_0()
|
|
||||||
.left_0()
|
|
||||||
.h_px()
|
|
||||||
.w_full()
|
|
||||||
.bg(cx.theme().element_background),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
.when(self.disabled, |this| {
|
.when(self.disabled, |this| {
|
||||||
let disabled_style = style.disabled(cx);
|
let disabled_style = style.disabled(cx);
|
||||||
this.cursor_not_allowed()
|
this.cursor_not_allowed()
|
||||||
@@ -513,7 +513,7 @@ impl ButtonVariant {
|
|||||||
fn bg_color(&self, cx: &App) -> Hsla {
|
fn bg_color(&self, cx: &App) -> Hsla {
|
||||||
match self {
|
match self {
|
||||||
ButtonVariant::Primary => cx.theme().element_background,
|
ButtonVariant::Primary => cx.theme().element_background,
|
||||||
ButtonVariant::Secondary => cx.theme().elevated_surface_background,
|
ButtonVariant::Secondary => cx.theme().secondary_background,
|
||||||
ButtonVariant::Danger => cx.theme().danger_background,
|
ButtonVariant::Danger => cx.theme().danger_background,
|
||||||
ButtonVariant::Warning => cx.theme().warning_background,
|
ButtonVariant::Warning => cx.theme().warning_background,
|
||||||
ButtonVariant::Ghost { alt } => {
|
ButtonVariant::Ghost { alt } => {
|
||||||
@@ -531,7 +531,7 @@ impl ButtonVariant {
|
|||||||
fn text_color(&self, cx: &App) -> Hsla {
|
fn text_color(&self, cx: &App) -> Hsla {
|
||||||
match self {
|
match self {
|
||||||
ButtonVariant::Primary => cx.theme().element_foreground,
|
ButtonVariant::Primary => cx.theme().element_foreground,
|
||||||
ButtonVariant::Secondary => cx.theme().text_muted,
|
ButtonVariant::Secondary => cx.theme().secondary_foreground,
|
||||||
ButtonVariant::Danger => cx.theme().danger_foreground,
|
ButtonVariant::Danger => cx.theme().danger_foreground,
|
||||||
ButtonVariant::Warning => cx.theme().warning_foreground,
|
ButtonVariant::Warning => cx.theme().warning_foreground,
|
||||||
ButtonVariant::Transparent => cx.theme().text_placeholder,
|
ButtonVariant::Transparent => cx.theme().text_placeholder,
|
||||||
|
|||||||
@@ -1,49 +1,109 @@
|
|||||||
|
use std::rc::Rc;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
use gpui::prelude::FluentBuilder as _;
|
use gpui::prelude::FluentBuilder as _;
|
||||||
use gpui::{
|
use gpui::{
|
||||||
div, svg, App, ElementId, InteractiveElement, IntoElement, ParentElement, RenderOnce,
|
div, px, relative, rems, svg, Animation, AnimationExt, AnyElement, App, Div, ElementId,
|
||||||
SharedString, StatefulInteractiveElement as _, Styled as _, Window,
|
InteractiveElement, IntoElement, ParentElement, RenderOnce, SharedString,
|
||||||
|
StatefulInteractiveElement, StyleRefinement, Styled, Window,
|
||||||
};
|
};
|
||||||
use theme::ActiveTheme;
|
use theme::ActiveTheme;
|
||||||
|
|
||||||
use crate::{h_flex, v_flex, Disableable, IconName, Selectable};
|
use crate::icon::IconNamed;
|
||||||
|
use crate::{v_flex, Disableable, IconName, Selectable, Sizable, Size, StyledExt as _};
|
||||||
type OnClick = Option<Box<dyn Fn(&bool, &mut Window, &mut App) + 'static>>;
|
|
||||||
|
|
||||||
/// A Checkbox element.
|
/// A Checkbox element.
|
||||||
|
#[allow(clippy::type_complexity)]
|
||||||
#[derive(IntoElement)]
|
#[derive(IntoElement)]
|
||||||
pub struct Checkbox {
|
pub struct Checkbox {
|
||||||
id: ElementId,
|
id: ElementId,
|
||||||
|
base: Div,
|
||||||
|
style: StyleRefinement,
|
||||||
label: Option<SharedString>,
|
label: Option<SharedString>,
|
||||||
|
children: Vec<AnyElement>,
|
||||||
checked: bool,
|
checked: bool,
|
||||||
disabled: bool,
|
disabled: bool,
|
||||||
on_click: OnClick,
|
size: Size,
|
||||||
|
tab_stop: bool,
|
||||||
|
tab_index: isize,
|
||||||
|
on_click: Option<Rc<dyn Fn(&bool, &mut Window, &mut App) + 'static>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Checkbox {
|
impl Checkbox {
|
||||||
|
/// Create a new Checkbox with the given id.
|
||||||
pub fn new(id: impl Into<ElementId>) -> Self {
|
pub fn new(id: impl Into<ElementId>) -> Self {
|
||||||
Self {
|
Self {
|
||||||
id: id.into(),
|
id: id.into(),
|
||||||
|
base: div(),
|
||||||
|
style: StyleRefinement::default(),
|
||||||
label: None,
|
label: None,
|
||||||
|
children: Vec::new(),
|
||||||
checked: false,
|
checked: false,
|
||||||
disabled: false,
|
disabled: false,
|
||||||
|
size: Size::default(),
|
||||||
on_click: None,
|
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 {
|
pub fn label(mut self, label: impl Into<SharedString>) -> Self {
|
||||||
self.label = Some(label.into());
|
self.label = Some(label.into());
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Set the checked state for the checkbox.
|
||||||
pub fn checked(mut self, checked: bool) -> Self {
|
pub fn checked(mut self, checked: bool) -> Self {
|
||||||
self.checked = checked;
|
self.checked = checked;
|
||||||
self
|
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 {
|
pub fn on_click(mut self, handler: impl Fn(&bool, &mut Window, &mut App) + 'static) -> Self {
|
||||||
self.on_click = Some(Box::new(handler));
|
self.on_click = Some(Rc::new(handler));
|
||||||
self
|
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 {
|
impl Disableable for Checkbox {
|
||||||
@@ -63,64 +123,190 @@ impl Selectable for Checkbox {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl RenderOnce for Checkbox {
|
impl ParentElement for Checkbox {
|
||||||
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
|
fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
|
||||||
let icon_color = if self.disabled {
|
self.children.extend(elements);
|
||||||
cx.theme().icon_muted
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
} else {
|
||||||
cx.theme().icon_accent
|
cx.theme().text
|
||||||
};
|
};
|
||||||
|
|
||||||
h_flex()
|
|
||||||
.id(self.id)
|
|
||||||
.gap_2()
|
|
||||||
.items_center()
|
|
||||||
.child(
|
|
||||||
v_flex()
|
|
||||||
.flex_shrink_0()
|
|
||||||
.relative()
|
|
||||||
.rounded_sm()
|
|
||||||
.size_5()
|
|
||||||
.bg(cx.theme().elevated_surface_background)
|
|
||||||
.child(
|
|
||||||
svg()
|
svg()
|
||||||
.absolute()
|
.absolute()
|
||||||
.top_0p5()
|
.top_px()
|
||||||
.left_0p5()
|
.left_px()
|
||||||
.size_4()
|
.map(|this| match size {
|
||||||
.text_color(icon_color)
|
Size::XSmall => this.size_2(),
|
||||||
.map(|this| match self.checked {
|
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()),
|
true => this.path(IconName::Check.path()),
|
||||||
_ => this,
|
_ => 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| {
|
.map(|this| {
|
||||||
if let Some(label) = self.label {
|
if let Some(label) = self.label {
|
||||||
this.text_color(cx.theme().text_muted).child(
|
this.child(
|
||||||
div()
|
div()
|
||||||
.w_full()
|
.size_full()
|
||||||
.overflow_x_hidden()
|
.text_color(cx.theme().text)
|
||||||
.text_ellipsis()
|
.when(self.disabled, |this| {
|
||||||
.text_sm()
|
this.text_color(cx.theme().text_muted)
|
||||||
|
})
|
||||||
|
.line_height(relative(1.))
|
||||||
.child(label),
|
.child(label),
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
this
|
this
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.when(self.disabled, |this| {
|
.children(self.children),
|
||||||
this.cursor_not_allowed()
|
)
|
||||||
.text_color(cx.theme().text_placeholder)
|
|
||||||
})
|
})
|
||||||
.when_some(
|
.on_mouse_down(gpui::MouseButton::Left, |_, window, _| {
|
||||||
self.on_click.filter(|_| !self.disabled),
|
// Avoid focus on mouse down.
|
||||||
|this, on_click| {
|
window.prevent_default();
|
||||||
this.on_click(move |_, window, cx| {
|
|
||||||
let checked = !self.checked;
|
|
||||||
on_click(&checked, window, cx);
|
|
||||||
})
|
})
|
||||||
},
|
.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);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,32 +1,27 @@
|
|||||||
|
use std::ops::Deref;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use gpui::prelude::FluentBuilder as _;
|
use gpui::prelude::FluentBuilder as _;
|
||||||
use gpui::{
|
use gpui::{
|
||||||
div, px, App, AppContext, Axis, Context, Element, Entity, InteractiveElement as _, IntoElement,
|
div, px, App, AppContext, Axis, Context, Element, Entity, IntoElement, MouseMoveEvent,
|
||||||
MouseMoveEvent, MouseUpEvent, ParentElement as _, Pixels, Point, Render,
|
MouseUpEvent, ParentElement as _, Pixels, Point, Render, Style, Styled as _, WeakEntity,
|
||||||
StatefulInteractiveElement, Style, Styled as _, WeakEntity, Window,
|
Window,
|
||||||
};
|
};
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use theme::ActiveTheme;
|
|
||||||
|
|
||||||
use super::{DockArea, DockItem};
|
use super::{DockArea, DockItem};
|
||||||
use crate::dock_area::panel::PanelView;
|
use crate::dock_area::panel::PanelView;
|
||||||
use crate::dock_area::tab_panel::TabPanel;
|
use crate::dock_area::tab_panel::TabPanel;
|
||||||
use crate::resizable::{HANDLE_PADDING, HANDLE_SIZE, PANEL_MIN_SIZE};
|
use crate::resizable::{resize_handle, PANEL_MIN_SIZE};
|
||||||
use crate::{AxisExt as _, StyledExt};
|
use crate::StyledExt;
|
||||||
|
|
||||||
#[derive(Clone, Render)]
|
#[derive(Clone, Render)]
|
||||||
struct ResizePanel;
|
struct ResizePanel;
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
pub enum DockPlacement {
|
pub enum DockPlacement {
|
||||||
#[serde(rename = "center")]
|
|
||||||
Center,
|
Center,
|
||||||
#[serde(rename = "left")]
|
|
||||||
Left,
|
Left,
|
||||||
#[serde(rename = "bottom")]
|
|
||||||
Bottom,
|
Bottom,
|
||||||
#[serde(rename = "right")]
|
|
||||||
Right,
|
Right,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -58,16 +53,21 @@ impl DockPlacement {
|
|||||||
pub struct Dock {
|
pub struct Dock {
|
||||||
pub(super) placement: DockPlacement,
|
pub(super) placement: DockPlacement,
|
||||||
dock_area: WeakEntity<DockArea>,
|
dock_area: WeakEntity<DockArea>,
|
||||||
|
|
||||||
|
/// Dock layout
|
||||||
pub(crate) panel: DockItem,
|
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.
|
/// 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,
|
pub(super) size: Pixels,
|
||||||
|
|
||||||
|
/// Whether the Dock is open
|
||||||
pub(super) open: bool,
|
pub(super) open: bool,
|
||||||
|
|
||||||
/// Whether the Dock is collapsible, default: true
|
/// Whether the Dock is collapsible, default: true
|
||||||
pub(super) collapsible: bool,
|
pub(super) collapsible: bool,
|
||||||
|
|
||||||
// Runtime state
|
|
||||||
/// Whether the Dock is resizing
|
/// Whether the Dock is resizing
|
||||||
is_resizing: bool,
|
resizing: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Dock {
|
impl Dock {
|
||||||
@@ -98,7 +98,7 @@ impl Dock {
|
|||||||
open: true,
|
open: true,
|
||||||
collapsible: true,
|
collapsible: true,
|
||||||
size: px(200.0),
|
size: px(200.0),
|
||||||
is_resizing: false,
|
resizing: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -231,54 +231,16 @@ impl Dock {
|
|||||||
cx: &mut Context<Self>,
|
cx: &mut Context<Self>,
|
||||||
) -> impl IntoElement {
|
) -> impl IntoElement {
|
||||||
let axis = self.placement.axis();
|
let axis = self.placement.axis();
|
||||||
let neg_offset = -HANDLE_PADDING;
|
|
||||||
let view = cx.entity().clone();
|
let view = cx.entity().clone();
|
||||||
|
|
||||||
div()
|
resize_handle("resize-handle", axis)
|
||||||
.id("resize-handle")
|
.placement(self.placement)
|
||||||
.occlude()
|
|
||||||
.absolute()
|
|
||||||
.flex_shrink_0()
|
|
||||||
.when(self.placement.is_left(), |this| {
|
|
||||||
// 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)
|
|
||||||
.pt_12()
|
|
||||||
.pb_4()
|
|
||||||
})
|
|
||||||
.when(self.placement.is_right(), |this| {
|
|
||||||
this.cursor_col_resize()
|
|
||||||
.top_0()
|
|
||||||
.left(px(-0.5))
|
|
||||||
.h_full()
|
|
||||||
.w(HANDLE_SIZE)
|
|
||||||
.pt_12()
|
|
||||||
.pb_4()
|
|
||||||
})
|
|
||||||
.when(self.placement.is_bottom(), |this| {
|
|
||||||
this.cursor_row_resize()
|
|
||||||
.top(neg_offset)
|
|
||||||
.left_0()
|
|
||||||
.w_full()
|
|
||||||
.h(HANDLE_SIZE)
|
|
||||||
.py(HANDLE_PADDING)
|
|
||||||
})
|
|
||||||
.child(
|
|
||||||
div()
|
|
||||||
.rounded_full()
|
|
||||||
.hover(|this| this.bg(cx.theme().border_variant))
|
|
||||||
.when(axis.is_horizontal(), |this| this.h_full().w(HANDLE_SIZE))
|
|
||||||
.when(axis.is_vertical(), |this| this.w_full().h(HANDLE_SIZE)),
|
|
||||||
)
|
|
||||||
.on_drag(ResizePanel {}, move |info, _, _, cx| {
|
.on_drag(ResizePanel {}, move |info, _, _, cx| {
|
||||||
cx.stop_propagation();
|
cx.stop_propagation();
|
||||||
view.update(cx, |view, _| {
|
view.update(cx, |view, _cx| {
|
||||||
view.is_resizing = true;
|
view.resizing = true;
|
||||||
});
|
});
|
||||||
cx.new(|_| info.clone())
|
cx.new(|_| info.deref().clone())
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -288,7 +250,7 @@ impl Dock {
|
|||||||
_window: &mut Window,
|
_window: &mut Window,
|
||||||
cx: &mut Context<Self>,
|
cx: &mut Context<Self>,
|
||||||
) {
|
) {
|
||||||
if !self.is_resizing {
|
if !self.resizing {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -349,7 +311,7 @@ impl Dock {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn done_resizing(&mut self, _window: &mut Window, _cx: &mut Context<Self>) {
|
fn done_resizing(&mut self, _window: &mut Window, _cx: &mut Context<Self>) {
|
||||||
self.is_resizing = false;
|
self.resizing = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -440,7 +402,7 @@ impl Element for DockElement {
|
|||||||
) {
|
) {
|
||||||
window.on_mouse_event({
|
window.on_mouse_event({
|
||||||
let view = self.view.clone();
|
let view = self.view.clone();
|
||||||
let is_resizing = view.read(cx).is_resizing;
|
let is_resizing = view.read(cx).resizing;
|
||||||
move |e: &MouseMoveEvent, phase, window, cx| {
|
move |e: &MouseMoveEvent, phase, window, cx| {
|
||||||
if !is_resizing {
|
if !is_resizing {
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -2,30 +2,24 @@ use std::sync::Arc;
|
|||||||
|
|
||||||
use gpui::prelude::FluentBuilder;
|
use gpui::prelude::FluentBuilder;
|
||||||
use gpui::{
|
use gpui::{
|
||||||
actions, canvas, div, px, AnyElement, AnyView, App, AppContext, Axis, Bounds, Context, Edges,
|
actions, div, px, AnyElement, AnyView, App, AppContext, Axis, Bounds, Context, Decorations,
|
||||||
Entity, EntityId, EventEmitter, Focusable, InteractiveElement as _, IntoElement,
|
Edges, Entity, EntityId, EventEmitter, Focusable, InteractiveElement as _, IntoElement,
|
||||||
ParentElement as _, Pixels, Render, SharedString, Styled, Subscription, WeakEntity, Window,
|
ParentElement as _, Pixels, Render, SharedString, Styled, Subscription, WeakEntity, Window,
|
||||||
};
|
};
|
||||||
|
use theme::CLIENT_SIDE_DECORATION_ROUNDING;
|
||||||
|
|
||||||
use crate::dock_area::dock::{Dock, DockPlacement};
|
use crate::dock_area::dock::{Dock, DockPlacement};
|
||||||
use crate::dock_area::panel::{Panel, PanelEvent, PanelStyle, PanelView};
|
use crate::dock_area::panel::{Panel, PanelEvent, PanelStyle, PanelView};
|
||||||
use crate::dock_area::stack_panel::StackPanel;
|
use crate::dock_area::stack_panel::StackPanel;
|
||||||
use crate::dock_area::tab_panel::TabPanel;
|
use crate::dock_area::tab_panel::TabPanel;
|
||||||
|
use crate::ElementExt;
|
||||||
|
|
||||||
pub mod dock;
|
pub mod dock;
|
||||||
pub mod panel;
|
pub mod panel;
|
||||||
pub mod stack_panel;
|
pub mod stack_panel;
|
||||||
pub mod tab_panel;
|
pub mod tab_panel;
|
||||||
|
|
||||||
actions!(
|
actions!(dock, [ToggleZoom, ClosePanel]);
|
||||||
dock,
|
|
||||||
[
|
|
||||||
/// Zoom the current panel
|
|
||||||
ToggleZoom,
|
|
||||||
/// Close the current panel
|
|
||||||
ClosePanel
|
|
||||||
]
|
|
||||||
);
|
|
||||||
|
|
||||||
pub enum DockEvent {
|
pub enum DockEvent {
|
||||||
/// The layout of the dock has changed, subscribers this to save the layout.
|
/// The layout of the dock has changed, subscribers this to save the layout.
|
||||||
@@ -38,20 +32,31 @@ pub enum DockEvent {
|
|||||||
/// The main area of the dock.
|
/// The main area of the dock.
|
||||||
pub struct DockArea {
|
pub struct DockArea {
|
||||||
pub(crate) bounds: Bounds<Pixels>,
|
pub(crate) bounds: Bounds<Pixels>,
|
||||||
|
|
||||||
/// The center view of the dockarea.
|
/// The center view of the dockarea.
|
||||||
pub items: DockItem,
|
pub items: DockItem,
|
||||||
/// The entity_id of the [`TabPanel`](TabPanel) where each toggle button should be displayed,
|
|
||||||
toggle_button_panels: Edges<Option<EntityId>>,
|
|
||||||
/// The left dock of the dock_area.
|
/// The left dock of the dock_area.
|
||||||
left_dock: Option<Entity<Dock>>,
|
left_dock: Option<Entity<Dock>>,
|
||||||
|
|
||||||
/// The bottom dock of the dock_area.
|
/// The bottom dock of the dock_area.
|
||||||
bottom_dock: Option<Entity<Dock>>,
|
bottom_dock: Option<Entity<Dock>>,
|
||||||
|
|
||||||
/// The right dock of the dock_area.
|
/// The right dock of the dock_area.
|
||||||
right_dock: Option<Entity<Dock>>,
|
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.
|
/// The top zoom view of the dock_area, if any.
|
||||||
zoom_view: Option<AnyView>,
|
zoom_view: Option<AnyView>,
|
||||||
|
|
||||||
/// Lock panels layout, but allow to resize.
|
/// Lock panels layout, but allow to resize.
|
||||||
is_locked: bool,
|
is_locked: bool,
|
||||||
|
|
||||||
/// The panel style, default is [`PanelStyle::Default`](PanelStyle::Default).
|
/// The panel style, default is [`PanelStyle::Default`](PanelStyle::Default).
|
||||||
pub(crate) panel_style: PanelStyle,
|
pub(crate) panel_style: PanelStyle,
|
||||||
subscriptions: Vec<Subscription>,
|
subscriptions: Vec<Subscription>,
|
||||||
@@ -198,19 +203,16 @@ impl DockItem {
|
|||||||
/// Returns all panel ids
|
/// Returns all panel ids
|
||||||
pub fn panel_ids(&self, cx: &App) -> Vec<SharedString> {
|
pub fn panel_ids(&self, cx: &App) -> Vec<SharedString> {
|
||||||
match self {
|
match self {
|
||||||
Self::Tabs { view, .. } => view.read(cx).panel_ids(cx),
|
|
||||||
Self::Split { items, .. } => {
|
|
||||||
let mut total = vec![];
|
|
||||||
|
|
||||||
for item in items.iter() {
|
|
||||||
if let DockItem::Tabs { view, .. } = item {
|
|
||||||
total.extend(view.read(cx).panel_ids(cx));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
total
|
|
||||||
}
|
|
||||||
Self::Panel { .. } => vec![],
|
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(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -330,6 +332,7 @@ impl DockArea {
|
|||||||
items: dock_item,
|
items: dock_item,
|
||||||
zoom_view: None,
|
zoom_view: None,
|
||||||
toggle_button_panels: Edges::default(),
|
toggle_button_panels: Edges::default(),
|
||||||
|
toggle_button_visible: true,
|
||||||
left_dock: None,
|
left_dock: None,
|
||||||
right_dock: None,
|
right_dock: None,
|
||||||
bottom_dock: None,
|
bottom_dock: None,
|
||||||
@@ -344,7 +347,7 @@ impl DockArea {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Set the panel style of the dock area.
|
/// Set the panel style of the dock area.
|
||||||
pub fn panel_style(mut self, style: PanelStyle) -> Self {
|
pub fn style(mut self, style: PanelStyle) -> Self {
|
||||||
self.panel_style = style;
|
self.panel_style = style;
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
@@ -585,9 +588,6 @@ impl DockArea {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
DockPlacement::Right => {
|
DockPlacement::Right => {
|
||||||
if let Some(dock) = self.right_dock.as_ref() {
|
|
||||||
dock.update(cx, |dock, cx| dock.add_panel(panel, window, cx))
|
|
||||||
} else {
|
|
||||||
self.set_right_dock(
|
self.set_right_dock(
|
||||||
DockItem::tabs(vec![panel], None, &weak_self, window, cx),
|
DockItem::tabs(vec![panel], None, &weak_self, window, cx),
|
||||||
Some(px(320.)),
|
Some(px(320.)),
|
||||||
@@ -596,7 +596,6 @@ impl DockArea {
|
|||||||
cx,
|
cx,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
DockPlacement::Center => {
|
DockPlacement::Center => {
|
||||||
self.items
|
self.items
|
||||||
.add_panel(panel, &cx.entity().downgrade(), window, cx);
|
.add_panel(panel, &cx.entity().downgrade(), window, cx);
|
||||||
@@ -649,31 +648,35 @@ impl DockArea {
|
|||||||
cx.subscribe_in(
|
cx.subscribe_in(
|
||||||
view,
|
view,
|
||||||
window,
|
window,
|
||||||
move |_, panel, event, window, cx| match event {
|
move |_this, panel, event, window, cx| match event {
|
||||||
PanelEvent::ZoomIn => {
|
PanelEvent::ZoomIn => {
|
||||||
let panel = panel.clone();
|
let panel = panel.clone();
|
||||||
cx.spawn_in(window, async move |view, window| {
|
cx.spawn_in(window, async move |view, window| {
|
||||||
_ = view.update_in(window, |view, window, cx| {
|
view.update_in(window, |view, window, cx| {
|
||||||
view.set_zoomed_in(panel, window, cx);
|
view.set_zoomed_in(panel, window, cx);
|
||||||
cx.notify();
|
cx.notify();
|
||||||
});
|
})
|
||||||
|
.ok();
|
||||||
})
|
})
|
||||||
.detach();
|
.detach();
|
||||||
}
|
}
|
||||||
PanelEvent::ZoomOut => cx
|
PanelEvent::ZoomOut => {
|
||||||
.spawn_in(window, async move |view, window| {
|
cx.spawn_in(window, async move |view, window| {
|
||||||
_ = view.update_in(window, |view, window, cx| {
|
_ = view.update_in(window, |view, window, cx| {
|
||||||
view.set_zoomed_out(window, cx);
|
view.set_zoomed_out(window, cx);
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
.detach(),
|
.detach();
|
||||||
|
}
|
||||||
PanelEvent::LayoutChanged => {
|
PanelEvent::LayoutChanged => {
|
||||||
cx.spawn_in(window, async move |view, window| {
|
cx.spawn_in(window, async move |view, window| {
|
||||||
_ = view.update_in(window, |view, window, cx| {
|
view.update_in(window, |view, window, cx| {
|
||||||
view.update_toggle_button_tab_panels(window, cx)
|
view.update_toggle_button_tab_panels(window, cx)
|
||||||
});
|
})
|
||||||
|
.ok();
|
||||||
})
|
})
|
||||||
.detach();
|
.detach();
|
||||||
|
// Emit layout changed event for dock
|
||||||
cx.emit(DockEvent::LayoutChanged);
|
cx.emit(DockEvent::LayoutChanged);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -740,23 +743,27 @@ impl EventEmitter<DockEvent> for DockArea {}
|
|||||||
impl Render for DockArea {
|
impl Render for DockArea {
|
||||||
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||||
let view = cx.entity().clone();
|
let view = cx.entity().clone();
|
||||||
|
let decorations = window.window_decorations();
|
||||||
|
|
||||||
div()
|
div()
|
||||||
.id("dock-area")
|
.id("dock-area")
|
||||||
.relative()
|
.relative()
|
||||||
.size_full()
|
.size_full()
|
||||||
.overflow_hidden()
|
.overflow_hidden()
|
||||||
.child(
|
.on_prepaint(move |bounds, _, cx| view.update(cx, |r, _| r.bounds = bounds))
|
||||||
canvas(
|
|
||||||
move |bounds, _, cx| view.update(cx, |r, _| r.bounds = bounds),
|
|
||||||
|_, _, _, _| {},
|
|
||||||
)
|
|
||||||
.absolute()
|
|
||||||
.size_full(),
|
|
||||||
)
|
|
||||||
.map(|this| {
|
.map(|this| {
|
||||||
if let Some(zoom_view) = self.zoom_view.clone() {
|
if let Some(zoom_view) = self.zoom_view.clone() {
|
||||||
this.child(zoom_view)
|
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 {
|
} else {
|
||||||
// render dock
|
// render dock
|
||||||
this.child(
|
this.child(
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ use gpui::{
|
|||||||
use crate::button::Button;
|
use crate::button::Button;
|
||||||
use crate::menu::PopupMenu;
|
use crate::menu::PopupMenu;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
pub enum PanelEvent {
|
pub enum PanelEvent {
|
||||||
ZoomIn,
|
ZoomIn,
|
||||||
ZoomOut,
|
ZoomOut,
|
||||||
|
|||||||
@@ -7,13 +7,14 @@ use gpui::{
|
|||||||
Window,
|
Window,
|
||||||
};
|
};
|
||||||
use smallvec::SmallVec;
|
use smallvec::SmallVec;
|
||||||
|
use theme::{ActiveTheme, CLIENT_SIDE_DECORATION_ROUNDING};
|
||||||
|
|
||||||
use super::{DockArea, PanelEvent};
|
use super::{DockArea, PanelEvent};
|
||||||
use crate::dock_area::panel::{Panel, PanelView};
|
use crate::dock_area::panel::{Panel, PanelView};
|
||||||
use crate::dock_area::tab_panel::TabPanel;
|
use crate::dock_area::tab_panel::TabPanel;
|
||||||
use crate::resizable::{
|
use crate::resizable::{
|
||||||
h_resizable, resizable_panel, v_resizable, ResizablePanel, ResizablePanelEvent,
|
resizable_panel, ResizablePanelEvent, ResizablePanelGroup, ResizablePanelState, ResizableState,
|
||||||
ResizablePanelGroup,
|
PANEL_MIN_SIZE,
|
||||||
};
|
};
|
||||||
use crate::{h_flex, AxisExt as _, Placement};
|
use crate::{h_flex, AxisExt as _, Placement};
|
||||||
|
|
||||||
@@ -22,9 +23,8 @@ pub struct StackPanel {
|
|||||||
pub(super) axis: Axis,
|
pub(super) axis: Axis,
|
||||||
focus_handle: FocusHandle,
|
focus_handle: FocusHandle,
|
||||||
pub(crate) panels: SmallVec<[Arc<dyn PanelView>; 2]>,
|
pub(crate) panels: SmallVec<[Arc<dyn PanelView>; 2]>,
|
||||||
panel_group: Entity<ResizablePanelGroup>,
|
state: Entity<ResizableState>,
|
||||||
#[allow(dead_code)]
|
_subscriptions: Vec<Subscription>,
|
||||||
subscriptions: Vec<Subscription>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Panel for StackPanel {
|
impl Panel for StackPanel {
|
||||||
@@ -39,28 +39,23 @@ impl Panel for StackPanel {
|
|||||||
|
|
||||||
impl StackPanel {
|
impl StackPanel {
|
||||||
pub fn new(axis: Axis, window: &mut Window, cx: &mut Context<Self>) -> Self {
|
pub fn new(axis: Axis, window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||||
let panel_group = cx.new(|cx| {
|
let state = cx.new(|_| ResizableState::default());
|
||||||
if axis == Axis::Horizontal {
|
|
||||||
h_resizable(window, cx)
|
|
||||||
} else {
|
|
||||||
v_resizable(window, cx)
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Bubble up the resize event.
|
// Bubble up the resize event.
|
||||||
let subscriptions = vec![cx.subscribe_in(
|
let subscriptions =
|
||||||
&panel_group,
|
vec![
|
||||||
window,
|
cx.subscribe_in(&state, window, |_, _, _: &ResizablePanelEvent, _, cx| {
|
||||||
|_, _, _: &ResizablePanelEvent, _, cx| cx.emit(PanelEvent::LayoutChanged),
|
cx.emit(PanelEvent::LayoutChanged)
|
||||||
)];
|
}),
|
||||||
|
];
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
axis,
|
axis,
|
||||||
parent: None,
|
parent: None,
|
||||||
focus_handle: cx.focus_handle(),
|
focus_handle: cx.focus_handle(),
|
||||||
panels: SmallVec::new(),
|
panels: SmallVec::new(),
|
||||||
panel_group,
|
state,
|
||||||
subscriptions,
|
_subscriptions: subscriptions,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -70,7 +65,7 @@ impl StackPanel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Return true if self or parent only have last panel.
|
/// Return true if self or parent only have last panel.
|
||||||
pub(super) fn is_last_panel(&self, cx: &App) -> bool {
|
pub fn is_last_panel(&self, cx: &App) -> bool {
|
||||||
if self.panels.len() > 1 {
|
if self.panels.len() > 1 {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -84,12 +79,12 @@ impl StackPanel {
|
|||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(super) fn panels_len(&self) -> usize {
|
pub fn panels_len(&self) -> usize {
|
||||||
self.panels.len()
|
self.panels.len()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Return the index of the panel.
|
/// Return the index of the panel.
|
||||||
pub(crate) fn index_of_panel(&self, panel: Arc<dyn PanelView>) -> Option<usize> {
|
pub fn index_of_panel(&self, panel: Arc<dyn PanelView>) -> Option<usize> {
|
||||||
self.panels.iter().position(|p| p == &panel)
|
self.panels.iter().position(|p| p == &panel)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -172,13 +167,6 @@ impl StackPanel {
|
|||||||
self.insert_panel(panel, ix + 1, size, dock_area, window, cx);
|
self.insert_panel(panel, ix + 1, size, dock_area, window, cx);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn new_resizable_panel(panel: Arc<dyn PanelView>, size: Option<Pixels>) -> ResizablePanel {
|
|
||||||
resizable_panel()
|
|
||||||
.content_view(panel.view())
|
|
||||||
.content_visible(move |cx| panel.visible(cx))
|
|
||||||
.when_some(size, |this, size| this.size(size))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn insert_panel(
|
fn insert_panel(
|
||||||
&mut self,
|
&mut self,
|
||||||
panel: Arc<dyn PanelView>,
|
panel: Arc<dyn PanelView>,
|
||||||
@@ -225,14 +213,21 @@ impl StackPanel {
|
|||||||
ix
|
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());
|
self.panels.insert(ix, panel.clone());
|
||||||
self.panel_group.update(cx, |view, cx| {
|
|
||||||
view.insert_child(
|
// Update resizable state
|
||||||
Self::new_resizable_panel(panel.clone(), size),
|
self.state.update(cx, |state, cx| {
|
||||||
ix,
|
state.insert_panel(Some(size), Some(ix), cx);
|
||||||
window,
|
|
||||||
cx,
|
|
||||||
)
|
|
||||||
});
|
});
|
||||||
|
|
||||||
cx.emit(PanelEvent::LayoutChanged);
|
cx.emit(PanelEvent::LayoutChanged);
|
||||||
@@ -240,47 +235,47 @@ impl StackPanel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Remove panel from the stack.
|
/// Remove panel from the stack.
|
||||||
|
///
|
||||||
|
/// If `ix` is not found, do nothing.
|
||||||
pub fn remove_panel(
|
pub fn remove_panel(
|
||||||
&mut self,
|
&mut self,
|
||||||
panel: Arc<dyn PanelView>,
|
panel: Arc<dyn PanelView>,
|
||||||
window: &mut Window,
|
window: &mut Window,
|
||||||
cx: &mut Context<Self>,
|
cx: &mut Context<Self>,
|
||||||
) {
|
) {
|
||||||
if let Some(ix) = self.index_of_panel(panel.clone()) {
|
let Some(ix) = self.index_of_panel(panel.clone()) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
self.panels.remove(ix);
|
self.panels.remove(ix);
|
||||||
self.panel_group.update(cx, |view, cx| {
|
self.state.update(cx, |state, cx| {
|
||||||
view.remove_child(ix, window, cx);
|
state.remove_panel(ix, cx);
|
||||||
});
|
});
|
||||||
|
|
||||||
cx.emit(PanelEvent::LayoutChanged);
|
cx.emit(PanelEvent::LayoutChanged);
|
||||||
|
|
||||||
self.remove_self_if_empty(window, cx);
|
self.remove_self_if_empty(window, cx);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
/// Replace the old panel with the new panel at same index.
|
/// Replace the old panel with the new panel at same index.
|
||||||
pub(super) fn replace_panel(
|
pub fn replace_panel(
|
||||||
&mut self,
|
&mut self,
|
||||||
old_panel: Arc<dyn PanelView>,
|
old_panel: Arc<dyn PanelView>,
|
||||||
new_panel: Entity<StackPanel>,
|
new_panel: Entity<StackPanel>,
|
||||||
window: &mut Window,
|
_window: &mut Window,
|
||||||
cx: &mut Context<Self>,
|
cx: &mut Context<Self>,
|
||||||
) {
|
) {
|
||||||
if let Some(ix) = self.index_of_panel(old_panel.clone()) {
|
if let Some(ix) = self.index_of_panel(old_panel.clone()) {
|
||||||
self.panels[ix] = Arc::new(new_panel.clone());
|
self.panels[ix] = Arc::new(new_panel.clone());
|
||||||
self.panel_group.update(cx, |view, cx| {
|
self.state.update(cx, |state, cx| {
|
||||||
view.replace_child(
|
state.replace_panel(ix, ResizablePanelState::default(), cx);
|
||||||
Self::new_resizable_panel(Arc::new(new_panel.clone()), None),
|
|
||||||
ix,
|
|
||||||
window,
|
|
||||||
cx,
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
cx.emit(PanelEvent::LayoutChanged);
|
cx.emit(PanelEvent::LayoutChanged);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// If children is empty, remove self from parent view.
|
/// If children is empty, remove self from parent view.
|
||||||
pub(crate) fn remove_self_if_empty(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
pub fn remove_self_if_empty(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||||
if self.is_root() {
|
if self.is_root() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -301,11 +296,7 @@ impl StackPanel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Find the first top left in the stack.
|
/// Find the first top left in the stack.
|
||||||
pub(super) fn left_top_tab_panel(
|
pub fn left_top_tab_panel(&self, check_parent: bool, cx: &App) -> Option<Entity<TabPanel>> {
|
||||||
&self,
|
|
||||||
check_parent: bool,
|
|
||||||
cx: &App,
|
|
||||||
) -> Option<Entity<TabPanel>> {
|
|
||||||
if check_parent {
|
if check_parent {
|
||||||
if let Some(parent) = self.parent.as_ref().and_then(|parent| parent.upgrade()) {
|
if let Some(parent) = self.parent.as_ref().and_then(|parent| parent.upgrade()) {
|
||||||
if let Some(panel) = parent.read(cx).left_top_tab_panel(true, cx) {
|
if let Some(panel) = parent.read(cx).left_top_tab_panel(true, cx) {
|
||||||
@@ -329,11 +320,7 @@ impl StackPanel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Find the first top right in the stack.
|
/// Find the first top right in the stack.
|
||||||
pub(super) fn right_top_tab_panel(
|
pub fn right_top_tab_panel(&self, check_parent: bool, cx: &App) -> Option<Entity<TabPanel>> {
|
||||||
&self,
|
|
||||||
check_parent: bool,
|
|
||||||
cx: &App,
|
|
||||||
) -> Option<Entity<TabPanel>> {
|
|
||||||
if check_parent {
|
if check_parent {
|
||||||
if let Some(parent) = self.parent.as_ref().and_then(|parent| parent.upgrade()) {
|
if let Some(parent) = self.parent.as_ref().and_then(|parent| parent.upgrade()) {
|
||||||
if let Some(panel) = parent.read(cx).right_top_tab_panel(true, cx) {
|
if let Some(panel) = parent.read(cx).right_top_tab_panel(true, cx) {
|
||||||
@@ -362,17 +349,17 @@ impl StackPanel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Remove all panels from the stack.
|
/// Remove all panels from the stack.
|
||||||
pub(super) fn remove_all_panels(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
pub fn remove_all_panels(&mut self, _: &mut Window, cx: &mut Context<Self>) {
|
||||||
self.panels.clear();
|
self.panels.clear();
|
||||||
self.panel_group
|
self.state.update(cx, |state, cx| {
|
||||||
.update(cx, |view, cx| view.remove_all_children(window, cx));
|
state.clear();
|
||||||
|
cx.notify();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Change the axis of the stack panel.
|
/// Change the axis of the stack panel.
|
||||||
pub(super) fn set_axis(&mut self, axis: Axis, window: &mut Window, cx: &mut Context<Self>) {
|
pub fn set_axis(&mut self, axis: Axis, _: &mut Window, cx: &mut Context<Self>) {
|
||||||
self.axis = axis;
|
self.axis = axis;
|
||||||
self.panel_group
|
|
||||||
.update(cx, |view, cx| view.set_axis(axis, window, cx));
|
|
||||||
cx.notify();
|
cx.notify();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -384,14 +371,26 @@ impl Focusable for StackPanel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl EventEmitter<PanelEvent> for StackPanel {}
|
impl EventEmitter<PanelEvent> for StackPanel {}
|
||||||
|
|
||||||
impl EventEmitter<DismissEvent> for StackPanel {}
|
impl EventEmitter<DismissEvent> for StackPanel {}
|
||||||
|
|
||||||
impl Render for StackPanel {
|
impl Render for StackPanel {
|
||||||
fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
|
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||||
h_flex()
|
h_flex()
|
||||||
.size_full()
|
.size_full()
|
||||||
.overflow_hidden()
|
.overflow_hidden()
|
||||||
.child(self.panel_group.clone())
|
.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))
|
||||||
|
})),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,13 +7,13 @@ use gpui::{
|
|||||||
MouseButton, ParentElement, Pixels, Render, ScrollHandle, SharedString,
|
MouseButton, ParentElement, Pixels, Render, ScrollHandle, SharedString,
|
||||||
StatefulInteractiveElement, Styled, WeakEntity, Window,
|
StatefulInteractiveElement, Styled, WeakEntity, Window,
|
||||||
};
|
};
|
||||||
use theme::ActiveTheme;
|
use theme::{ActiveTheme, CLIENT_SIDE_DECORATION_ROUNDING, TABBAR_HEIGHT};
|
||||||
|
|
||||||
use super::panel::PanelView;
|
|
||||||
use super::stack_panel::StackPanel;
|
|
||||||
use super::{ClosePanel, DockArea, PanelEvent, PanelStyle, ToggleZoom};
|
|
||||||
use crate::button::{Button, ButtonVariants as _};
|
use crate::button::{Button, ButtonVariants as _};
|
||||||
use crate::dock_area::panel::Panel;
|
use crate::dock_area::dock::DockPlacement;
|
||||||
|
use crate::dock_area::panel::{Panel, PanelView};
|
||||||
|
use crate::dock_area::stack_panel::StackPanel;
|
||||||
|
use crate::dock_area::{ClosePanel, DockArea, PanelEvent, PanelStyle, ToggleZoom};
|
||||||
use crate::menu::{DropdownMenu, PopupMenu};
|
use crate::menu::{DropdownMenu, PopupMenu};
|
||||||
use crate::tab::tab_bar::TabBar;
|
use crate::tab::tab_bar::TabBar;
|
||||||
use crate::tab::Tab;
|
use crate::tab::Tab;
|
||||||
@@ -65,16 +65,29 @@ impl Render for DragPanel {
|
|||||||
pub struct TabPanel {
|
pub struct TabPanel {
|
||||||
focus_handle: FocusHandle,
|
focus_handle: FocusHandle,
|
||||||
dock_area: WeakEntity<DockArea>,
|
dock_area: WeakEntity<DockArea>,
|
||||||
/// The stock_panel can be None, if is None, that means the panels can't be split or move
|
|
||||||
stack_panel: Option<WeakEntity<StackPanel>>,
|
/// List of panels in the tab panel
|
||||||
pub(crate) panels: Vec<Arc<dyn PanelView>>,
|
pub(crate) panels: Vec<Arc<dyn PanelView>>,
|
||||||
|
|
||||||
|
/// Current active panel index
|
||||||
pub(crate) active_ix: usize,
|
pub(crate) active_ix: usize,
|
||||||
|
|
||||||
/// If this is true, the Panel closeable will follow the active panel's closeable,
|
/// If this is true, the Panel closeable will follow the active panel's closeable,
|
||||||
/// otherwise this TabPanel will not able to close
|
/// otherwise this TabPanel will not able to close
|
||||||
pub(crate) closable: bool,
|
pub(crate) closable: bool,
|
||||||
|
|
||||||
|
/// The stock_panel can be None, if is None, that means the panels can't be split or move
|
||||||
|
stack_panel: Option<WeakEntity<StackPanel>>,
|
||||||
|
|
||||||
|
/// Scroll handle for the tab bar
|
||||||
tab_bar_scroll_handle: ScrollHandle,
|
tab_bar_scroll_handle: ScrollHandle,
|
||||||
is_zoomed: bool,
|
|
||||||
is_collapsed: bool,
|
/// Whether the tab panel is zoomeds
|
||||||
|
zoomed: bool,
|
||||||
|
|
||||||
|
/// Whether the tab panel is collapsed
|
||||||
|
collapsed: bool,
|
||||||
|
|
||||||
/// When drag move, will get the placement of the panel to be split
|
/// When drag move, will get the placement of the panel to be split
|
||||||
will_split_placement: Option<Placement>,
|
will_split_placement: Option<Placement>,
|
||||||
}
|
}
|
||||||
@@ -142,8 +155,8 @@ impl TabPanel {
|
|||||||
active_ix: 0,
|
active_ix: 0,
|
||||||
tab_bar_scroll_handle: ScrollHandle::new(),
|
tab_bar_scroll_handle: ScrollHandle::new(),
|
||||||
will_split_placement: None,
|
will_split_placement: None,
|
||||||
is_zoomed: false,
|
zoomed: false,
|
||||||
is_collapsed: false,
|
collapsed: false,
|
||||||
closable: true,
|
closable: true,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -339,7 +352,7 @@ impl TabPanel {
|
|||||||
_window: &mut Window,
|
_window: &mut Window,
|
||||||
cx: &mut Context<Self>,
|
cx: &mut Context<Self>,
|
||||||
) {
|
) {
|
||||||
self.is_collapsed = collapsed;
|
self.collapsed = collapsed;
|
||||||
cx.notify();
|
cx.notify();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -352,7 +365,7 @@ impl TabPanel {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if self.is_zoomed {
|
if self.zoomed {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -408,7 +421,7 @@ impl TabPanel {
|
|||||||
window: &mut Window,
|
window: &mut Window,
|
||||||
cx: &mut Context<Self>,
|
cx: &mut Context<Self>,
|
||||||
) -> impl IntoElement {
|
) -> impl IntoElement {
|
||||||
let is_zoomed = self.is_zoomed && state.zoomable;
|
let is_zoomed = self.zoomed && state.zoomable;
|
||||||
let view = cx.entity().clone();
|
let view = cx.entity().clone();
|
||||||
let build_popup_menu = move |this, cx: &App| view.read(cx).popup_menu(this, cx);
|
let build_popup_menu = move |this, cx: &App| view.read(cx).popup_menu(this, cx);
|
||||||
let toolbar = self.toolbar_buttons(window, cx);
|
let toolbar = self.toolbar_buttons(window, cx);
|
||||||
@@ -420,7 +433,7 @@ impl TabPanel {
|
|||||||
.occlude()
|
.occlude()
|
||||||
.rounded_full()
|
.rounded_full()
|
||||||
.children(toolbar.into_iter().map(|btn| btn.small().ghost().rounded()))
|
.children(toolbar.into_iter().map(|btn| btn.small().ghost().rounded()))
|
||||||
.when(self.is_zoomed, |this| {
|
.when(self.zoomed, |this| {
|
||||||
this.child(
|
this.child(
|
||||||
Button::new("zoom")
|
Button::new("zoom")
|
||||||
.icon(IconName::Zoom)
|
.icon(IconName::Zoom)
|
||||||
@@ -433,8 +446,7 @@ impl TabPanel {
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
.when(has_toolbar, |this| {
|
.when(has_toolbar, |this| {
|
||||||
this.bg(cx.theme().surface_background)
|
this.child(div().flex_shrink_0().h_4().w_px().bg(cx.theme().border))
|
||||||
.child(div().flex_shrink_0().h_4().w_px().bg(cx.theme().border))
|
|
||||||
})
|
})
|
||||||
.child(
|
.child(
|
||||||
Button::new("menu")
|
Button::new("menu")
|
||||||
@@ -461,21 +473,113 @@ impl TabPanel {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn render_dock_toggle_button(
|
||||||
|
&self,
|
||||||
|
placement: DockPlacement,
|
||||||
|
_window: &mut Window,
|
||||||
|
cx: &mut Context<Self>,
|
||||||
|
) -> Option<Button> {
|
||||||
|
let dock_area = self.dock_area.upgrade()?.read(cx);
|
||||||
|
|
||||||
|
if self.zoomed {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
if !dock_area.toggle_button_visible {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
if !dock_area.is_dock_collapsible(placement, cx) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let view_entity_id = cx.entity().entity_id();
|
||||||
|
let toggle_button_panels = dock_area.toggle_button_panels;
|
||||||
|
|
||||||
|
// Check if current TabPanel's entity_id matches the one stored in DockArea for this placement
|
||||||
|
if !match placement {
|
||||||
|
DockPlacement::Left => {
|
||||||
|
dock_area.left_dock.is_some() && toggle_button_panels.left == Some(view_entity_id)
|
||||||
|
}
|
||||||
|
DockPlacement::Right => {
|
||||||
|
dock_area.right_dock.is_some() && toggle_button_panels.right == Some(view_entity_id)
|
||||||
|
}
|
||||||
|
DockPlacement::Bottom => {
|
||||||
|
dock_area.bottom_dock.is_some()
|
||||||
|
&& toggle_button_panels.bottom == Some(view_entity_id)
|
||||||
|
}
|
||||||
|
DockPlacement::Center => unreachable!(),
|
||||||
|
} {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let is_open = dock_area.is_dock_open(placement, cx);
|
||||||
|
|
||||||
|
let icon = match placement {
|
||||||
|
DockPlacement::Left => {
|
||||||
|
if is_open {
|
||||||
|
IconName::PanelLeft
|
||||||
|
} else {
|
||||||
|
IconName::PanelLeftOpen
|
||||||
|
}
|
||||||
|
}
|
||||||
|
DockPlacement::Right => {
|
||||||
|
if is_open {
|
||||||
|
IconName::PanelRight
|
||||||
|
} else {
|
||||||
|
IconName::PanelRightOpen
|
||||||
|
}
|
||||||
|
}
|
||||||
|
DockPlacement::Bottom => {
|
||||||
|
if is_open {
|
||||||
|
IconName::PanelBottom
|
||||||
|
} else {
|
||||||
|
IconName::PanelBottomOpen
|
||||||
|
}
|
||||||
|
}
|
||||||
|
DockPlacement::Center => unreachable!(),
|
||||||
|
};
|
||||||
|
|
||||||
|
Some(
|
||||||
|
Button::new(SharedString::from(format!("toggle-dock:{:?}", placement)))
|
||||||
|
.icon(icon)
|
||||||
|
.small()
|
||||||
|
.ghost()
|
||||||
|
.tab_stop(false)
|
||||||
|
.tooltip(match is_open {
|
||||||
|
true => "Collapse",
|
||||||
|
false => "Expand",
|
||||||
|
})
|
||||||
|
.on_click(cx.listener({
|
||||||
|
let dock_area = self.dock_area.clone();
|
||||||
|
move |_this, _ev, window, cx| {
|
||||||
|
_ = dock_area.update(cx, |dock_area, cx| {
|
||||||
|
dock_area.toggle_dock(placement, window, cx);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
fn render_title_bar(
|
fn render_title_bar(
|
||||||
&self,
|
&self,
|
||||||
state: &TabState,
|
state: &TabState,
|
||||||
window: &mut Window,
|
window: &mut Window,
|
||||||
cx: &mut Context<Self>,
|
cx: &mut Context<Self>,
|
||||||
) -> impl IntoElement {
|
) -> impl IntoElement {
|
||||||
let view = cx.entity().clone();
|
// Get the dock area entity
|
||||||
|
|
||||||
let Some(dock_area) = self.dock_area.upgrade() else {
|
let Some(dock_area) = self.dock_area.upgrade() else {
|
||||||
|
// Return a default element if the dock area is not available
|
||||||
return div().into_any_element();
|
return div().into_any_element();
|
||||||
};
|
};
|
||||||
|
|
||||||
let panel_style = dock_area.read(cx).panel_style;
|
let left_dock_button = self.render_dock_toggle_button(DockPlacement::Left, window, cx);
|
||||||
|
let bottom_dock_button = self.render_dock_toggle_button(DockPlacement::Bottom, window, cx);
|
||||||
|
let right_dock_button = self.render_dock_toggle_button(DockPlacement::Right, window, cx);
|
||||||
|
let has_extend_dock_button = left_dock_button.is_some() || bottom_dock_button.is_some();
|
||||||
|
let tabs_count = self.panels.len();
|
||||||
|
|
||||||
if self.panels.len() == 1 && panel_style == PanelStyle::Default {
|
if tabs_count == 1 && dock_area.read(cx).panel_style == PanelStyle::Default {
|
||||||
let panel = self.panels.first().unwrap();
|
let panel = self.panels.first().unwrap();
|
||||||
|
|
||||||
if !panel.visible(cx) {
|
if !panel.visible(cx) {
|
||||||
@@ -486,13 +590,28 @@ impl TabPanel {
|
|||||||
.justify_between()
|
.justify_between()
|
||||||
.items_center()
|
.items_center()
|
||||||
.line_height(rems(1.0))
|
.line_height(rems(1.0))
|
||||||
.h(px(30.))
|
.h(TABBAR_HEIGHT)
|
||||||
.py_2()
|
.py_2()
|
||||||
.px_3()
|
.pl_3()
|
||||||
|
.pr_2()
|
||||||
|
.bg(cx.theme().panel_background)
|
||||||
|
.when(left_dock_button.is_some(), |this| this.pl_2())
|
||||||
|
.when(right_dock_button.is_some(), |this| this.pr_2())
|
||||||
|
.when(has_extend_dock_button, |this| {
|
||||||
|
this.child(
|
||||||
|
h_flex()
|
||||||
|
.flex_shrink_0()
|
||||||
|
.mr_1()
|
||||||
|
.gap_1()
|
||||||
|
.children(left_dock_button)
|
||||||
|
.children(bottom_dock_button),
|
||||||
|
)
|
||||||
|
})
|
||||||
.child(
|
.child(
|
||||||
div()
|
div()
|
||||||
.id("tab")
|
.id("tab")
|
||||||
.flex_1()
|
.flex_1()
|
||||||
|
.px_2()
|
||||||
.min_w_16()
|
.min_w_16()
|
||||||
.overflow_hidden()
|
.overflow_hidden()
|
||||||
.whitespace_nowrap()
|
.whitespace_nowrap()
|
||||||
@@ -507,7 +626,7 @@ impl TabPanel {
|
|||||||
this.on_drag(
|
this.on_drag(
|
||||||
DragPanel {
|
DragPanel {
|
||||||
panel: panel.clone(),
|
panel: panel.clone(),
|
||||||
tab_panel: view,
|
tab_panel: cx.entity(),
|
||||||
},
|
},
|
||||||
|drag, _, _, cx| {
|
|drag, _, _, cx| {
|
||||||
cx.stop_propagation();
|
cx.stop_propagation();
|
||||||
@@ -521,30 +640,49 @@ impl TabPanel {
|
|||||||
.flex_shrink_0()
|
.flex_shrink_0()
|
||||||
.ml_1()
|
.ml_1()
|
||||||
.gap_1()
|
.gap_1()
|
||||||
.child(self.render_toolbar(state, window, cx)),
|
.child(self.render_toolbar(state, window, cx))
|
||||||
|
.children(right_dock_button),
|
||||||
)
|
)
|
||||||
.into_any_element();
|
.into_any_element();
|
||||||
}
|
}
|
||||||
|
|
||||||
let tabs_count = self.panels.len();
|
TabBar::new()
|
||||||
|
.track_scroll(&self.tab_bar_scroll_handle)
|
||||||
TabBar::new("tab-bar")
|
.h(TABBAR_HEIGHT)
|
||||||
.track_scroll(self.tab_bar_scroll_handle.clone())
|
.when(has_extend_dock_button, |this| {
|
||||||
|
this.prefix(
|
||||||
|
h_flex()
|
||||||
|
.items_center()
|
||||||
|
.top_0()
|
||||||
|
.right(-px(1.))
|
||||||
|
.border_r_1()
|
||||||
|
.border_b_1()
|
||||||
|
.h_full()
|
||||||
|
.border_color(cx.theme().border)
|
||||||
|
.bg(cx.theme().surface_background)
|
||||||
|
.px_2()
|
||||||
|
.children(left_dock_button)
|
||||||
|
.children(bottom_dock_button),
|
||||||
|
)
|
||||||
|
})
|
||||||
.children(self.panels.iter().enumerate().filter_map(|(ix, panel)| {
|
.children(self.panels.iter().enumerate().filter_map(|(ix, panel)| {
|
||||||
|
let disabled = self.collapsed;
|
||||||
let mut active = state.active_panel.as_ref() == Some(panel);
|
let mut active = state.active_panel.as_ref() == Some(panel);
|
||||||
let disabled = self.is_collapsed;
|
|
||||||
|
|
||||||
|
// If the panel is not visible, hide the tabbar
|
||||||
if !panel.visible(cx) {
|
if !panel.visible(cx) {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Always not show active tab style, if the panel is collapsed
|
// Always not show active tab style, if the panel is collapsed
|
||||||
if self.is_collapsed {
|
if self.collapsed {
|
||||||
active = false;
|
active = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
Some(
|
Some(
|
||||||
Tab::new(("tab", ix), panel.title(cx))
|
Tab::new()
|
||||||
|
.ix(ix)
|
||||||
|
.label(panel.title(cx))
|
||||||
.py_2()
|
.py_2()
|
||||||
.selected(active)
|
.selected(active)
|
||||||
.disabled(disabled)
|
.disabled(disabled)
|
||||||
@@ -563,7 +701,7 @@ impl TabPanel {
|
|||||||
}))
|
}))
|
||||||
.when(state.draggable, |this| {
|
.when(state.draggable, |this| {
|
||||||
this.on_drag(
|
this.on_drag(
|
||||||
DragPanel::new(panel.clone(), view.clone()),
|
DragPanel::new(panel.clone(), cx.entity().clone()),
|
||||||
|drag, _, _, cx| {
|
|drag, _, _, cx| {
|
||||||
cx.stop_propagation();
|
cx.stop_propagation();
|
||||||
cx.new(|_| drag.clone())
|
cx.new(|_| drag.clone())
|
||||||
@@ -587,16 +725,17 @@ impl TabPanel {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
}))
|
}))
|
||||||
.child(
|
.last_empty_space(
|
||||||
// empty space to allow move to last tab right
|
// empty space to allow move to last tab right
|
||||||
div()
|
div()
|
||||||
.id("tab-bar-empty-space")
|
.id("tab-bar-empty-space")
|
||||||
.h_full()
|
.h_full()
|
||||||
.flex_grow()
|
.flex_grow()
|
||||||
.min_w_16()
|
.min_w_16()
|
||||||
.rounded(cx.theme().radius)
|
|
||||||
.when(state.droppable, |this| {
|
.when(state.droppable, |this| {
|
||||||
this.drag_over::<DragPanel>(|this, _, _, cx| {
|
let view = cx.entity();
|
||||||
|
|
||||||
|
this.drag_over::<DragPanel>(|this, _d, _window, cx| {
|
||||||
this.bg(cx.theme().surface_background)
|
this.bg(cx.theme().surface_background)
|
||||||
})
|
})
|
||||||
.on_drop(cx.listener(
|
.on_drop(cx.listener(
|
||||||
@@ -614,16 +753,22 @@ impl TabPanel {
|
|||||||
))
|
))
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.suffix(
|
.when(!self.collapsed, |this| {
|
||||||
|
this.suffix(
|
||||||
h_flex()
|
h_flex()
|
||||||
.items_center()
|
.items_center()
|
||||||
|
.px_2()
|
||||||
|
.gap_1()
|
||||||
.top_0()
|
.top_0()
|
||||||
.right_0()
|
.right_0()
|
||||||
.h_full()
|
.h_full()
|
||||||
.px_2()
|
.border_color(cx.theme().border)
|
||||||
.gap_1()
|
.border_l_1()
|
||||||
.child(self.render_toolbar(state, window, cx)),
|
.border_b_1()
|
||||||
|
.child(self.render_toolbar(state, window, cx))
|
||||||
|
.when_some(right_dock_button, |this, btn| this.child(btn)),
|
||||||
)
|
)
|
||||||
|
})
|
||||||
.into_any_element()
|
.into_any_element()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -633,7 +778,7 @@ impl TabPanel {
|
|||||||
_window: &mut Window,
|
_window: &mut Window,
|
||||||
cx: &mut Context<Self>,
|
cx: &mut Context<Self>,
|
||||||
) -> impl IntoElement {
|
) -> impl IntoElement {
|
||||||
if self.is_collapsed {
|
if self.collapsed {
|
||||||
return Empty {}.into_any_element();
|
return Empty {}.into_any_element();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -646,14 +791,13 @@ impl TabPanel {
|
|||||||
.group("")
|
.group("")
|
||||||
.overflow_hidden()
|
.overflow_hidden()
|
||||||
.flex_1()
|
.flex_1()
|
||||||
.p_1()
|
|
||||||
.child(
|
.child(
|
||||||
div()
|
div()
|
||||||
.size_full()
|
.size_full()
|
||||||
.rounded(cx.theme().radius_lg)
|
|
||||||
.when(cx.theme().shadow, |this| this.shadow_sm())
|
|
||||||
.when(cx.theme().mode.is_dark(), |this| this.shadow_lg())
|
|
||||||
.bg(cx.theme().panel_background)
|
.bg(cx.theme().panel_background)
|
||||||
|
.when(cx.theme().platform.is_linux(), |this| {
|
||||||
|
this.rounded_b(CLIENT_SIDE_DECORATION_ROUNDING)
|
||||||
|
})
|
||||||
.overflow_hidden()
|
.overflow_hidden()
|
||||||
.child(
|
.child(
|
||||||
active_panel
|
active_panel
|
||||||
@@ -667,7 +811,6 @@ impl TabPanel {
|
|||||||
div()
|
div()
|
||||||
.invisible()
|
.invisible()
|
||||||
.absolute()
|
.absolute()
|
||||||
.p_1()
|
|
||||||
.child(
|
.child(
|
||||||
div()
|
div()
|
||||||
.rounded(cx.theme().radius_lg)
|
.rounded(cx.theme().radius_lg)
|
||||||
@@ -911,16 +1054,16 @@ impl TabPanel {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if !self.is_zoomed {
|
if !self.zoomed {
|
||||||
cx.emit(PanelEvent::ZoomIn)
|
cx.emit(PanelEvent::ZoomIn)
|
||||||
} else {
|
} else {
|
||||||
cx.emit(PanelEvent::ZoomOut)
|
cx.emit(PanelEvent::ZoomOut)
|
||||||
}
|
}
|
||||||
|
|
||||||
self.is_zoomed = !self.is_zoomed;
|
self.zoomed = !self.zoomed;
|
||||||
|
|
||||||
cx.spawn({
|
cx.spawn({
|
||||||
let is_zoomed = self.is_zoomed;
|
let is_zoomed = self.zoomed;
|
||||||
async move |view, cx| {
|
async move |view, cx| {
|
||||||
view.update(cx, |view, cx| {
|
view.update(cx, |view, cx| {
|
||||||
view.set_zoomed(is_zoomed, cx);
|
view.set_zoomed(is_zoomed, cx);
|
||||||
@@ -933,15 +1076,17 @@ impl TabPanel {
|
|||||||
|
|
||||||
fn on_action_close_panel(
|
fn on_action_close_panel(
|
||||||
&mut self,
|
&mut self,
|
||||||
_: &ClosePanel,
|
_ev: &ClosePanel,
|
||||||
window: &mut Window,
|
window: &mut Window,
|
||||||
cx: &mut Context<Self>,
|
cx: &mut Context<Self>,
|
||||||
) {
|
) {
|
||||||
|
if self.panels.len() > 1 {
|
||||||
if let Some(panel) = self.active_panel(cx) {
|
if let Some(panel) = self.active_panel(cx) {
|
||||||
self.remove_panel(&panel, window, cx);
|
self.remove_panel(&panel, window, cx);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl Focusable for TabPanel {
|
impl Focusable for TabPanel {
|
||||||
fn focus_handle(&self, cx: &App) -> gpui::FocusHandle {
|
fn focus_handle(&self, cx: &App) -> gpui::FocusHandle {
|
||||||
@@ -954,7 +1099,6 @@ impl Focusable for TabPanel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl EventEmitter<DismissEvent> for TabPanel {}
|
impl EventEmitter<DismissEvent> for TabPanel {}
|
||||||
|
|
||||||
impl EventEmitter<PanelEvent> for TabPanel {}
|
impl EventEmitter<PanelEvent> for TabPanel {}
|
||||||
|
|
||||||
impl Render for TabPanel {
|
impl Render for TabPanel {
|
||||||
@@ -975,11 +1119,12 @@ impl Render for TabPanel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
v_flex()
|
v_flex()
|
||||||
.when(!self.is_collapsed, |this| {
|
.when(!self.collapsed, |this| {
|
||||||
this.on_action(cx.listener(Self::on_action_toggle_zoom))
|
this.on_action(cx.listener(Self::on_action_toggle_zoom))
|
||||||
.on_action(cx.listener(Self::on_action_close_panel))
|
.on_action(cx.listener(Self::on_action_close_panel))
|
||||||
})
|
})
|
||||||
.id("tab-panel")
|
.id("tab-panel")
|
||||||
|
.tab_group()
|
||||||
.track_focus(&focus_handle)
|
.track_focus(&focus_handle)
|
||||||
.size_full()
|
.size_full()
|
||||||
.overflow_hidden()
|
.overflow_hidden()
|
||||||
|
|||||||
@@ -1,811 +0,0 @@
|
|||||||
use gpui::prelude::FluentBuilder;
|
|
||||||
use gpui::{
|
|
||||||
anchored, canvas, deferred, div, px, rems, AnyElement, App, AppContext, Bounds, ClickEvent,
|
|
||||||
Context, DismissEvent, ElementId, Empty, Entity, EventEmitter, FocusHandle, Focusable,
|
|
||||||
InteractiveElement, IntoElement, KeyBinding, Length, ParentElement, Pixels, Render, RenderOnce,
|
|
||||||
SharedString, StatefulInteractiveElement, Styled, Subscription, Task, WeakEntity, Window,
|
|
||||||
};
|
|
||||||
use theme::ActiveTheme;
|
|
||||||
|
|
||||||
use crate::actions::{Cancel, Confirm, SelectDown, SelectUp};
|
|
||||||
use crate::input::clear_button::clear_button;
|
|
||||||
use crate::list::{List, ListDelegate, ListItem};
|
|
||||||
use crate::{h_flex, v_flex, Disableable as _, Icon, IconName, Sizable, Size, StyleSized};
|
|
||||||
|
|
||||||
const CONTEXT: &str = "Dropdown";
|
|
||||||
|
|
||||||
#[derive(Clone)]
|
|
||||||
pub enum ListEvent {
|
|
||||||
/// Single click or move to selected row.
|
|
||||||
SelectItem(usize),
|
|
||||||
/// Double click on the row.
|
|
||||||
ConfirmItem(usize),
|
|
||||||
// Cancel the selection.
|
|
||||||
Cancel,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn init(cx: &mut App) {
|
|
||||||
cx.bind_keys([
|
|
||||||
KeyBinding::new("up", SelectUp, Some(CONTEXT)),
|
|
||||||
KeyBinding::new("down", SelectDown, Some(CONTEXT)),
|
|
||||||
KeyBinding::new("enter", Confirm { secondary: false }, Some(CONTEXT)),
|
|
||||||
KeyBinding::new(
|
|
||||||
"secondary-enter",
|
|
||||||
Confirm { secondary: true },
|
|
||||||
Some(CONTEXT),
|
|
||||||
),
|
|
||||||
KeyBinding::new("escape", Cancel, Some(CONTEXT)),
|
|
||||||
])
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A trait for items that can be displayed in a dropdown.
|
|
||||||
pub trait DropdownItem {
|
|
||||||
type Value: Clone;
|
|
||||||
fn title(&self) -> SharedString;
|
|
||||||
/// Customize the display title used to selected item in Dropdown Input.
|
|
||||||
///
|
|
||||||
/// If return None, the title will be used.
|
|
||||||
fn display_title(&self) -> Option<AnyElement> {
|
|
||||||
None
|
|
||||||
}
|
|
||||||
fn value(&self) -> &Self::Value;
|
|
||||||
}
|
|
||||||
|
|
||||||
impl DropdownItem for String {
|
|
||||||
type Value = Self;
|
|
||||||
|
|
||||||
fn title(&self) -> SharedString {
|
|
||||||
SharedString::from(self.to_string())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn value(&self) -> &Self::Value {
|
|
||||||
self
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl DropdownItem for SharedString {
|
|
||||||
type Value = Self;
|
|
||||||
|
|
||||||
fn title(&self) -> SharedString {
|
|
||||||
SharedString::from(self.to_string())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn value(&self) -> &Self::Value {
|
|
||||||
self
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub trait DropdownDelegate: Sized {
|
|
||||||
type Item: DropdownItem;
|
|
||||||
|
|
||||||
fn len(&self) -> usize;
|
|
||||||
|
|
||||||
fn is_empty(&self) -> bool {
|
|
||||||
self.len() == 0
|
|
||||||
}
|
|
||||||
|
|
||||||
fn get(&self, ix: usize) -> Option<&Self::Item>;
|
|
||||||
|
|
||||||
fn position<V>(&self, value: &V) -> Option<usize>
|
|
||||||
where
|
|
||||||
Self::Item: DropdownItem<Value = V>,
|
|
||||||
V: PartialEq,
|
|
||||||
{
|
|
||||||
(0..self.len()).find(|&i| self.get(i).is_some_and(|item| item.value() == value))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn can_search(&self) -> bool {
|
|
||||||
false
|
|
||||||
}
|
|
||||||
|
|
||||||
fn perform_search(&mut self, _query: &str, _window: &mut Window, _: &mut App) -> Task<()> {
|
|
||||||
Task::ready(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<T: DropdownItem> DropdownDelegate for Vec<T> {
|
|
||||||
type Item = T;
|
|
||||||
|
|
||||||
fn len(&self) -> usize {
|
|
||||||
self.len()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn get(&self, ix: usize) -> Option<&Self::Item> {
|
|
||||||
self.as_slice().get(ix)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn position<V>(&self, value: &V) -> Option<usize>
|
|
||||||
where
|
|
||||||
Self::Item: DropdownItem<Value = V>,
|
|
||||||
V: PartialEq,
|
|
||||||
{
|
|
||||||
self.iter().position(|v| v.value() == value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct DropdownListDelegate<D: DropdownDelegate + 'static> {
|
|
||||||
delegate: D,
|
|
||||||
dropdown: WeakEntity<DropdownState<D>>,
|
|
||||||
selected_index: Option<usize>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<D> ListDelegate for DropdownListDelegate<D>
|
|
||||||
where
|
|
||||||
D: DropdownDelegate + 'static,
|
|
||||||
{
|
|
||||||
type Item = ListItem;
|
|
||||||
|
|
||||||
fn items_count(&self, _: &App) -> usize {
|
|
||||||
self.delegate.len()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn render_item(
|
|
||||||
&self,
|
|
||||||
ix: usize,
|
|
||||||
_: &mut gpui::Window,
|
|
||||||
cx: &mut gpui::Context<List<Self>>,
|
|
||||||
) -> Option<Self::Item> {
|
|
||||||
let selected = self.selected_index == Some(ix);
|
|
||||||
let size = self
|
|
||||||
.dropdown
|
|
||||||
.upgrade()
|
|
||||||
.map_or(Size::Medium, |dropdown| dropdown.read(cx).size);
|
|
||||||
|
|
||||||
if let Some(item) = self.delegate.get(ix) {
|
|
||||||
let list_item = ListItem::new(("list-item", ix))
|
|
||||||
.check_icon(IconName::Check)
|
|
||||||
.selected(selected)
|
|
||||||
.input_font_size(size)
|
|
||||||
.list_size(size)
|
|
||||||
.child(div().whitespace_nowrap().child(item.title().to_string()));
|
|
||||||
Some(list_item)
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn cancel(&mut self, window: &mut Window, cx: &mut Context<List<Self>>) {
|
|
||||||
let dropdown = self.dropdown.clone();
|
|
||||||
cx.defer_in(window, move |_, window, cx| {
|
|
||||||
_ = dropdown.update(cx, |this, cx| {
|
|
||||||
this.open = false;
|
|
||||||
this.focus(window, cx);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
fn confirm(&mut self, _secondary: bool, window: &mut Window, cx: &mut Context<List<Self>>) {
|
|
||||||
let selected_value = self
|
|
||||||
.selected_index
|
|
||||||
.and_then(|ix| self.delegate.get(ix))
|
|
||||||
.map(|item| item.value().clone());
|
|
||||||
let dropdown = self.dropdown.clone();
|
|
||||||
|
|
||||||
cx.defer_in(window, move |_, window, cx| {
|
|
||||||
_ = dropdown.update(cx, |this, cx| {
|
|
||||||
cx.emit(DropdownEvent::Confirm(selected_value.clone()));
|
|
||||||
this.selected_value = selected_value;
|
|
||||||
this.open = false;
|
|
||||||
this.focus(window, cx);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
fn perform_search(
|
|
||||||
&mut self,
|
|
||||||
query: &str,
|
|
||||||
window: &mut Window,
|
|
||||||
cx: &mut Context<List<Self>>,
|
|
||||||
) -> Task<()> {
|
|
||||||
self.dropdown.upgrade().map_or(Task::ready(()), |dropdown| {
|
|
||||||
dropdown.update(cx, |_, cx| self.delegate.perform_search(query, window, cx))
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn set_selected_index(
|
|
||||||
&mut self,
|
|
||||||
ix: Option<usize>,
|
|
||||||
_: &mut Window,
|
|
||||||
_: &mut Context<List<Self>>,
|
|
||||||
) {
|
|
||||||
self.selected_index = ix;
|
|
||||||
}
|
|
||||||
|
|
||||||
fn render_empty(&self, window: &mut Window, cx: &mut Context<List<Self>>) -> impl IntoElement {
|
|
||||||
if let Some(empty) = self
|
|
||||||
.dropdown
|
|
||||||
.upgrade()
|
|
||||||
.and_then(|dropdown| dropdown.read(cx).empty.as_ref())
|
|
||||||
{
|
|
||||||
empty(window, cx).into_any_element()
|
|
||||||
} else {
|
|
||||||
h_flex()
|
|
||||||
.justify_center()
|
|
||||||
.py_6()
|
|
||||||
.text_color(cx.theme().text_muted)
|
|
||||||
.child(Icon::new(IconName::Loader).size(px(28.)))
|
|
||||||
.into_any_element()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub enum DropdownEvent<D: DropdownDelegate + 'static> {
|
|
||||||
Confirm(Option<<D::Item as DropdownItem>::Value>),
|
|
||||||
}
|
|
||||||
|
|
||||||
type DropdownStateEmpty = Option<Box<dyn Fn(&Window, &App) -> AnyElement>>;
|
|
||||||
|
|
||||||
/// State of the [`Dropdown`].
|
|
||||||
pub struct DropdownState<D: DropdownDelegate + 'static> {
|
|
||||||
focus_handle: FocusHandle,
|
|
||||||
list: Entity<List<DropdownListDelegate<D>>>,
|
|
||||||
size: Size,
|
|
||||||
empty: DropdownStateEmpty,
|
|
||||||
/// Store the bounds of the input
|
|
||||||
bounds: Bounds<Pixels>,
|
|
||||||
open: bool,
|
|
||||||
selected_value: Option<<D::Item as DropdownItem>::Value>,
|
|
||||||
_subscriptions: Vec<Subscription>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A Dropdown element.
|
|
||||||
#[derive(IntoElement)]
|
|
||||||
pub struct Dropdown<D: DropdownDelegate + 'static> {
|
|
||||||
id: ElementId,
|
|
||||||
state: Entity<DropdownState<D>>,
|
|
||||||
size: Size,
|
|
||||||
icon: Option<Icon>,
|
|
||||||
cleanable: bool,
|
|
||||||
placeholder: Option<SharedString>,
|
|
||||||
title_prefix: Option<SharedString>,
|
|
||||||
empty: Option<AnyElement>,
|
|
||||||
width: Length,
|
|
||||||
menu_width: Length,
|
|
||||||
disabled: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct SearchableVec<T> {
|
|
||||||
items: Vec<T>,
|
|
||||||
matched_items: Vec<T>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<T: DropdownItem + Clone> SearchableVec<T> {
|
|
||||||
pub fn new(items: impl Into<Vec<T>>) -> Self {
|
|
||||||
let items = items.into();
|
|
||||||
Self {
|
|
||||||
items: items.clone(),
|
|
||||||
matched_items: items,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<T: DropdownItem + Clone> DropdownDelegate for SearchableVec<T> {
|
|
||||||
type Item = T;
|
|
||||||
|
|
||||||
fn len(&self) -> usize {
|
|
||||||
self.matched_items.len()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn get(&self, ix: usize) -> Option<&Self::Item> {
|
|
||||||
self.matched_items.get(ix)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn position<V>(&self, value: &V) -> Option<usize>
|
|
||||||
where
|
|
||||||
Self::Item: DropdownItem<Value = V>,
|
|
||||||
V: PartialEq,
|
|
||||||
{
|
|
||||||
for (ix, item) in self.matched_items.iter().enumerate() {
|
|
||||||
if item.value() == value {
|
|
||||||
return Some(ix);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
None
|
|
||||||
}
|
|
||||||
|
|
||||||
fn can_search(&self) -> bool {
|
|
||||||
true
|
|
||||||
}
|
|
||||||
|
|
||||||
fn perform_search(&mut self, query: &str, _window: &mut Window, _: &mut App) -> Task<()> {
|
|
||||||
self.matched_items = self
|
|
||||||
.items
|
|
||||||
.iter()
|
|
||||||
.filter(|item| item.title().to_lowercase().contains(&query.to_lowercase()))
|
|
||||||
.cloned()
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
Task::ready(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<Vec<SharedString>> for SearchableVec<SharedString> {
|
|
||||||
fn from(items: Vec<SharedString>) -> Self {
|
|
||||||
Self {
|
|
||||||
items: items.clone(),
|
|
||||||
matched_items: items,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<D> DropdownState<D>
|
|
||||||
where
|
|
||||||
D: DropdownDelegate + 'static,
|
|
||||||
{
|
|
||||||
pub fn new(
|
|
||||||
delegate: D,
|
|
||||||
selected_index: Option<usize>,
|
|
||||||
window: &mut Window,
|
|
||||||
cx: &mut Context<Self>,
|
|
||||||
) -> Self {
|
|
||||||
let focus_handle = cx.focus_handle();
|
|
||||||
let delegate = DropdownListDelegate {
|
|
||||||
delegate,
|
|
||||||
dropdown: cx.entity().downgrade(),
|
|
||||||
selected_index,
|
|
||||||
};
|
|
||||||
|
|
||||||
let searchable = delegate.delegate.can_search();
|
|
||||||
|
|
||||||
let list = cx.new(|cx| {
|
|
||||||
let mut list = List::new(delegate, window, cx)
|
|
||||||
.max_h(rems(20.))
|
|
||||||
.reset_on_cancel(false);
|
|
||||||
if !searchable {
|
|
||||||
list = list.no_query();
|
|
||||||
}
|
|
||||||
list
|
|
||||||
});
|
|
||||||
|
|
||||||
let _subscriptions = vec![
|
|
||||||
cx.on_blur(&list.focus_handle(cx), window, Self::on_blur),
|
|
||||||
cx.on_blur(&focus_handle, window, Self::on_blur),
|
|
||||||
];
|
|
||||||
|
|
||||||
let mut this = Self {
|
|
||||||
focus_handle,
|
|
||||||
list,
|
|
||||||
size: Size::Medium,
|
|
||||||
selected_value: None,
|
|
||||||
open: false,
|
|
||||||
bounds: Bounds::default(),
|
|
||||||
empty: None,
|
|
||||||
_subscriptions,
|
|
||||||
};
|
|
||||||
this.set_selected_index(selected_index, window, cx);
|
|
||||||
this
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn empty<E, F>(mut self, f: F) -> Self
|
|
||||||
where
|
|
||||||
E: IntoElement,
|
|
||||||
F: Fn(&Window, &App) -> E + 'static,
|
|
||||||
{
|
|
||||||
self.empty = Some(Box::new(move |window, cx| f(window, cx).into_any_element()));
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn set_selected_index(
|
|
||||||
&mut self,
|
|
||||||
selected_index: Option<usize>,
|
|
||||||
window: &mut Window,
|
|
||||||
cx: &mut Context<Self>,
|
|
||||||
) {
|
|
||||||
self.list.update(cx, |list, cx| {
|
|
||||||
list.set_selected_index(selected_index, window, cx);
|
|
||||||
});
|
|
||||||
self.update_selected_value(window, cx);
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn set_selected_value(
|
|
||||||
&mut self,
|
|
||||||
selected_value: &<D::Item as DropdownItem>::Value,
|
|
||||||
window: &mut Window,
|
|
||||||
cx: &mut Context<Self>,
|
|
||||||
) where
|
|
||||||
<<D as DropdownDelegate>::Item as DropdownItem>::Value: PartialEq,
|
|
||||||
{
|
|
||||||
let delegate = self.list.read(cx).delegate();
|
|
||||||
let selected_index = delegate.delegate.position(selected_value);
|
|
||||||
self.set_selected_index(selected_index, window, cx);
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn selected_index(&self, cx: &App) -> Option<usize> {
|
|
||||||
self.list.read(cx).selected_index()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn update_selected_value(&mut self, _: &Window, cx: &App) {
|
|
||||||
self.selected_value = self
|
|
||||||
.selected_index(cx)
|
|
||||||
.and_then(|ix| self.list.read(cx).delegate().delegate.get(ix))
|
|
||||||
.map(|item| item.value().clone());
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn selected_value(&self) -> Option<&<D::Item as DropdownItem>::Value> {
|
|
||||||
self.selected_value.as_ref()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn focus(&self, window: &mut Window, cx: &mut App) {
|
|
||||||
self.focus_handle.focus(window, cx);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn on_blur(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
|
||||||
// When the dropdown and dropdown menu are both not focused, close the dropdown menu.
|
|
||||||
if self.list.focus_handle(cx).is_focused(window) || self.focus_handle.is_focused(window) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
self.open = false;
|
|
||||||
cx.notify();
|
|
||||||
}
|
|
||||||
|
|
||||||
fn up(&mut self, _: &SelectUp, window: &mut Window, cx: &mut Context<Self>) {
|
|
||||||
if !self.open {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
self.list.focus_handle(cx).focus(window, cx);
|
|
||||||
cx.propagate();
|
|
||||||
}
|
|
||||||
|
|
||||||
fn down(&mut self, _: &SelectDown, window: &mut Window, cx: &mut Context<Self>) {
|
|
||||||
if !self.open {
|
|
||||||
self.open = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
self.list.focus_handle(cx).focus(window, cx);
|
|
||||||
cx.propagate();
|
|
||||||
}
|
|
||||||
|
|
||||||
fn enter(&mut self, _: &Confirm, window: &mut Window, cx: &mut Context<Self>) {
|
|
||||||
// Propagate the event to the parent view, for example to the Modal to support ENTER to confirm.
|
|
||||||
cx.propagate();
|
|
||||||
|
|
||||||
if !self.open {
|
|
||||||
self.open = true;
|
|
||||||
cx.notify();
|
|
||||||
} else {
|
|
||||||
self.list.focus_handle(cx).focus(window, cx);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn toggle_menu(&mut self, _: &ClickEvent, window: &mut Window, cx: &mut Context<Self>) {
|
|
||||||
cx.stop_propagation();
|
|
||||||
|
|
||||||
self.open = !self.open;
|
|
||||||
if self.open {
|
|
||||||
self.list.focus_handle(cx).focus(window, cx);
|
|
||||||
}
|
|
||||||
cx.notify();
|
|
||||||
}
|
|
||||||
|
|
||||||
fn escape(&mut self, _: &Cancel, _: &mut Window, cx: &mut Context<Self>) {
|
|
||||||
if !self.open {
|
|
||||||
cx.propagate();
|
|
||||||
}
|
|
||||||
|
|
||||||
self.open = false;
|
|
||||||
cx.notify();
|
|
||||||
}
|
|
||||||
|
|
||||||
fn clean(&mut self, _: &ClickEvent, window: &mut Window, cx: &mut Context<Self>) {
|
|
||||||
self.set_selected_index(None, window, cx);
|
|
||||||
cx.emit(DropdownEvent::Confirm(None));
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Set the items for the dropdown.
|
|
||||||
pub fn set_items(&mut self, items: D, _: &mut Window, cx: &mut Context<Self>)
|
|
||||||
where
|
|
||||||
D: DropdownDelegate + 'static,
|
|
||||||
{
|
|
||||||
self.list.update(cx, |list, _| {
|
|
||||||
list.delegate_mut().delegate = items;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<D> Render for DropdownState<D>
|
|
||||||
where
|
|
||||||
D: DropdownDelegate + 'static,
|
|
||||||
{
|
|
||||||
fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
|
|
||||||
Empty
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<D> Dropdown<D>
|
|
||||||
where
|
|
||||||
D: DropdownDelegate + 'static,
|
|
||||||
{
|
|
||||||
pub fn new(state: &Entity<DropdownState<D>>) -> Self {
|
|
||||||
Self {
|
|
||||||
id: ("dropdown", state.entity_id()).into(),
|
|
||||||
state: state.clone(),
|
|
||||||
placeholder: None,
|
|
||||||
size: Size::Medium,
|
|
||||||
icon: None,
|
|
||||||
cleanable: false,
|
|
||||||
title_prefix: None,
|
|
||||||
empty: None,
|
|
||||||
width: Length::Auto,
|
|
||||||
menu_width: Length::Auto,
|
|
||||||
disabled: false,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Set the width of the dropdown input, default: Length::Auto
|
|
||||||
pub fn width(mut self, width: impl Into<Length>) -> Self {
|
|
||||||
self.width = width.into();
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Set the width of the dropdown menu, default: Length::Auto
|
|
||||||
pub fn menu_width(mut self, width: impl Into<Length>) -> Self {
|
|
||||||
self.menu_width = width.into();
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Set the placeholder for display when dropdown value is empty.
|
|
||||||
pub fn placeholder(mut self, placeholder: impl Into<SharedString>) -> Self {
|
|
||||||
self.placeholder = Some(placeholder.into());
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Set the right icon for the dropdown input, instead of the default arrow icon.
|
|
||||||
pub fn icon(mut self, icon: impl Into<Icon>) -> Self {
|
|
||||||
self.icon = Some(icon.into());
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Set title prefix for the dropdown.
|
|
||||||
///
|
|
||||||
/// e.g.: Country: United States
|
|
||||||
///
|
|
||||||
/// You should set the label is `Country: `
|
|
||||||
pub fn title_prefix(mut self, prefix: impl Into<SharedString>) -> Self {
|
|
||||||
self.title_prefix = Some(prefix.into());
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Set true to show the clear button when the input field is not empty.
|
|
||||||
pub fn cleanable(mut self) -> Self {
|
|
||||||
self.cleanable = true;
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Set the disable state for the dropdown.
|
|
||||||
pub fn disabled(mut self, disabled: bool) -> Self {
|
|
||||||
self.disabled = disabled;
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn empty(mut self, el: impl IntoElement) -> Self {
|
|
||||||
self.empty = Some(el.into_any_element());
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Returns the title element for the dropdown input.
|
|
||||||
fn display_title(&self, _: &Window, cx: &App) -> impl IntoElement {
|
|
||||||
let default_title = div()
|
|
||||||
.text_color(cx.theme().text_accent)
|
|
||||||
.child(
|
|
||||||
self.placeholder
|
|
||||||
.clone()
|
|
||||||
.unwrap_or_else(|| "Please select".into()),
|
|
||||||
)
|
|
||||||
.when(self.disabled, |this| this.text_color(cx.theme().text_muted));
|
|
||||||
|
|
||||||
let Some(selected_index) = &self.state.read(cx).selected_index(cx) else {
|
|
||||||
return default_title;
|
|
||||||
};
|
|
||||||
|
|
||||||
let Some(title) = self
|
|
||||||
.state
|
|
||||||
.read(cx)
|
|
||||||
.list
|
|
||||||
.read(cx)
|
|
||||||
.delegate()
|
|
||||||
.delegate
|
|
||||||
.get(*selected_index)
|
|
||||||
.map(|item| {
|
|
||||||
if let Some(el) = item.display_title() {
|
|
||||||
el
|
|
||||||
} else if let Some(prefix) = self.title_prefix.as_ref() {
|
|
||||||
format!("{}{}", prefix, item.title()).into_any_element()
|
|
||||||
} else {
|
|
||||||
item.title().into_any_element()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
else {
|
|
||||||
return default_title;
|
|
||||||
};
|
|
||||||
|
|
||||||
div()
|
|
||||||
.when(self.disabled, |this| this.text_color(cx.theme().text_muted))
|
|
||||||
.child(title)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<D> Sizable for Dropdown<D>
|
|
||||||
where
|
|
||||||
D: DropdownDelegate + 'static,
|
|
||||||
{
|
|
||||||
fn with_size(mut self, size: impl Into<Size>) -> Self {
|
|
||||||
self.size = size.into();
|
|
||||||
self
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<D> EventEmitter<DropdownEvent<D>> for DropdownState<D> where D: DropdownDelegate + 'static {}
|
|
||||||
impl<D> EventEmitter<DismissEvent> for DropdownState<D> where D: DropdownDelegate + 'static {}
|
|
||||||
impl<D> Focusable for DropdownState<D>
|
|
||||||
where
|
|
||||||
D: DropdownDelegate,
|
|
||||||
{
|
|
||||||
fn focus_handle(&self, cx: &App) -> FocusHandle {
|
|
||||||
if self.open {
|
|
||||||
self.list.focus_handle(cx)
|
|
||||||
} else {
|
|
||||||
self.focus_handle.clone()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
impl<D> Focusable for Dropdown<D>
|
|
||||||
where
|
|
||||||
D: DropdownDelegate,
|
|
||||||
{
|
|
||||||
fn focus_handle(&self, cx: &App) -> FocusHandle {
|
|
||||||
self.state.focus_handle(cx)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<D> RenderOnce for Dropdown<D>
|
|
||||||
where
|
|
||||||
D: DropdownDelegate + 'static,
|
|
||||||
{
|
|
||||||
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
|
|
||||||
let is_focused = self.focus_handle(cx).is_focused(window);
|
|
||||||
// If the size has change, set size to self.list, to change the QueryInput size.
|
|
||||||
let old_size = self.state.read(cx).list.read(cx).size;
|
|
||||||
if old_size != self.size {
|
|
||||||
self.state
|
|
||||||
.read(cx)
|
|
||||||
.list
|
|
||||||
.clone()
|
|
||||||
.update(cx, |this, cx| this.set_size(self.size, window, cx));
|
|
||||||
self.state.update(cx, |this, _| {
|
|
||||||
this.size = self.size;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
let state = self.state.read(cx);
|
|
||||||
let show_clean = self.cleanable && state.selected_index(cx).is_some();
|
|
||||||
let bounds = state.bounds;
|
|
||||||
let allow_open = !(state.open || self.disabled);
|
|
||||||
let outline_visible = state.open || is_focused && !self.disabled;
|
|
||||||
let popup_radius = cx.theme().radius.min(px(8.));
|
|
||||||
|
|
||||||
div()
|
|
||||||
.id(self.id.clone())
|
|
||||||
.key_context(CONTEXT)
|
|
||||||
.track_focus(&self.focus_handle(cx))
|
|
||||||
.on_action(window.listener_for(&self.state, DropdownState::up))
|
|
||||||
.on_action(window.listener_for(&self.state, DropdownState::down))
|
|
||||||
.on_action(window.listener_for(&self.state, DropdownState::enter))
|
|
||||||
.on_action(window.listener_for(&self.state, DropdownState::escape))
|
|
||||||
.size_full()
|
|
||||||
.relative()
|
|
||||||
.input_font_size(self.size)
|
|
||||||
.child(
|
|
||||||
div()
|
|
||||||
.id(ElementId::Name(format!("{}-input", self.id).into()))
|
|
||||||
.relative()
|
|
||||||
.flex()
|
|
||||||
.items_center()
|
|
||||||
.justify_between()
|
|
||||||
.bg(cx.theme().background)
|
|
||||||
.border_1()
|
|
||||||
.border_color(cx.theme().border)
|
|
||||||
.rounded(cx.theme().radius)
|
|
||||||
.when(cx.theme().shadow, |this| this.shadow_sm())
|
|
||||||
.overflow_hidden()
|
|
||||||
.input_font_size(self.size)
|
|
||||||
.map(|this| match self.width {
|
|
||||||
Length::Definite(l) => this.flex_none().w(l),
|
|
||||||
Length::Auto => this.w_full(),
|
|
||||||
})
|
|
||||||
.when(outline_visible, |this| this.border_color(cx.theme().ring))
|
|
||||||
.input_size(self.size)
|
|
||||||
.when(allow_open, |this| {
|
|
||||||
this.on_click(window.listener_for(&self.state, DropdownState::toggle_menu))
|
|
||||||
})
|
|
||||||
.child(
|
|
||||||
h_flex()
|
|
||||||
.w_full()
|
|
||||||
.items_center()
|
|
||||||
.justify_between()
|
|
||||||
.gap_1()
|
|
||||||
.child(
|
|
||||||
div()
|
|
||||||
.w_full()
|
|
||||||
.overflow_hidden()
|
|
||||||
.whitespace_nowrap()
|
|
||||||
.truncate()
|
|
||||||
.child(self.display_title(window, cx)),
|
|
||||||
)
|
|
||||||
.when(show_clean, |this| {
|
|
||||||
this.child(clear_button(cx).map(|this| {
|
|
||||||
if self.disabled {
|
|
||||||
this.disabled(true)
|
|
||||||
} else {
|
|
||||||
this.on_click(
|
|
||||||
window.listener_for(&self.state, DropdownState::clean),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}))
|
|
||||||
})
|
|
||||||
.when(!show_clean, |this| {
|
|
||||||
let icon = match self.icon.clone() {
|
|
||||||
Some(icon) => icon,
|
|
||||||
None => {
|
|
||||||
if state.open {
|
|
||||||
Icon::new(IconName::CaretUp)
|
|
||||||
} else {
|
|
||||||
Icon::new(IconName::CaretDown)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
this.child(icon.xsmall().text_color(match self.disabled {
|
|
||||||
true => cx.theme().text_placeholder,
|
|
||||||
false => cx.theme().text_muted,
|
|
||||||
}))
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.child(
|
|
||||||
canvas(
|
|
||||||
{
|
|
||||||
let state = self.state.clone();
|
|
||||||
move |bounds, _, cx| state.update(cx, |r, _| r.bounds = bounds)
|
|
||||||
},
|
|
||||||
|_, _, _, _| {},
|
|
||||||
)
|
|
||||||
.absolute()
|
|
||||||
.size_full(),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.when(state.open, |this| {
|
|
||||||
this.child(
|
|
||||||
deferred(
|
|
||||||
anchored().snap_to_window_with_margin(px(8.)).child(
|
|
||||||
div()
|
|
||||||
.occlude()
|
|
||||||
.map(|this| match self.menu_width {
|
|
||||||
Length::Auto => this.w(bounds.size.width),
|
|
||||||
Length::Definite(w) => this.w(w),
|
|
||||||
})
|
|
||||||
.child(
|
|
||||||
v_flex()
|
|
||||||
.occlude()
|
|
||||||
.mt_1p5()
|
|
||||||
.bg(cx.theme().background)
|
|
||||||
.border_1()
|
|
||||||
.border_color(cx.theme().border)
|
|
||||||
.rounded(popup_radius)
|
|
||||||
.when(cx.theme().shadow, |this| this.shadow_md())
|
|
||||||
.child(state.list.clone()),
|
|
||||||
)
|
|
||||||
.on_mouse_down_out(window.listener_for(
|
|
||||||
&self.state,
|
|
||||||
|this, _, window, cx| {
|
|
||||||
this.escape(&Cancel, window, cx);
|
|
||||||
},
|
|
||||||
)),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.with_priority(1),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
177
crates/ui/src/group_box.rs
Normal file
@@ -0,0 +1,177 @@
|
|||||||
|
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),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,17 +1,29 @@
|
|||||||
use gpui::prelude::FluentBuilder as _;
|
use gpui::prelude::FluentBuilder as _;
|
||||||
use gpui::{
|
use gpui::{
|
||||||
svg, AnyElement, App, AppContext, Entity, Hsla, IntoElement, Radians, Render, RenderOnce,
|
svg, AnyElement, App, AppContext, Context, Entity, Hsla, IntoElement, Radians, Render,
|
||||||
SharedString, StyleRefinement, Styled, Svg, Transformation, Window,
|
RenderOnce, SharedString, StyleRefinement, Styled, Svg, Transformation, Window,
|
||||||
};
|
};
|
||||||
use theme::ActiveTheme;
|
use theme::ActiveTheme;
|
||||||
|
|
||||||
use crate::{Sizable, Size};
|
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)]
|
#[derive(IntoElement, Clone)]
|
||||||
pub enum IconName {
|
pub enum IconName {
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
ArrowRight,
|
ArrowRight,
|
||||||
Boom,
|
Boom,
|
||||||
|
Book,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
CaretDown,
|
CaretDown,
|
||||||
CaretRight,
|
CaretRight,
|
||||||
@@ -22,6 +34,7 @@ pub enum IconName {
|
|||||||
CloseCircle,
|
CloseCircle,
|
||||||
CloseCircleFill,
|
CloseCircleFill,
|
||||||
Copy,
|
Copy,
|
||||||
|
Device,
|
||||||
Door,
|
Door,
|
||||||
Ellipsis,
|
Ellipsis,
|
||||||
Emoji,
|
Emoji,
|
||||||
@@ -36,13 +49,19 @@ pub enum IconName {
|
|||||||
Plus,
|
Plus,
|
||||||
PlusCircle,
|
PlusCircle,
|
||||||
Profile,
|
Profile,
|
||||||
|
Reset,
|
||||||
Relay,
|
Relay,
|
||||||
Reply,
|
Reply,
|
||||||
|
Refresh,
|
||||||
|
Scan,
|
||||||
Search,
|
Search,
|
||||||
Settings,
|
Settings,
|
||||||
|
Settings2,
|
||||||
Sun,
|
Sun,
|
||||||
Ship,
|
Ship,
|
||||||
Shield,
|
Shield,
|
||||||
|
Group,
|
||||||
|
UserKey,
|
||||||
Upload,
|
Upload,
|
||||||
Usb,
|
Usb,
|
||||||
PanelLeft,
|
PanelLeft,
|
||||||
@@ -63,11 +82,19 @@ pub enum IconName {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl IconName {
|
impl IconName {
|
||||||
pub fn path(self) -> SharedString {
|
/// 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 {
|
match self {
|
||||||
Self::ArrowLeft => "icons/arrow-left.svg",
|
Self::ArrowLeft => "icons/arrow-left.svg",
|
||||||
Self::ArrowRight => "icons/arrow-right.svg",
|
Self::ArrowRight => "icons/arrow-right.svg",
|
||||||
Self::Boom => "icons/boom.svg",
|
Self::Boom => "icons/boom.svg",
|
||||||
|
Self::Book => "icons/book.svg",
|
||||||
Self::ChevronDown => "icons/chevron-down.svg",
|
Self::ChevronDown => "icons/chevron-down.svg",
|
||||||
Self::CaretDown => "icons/caret-down.svg",
|
Self::CaretDown => "icons/caret-down.svg",
|
||||||
Self::CaretRight => "icons/caret-right.svg",
|
Self::CaretRight => "icons/caret-right.svg",
|
||||||
@@ -78,6 +105,7 @@ impl IconName {
|
|||||||
Self::CloseCircle => "icons/close-circle.svg",
|
Self::CloseCircle => "icons/close-circle.svg",
|
||||||
Self::CloseCircleFill => "icons/close-circle-fill.svg",
|
Self::CloseCircleFill => "icons/close-circle-fill.svg",
|
||||||
Self::Copy => "icons/copy.svg",
|
Self::Copy => "icons/copy.svg",
|
||||||
|
Self::Device => "icons/device.svg",
|
||||||
Self::Door => "icons/door.svg",
|
Self::Door => "icons/door.svg",
|
||||||
Self::Ellipsis => "icons/ellipsis.svg",
|
Self::Ellipsis => "icons/ellipsis.svg",
|
||||||
Self::Emoji => "icons/emoji.svg",
|
Self::Emoji => "icons/emoji.svg",
|
||||||
@@ -92,15 +120,21 @@ impl IconName {
|
|||||||
Self::Plus => "icons/plus.svg",
|
Self::Plus => "icons/plus.svg",
|
||||||
Self::PlusCircle => "icons/plus-circle.svg",
|
Self::PlusCircle => "icons/plus-circle.svg",
|
||||||
Self::Profile => "icons/profile.svg",
|
Self::Profile => "icons/profile.svg",
|
||||||
|
Self::Reset => "icons/reset.svg",
|
||||||
Self::Relay => "icons/relay.svg",
|
Self::Relay => "icons/relay.svg",
|
||||||
Self::Reply => "icons/reply.svg",
|
Self::Reply => "icons/reply.svg",
|
||||||
|
Self::Refresh => "icons/refresh.svg",
|
||||||
|
Self::Scan => "icons/scan.svg",
|
||||||
Self::Search => "icons/search.svg",
|
Self::Search => "icons/search.svg",
|
||||||
Self::Settings => "icons/settings.svg",
|
Self::Settings => "icons/settings.svg",
|
||||||
|
Self::Settings2 => "icons/settings2.svg",
|
||||||
Self::Sun => "icons/sun.svg",
|
Self::Sun => "icons/sun.svg",
|
||||||
Self::Ship => "icons/ship.svg",
|
Self::Ship => "icons/ship.svg",
|
||||||
Self::Shield => "icons/shield.svg",
|
Self::Shield => "icons/shield.svg",
|
||||||
|
Self::UserKey => "icons/user-key.svg",
|
||||||
Self::Upload => "icons/upload.svg",
|
Self::Upload => "icons/upload.svg",
|
||||||
Self::Usb => "icons/usb.svg",
|
Self::Usb => "icons/usb.svg",
|
||||||
|
Self::Group => "icons/group.svg",
|
||||||
Self::PanelLeft => "icons/panel-left.svg",
|
Self::PanelLeft => "icons/panel-left.svg",
|
||||||
Self::PanelLeftOpen => "icons/panel-left-open.svg",
|
Self::PanelLeftOpen => "icons/panel-left-open.svg",
|
||||||
Self::PanelRight => "icons/panel-right.svg",
|
Self::PanelRight => "icons/panel-right.svg",
|
||||||
@@ -119,17 +153,6 @@ impl IconName {
|
|||||||
}
|
}
|
||||||
.into()
|
.into()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Return the icon as a Entity<Icon>
|
|
||||||
pub fn view(self, window: &mut Window, cx: &mut App) -> Entity<Icon> {
|
|
||||||
Icon::build(self).view(window, cx)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<IconName> for Icon {
|
|
||||||
fn from(val: IconName) -> Self {
|
|
||||||
Icon::build(val)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl From<IconName> for AnyElement {
|
impl From<IconName> for AnyElement {
|
||||||
@@ -139,7 +162,7 @@ impl From<IconName> for AnyElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl RenderOnce for IconName {
|
impl RenderOnce for IconName {
|
||||||
fn render(self, _window: &mut Window, _cx: &mut App) -> impl IntoElement {
|
fn render(self, _: &mut Window, _cx: &mut App) -> impl IntoElement {
|
||||||
Icon::build(self)
|
Icon::build(self)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -147,6 +170,7 @@ impl RenderOnce for IconName {
|
|||||||
#[derive(IntoElement)]
|
#[derive(IntoElement)]
|
||||||
pub struct Icon {
|
pub struct Icon {
|
||||||
base: Svg,
|
base: Svg,
|
||||||
|
style: StyleRefinement,
|
||||||
path: SharedString,
|
path: SharedString,
|
||||||
text_color: Option<Hsla>,
|
text_color: Option<Hsla>,
|
||||||
size: Option<Size>,
|
size: Option<Size>,
|
||||||
@@ -157,6 +181,7 @@ impl Default for Icon {
|
|||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self {
|
Self {
|
||||||
base: svg().flex_none().size_4(),
|
base: svg().flex_none().size_4(),
|
||||||
|
style: StyleRefinement::default(),
|
||||||
path: "".into(),
|
path: "".into(),
|
||||||
text_color: None,
|
text_color: None,
|
||||||
size: None,
|
size: None,
|
||||||
@@ -168,23 +193,20 @@ impl Default for Icon {
|
|||||||
impl Clone for Icon {
|
impl Clone for Icon {
|
||||||
fn clone(&self) -> Self {
|
fn clone(&self) -> Self {
|
||||||
let mut this = Self::default().path(self.path.clone());
|
let mut this = Self::default().path(self.path.clone());
|
||||||
if let Some(size) = self.size {
|
this.style = self.style.clone();
|
||||||
this = this.with_size(size);
|
this.rotation = self.rotation;
|
||||||
}
|
this.size = self.size;
|
||||||
|
this.text_color = self.text_color;
|
||||||
this
|
this
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub trait IconNamed {
|
|
||||||
fn path(&self) -> SharedString;
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Icon {
|
impl Icon {
|
||||||
pub fn new(icon: impl Into<Icon>) -> Self {
|
pub fn new(icon: impl Into<Icon>) -> Self {
|
||||||
icon.into()
|
icon.into()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn build(name: IconName) -> Self {
|
fn build(name: impl IconNamed) -> Self {
|
||||||
Self::default().path(name.path())
|
Self::default().path(name.path())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -197,7 +219,7 @@ impl Icon {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Create a new view for the icon
|
/// Create a new view for the icon
|
||||||
pub fn view(self, _window: &mut Window, cx: &mut App) -> Entity<Icon> {
|
pub fn view(self, cx: &mut App) -> Entity<Icon> {
|
||||||
cx.new(|_| self)
|
cx.new(|_| self)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -221,7 +243,7 @@ impl Icon {
|
|||||||
|
|
||||||
impl Styled for Icon {
|
impl Styled for Icon {
|
||||||
fn style(&mut self) -> &mut StyleRefinement {
|
fn style(&mut self) -> &mut StyleRefinement {
|
||||||
self.base.style()
|
&mut self.style
|
||||||
}
|
}
|
||||||
|
|
||||||
fn text_color(mut self, color: impl Into<Hsla>) -> Self {
|
fn text_color(mut self, color: impl Into<Hsla>) -> Self {
|
||||||
@@ -240,9 +262,15 @@ impl Sizable for Icon {
|
|||||||
impl RenderOnce for Icon {
|
impl RenderOnce for Icon {
|
||||||
fn render(self, window: &mut Window, _cx: &mut App) -> impl IntoElement {
|
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_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();
|
||||||
|
|
||||||
self.base
|
let mut base = self.base;
|
||||||
|
*base.style() = self.style;
|
||||||
|
|
||||||
|
base.flex_shrink_0()
|
||||||
.text_color(text_color)
|
.text_color(text_color)
|
||||||
|
.when(!has_base_size, |this| this.size(text_size))
|
||||||
.when_some(self.size, |this, size| match size {
|
.when_some(self.size, |this, size| match size {
|
||||||
Size::Size(px) => this.size(px),
|
Size::Size(px) => this.size(px),
|
||||||
Size::XSmall => this.size_3(),
|
Size::XSmall => this.size_3(),
|
||||||
@@ -261,16 +289,17 @@ impl From<Icon> for AnyElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl Render for Icon {
|
impl Render for Icon {
|
||||||
fn render(
|
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||||
&mut self,
|
|
||||||
_window: &mut gpui::Window,
|
|
||||||
cx: &mut gpui::Context<Self>,
|
|
||||||
) -> impl IntoElement {
|
|
||||||
let text_color = self.text_color.unwrap_or_else(|| cx.theme().icon);
|
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();
|
||||||
|
|
||||||
svg()
|
let mut base = svg().flex_none();
|
||||||
.flex_none()
|
*base.style() = self.style.clone();
|
||||||
|
|
||||||
|
base.flex_shrink_0()
|
||||||
.text_color(text_color)
|
.text_color(text_color)
|
||||||
|
.when(!has_base_size, |this| this.size(text_size))
|
||||||
.when_some(self.size, |this, size| match size {
|
.when_some(self.size, |this, size| match size {
|
||||||
Size::Size(px) => this.size(px),
|
Size::Size(px) => this.size(px),
|
||||||
Size::XSmall => this.size_3(),
|
Size::XSmall => this.size_3(),
|
||||||
@@ -278,7 +307,7 @@ impl Render for Icon {
|
|||||||
Size::Medium => this.size_5(),
|
Size::Medium => this.size_5(),
|
||||||
Size::Large => this.size_6(),
|
Size::Large => this.size_6(),
|
||||||
})
|
})
|
||||||
.when(!self.path.is_empty(), |this| this.path(self.path.clone()))
|
.path(self.path.clone())
|
||||||
.when_some(self.rotation, |this, rotation| {
|
.when_some(self.rotation, |this, rotation| {
|
||||||
this.with_transformation(Transformation::rotate(rotation))
|
this.with_transformation(Transformation::rotate(rotation))
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -544,16 +544,12 @@ impl InputState {
|
|||||||
/// Set the text of the input field.
|
/// Set the text of the input field.
|
||||||
///
|
///
|
||||||
/// And the selection_range will be reset to 0..0.
|
/// And the selection_range will be reset to 0..0.
|
||||||
pub fn set_value(
|
pub fn set_value<T>(&mut self, value: T, window: &mut Window, cx: &mut Context<Self>)
|
||||||
&mut self,
|
where
|
||||||
value: impl Into<SharedString>,
|
T: Into<SharedString>,
|
||||||
window: &mut Window,
|
{
|
||||||
cx: &mut Context<Self>,
|
|
||||||
) {
|
|
||||||
self.history.ignore = true;
|
self.history.ignore = true;
|
||||||
let was_disabled = self.disabled;
|
|
||||||
self.replace_text(value, window, cx);
|
self.replace_text(value, window, cx);
|
||||||
self.disabled = was_disabled;
|
|
||||||
self.history.ignore = false;
|
self.history.ignore = false;
|
||||||
|
|
||||||
// Ensure cursor to start when set text
|
// Ensure cursor to start when set text
|
||||||
@@ -565,48 +561,50 @@ impl InputState {
|
|||||||
|
|
||||||
// Move scroll to top
|
// Move scroll to top
|
||||||
self.scroll_handle.set_offset(point(px(0.), px(0.)));
|
self.scroll_handle.set_offset(point(px(0.), px(0.)));
|
||||||
|
|
||||||
cx.notify();
|
cx.notify();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Insert text at the current cursor position.
|
/// Insert text at the current cursor position.
|
||||||
///
|
///
|
||||||
/// And the cursor will be moved to the end of inserted text.
|
/// And the cursor will be moved to the end of inserted text.
|
||||||
pub fn insert(
|
pub fn insert<T>(&mut self, text: T, window: &mut Window, cx: &mut Context<Self>)
|
||||||
&mut self,
|
where
|
||||||
text: impl Into<SharedString>,
|
T: Into<SharedString>,
|
||||||
window: &mut Window,
|
{
|
||||||
cx: &mut Context<Self>,
|
let was_disabled = self.disabled;
|
||||||
) {
|
self.disabled = false;
|
||||||
let text: SharedString = text.into();
|
let text: SharedString = text.into();
|
||||||
let range_utf16 = self.range_to_utf16(&(self.cursor()..self.cursor()));
|
let range_utf16 = self.range_to_utf16(&(self.cursor()..self.cursor()));
|
||||||
self.replace_text_in_range_silent(Some(range_utf16), &text, window, cx);
|
self.replace_text_in_range_silent(Some(range_utf16), &text, window, cx);
|
||||||
self.selected_range = (self.selected_range.end..self.selected_range.end).into();
|
self.selected_range = (self.selected_range.end..self.selected_range.end).into();
|
||||||
|
self.disabled = was_disabled;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Replace text at the current cursor position.
|
/// Replace text at the current cursor position.
|
||||||
///
|
///
|
||||||
/// And the cursor will be moved to the end of replaced text.
|
/// And the cursor will be moved to the end of replaced text.
|
||||||
pub fn replace(
|
pub fn replace<T>(&mut self, text: T, window: &mut Window, cx: &mut Context<Self>)
|
||||||
&mut self,
|
where
|
||||||
text: impl Into<SharedString>,
|
T: Into<SharedString>,
|
||||||
window: &mut Window,
|
{
|
||||||
cx: &mut Context<Self>,
|
let was_disabled = self.disabled;
|
||||||
) {
|
self.disabled = false;
|
||||||
let text: SharedString = text.into();
|
let text: SharedString = text.into();
|
||||||
self.replace_text_in_range_silent(None, &text, window, cx);
|
self.replace_text_in_range_silent(None, &text, window, cx);
|
||||||
self.selected_range = (self.selected_range.end..self.selected_range.end).into();
|
self.selected_range = (self.selected_range.end..self.selected_range.end).into();
|
||||||
|
self.disabled = was_disabled;
|
||||||
}
|
}
|
||||||
|
|
||||||
fn replace_text(
|
fn replace_text<T>(&mut self, text: T, window: &mut Window, cx: &mut Context<Self>)
|
||||||
&mut self,
|
where
|
||||||
text: impl Into<SharedString>,
|
T: Into<SharedString>,
|
||||||
window: &mut Window,
|
{
|
||||||
cx: &mut Context<Self>,
|
let was_disabled = self.disabled;
|
||||||
) {
|
self.disabled = false;
|
||||||
let text: SharedString = text.into();
|
let text: SharedString = text.into();
|
||||||
let range = 0..self.text.chars().map(|c| c.len_utf16()).sum();
|
let range = 0..self.text.chars().map(|c| c.len_utf16()).sum();
|
||||||
self.replace_text_in_range_silent(Some(range), &text, window, cx);
|
self.replace_text_in_range_silent(Some(range), &text, window, cx);
|
||||||
|
self.disabled = was_disabled;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Set with password masked state.
|
/// Set with password masked state.
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ pub mod button;
|
|||||||
pub mod checkbox;
|
pub mod checkbox;
|
||||||
pub mod divider;
|
pub mod divider;
|
||||||
pub mod dock_area;
|
pub mod dock_area;
|
||||||
|
pub mod group_box;
|
||||||
pub mod history;
|
pub mod history;
|
||||||
pub mod indicator;
|
pub mod indicator;
|
||||||
pub mod input;
|
pub mod input;
|
||||||
|
|||||||
@@ -93,9 +93,8 @@ impl RenderOnce for MenuItemElement {
|
|||||||
.id(self.id)
|
.id(self.id)
|
||||||
.group(&self.group_name)
|
.group(&self.group_name)
|
||||||
.gap_x_1()
|
.gap_x_1()
|
||||||
.py_1()
|
.p_1()
|
||||||
.px_2()
|
.text_sm()
|
||||||
.text_base()
|
|
||||||
.text_color(cx.theme().text)
|
.text_color(cx.theme().text)
|
||||||
.relative()
|
.relative()
|
||||||
.items_center()
|
.items_center()
|
||||||
|
|||||||
@@ -70,6 +70,7 @@ pub enum PopupMenuItem {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl FluentBuilder for PopupMenuItem {}
|
impl FluentBuilder for PopupMenuItem {}
|
||||||
|
|
||||||
impl PopupMenuItem {
|
impl PopupMenuItem {
|
||||||
/// Create a new menu item with the given label.
|
/// Create a new menu item with the given label.
|
||||||
#[inline]
|
#[inline]
|
||||||
@@ -1028,7 +1029,7 @@ impl PopupMenu {
|
|||||||
Icon::empty()
|
Icon::empty()
|
||||||
};
|
};
|
||||||
|
|
||||||
Some(icon.xsmall())
|
Some(icon.small())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[inline]
|
#[inline]
|
||||||
@@ -1072,7 +1073,7 @@ impl PopupMenu {
|
|||||||
|
|
||||||
let selected = self.selected_index == Some(ix);
|
let selected = self.selected_index == Some(ix);
|
||||||
const EDGE_PADDING: Pixels = px(4.);
|
const EDGE_PADDING: Pixels = px(4.);
|
||||||
const INNER_PADDING: Pixels = px(8.);
|
const INNER_PADDING: Pixels = px(4.);
|
||||||
|
|
||||||
let is_submenu = matches!(item, PopupMenuItem::Submenu { .. });
|
let is_submenu = matches!(item, PopupMenuItem::Submenu { .. });
|
||||||
let group_name = format!("{}:item-{}", cx.entity().entity_id(), ix);
|
let group_name = format!("{}:item-{}", cx.entity().entity_id(), ix);
|
||||||
@@ -1111,18 +1112,17 @@ impl PopupMenu {
|
|||||||
.border_color(cx.theme().border)
|
.border_color(cx.theme().border)
|
||||||
.disabled(true),
|
.disabled(true),
|
||||||
PopupMenuItem::Label(label) => this.disabled(true).cursor_default().child(
|
PopupMenuItem::Label(label) => this.disabled(true).cursor_default().child(
|
||||||
h_flex()
|
h_flex().cursor_default().items_center().gap_x_1().child(
|
||||||
.cursor_default()
|
div()
|
||||||
.items_center()
|
.flex_1()
|
||||||
.gap_x_1()
|
.text_xs()
|
||||||
.children(Self::render_icon(has_left_icon, false, None, window, cx))
|
.font_semibold()
|
||||||
.child(div().flex_1().child(label.clone())),
|
.text_color(cx.theme().text_muted)
|
||||||
|
.child(label.clone()),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
PopupMenuItem::ElementItem {
|
PopupMenuItem::ElementItem {
|
||||||
render,
|
render, disabled, ..
|
||||||
icon,
|
|
||||||
disabled,
|
|
||||||
..
|
|
||||||
} => this
|
} => this
|
||||||
.when(!disabled, |this| {
|
.when(!disabled, |this| {
|
||||||
this.on_click(
|
this.on_click(
|
||||||
@@ -1135,14 +1135,7 @@ impl PopupMenu {
|
|||||||
.flex_1()
|
.flex_1()
|
||||||
.min_h(item_height)
|
.min_h(item_height)
|
||||||
.items_center()
|
.items_center()
|
||||||
.gap_x_1()
|
.gap_x_2()
|
||||||
.children(Self::render_icon(
|
|
||||||
has_left_icon,
|
|
||||||
is_left_check,
|
|
||||||
icon.clone(),
|
|
||||||
window,
|
|
||||||
cx,
|
|
||||||
))
|
|
||||||
.child((render)(window, cx))
|
.child((render)(window, cx))
|
||||||
.children(right_check_icon.map(|icon| icon.ml_3())),
|
.children(right_check_icon.map(|icon| icon.ml_3())),
|
||||||
),
|
),
|
||||||
@@ -1281,6 +1274,7 @@ impl Render for PopupMenu {
|
|||||||
let view = cx.entity().clone();
|
let view = cx.entity().clone();
|
||||||
let items_count = self.menu_items.len();
|
let items_count = self.menu_items.len();
|
||||||
|
|
||||||
|
let max_width = self.max_width();
|
||||||
let max_height = self.max_height.unwrap_or_else(|| {
|
let max_height = self.max_height.unwrap_or_else(|| {
|
||||||
let window_half_height = window.window_bounds().get_bounds().size.height * 0.5;
|
let window_half_height = window.window_bounds().get_bounds().size.height * 0.5;
|
||||||
window_half_height.min(px(450.))
|
window_half_height.min(px(450.))
|
||||||
@@ -1291,7 +1285,6 @@ impl Render for PopupMenu {
|
|||||||
.iter()
|
.iter()
|
||||||
.any(|item| item.has_left_icon(self.check_side));
|
.any(|item| item.has_left_icon(self.check_side));
|
||||||
|
|
||||||
let max_width = self.max_width();
|
|
||||||
let options = RenderOptions {
|
let options = RenderOptions {
|
||||||
has_left_icon,
|
has_left_icon,
|
||||||
check_side: self.check_side,
|
check_side: self.check_side,
|
||||||
|
|||||||
@@ -343,7 +343,7 @@ impl RenderOnce for Modal {
|
|||||||
});
|
});
|
||||||
|
|
||||||
let window_paddings = crate::root::window_paddings(window, cx);
|
let window_paddings = crate::root::window_paddings(window, cx);
|
||||||
let radius = (cx.theme().radius_lg * 2.).min(px(20.));
|
let radius = cx.theme().radius_lg;
|
||||||
|
|
||||||
let view_size = window.viewport_size()
|
let view_size = window.viewport_size()
|
||||||
- gpui::size(
|
- gpui::size(
|
||||||
@@ -360,8 +360,8 @@ impl RenderOnce for Modal {
|
|||||||
let y = self.margin_top.unwrap_or(view_size.height / 10.) + offset_top;
|
let y = self.margin_top.unwrap_or(view_size.height / 10.) + offset_top;
|
||||||
let x = bounds.center().x - self.width / 2.;
|
let x = bounds.center().x - self.width / 2.;
|
||||||
|
|
||||||
let mut padding_right = px(16.);
|
let mut padding_right = px(8.);
|
||||||
let mut padding_left = px(16.);
|
let mut padding_left = px(8.);
|
||||||
|
|
||||||
if let Some(pl) = self.style.padding.left {
|
if let Some(pl) = self.style.padding.left {
|
||||||
padding_left = pl.to_pixels(self.width.into(), window.rem_size());
|
padding_left = pl.to_pixels(self.width.into(), window.rem_size());
|
||||||
|
|||||||
@@ -1,776 +0,0 @@
|
|||||||
use std::ops::Deref;
|
|
||||||
use std::rc::Rc;
|
|
||||||
|
|
||||||
use gpui::prelude::FluentBuilder;
|
|
||||||
use gpui::{
|
|
||||||
actions, anchored, canvas, div, px, rems, Action, AnyElement, App, AppContext, AsKeystroke,
|
|
||||||
Bounds, Context, Corner, DismissEvent, Edges, Entity, EventEmitter, FocusHandle, Focusable,
|
|
||||||
InteractiveElement, IntoElement, KeyBinding, Keystroke, ParentElement, Pixels, Render,
|
|
||||||
ScrollHandle, SharedString, StatefulInteractiveElement, Styled, Subscription, WeakEntity,
|
|
||||||
Window,
|
|
||||||
};
|
|
||||||
use theme::ActiveTheme;
|
|
||||||
|
|
||||||
use crate::button::Button;
|
|
||||||
use crate::list::ListItem;
|
|
||||||
use crate::popover::Popover;
|
|
||||||
use crate::scroll::{Scrollbar, ScrollbarState};
|
|
||||||
use crate::{h_flex, v_flex, Icon, IconName, Selectable, Sizable as _, StyledExt};
|
|
||||||
|
|
||||||
actions!(
|
|
||||||
menu,
|
|
||||||
[
|
|
||||||
/// Trigger confirm action when user presses enter button
|
|
||||||
Confirm,
|
|
||||||
/// Trigger dismiss action when user presses escape button
|
|
||||||
Dismiss,
|
|
||||||
/// Select the next item when user presses up button
|
|
||||||
SelectNext,
|
|
||||||
/// Select the previous item when user preses down button
|
|
||||||
SelectPrev
|
|
||||||
]
|
|
||||||
);
|
|
||||||
|
|
||||||
const ITEM_HEIGHT: Pixels = px(26.);
|
|
||||||
|
|
||||||
pub fn init(cx: &mut App) {
|
|
||||||
let context = Some("PopupMenu");
|
|
||||||
|
|
||||||
cx.bind_keys([
|
|
||||||
KeyBinding::new("enter", Confirm, context),
|
|
||||||
KeyBinding::new("escape", Dismiss, context),
|
|
||||||
KeyBinding::new("up", SelectPrev, context),
|
|
||||||
KeyBinding::new("down", SelectNext, context),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
pub trait PopupMenuExt: Styled + Selectable + InteractiveElement + IntoElement + 'static {
|
|
||||||
/// Create a popup menu with the given items, anchored to the TopLeft corner
|
|
||||||
fn popup_menu(
|
|
||||||
self,
|
|
||||||
f: impl Fn(PopupMenu, &mut Window, &mut Context<PopupMenu>) -> PopupMenu + 'static,
|
|
||||||
) -> Popover<PopupMenu> {
|
|
||||||
self.popup_menu_with_anchor(Corner::TopLeft, f)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Create a popup menu with the given items, anchored to the given corner
|
|
||||||
fn popup_menu_with_anchor(
|
|
||||||
mut self,
|
|
||||||
anchor: impl Into<Corner>,
|
|
||||||
f: impl Fn(PopupMenu, &mut Window, &mut Context<PopupMenu>) -> PopupMenu + 'static,
|
|
||||||
) -> Popover<PopupMenu> {
|
|
||||||
let style = self.style().clone();
|
|
||||||
let id = self.interactivity().element_id.clone();
|
|
||||||
|
|
||||||
Popover::new(SharedString::from(format!("popup-menu:{id:?}")))
|
|
||||||
.no_style()
|
|
||||||
.trigger(self)
|
|
||||||
.trigger_style(style)
|
|
||||||
.anchor(anchor.into())
|
|
||||||
.content(move |window, cx| {
|
|
||||||
PopupMenu::build(window, cx, |menu, window, cx| f(menu, window, cx))
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl PopupMenuExt for Button {}
|
|
||||||
|
|
||||||
enum PopupMenuItem {
|
|
||||||
Title(SharedString),
|
|
||||||
Separator,
|
|
||||||
Item {
|
|
||||||
icon: Option<Icon>,
|
|
||||||
label: SharedString,
|
|
||||||
action: Option<Box<dyn Action>>,
|
|
||||||
#[allow(clippy::type_complexity)]
|
|
||||||
handler: Rc<dyn Fn(&mut Window, &mut App)>,
|
|
||||||
},
|
|
||||||
ElementItem {
|
|
||||||
#[allow(clippy::type_complexity)]
|
|
||||||
render: Box<dyn Fn(&mut Window, &mut App) -> AnyElement + 'static>,
|
|
||||||
#[allow(clippy::type_complexity)]
|
|
||||||
handler: Rc<dyn Fn(&mut Window, &mut App)>,
|
|
||||||
},
|
|
||||||
Submenu {
|
|
||||||
icon: Option<Icon>,
|
|
||||||
label: SharedString,
|
|
||||||
menu: Entity<PopupMenu>,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
impl PopupMenuItem {
|
|
||||||
fn is_clickable(&self) -> bool {
|
|
||||||
!matches!(self, PopupMenuItem::Separator)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn is_separator(&self) -> bool {
|
|
||||||
matches!(self, PopupMenuItem::Separator)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn has_icon(&self) -> bool {
|
|
||||||
matches!(self, PopupMenuItem::Item { icon: Some(_), .. })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct PopupMenu {
|
|
||||||
/// The parent menu of this menu, if this is a submenu
|
|
||||||
parent_menu: Option<WeakEntity<Self>>,
|
|
||||||
focus_handle: FocusHandle,
|
|
||||||
menu_items: Vec<PopupMenuItem>,
|
|
||||||
has_icon: bool,
|
|
||||||
selected_index: Option<usize>,
|
|
||||||
min_width: Pixels,
|
|
||||||
max_width: Pixels,
|
|
||||||
hovered_menu_ix: Option<usize>,
|
|
||||||
bounds: Bounds<Pixels>,
|
|
||||||
|
|
||||||
scrollable: bool,
|
|
||||||
scroll_handle: ScrollHandle,
|
|
||||||
scroll_state: ScrollbarState,
|
|
||||||
|
|
||||||
action_focus_handle: Option<FocusHandle>,
|
|
||||||
#[allow(dead_code)]
|
|
||||||
subscriptions: Vec<Subscription>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl PopupMenu {
|
|
||||||
pub fn build(
|
|
||||||
window: &mut Window,
|
|
||||||
cx: &mut App,
|
|
||||||
f: impl FnOnce(Self, &mut Window, &mut Context<PopupMenu>) -> Self,
|
|
||||||
) -> Entity<Self> {
|
|
||||||
cx.new(|cx| {
|
|
||||||
let focus_handle = cx.focus_handle();
|
|
||||||
let subscriptions =
|
|
||||||
vec![
|
|
||||||
cx.on_blur(&focus_handle, window, |this: &mut PopupMenu, window, cx| {
|
|
||||||
this.dismiss(&Dismiss, window, cx)
|
|
||||||
}),
|
|
||||||
];
|
|
||||||
let menu = Self {
|
|
||||||
focus_handle,
|
|
||||||
action_focus_handle: None,
|
|
||||||
parent_menu: None,
|
|
||||||
menu_items: Vec::new(),
|
|
||||||
selected_index: None,
|
|
||||||
min_width: px(120.),
|
|
||||||
max_width: px(500.),
|
|
||||||
has_icon: false,
|
|
||||||
hovered_menu_ix: None,
|
|
||||||
bounds: Bounds::default(),
|
|
||||||
scrollable: false,
|
|
||||||
scroll_handle: ScrollHandle::default(),
|
|
||||||
scroll_state: ScrollbarState::default(),
|
|
||||||
subscriptions,
|
|
||||||
};
|
|
||||||
|
|
||||||
f(menu, window, cx)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Bind the focus handle of the menu, when clicked, it will focus back to this handle and then dispatch the action
|
|
||||||
pub fn track_focus(mut self, focus_handle: &FocusHandle) -> Self {
|
|
||||||
self.action_focus_handle = Some(focus_handle.clone());
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Set min width of the popup menu, default is 120px
|
|
||||||
pub fn min_w(mut self, width: impl Into<Pixels>) -> Self {
|
|
||||||
self.min_width = width.into();
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Set max width of the popup menu, default is 500px
|
|
||||||
pub fn max_w(mut self, width: impl Into<Pixels>) -> Self {
|
|
||||||
self.max_width = width.into();
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Set the menu to be scrollable to show vertical scrollbar.
|
|
||||||
///
|
|
||||||
/// NOTE: If this is true, the sub-menus will cannot be support.
|
|
||||||
pub fn scrollable(mut self) -> Self {
|
|
||||||
self.scrollable = true;
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Add Menu Item
|
|
||||||
pub fn menu(mut self, label: impl Into<SharedString>, action: Box<dyn Action>) -> Self {
|
|
||||||
self.add_menu_item(label, None, action);
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Add Menu to open link
|
|
||||||
pub fn link(mut self, label: impl Into<SharedString>, href: impl Into<String>) -> Self {
|
|
||||||
let href = href.into();
|
|
||||||
self.menu_items.push(PopupMenuItem::Item {
|
|
||||||
icon: None,
|
|
||||||
label: label.into(),
|
|
||||||
action: None,
|
|
||||||
handler: Rc::new(move |_window, cx| cx.open_url(&href)),
|
|
||||||
});
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Add Menu to open link
|
|
||||||
pub fn link_with_icon(
|
|
||||||
mut self,
|
|
||||||
label: impl Into<SharedString>,
|
|
||||||
icon: impl Into<Icon>,
|
|
||||||
href: impl Into<String>,
|
|
||||||
) -> Self {
|
|
||||||
let href = href.into();
|
|
||||||
self.menu_items.push(PopupMenuItem::Item {
|
|
||||||
icon: Some(icon.into()),
|
|
||||||
label: label.into(),
|
|
||||||
action: None,
|
|
||||||
handler: Rc::new(move |_window, cx| cx.open_url(&href)),
|
|
||||||
});
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Add Menu Item with Icon
|
|
||||||
pub fn menu_with_icon(
|
|
||||||
mut self,
|
|
||||||
label: impl Into<SharedString>,
|
|
||||||
icon: impl Into<Icon>,
|
|
||||||
action: Box<dyn Action>,
|
|
||||||
) -> Self {
|
|
||||||
self.add_menu_item(label, Some(icon.into()), action);
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Add Menu Item with check icon
|
|
||||||
pub fn menu_with_check(
|
|
||||||
mut self,
|
|
||||||
label: impl Into<SharedString>,
|
|
||||||
checked: bool,
|
|
||||||
action: Box<dyn Action>,
|
|
||||||
) -> Self {
|
|
||||||
if checked {
|
|
||||||
self.add_menu_item(label, Some(IconName::Check.into()), action);
|
|
||||||
} else {
|
|
||||||
self.add_menu_item(label, None, action);
|
|
||||||
}
|
|
||||||
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Add Menu Item with custom element render.
|
|
||||||
pub fn menu_with_element<F, E>(mut self, builder: F, action: Box<dyn Action>) -> Self
|
|
||||||
where
|
|
||||||
F: Fn(&mut Window, &mut App) -> E + 'static,
|
|
||||||
E: IntoElement,
|
|
||||||
{
|
|
||||||
self.menu_items.push(PopupMenuItem::ElementItem {
|
|
||||||
render: Box::new(move |window, cx| builder(window, cx).into_any_element()),
|
|
||||||
handler: self.wrap_handler(action),
|
|
||||||
});
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
#[allow(clippy::type_complexity)]
|
|
||||||
fn wrap_handler(&self, action: Box<dyn Action>) -> Rc<dyn Fn(&mut Window, &mut App)> {
|
|
||||||
let action_focus_handle = self.action_focus_handle.clone();
|
|
||||||
|
|
||||||
Rc::new(move |window, cx| {
|
|
||||||
window.activate_window();
|
|
||||||
|
|
||||||
// Focus back to the user expected focus handle
|
|
||||||
// Then the actions listened on that focus handle can be received
|
|
||||||
//
|
|
||||||
// For example:
|
|
||||||
//
|
|
||||||
// TabPanel
|
|
||||||
// |- PopupMenu
|
|
||||||
// |- PanelContent (actions are listened here)
|
|
||||||
//
|
|
||||||
// The `PopupMenu` and `PanelContent` are at the same level in the TabPanel
|
|
||||||
// If the actions are listened on the `PanelContent`,
|
|
||||||
// it can't receive the actions from the `PopupMenu`, unless we focus on `PanelContent`.
|
|
||||||
if let Some(handle) = action_focus_handle.as_ref() {
|
|
||||||
window.focus(handle);
|
|
||||||
}
|
|
||||||
|
|
||||||
window.dispatch_action(action.boxed_clone(), cx);
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn add_menu_item(
|
|
||||||
&mut self,
|
|
||||||
label: impl Into<SharedString>,
|
|
||||||
icon: Option<Icon>,
|
|
||||||
action: Box<dyn Action>,
|
|
||||||
) -> &mut Self {
|
|
||||||
if icon.is_some() {
|
|
||||||
self.has_icon = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
self.menu_items.push(PopupMenuItem::Item {
|
|
||||||
icon,
|
|
||||||
label: label.into(),
|
|
||||||
action: Some(action.boxed_clone()),
|
|
||||||
handler: self.wrap_handler(action),
|
|
||||||
});
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Add a title menu item
|
|
||||||
pub fn title(mut self, label: impl Into<SharedString>) -> Self {
|
|
||||||
if self.menu_items.is_empty() {
|
|
||||||
return self;
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(PopupMenuItem::Title(_)) = self.menu_items.last() {
|
|
||||||
return self;
|
|
||||||
}
|
|
||||||
|
|
||||||
self.menu_items.push(PopupMenuItem::Title(label.into()));
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Add a separator Menu Item
|
|
||||||
pub fn separator(mut self) -> Self {
|
|
||||||
if self.menu_items.is_empty() {
|
|
||||||
return self;
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(PopupMenuItem::Separator) = self.menu_items.last() {
|
|
||||||
return self;
|
|
||||||
}
|
|
||||||
|
|
||||||
self.menu_items.push(PopupMenuItem::Separator);
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn submenu(
|
|
||||||
self,
|
|
||||||
label: impl Into<SharedString>,
|
|
||||||
window: &mut Window,
|
|
||||||
cx: &mut Context<Self>,
|
|
||||||
f: impl Fn(PopupMenu, &mut Window, &mut Context<PopupMenu>) -> PopupMenu + 'static,
|
|
||||||
) -> Self {
|
|
||||||
self.submenu_with_icon(None, label, window, cx, f)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Add a Submenu item with icon
|
|
||||||
pub fn submenu_with_icon(
|
|
||||||
mut self,
|
|
||||||
icon: Option<Icon>,
|
|
||||||
label: impl Into<SharedString>,
|
|
||||||
window: &mut Window,
|
|
||||||
cx: &mut Context<Self>,
|
|
||||||
f: impl Fn(PopupMenu, &mut Window, &mut Context<PopupMenu>) -> PopupMenu + 'static,
|
|
||||||
) -> Self {
|
|
||||||
let submenu = PopupMenu::build(window, cx, f);
|
|
||||||
let parent_menu = cx.entity().downgrade();
|
|
||||||
|
|
||||||
submenu.update(cx, |view, _| {
|
|
||||||
view.parent_menu = Some(parent_menu);
|
|
||||||
});
|
|
||||||
|
|
||||||
self.menu_items.push(PopupMenuItem::Submenu {
|
|
||||||
icon,
|
|
||||||
label: label.into(),
|
|
||||||
menu: submenu,
|
|
||||||
});
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn active_submenu(&self) -> Option<Entity<PopupMenu>> {
|
|
||||||
if let Some(ix) = self.hovered_menu_ix {
|
|
||||||
if let Some(item) = self.menu_items.get(ix) {
|
|
||||||
return match item {
|
|
||||||
PopupMenuItem::Submenu { menu, .. } => Some(menu.clone()),
|
|
||||||
_ => None,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
None
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn is_empty(&self) -> bool {
|
|
||||||
self.menu_items.is_empty()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn clickable_menu_items(&self) -> impl Iterator<Item = (usize, &PopupMenuItem)> {
|
|
||||||
self.menu_items
|
|
||||||
.iter()
|
|
||||||
.enumerate()
|
|
||||||
.filter(|(_, item)| item.is_clickable())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn on_click(&mut self, ix: usize, window: &mut Window, cx: &mut Context<Self>) {
|
|
||||||
cx.stop_propagation();
|
|
||||||
window.prevent_default();
|
|
||||||
self.selected_index = Some(ix);
|
|
||||||
self.confirm(&Confirm, window, cx);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn confirm(&mut self, _: &Confirm, window: &mut Window, cx: &mut Context<Self>) {
|
|
||||||
if let Some(index) = self.selected_index {
|
|
||||||
let item = self.menu_items.get(index);
|
|
||||||
match item {
|
|
||||||
Some(PopupMenuItem::Item { handler, .. }) => {
|
|
||||||
handler(window, cx);
|
|
||||||
self.dismiss(&Dismiss, window, cx)
|
|
||||||
}
|
|
||||||
Some(PopupMenuItem::ElementItem { handler, .. }) => {
|
|
||||||
handler(window, cx);
|
|
||||||
self.dismiss(&Dismiss, window, cx)
|
|
||||||
}
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn select_next(&mut self, _: &SelectNext, _window: &mut Window, cx: &mut Context<Self>) {
|
|
||||||
let count = self.clickable_menu_items().count();
|
|
||||||
if count > 0 {
|
|
||||||
let last_ix = count.saturating_sub(1);
|
|
||||||
let ix = self
|
|
||||||
.selected_index
|
|
||||||
.map(|index| if index == last_ix { 0 } else { index + 1 })
|
|
||||||
.unwrap_or(0);
|
|
||||||
|
|
||||||
self.selected_index = Some(ix);
|
|
||||||
cx.notify();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn select_prev(&mut self, _: &SelectPrev, _window: &mut Window, cx: &mut Context<Self>) {
|
|
||||||
let count = self.clickable_menu_items().count();
|
|
||||||
if count > 0 {
|
|
||||||
let last_ix = count.saturating_sub(1);
|
|
||||||
|
|
||||||
let ix = self
|
|
||||||
.selected_index
|
|
||||||
.map(|index| {
|
|
||||||
if index == last_ix {
|
|
||||||
0
|
|
||||||
} else {
|
|
||||||
index.saturating_sub(1)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.unwrap_or(last_ix);
|
|
||||||
self.selected_index = Some(ix);
|
|
||||||
cx.notify();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TODO: fix this
|
|
||||||
#[allow(clippy::only_used_in_recursion)]
|
|
||||||
fn dismiss(&mut self, _: &Dismiss, window: &mut Window, cx: &mut Context<Self>) {
|
|
||||||
if self.active_submenu().is_some() {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
cx.emit(DismissEvent);
|
|
||||||
|
|
||||||
// Dismiss parent menu, when this menu is dismissed
|
|
||||||
if let Some(parent_menu) = self.parent_menu.clone().and_then(|menu| menu.upgrade()) {
|
|
||||||
parent_menu.update(cx, |view, cx| {
|
|
||||||
view.hovered_menu_ix = None;
|
|
||||||
view.dismiss(&Dismiss, window, cx);
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn render_keybinding(
|
|
||||||
action: Option<Box<dyn Action>>,
|
|
||||||
window: &mut Window,
|
|
||||||
cx: &mut Context<Self>,
|
|
||||||
) -> Option<impl IntoElement> {
|
|
||||||
if let Some(action) = action {
|
|
||||||
if let Some(keybinding) = window.bindings_for_action(action.deref()).first() {
|
|
||||||
let el = div().text_color(cx.theme().text_muted).children(
|
|
||||||
keybinding
|
|
||||||
.keystrokes()
|
|
||||||
.iter()
|
|
||||||
.map(|key| key_shortcut(key.as_keystroke().clone())),
|
|
||||||
);
|
|
||||||
|
|
||||||
return Some(el);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
None
|
|
||||||
}
|
|
||||||
|
|
||||||
fn render_icon(
|
|
||||||
has_icon: bool,
|
|
||||||
icon: Option<Icon>,
|
|
||||||
_window: &Window,
|
|
||||||
_cx: &Context<Self>,
|
|
||||||
) -> Option<impl IntoElement> {
|
|
||||||
let icon_placeholder = if has_icon { Some(Icon::empty()) } else { None };
|
|
||||||
|
|
||||||
if !has_icon {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
|
|
||||||
let icon = h_flex()
|
|
||||||
.w_3p5()
|
|
||||||
.h_3p5()
|
|
||||||
.items_center()
|
|
||||||
.justify_center()
|
|
||||||
.text_sm()
|
|
||||||
.map(|this| {
|
|
||||||
if let Some(icon) = icon {
|
|
||||||
this.child(icon.clone().small())
|
|
||||||
} else {
|
|
||||||
this.children(icon_placeholder.clone())
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
Some(icon)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl FluentBuilder for PopupMenu {}
|
|
||||||
|
|
||||||
impl EventEmitter<DismissEvent> for PopupMenu {}
|
|
||||||
|
|
||||||
impl Focusable for PopupMenu {
|
|
||||||
fn focus_handle(&self, _: &App) -> FocusHandle {
|
|
||||||
self.focus_handle.clone()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Render for PopupMenu {
|
|
||||||
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
|
||||||
let view = cx.entity().clone();
|
|
||||||
let has_icon = self.menu_items.iter().any(|item| item.has_icon());
|
|
||||||
let items_count = self.menu_items.len();
|
|
||||||
let max_width = self.max_width;
|
|
||||||
let bounds = self.bounds;
|
|
||||||
|
|
||||||
let window_haft_height = window.window_bounds().get_bounds().size.height * 0.5;
|
|
||||||
let max_height = window_haft_height.min(px(450.));
|
|
||||||
|
|
||||||
v_flex()
|
|
||||||
.id("popup-menu")
|
|
||||||
.key_context("PopupMenu")
|
|
||||||
.track_focus(&self.focus_handle)
|
|
||||||
.on_action(cx.listener(Self::select_next))
|
|
||||||
.on_action(cx.listener(Self::select_prev))
|
|
||||||
.on_action(cx.listener(Self::confirm))
|
|
||||||
.on_action(cx.listener(Self::dismiss))
|
|
||||||
.on_mouse_down_out(cx.listener(|this, _, window, cx| this.dismiss(&Dismiss, window, cx)))
|
|
||||||
.popover_style(cx)
|
|
||||||
.relative()
|
|
||||||
.p_1()
|
|
||||||
.child(
|
|
||||||
div()
|
|
||||||
.id("popup-menu-items")
|
|
||||||
.when(self.scrollable, |this| {
|
|
||||||
this.max_h(max_height)
|
|
||||||
.overflow_y_scroll()
|
|
||||||
.track_scroll(&self.scroll_handle)
|
|
||||||
})
|
|
||||||
.child(
|
|
||||||
v_flex()
|
|
||||||
.gap_y_0p5()
|
|
||||||
.min_w(self.min_width)
|
|
||||||
.max_w(self.max_width)
|
|
||||||
.min_w(rems(8.))
|
|
||||||
.child({
|
|
||||||
canvas(
|
|
||||||
move |bounds, _, cx| view.update(cx, |r, _| r.bounds = bounds),
|
|
||||||
|_, _, _, _| {},
|
|
||||||
)
|
|
||||||
.absolute()
|
|
||||||
.size_full()
|
|
||||||
})
|
|
||||||
.children(
|
|
||||||
self.menu_items
|
|
||||||
.iter_mut()
|
|
||||||
.enumerate()
|
|
||||||
// Skip last separator
|
|
||||||
.filter(|(ix, item)| !(*ix == items_count - 1 && item.is_separator()))
|
|
||||||
.map(|(ix, item)| {
|
|
||||||
let this = ListItem::new(("menu-item", ix))
|
|
||||||
.relative()
|
|
||||||
.items_center()
|
|
||||||
.py_0()
|
|
||||||
.px_2()
|
|
||||||
.rounded_md()
|
|
||||||
.text_sm()
|
|
||||||
.on_mouse_enter(cx.listener(move |this, _, _window, cx| {
|
|
||||||
this.hovered_menu_ix = Some(ix);
|
|
||||||
cx.notify();
|
|
||||||
}));
|
|
||||||
|
|
||||||
match item {
|
|
||||||
PopupMenuItem::Title(label) => {
|
|
||||||
this.child(
|
|
||||||
div()
|
|
||||||
.text_xs()
|
|
||||||
.font_semibold()
|
|
||||||
.text_color(cx.theme().text_muted)
|
|
||||||
.child(label.clone())
|
|
||||||
)
|
|
||||||
},
|
|
||||||
PopupMenuItem::Separator => this.h_auto().p_0().disabled(true).child(
|
|
||||||
div()
|
|
||||||
.rounded_none()
|
|
||||||
.h(px(1.))
|
|
||||||
.mx_neg_1()
|
|
||||||
.my_0p5()
|
|
||||||
.bg(cx.theme().border_disabled),
|
|
||||||
),
|
|
||||||
PopupMenuItem::ElementItem { render, .. } => this
|
|
||||||
.on_click(
|
|
||||||
cx.listener(move |this, _, window, cx| {
|
|
||||||
this.on_click(ix, window, cx)
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.child(
|
|
||||||
h_flex()
|
|
||||||
.min_h(ITEM_HEIGHT)
|
|
||||||
.items_center()
|
|
||||||
.gap_x_1()
|
|
||||||
.children(Self::render_icon(has_icon, None, window, cx))
|
|
||||||
.child((render)(window, cx)),
|
|
||||||
),
|
|
||||||
PopupMenuItem::Item {
|
|
||||||
icon, label, action, ..
|
|
||||||
} => {
|
|
||||||
let action = action.as_ref().map(|action| action.boxed_clone());
|
|
||||||
let key = Self::render_keybinding(action, window, cx);
|
|
||||||
|
|
||||||
this.on_click(
|
|
||||||
cx.listener(move |this, _, window, cx| {
|
|
||||||
this.on_click(ix, window, cx)
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.child(
|
|
||||||
h_flex()
|
|
||||||
.h(ITEM_HEIGHT)
|
|
||||||
.items_center()
|
|
||||||
.gap_x_1p5()
|
|
||||||
.children(Self::render_icon(has_icon, icon.clone(), window, cx))
|
|
||||||
.child(
|
|
||||||
h_flex()
|
|
||||||
.flex_1()
|
|
||||||
.gap_2()
|
|
||||||
.items_center()
|
|
||||||
.justify_between()
|
|
||||||
.child(label.clone())
|
|
||||||
.children(key),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
PopupMenuItem::Submenu { icon, label, menu } => this
|
|
||||||
.when(self.hovered_menu_ix == Some(ix), |this| this.selected(true))
|
|
||||||
.child(
|
|
||||||
h_flex()
|
|
||||||
.items_start()
|
|
||||||
.child(
|
|
||||||
h_flex()
|
|
||||||
.size_full()
|
|
||||||
.items_center()
|
|
||||||
.gap_x_1p5()
|
|
||||||
.children(Self::render_icon(
|
|
||||||
has_icon,
|
|
||||||
icon.clone(),
|
|
||||||
window,
|
|
||||||
cx,
|
|
||||||
))
|
|
||||||
.child(
|
|
||||||
h_flex()
|
|
||||||
.flex_1()
|
|
||||||
.gap_2()
|
|
||||||
.items_center()
|
|
||||||
.justify_between()
|
|
||||||
.child(label.clone())
|
|
||||||
.child(IconName::CaretRight),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.when_some(self.hovered_menu_ix, |this, hovered_ix| {
|
|
||||||
let (anchor, left) = if window.bounds().size.width
|
|
||||||
- bounds.origin.x
|
|
||||||
< max_width
|
|
||||||
{
|
|
||||||
(Corner::TopRight, -px(15.))
|
|
||||||
} else {
|
|
||||||
(Corner::TopLeft, bounds.size.width - px(10.))
|
|
||||||
};
|
|
||||||
|
|
||||||
let top = if bounds.origin.y + bounds.size.height
|
|
||||||
> window.bounds().size.height
|
|
||||||
{
|
|
||||||
px(32.)
|
|
||||||
} else {
|
|
||||||
-px(10.)
|
|
||||||
};
|
|
||||||
|
|
||||||
if hovered_ix == ix {
|
|
||||||
this.child(
|
|
||||||
anchored()
|
|
||||||
.anchor(anchor)
|
|
||||||
.child(
|
|
||||||
div()
|
|
||||||
.occlude()
|
|
||||||
.top(top)
|
|
||||||
.left(left)
|
|
||||||
.child(menu.clone()),
|
|
||||||
)
|
|
||||||
.snap_to_window_with_margin(Edges::all(px(8.))),
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
this
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.when(self.scrollable, |this| {
|
|
||||||
// TODO: When the menu is limited by `overflow_y_scroll`, the sub-menu will cannot be displayed.
|
|
||||||
this.child(
|
|
||||||
div()
|
|
||||||
.absolute()
|
|
||||||
.top_0()
|
|
||||||
.left_0()
|
|
||||||
.right_0p5()
|
|
||||||
.bottom_0()
|
|
||||||
.child(Scrollbar::vertical(&self.scroll_state, &self.scroll_handle)),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Return the Platform specific keybinding string by KeyStroke
|
|
||||||
pub fn key_shortcut(key: Keystroke) -> String {
|
|
||||||
if cfg!(target_os = "macos") {
|
|
||||||
return format!("{key}");
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut parts = vec![];
|
|
||||||
if key.modifiers.control {
|
|
||||||
parts.push("Ctrl");
|
|
||||||
}
|
|
||||||
if key.modifiers.alt {
|
|
||||||
parts.push("Alt");
|
|
||||||
}
|
|
||||||
if key.modifiers.platform {
|
|
||||||
parts.push("Win");
|
|
||||||
}
|
|
||||||
if key.modifiers.shift {
|
|
||||||
parts.push("Shift");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Capitalize the first letter
|
|
||||||
let key = if let Some(first_c) = key.key.chars().next() {
|
|
||||||
format!("{}{}", first_c.to_uppercase(), &key.key[1..])
|
|
||||||
} else {
|
|
||||||
key.key.to_string()
|
|
||||||
};
|
|
||||||
|
|
||||||
parts.push(&key);
|
|
||||||
parts.join("+")
|
|
||||||
}
|
|
||||||
@@ -1,24 +1,294 @@
|
|||||||
use gpui::{Axis, Context, Window};
|
use std::ops::Range;
|
||||||
|
|
||||||
|
use gpui::{
|
||||||
|
px, Along, App, Axis, Bounds, Context, ElementId, EventEmitter, IsZero, Pixels, Window,
|
||||||
|
};
|
||||||
|
|
||||||
mod panel;
|
mod panel;
|
||||||
mod resize_handle;
|
mod resize_handle;
|
||||||
|
|
||||||
pub use panel::*;
|
pub use panel::*;
|
||||||
pub(crate) use resize_handle::*;
|
pub(crate) use resize_handle::*;
|
||||||
|
|
||||||
pub fn h_resizable(
|
pub(crate) const PANEL_MIN_SIZE: Pixels = px(100.);
|
||||||
window: &mut Window,
|
|
||||||
cx: &mut Context<ResizablePanelGroup>,
|
/// Create a [`ResizablePanelGroup`] with horizontal resizing
|
||||||
) -> ResizablePanelGroup {
|
pub fn h_resizable(id: impl Into<ElementId>) -> ResizablePanelGroup {
|
||||||
ResizablePanelGroup::new(window, cx).axis(Axis::Horizontal)
|
ResizablePanelGroup::new(id).axis(Axis::Horizontal)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn v_resizable(
|
/// Create a [`ResizablePanelGroup`] with vertical resizing
|
||||||
window: &mut Window,
|
pub fn v_resizable(id: impl Into<ElementId>) -> ResizablePanelGroup {
|
||||||
cx: &mut Context<ResizablePanelGroup>,
|
ResizablePanelGroup::new(id).axis(Axis::Vertical)
|
||||||
) -> ResizablePanelGroup {
|
|
||||||
ResizablePanelGroup::new(window, cx).axis(Axis::Vertical)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Create a [`ResizablePanel`].
|
||||||
pub fn resizable_panel() -> ResizablePanel {
|
pub fn resizable_panel() -> ResizablePanel {
|
||||||
ResizablePanel::new()
|
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 {
|
||||||
|
if 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>,
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,50 +1,60 @@
|
|||||||
|
use std::ops::{Deref, Range};
|
||||||
use std::rc::Rc;
|
use std::rc::Rc;
|
||||||
|
|
||||||
use gpui::prelude::FluentBuilder;
|
use gpui::prelude::FluentBuilder;
|
||||||
use gpui::{
|
use gpui::{
|
||||||
canvas, div, px, relative, Along, AnyElement, AnyView, App, AppContext, Axis, Bounds, Context,
|
div, Along, AnyElement, App, AppContext, Axis, Bounds, Context, Element, ElementId, Empty,
|
||||||
Element, Entity, EntityId, EventEmitter, IntoElement, IsZero, MouseMoveEvent, MouseUpEvent,
|
Entity, EventEmitter, InteractiveElement as _, IntoElement, IsZero as _, MouseMoveEvent,
|
||||||
ParentElement, Pixels, Render, StatefulInteractiveElement as _, Style, Styled, WeakEntity,
|
MouseUpEvent, ParentElement, Pixels, Render, RenderOnce, Style, Styled, Window,
|
||||||
Window,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
use super::resize_handle;
|
use super::{resizable_panel, resize_handle, ResizableState};
|
||||||
use crate::{h_flex, v_flex, AxisExt};
|
use crate::resizable::PANEL_MIN_SIZE;
|
||||||
|
use crate::{h_flex, v_flex, AxisExt, ElementExt};
|
||||||
pub(crate) const PANEL_MIN_SIZE: Pixels = px(100.);
|
|
||||||
|
|
||||||
pub enum ResizablePanelEvent {
|
pub enum ResizablePanelEvent {
|
||||||
Resized,
|
Resized,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Render)]
|
|
||||||
pub struct DragPanel(pub (EntityId, usize, Axis));
|
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[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 {
|
pub struct ResizablePanelGroup {
|
||||||
panels: Vec<Entity<ResizablePanel>>,
|
id: ElementId,
|
||||||
sizes: Vec<Pixels>,
|
state: Option<Entity<ResizableState>>,
|
||||||
axis: Axis,
|
axis: Axis,
|
||||||
size: Option<Pixels>,
|
size: Option<Pixels>,
|
||||||
bounds: Bounds<Pixels>,
|
children: Vec<ResizablePanel>,
|
||||||
resizing_panel_ix: Option<usize>,
|
on_resize: Rc<dyn Fn(&Entity<ResizableState>, &mut Window, &mut App)>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ResizablePanelGroup {
|
impl ResizablePanelGroup {
|
||||||
pub(super) fn new(_window: &mut Window, _cx: &mut Context<Self>) -> Self {
|
/// Create a new resizable panel group.
|
||||||
|
pub fn new(id: impl Into<ElementId>) -> Self {
|
||||||
Self {
|
Self {
|
||||||
|
id: id.into(),
|
||||||
axis: Axis::Horizontal,
|
axis: Axis::Horizontal,
|
||||||
sizes: Vec::new(),
|
children: vec![],
|
||||||
panels: Vec::new(),
|
state: None,
|
||||||
size: None,
|
size: None,
|
||||||
bounds: Bounds::default(),
|
on_resize: Rc::new(|_, _, _| {}),
|
||||||
resizing_panel_ix: None,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn load(&mut self, sizes: Vec<Pixels>, panels: Vec<Entity<ResizablePanel>>) {
|
/// Bind yourself to a resizable state entity.
|
||||||
self.sizes = sizes;
|
///
|
||||||
self.panels = panels;
|
/// 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.
|
/// Set the axis of the resizable panel group, default is horizontal.
|
||||||
@@ -53,35 +63,23 @@ impl ResizablePanelGroup {
|
|||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn set_axis(&mut self, axis: Axis, _window: &mut Window, cx: &mut Context<Self>) {
|
/// Add a panel to the group.
|
||||||
self.axis = axis;
|
///
|
||||||
cx.notify();
|
/// - 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.
|
||||||
/// Add a resizable panel to the group.
|
pub fn child(mut self, panel: impl Into<ResizablePanel>) -> Self {
|
||||||
pub fn child(
|
self.children.push(panel.into());
|
||||||
mut self,
|
|
||||||
panel: ResizablePanel,
|
|
||||||
window: &mut Window,
|
|
||||||
cx: &mut Context<Self>,
|
|
||||||
) -> Self {
|
|
||||||
self.add_child(panel, window, cx);
|
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Add a ResizablePanelGroup as a child to the group.
|
/// Add multiple panels to the group.
|
||||||
pub fn group(
|
pub fn children<I>(mut self, panels: impl IntoIterator<Item = I>) -> Self
|
||||||
self,
|
where
|
||||||
group: ResizablePanelGroup,
|
I: Into<ResizablePanel>,
|
||||||
window: &mut Window,
|
{
|
||||||
cx: &mut Context<Self>,
|
self.children = panels.into_iter().map(|panel| panel.into()).collect();
|
||||||
) -> Self {
|
self
|
||||||
let group: ResizablePanelGroup = group;
|
|
||||||
let size = group.size;
|
|
||||||
let panel = ResizablePanel::new()
|
|
||||||
.content_view(cx.new(|_| group).into())
|
|
||||||
.when_some(size, |this, size| this.size(size));
|
|
||||||
self.child(panel, window, cx)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Set size of the resizable panel group
|
/// Set size of the resizable panel group
|
||||||
@@ -93,375 +91,224 @@ impl ResizablePanelGroup {
|
|||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Calculates the sum of all panel sizes within the group.
|
/// Set the callback to be called when the panels are resized.
|
||||||
pub fn total_size(&self) -> Pixels {
|
///
|
||||||
self.sizes.iter().fold(px(0.0), |acc, &size| acc + size)
|
/// ## Callback arguments
|
||||||
}
|
///
|
||||||
|
/// - Entity<ResizableState>: The state of the ResizablePanelGroup.
|
||||||
pub fn add_child(
|
pub fn on_resize(
|
||||||
&mut self,
|
mut self,
|
||||||
panel: ResizablePanel,
|
on_resize: impl Fn(&Entity<ResizableState>, &mut Window, &mut App) + 'static,
|
||||||
_window: &mut Window,
|
) -> Self {
|
||||||
cx: &mut Context<Self>,
|
self.on_resize = Rc::new(on_resize);
|
||||||
) {
|
self
|
||||||
let mut panel = panel;
|
|
||||||
panel.axis = self.axis;
|
|
||||||
panel.group = Some(cx.entity().downgrade());
|
|
||||||
self.sizes.push(panel.initial_size.unwrap_or_default());
|
|
||||||
self.panels.push(cx.new(|_| panel));
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn insert_child(
|
|
||||||
&mut self,
|
|
||||||
panel: ResizablePanel,
|
|
||||||
ix: usize,
|
|
||||||
_window: &mut Window,
|
|
||||||
cx: &mut Context<Self>,
|
|
||||||
) {
|
|
||||||
let mut panel = panel;
|
|
||||||
panel.axis = self.axis;
|
|
||||||
panel.group = Some(cx.entity().downgrade());
|
|
||||||
|
|
||||||
self.sizes
|
|
||||||
.insert(ix, panel.initial_size.unwrap_or_default());
|
|
||||||
self.panels.insert(ix, cx.new(|_| panel));
|
|
||||||
|
|
||||||
cx.notify()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Replace a child panel with a new panel at the given index.
|
|
||||||
pub(crate) fn replace_child(
|
|
||||||
&mut self,
|
|
||||||
panel: ResizablePanel,
|
|
||||||
ix: usize,
|
|
||||||
_window: &mut Window,
|
|
||||||
cx: &mut Context<Self>,
|
|
||||||
) {
|
|
||||||
let mut panel = panel;
|
|
||||||
|
|
||||||
let old_panel = self.panels[ix].clone();
|
|
||||||
let old_panel_initial_size = old_panel.read(cx).initial_size;
|
|
||||||
let old_panel_size_ratio = old_panel.read(cx).size_ratio;
|
|
||||||
|
|
||||||
panel.initial_size = old_panel_initial_size;
|
|
||||||
panel.size_ratio = old_panel_size_ratio;
|
|
||||||
panel.axis = self.axis;
|
|
||||||
panel.group = Some(cx.entity().downgrade());
|
|
||||||
self.sizes[ix] = panel.initial_size.unwrap_or_default();
|
|
||||||
self.panels[ix] = cx.new(|_| panel);
|
|
||||||
cx.notify()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn remove_child(&mut self, ix: usize, _window: &mut Window, cx: &mut Context<Self>) {
|
|
||||||
self.sizes.remove(ix);
|
|
||||||
self.panels.remove(ix);
|
|
||||||
cx.notify()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn remove_all_children(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
|
|
||||||
self.sizes.clear();
|
|
||||||
self.panels.clear();
|
|
||||||
cx.notify()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn render_resize_handle(
|
|
||||||
&self,
|
|
||||||
ix: usize,
|
|
||||||
_window: &mut Window,
|
|
||||||
cx: &mut Context<Self>,
|
|
||||||
) -> impl IntoElement {
|
|
||||||
let view = cx.entity().clone();
|
|
||||||
resize_handle(("resizable-handle", ix), self.axis).on_drag(
|
|
||||||
DragPanel((cx.entity_id(), ix, self.axis)),
|
|
||||||
move |drag_panel, _, _window, cx| {
|
|
||||||
cx.stop_propagation();
|
|
||||||
// Set current resizing panel ix
|
|
||||||
view.update(cx, |view, _| {
|
|
||||||
view.resizing_panel_ix = Some(ix);
|
|
||||||
});
|
|
||||||
cx.new(|_| drag_panel.clone())
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn done_resizing(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
|
|
||||||
cx.emit(ResizablePanelEvent::Resized);
|
|
||||||
self.resizing_panel_ix = None;
|
|
||||||
}
|
|
||||||
|
|
||||||
fn sync_real_panel_sizes(&mut self, _window: &Window, cx: &App) {
|
|
||||||
for (i, panel) in self.panels.iter().enumerate() {
|
|
||||||
self.sizes[i] = panel.read(cx).bounds.size.along(self.axis)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The `ix`` is the index of the panel to resize,
|
impl<T> From<T> for ResizablePanel
|
||||||
/// and the `size` is the new size for the panel.
|
where
|
||||||
fn resize_panels(
|
T: Into<AnyElement>,
|
||||||
&mut self,
|
{
|
||||||
ix: usize,
|
fn from(value: T) -> Self {
|
||||||
size: Pixels,
|
resizable_panel().child(value.into())
|
||||||
window: &mut Window,
|
|
||||||
cx: &mut Context<Self>,
|
|
||||||
) {
|
|
||||||
let mut ix = ix;
|
|
||||||
// Only resize the left panels.
|
|
||||||
if ix >= self.panels.len() - 1 {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let size = size.floor();
|
|
||||||
let container_size = self.bounds.size.along(self.axis);
|
|
||||||
|
|
||||||
self.sync_real_panel_sizes(window, cx);
|
|
||||||
|
|
||||||
let mut changed = size - self.sizes[ix];
|
|
||||||
let is_expand = changed > px(0.);
|
|
||||||
|
|
||||||
let main_ix = ix;
|
|
||||||
let mut new_sizes = self.sizes.clone();
|
|
||||||
|
|
||||||
if is_expand {
|
|
||||||
new_sizes[ix] = size;
|
|
||||||
|
|
||||||
// Now to expand logic is correct.
|
|
||||||
while changed > px(0.) && ix < self.panels.len() - 1 {
|
|
||||||
ix += 1;
|
|
||||||
let available_size = (new_sizes[ix] - PANEL_MIN_SIZE).max(px(0.));
|
|
||||||
let to_reduce = changed.min(available_size);
|
|
||||||
new_sizes[ix] -= to_reduce;
|
|
||||||
changed -= to_reduce;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
let new_size = size.max(PANEL_MIN_SIZE);
|
|
||||||
new_sizes[ix] = new_size;
|
|
||||||
changed = size - PANEL_MIN_SIZE;
|
|
||||||
new_sizes[ix + 1] += self.sizes[ix] - new_size;
|
|
||||||
|
|
||||||
while changed < px(0.) && ix > 0 {
|
|
||||||
ix -= 1;
|
|
||||||
let available_size = self.sizes[ix] - PANEL_MIN_SIZE;
|
|
||||||
let to_increase = (changed).min(available_size);
|
|
||||||
new_sizes[ix] += to_increase;
|
|
||||||
changed += to_increase;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// If total size exceeds container size, adjust the main panel
|
impl From<ResizablePanelGroup> for ResizablePanel {
|
||||||
let total_size: Pixels = new_sizes.iter().map(|s| s.signum()).sum::<f32>().into();
|
fn from(value: ResizablePanelGroup) -> Self {
|
||||||
if total_size > container_size {
|
resizable_panel().child(value)
|
||||||
let overflow = total_size - container_size;
|
|
||||||
new_sizes[main_ix] = (new_sizes[main_ix] - overflow).max(PANEL_MIN_SIZE);
|
|
||||||
}
|
|
||||||
|
|
||||||
let total_size = new_sizes.iter().fold(px(0.0), |acc, &size| acc + size);
|
|
||||||
self.sizes = new_sizes;
|
|
||||||
for (i, panel) in self.panels.iter().enumerate() {
|
|
||||||
let size = self.sizes[i];
|
|
||||||
if size > px(0.) {
|
|
||||||
panel.update(cx, |this, _| {
|
|
||||||
this.size = Some(size);
|
|
||||||
this.size_ratio = Some(size / total_size);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl EventEmitter<ResizablePanelEvent> for ResizablePanelGroup {}
|
impl EventEmitter<ResizablePanelEvent> for ResizablePanelGroup {}
|
||||||
|
|
||||||
impl Render for ResizablePanelGroup {
|
impl RenderOnce for ResizablePanelGroup {
|
||||||
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||||
let view = cx.entity().clone();
|
let state = self.state.unwrap_or(
|
||||||
|
window.use_keyed_state(self.id.clone(), cx, |_, _| ResizableState::default()),
|
||||||
|
);
|
||||||
let container = if self.axis.is_horizontal() {
|
let container = if self.axis.is_horizontal() {
|
||||||
h_flex()
|
h_flex()
|
||||||
} else {
|
} else {
|
||||||
v_flex()
|
v_flex()
|
||||||
};
|
};
|
||||||
|
|
||||||
container
|
// Sync panels to the state
|
||||||
.size_full()
|
let panels_count = self.children.len();
|
||||||
.children(self.panels.iter().enumerate().map(|(ix, panel)| {
|
state.update(cx, |state, cx| {
|
||||||
if ix > 0 {
|
state.sync_panels_count(self.axis, panels_count, cx);
|
||||||
let handle = self.render_resize_handle(ix - 1, window, cx);
|
|
||||||
panel.update(cx, |view, _| {
|
|
||||||
view.resize_handle = Some(handle.into_any_element())
|
|
||||||
});
|
});
|
||||||
}
|
|
||||||
|
|
||||||
panel.clone()
|
container
|
||||||
}))
|
.id(self.id)
|
||||||
.child({
|
|
||||||
canvas(
|
|
||||||
move |bounds, _, cx| view.update(cx, |r, _| r.bounds = bounds),
|
|
||||||
|_, _, _, _| {},
|
|
||||||
)
|
|
||||||
.absolute()
|
|
||||||
.size_full()
|
.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 {
|
.child(ResizePanelGroupElement {
|
||||||
view: cx.entity().clone(),
|
state: state.clone(),
|
||||||
axis: self.axis,
|
axis: self.axis,
|
||||||
|
on_resize: self.on_resize.clone(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type ContentBuilder = Option<Rc<dyn Fn(&mut Window, &mut App) -> AnyElement>>;
|
/// A resizable panel inside a [`ResizablePanelGroup`].
|
||||||
type ContentVisible = Rc<Box<dyn Fn(&App) -> bool>>;
|
#[derive(IntoElement)]
|
||||||
|
|
||||||
pub struct ResizablePanel {
|
pub struct ResizablePanel {
|
||||||
group: Option<WeakEntity<ResizablePanelGroup>>,
|
axis: Axis,
|
||||||
|
panel_ix: usize,
|
||||||
|
state: Option<Entity<ResizableState>>,
|
||||||
/// Initial size is the size that the panel has when it is created.
|
/// Initial size is the size that the panel has when it is created.
|
||||||
initial_size: Option<Pixels>,
|
initial_size: Option<Pixels>,
|
||||||
/// size is the size that the panel has when it is resized or adjusted by flex layout.
|
/// size range limit of this panel.
|
||||||
size: Option<Pixels>,
|
size_range: Range<Pixels>,
|
||||||
/// the size ratio that the panel has relative to its group
|
children: Vec<AnyElement>,
|
||||||
size_ratio: Option<f32>,
|
visible: bool,
|
||||||
axis: Axis,
|
|
||||||
content_builder: ContentBuilder,
|
|
||||||
content_view: Option<AnyView>,
|
|
||||||
content_visible: ContentVisible,
|
|
||||||
/// The bounds of the resizable panel, when render the bounds will be updated.
|
|
||||||
bounds: Bounds<Pixels>,
|
|
||||||
resize_handle: Option<AnyElement>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ResizablePanel {
|
impl ResizablePanel {
|
||||||
|
/// Create a new resizable panel.
|
||||||
pub(super) fn new() -> Self {
|
pub(super) fn new() -> Self {
|
||||||
Self {
|
Self {
|
||||||
group: None,
|
panel_ix: 0,
|
||||||
initial_size: None,
|
initial_size: None,
|
||||||
size: None,
|
state: None,
|
||||||
size_ratio: None,
|
size_range: (PANEL_MIN_SIZE..Pixels::MAX),
|
||||||
axis: Axis::Horizontal,
|
axis: Axis::Horizontal,
|
||||||
content_builder: None,
|
children: vec![],
|
||||||
content_view: None,
|
visible: true,
|
||||||
content_visible: Rc::new(Box::new(|_| true)),
|
|
||||||
bounds: Bounds::default(),
|
|
||||||
resize_handle: None,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn content<F>(mut self, content: F) -> Self
|
/// Set the visibility of the panel, default is true.
|
||||||
where
|
pub fn visible(mut self, visible: bool) -> Self {
|
||||||
F: Fn(&mut Window, &mut App) -> AnyElement + 'static,
|
self.visible = visible;
|
||||||
{
|
|
||||||
self.content_builder = Some(Rc::new(content));
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn content_visible<F>(mut self, content_visible: F) -> Self
|
|
||||||
where
|
|
||||||
F: Fn(&App) -> bool + 'static,
|
|
||||||
{
|
|
||||||
self.content_visible = Rc::new(Box::new(content_visible));
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn content_view(mut self, content: AnyView) -> Self {
|
|
||||||
self.content_view = Some(content);
|
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Set the initial size of the panel.
|
/// Set the initial size of the panel.
|
||||||
pub fn size(mut self, size: Pixels) -> Self {
|
pub fn size(mut self, size: impl Into<Pixels>) -> Self {
|
||||||
self.initial_size = Some(size);
|
self.initial_size = Some(size.into());
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Save the real panel size, and update group sizes
|
/// Set the size range to limit panel resize.
|
||||||
fn update_size(
|
///
|
||||||
&mut self,
|
/// Default is [`PANEL_MIN_SIZE`] to [`Pixels::MAX`].
|
||||||
bounds: Bounds<Pixels>,
|
pub fn size_range(mut self, range: impl Into<Range<Pixels>>) -> Self {
|
||||||
_window: &mut Window,
|
self.size_range = range.into();
|
||||||
cx: &mut Context<Self>,
|
self
|
||||||
) {
|
|
||||||
let new_size = bounds.size.along(self.axis);
|
|
||||||
self.bounds = bounds;
|
|
||||||
self.size_ratio = None;
|
|
||||||
self.size = Some(new_size);
|
|
||||||
|
|
||||||
let entity_id = cx.entity_id();
|
|
||||||
|
|
||||||
if let Some(group) = self.group.as_ref() {
|
|
||||||
_ = group.update(cx, |view, _| {
|
|
||||||
if let Some(ix) = view.panels.iter().position(|v| v.entity_id() == entity_id) {
|
|
||||||
view.sizes[ix] = new_size;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
cx.notify();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl FluentBuilder for ResizablePanel {}
|
impl ParentElement for ResizablePanel {
|
||||||
|
fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
|
||||||
impl Render for ResizablePanel {
|
self.children.extend(elements);
|
||||||
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
}
|
||||||
if !(self.content_visible)(cx) {
|
|
||||||
// To keep size as initial size, to make sure the size will not be changed.
|
|
||||||
self.initial_size = self.size;
|
|
||||||
self.size = None;
|
|
||||||
|
|
||||||
return div();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let total_size = self
|
impl RenderOnce for ResizablePanel {
|
||||||
.group
|
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||||
.as_ref()
|
if !self.visible {
|
||||||
.and_then(|group| group.upgrade())
|
return div().id(("resizable-panel", self.panel_ix));
|
||||||
.map(|group| group.read(cx).total_size());
|
}
|
||||||
|
|
||||||
let view = cx.entity();
|
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()
|
div()
|
||||||
|
.id(("resizable-panel", self.panel_ix))
|
||||||
.flex()
|
.flex()
|
||||||
.flex_grow()
|
.flex_grow()
|
||||||
.size_full()
|
.size_full()
|
||||||
.relative()
|
.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())
|
.when(self.initial_size.is_none(), |this| this.flex_shrink())
|
||||||
.when(self.axis.is_vertical(), |this| this.min_h(PANEL_MIN_SIZE))
|
.when_some(self.initial_size, |this, initial_size| {
|
||||||
.when(self.axis.is_horizontal(), |this| this.min_w(PANEL_MIN_SIZE))
|
// The `self.size` is None, that mean the initial size for the panel,
|
||||||
.when_some(self.initial_size, |this, size| {
|
// so we need set `flex_shrink_0` To let it keep the initial size.
|
||||||
if size.is_zero() {
|
this.when(
|
||||||
this
|
panel_state.size.is_none() && !initial_size.is_zero(),
|
||||||
} else {
|
|this| this.flex_none(),
|
||||||
// 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(self.size.is_none() && size > px(0.), |this| {
|
|
||||||
this.flex_shrink_0()
|
|
||||||
})
|
|
||||||
.flex_basis(size)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.map(|this| match (self.size_ratio, self.size, total_size) {
|
|
||||||
(Some(size_ratio), _, _) => this.flex_basis(relative(size_ratio)),
|
|
||||||
(None, Some(size), Some(total_size)) => {
|
|
||||||
this.flex_basis(relative(size / total_size))
|
|
||||||
}
|
|
||||||
(None, Some(size), None) => this.flex_basis(size),
|
|
||||||
_ => this,
|
|
||||||
})
|
|
||||||
.child({
|
|
||||||
canvas(
|
|
||||||
move |bounds, window, cx| {
|
|
||||||
view.update(cx, |r, cx| r.update_size(bounds, window, cx))
|
|
||||||
},
|
|
||||||
|_, _, _, _| {},
|
|
||||||
)
|
)
|
||||||
.absolute()
|
.flex_basis(initial_size)
|
||||||
.size_full()
|
|
||||||
})
|
})
|
||||||
.when_some(self.content_builder.clone(), |this, c| {
|
.map(|this| match panel_state.size {
|
||||||
this.child(c(window, cx))
|
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())
|
||||||
|
},
|
||||||
|
))
|
||||||
})
|
})
|
||||||
.when_some(self.content_view.clone(), |this, c| this.child(c))
|
|
||||||
.when_some(self.resize_handle.take(), |this, c| this.child(c))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[allow(clippy::type_complexity)]
|
||||||
struct ResizePanelGroupElement {
|
struct ResizePanelGroupElement {
|
||||||
|
state: Entity<ResizableState>,
|
||||||
|
on_resize: Rc<dyn Fn(&Entity<ResizableState>, &mut Window, &mut App)>,
|
||||||
axis: Axis,
|
axis: Axis,
|
||||||
view: Entity<ResizablePanelGroup>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl IntoElement for ResizePanelGroupElement {
|
impl IntoElement for ResizePanelGroupElement {
|
||||||
@@ -516,44 +363,43 @@ impl Element for ResizePanelGroupElement {
|
|||||||
cx: &mut App,
|
cx: &mut App,
|
||||||
) {
|
) {
|
||||||
window.on_mouse_event({
|
window.on_mouse_event({
|
||||||
let view = self.view.clone();
|
let state = self.state.clone();
|
||||||
let axis = self.axis;
|
let axis = self.axis;
|
||||||
let current_ix = view.read(cx).resizing_panel_ix;
|
let current_ix = state.read(cx).resizing_panel_ix;
|
||||||
move |e: &MouseMoveEvent, phase, window, cx| {
|
move |e: &MouseMoveEvent, phase, window, cx| {
|
||||||
if !phase.bubble() {
|
if !phase.bubble() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let Some(ix) = current_ix else { return };
|
let Some(ix) = current_ix else { return };
|
||||||
|
|
||||||
view.update(cx, |view, cx| {
|
state.update(cx, |state, cx| {
|
||||||
let panel = view
|
let panel = state.panels.get(ix).expect("BUG: invalid panel index");
|
||||||
.panels
|
|
||||||
.get(ix)
|
|
||||||
.expect("BUG: invalid panel index")
|
|
||||||
.read(cx);
|
|
||||||
|
|
||||||
match axis {
|
match axis {
|
||||||
Axis::Horizontal => {
|
Axis::Horizontal => {
|
||||||
view.resize_panels(ix, e.position.x - panel.bounds.left(), window, cx)
|
state.resize_panel(ix, e.position.x - panel.bounds.left(), window, cx)
|
||||||
}
|
}
|
||||||
Axis::Vertical => {
|
Axis::Vertical => {
|
||||||
view.resize_panels(ix, e.position.y - panel.bounds.top(), window, cx);
|
state.resize_panel(ix, e.position.y - panel.bounds.top(), window, cx);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
cx.notify();
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// When any mouse up, stop dragging
|
// When any mouse up, stop dragging
|
||||||
window.on_mouse_event({
|
window.on_mouse_event({
|
||||||
let view = self.view.clone();
|
let state = self.state.clone();
|
||||||
let current_ix = view.read(cx).resizing_panel_ix;
|
let current_ix = state.read(cx).resizing_panel_ix;
|
||||||
|
let on_resize = self.on_resize.clone();
|
||||||
move |_: &MouseUpEvent, phase, window, cx| {
|
move |_: &MouseUpEvent, phase, window, cx| {
|
||||||
if current_ix.is_none() {
|
if current_ix.is_none() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if phase.bubble() {
|
if phase.bubble() {
|
||||||
view.update(cx, |view, cx| view.done_resizing(window, cx));
|
state.update(cx, |state, cx| state.done_resizing(cx));
|
||||||
|
on_resize(&state, window, cx);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||