Compare commits
60 Commits
ff5ae8280c
...
v1.0.0-bet
| Author | SHA1 | Date | |
|---|---|---|---|
| 2e451aae12 | |||
| cdfcfdd782 | |||
| 9817dd29a6 | |||
| f066cb8223 | |||
| 6d60726f27 | |||
| 80186a79e5 | |||
|
|
cffcb4711d | ||
|
|
44484d9992 | ||
|
|
15ac8d6775 | ||
|
|
6f0cefed33 | ||
|
|
c239e351b8 | ||
|
|
9ff18aae35 | ||
|
|
6fef2ae1c6 | ||
|
|
c2a723faa8 | ||
|
|
6b872527ad | ||
|
|
d9b16aea9a | ||
|
|
8345def015 | ||
|
|
b0ba2549d7 | ||
|
|
c8034642c5 | ||
|
|
8205c69e19 | ||
|
|
d36364d60d | ||
|
|
99363475e0 | ||
|
|
a52e1877fe | ||
|
|
b41de00c95 | ||
|
|
94cbb4aa0e | ||
|
|
40e7ca368b | ||
|
|
b91697defc | ||
|
|
1d57a2deab | ||
|
|
aa26c9ccba | ||
|
|
ff61c28a76 | ||
|
|
069eae8145 | ||
|
|
ccbcc644db | ||
|
|
15c5ce7677 | ||
|
|
40d726c986 | ||
|
|
fe4eb7df74 | ||
|
|
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 |
16
.github/workflows/release.yml
vendored
@@ -21,7 +21,7 @@ jobs:
|
||||
os: windows-11-arm
|
||||
target: aarch64-pc-windows-msvc
|
||||
- platform: macos-x64
|
||||
os: macos-13
|
||||
os: macos-15-intel
|
||||
target: x86_64-apple-darwin
|
||||
- platform: macos-arm64
|
||||
os: macos-latest
|
||||
@@ -45,7 +45,7 @@ jobs:
|
||||
# Windows and macOS builds using cargo-packager
|
||||
- name: Build with cargo-packager (Windows/macOS)
|
||||
if: runner.os != 'Linux'
|
||||
working-directory: crates/coop
|
||||
working-directory: desktop
|
||||
run: |
|
||||
cargo install cargo-packager --locked
|
||||
cargo packager --release
|
||||
@@ -130,7 +130,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Make get-crate-version executable
|
||||
run: chmod +x script/get-crate-version
|
||||
@@ -154,17 +154,15 @@ jobs:
|
||||
|
||||
- name: Create draft release
|
||||
id: create_release
|
||||
uses: softprops/action-gh-release@v2
|
||||
uses: akkuman/gitea-release-action@v1
|
||||
with:
|
||||
tag_name: ${{ steps.version.outputs.tag }}
|
||||
name: ${{ steps.version.outputs.tag }}
|
||||
server_url: "https://git.reya.su/"
|
||||
repository: "reya/coop"
|
||||
token: ${{ secrets.GITEA_TOKEN }}
|
||||
draft: true
|
||||
prerelease: false
|
||||
generate_release_notes: true
|
||||
files: |
|
||||
artifacts/**/*
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Output release info
|
||||
run: |
|
||||
|
||||
3
.gitignore
vendored
@@ -21,3 +21,6 @@ dist/
|
||||
.DS_Store
|
||||
# Added by goreleaser init:
|
||||
.intentionally-empty-file.o
|
||||
|
||||
.cargo/
|
||||
vendor/
|
||||
|
||||
1781
Cargo.lock
generated
13
Cargo.toml
@@ -1,18 +1,17 @@
|
||||
[workspace]
|
||||
resolver = "2"
|
||||
members = ["crates/*"]
|
||||
default-members = ["crates/coop"]
|
||||
members = ["crates/*", "desktop", "web"]
|
||||
default-members = ["desktop"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.3.0"
|
||||
edition = "2021"
|
||||
version = "1.0.0-beta4"
|
||||
edition = "2024"
|
||||
publish = false
|
||||
|
||||
[workspace.dependencies]
|
||||
|
||||
# GPUI
|
||||
gpui = { git = "https://github.com/zed-industries/zed" }
|
||||
gpui_platform = { git = "https://github.com/zed-industries/zed", features = ["font-kit", "screen-capture", "x11", "wayland", "runtime_shaders"] }
|
||||
gpui_platform = { git = "https://github.com/zed-industries/zed", features = ["font-kit", "x11", "wayland"] }
|
||||
gpui_linux = { git = "https://github.com/zed-industries/zed" }
|
||||
gpui_windows = { git = "https://github.com/zed-industries/zed" }
|
||||
gpui_macos = { git = "https://github.com/zed-industries/zed" }
|
||||
@@ -23,6 +22,7 @@ reqwest_client = { git = "https://github.com/zed-industries/zed" }
|
||||
nostr-lmdb = { 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-gossip-memory = { 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" ] }
|
||||
|
||||
@@ -42,6 +42,7 @@ smallvec = "1.14.0"
|
||||
smol = "2"
|
||||
tracing = "0.1.40"
|
||||
webbrowser = "1.0.4"
|
||||
tracing-subscriber = { version = "0.3.18", features = ["fmt", "env-filter"] }
|
||||
|
||||
[profile.release]
|
||||
strip = true
|
||||
|
||||
16
README.md
@@ -1,12 +1,12 @@
|
||||

|
||||
|
||||
<p>
|
||||
<a href="https://github.com/lumehq/coop/actions/workflows/rust.yml">
|
||||
<img alt="Actions" src="https://github.com/lumehq/coop/actions/workflows/rust.yml/badge.svg">
|
||||
<a href="https://github.com/reyakov/coop/actions/workflows/rust.yml">
|
||||
<img alt="Actions" src="https://github.com/reyakov/coop/actions/workflows/rust.yml/badge.svg">
|
||||
</a>
|
||||
<img alt="GitHub repo size" src="https://img.shields.io/github/repo-size/lumehq/coop">
|
||||
<img alt="GitHub issues" src="https://img.shields.io/github/issues-raw/lumehq/coop">
|
||||
<img alt="GitHub pull requests" src="https://img.shields.io/github/issues-pr/lumehq/coop">
|
||||
<img alt="GitHub repo size" src="https://img.shields.io/github/repo-size/reyakov/coop">
|
||||
<img alt="GitHub issues" src="https://img.shields.io/github/issues-raw/reyakov/coop">
|
||||
<img alt="GitHub pull requests" src="https://img.shields.io/github/issues-pr/reyakov/coop">
|
||||
</p>
|
||||
|
||||
Coop is a simple, fast, and reliable nostr client for secure messaging across all platforms.
|
||||
@@ -36,7 +36,7 @@ To install Coop, follow these steps:
|
||||
|
||||
1. **Download the Latest Release**:
|
||||
|
||||
- Visit the [Coop Releases page on GitHub](https://github.com/lumehq/coop/releases).
|
||||
- Visit the [Coop Releases page on GitHub](https://github.com/reyakov/coop/releases).
|
||||
- Download the package that matches your operating system (Windows, macOS, or Linux).
|
||||
|
||||
2. **Install**:
|
||||
@@ -65,7 +65,7 @@ Coop is built using Rust and GPUI. All Nostr related stuffs handled by [Rust Nos
|
||||
1. Clone the repository:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/lumehq/coop.git
|
||||
git clone https://github.com/reyakov/coop.git
|
||||
cd coop
|
||||
```
|
||||
|
||||
@@ -118,7 +118,7 @@ For more information, see the [Contributing](#contributing) section.
|
||||
- [Rust Nostr](https://github.com/rust-nostr/nostr/)
|
||||
- [GPUI](https://www.gpui.rs/)
|
||||
- [GPUI Components](https://github.com/longbridge/gpui-component/)
|
||||
- [Coop Issue Tracker](https://github.com/lumehq/coop/issues/)
|
||||
- [Coop Issue Tracker](https://github.com/reyakov/coop/issues/)
|
||||
|
||||
### License
|
||||
|
||||
|
||||
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 |
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 |
3
assets/icons/input.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
|
||||
<path d="M11.75 6.75H19.25C20.3546 6.75 21.25 7.64543 21.25 8.75V15.25C21.25 16.3546 20.3546 17.25 19.25 17.25H11.75" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M5.75 6.75H4.75C3.64543 6.75 2.75 7.64543 2.75 8.75V15.25C2.75 16.3546 3.64543 17.25 4.75 17.25H5.75" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M8.75 3.75V20.25" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 604 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 |
144
assets/themes/aurora.json
Normal file
@@ -0,0 +1,144 @@
|
||||
{
|
||||
"id": "aurora",
|
||||
"name": "Aurora",
|
||||
"author": "Coop",
|
||||
"url": "https://github.com/lumehq/coop",
|
||||
"light": {
|
||||
"background": "#fdfcfeff",
|
||||
"surface_background": "#f8f8ffff",
|
||||
"elevated_surface_background": "#f0f1feff",
|
||||
"panel_background": "#fdfcfeff",
|
||||
"overlay": "#211f4300",
|
||||
"title_bar": "#f0f1feff",
|
||||
"title_bar_inactive": "#fdfcfeff",
|
||||
"window_border": "#dadcffff",
|
||||
"border": "#dadcffff",
|
||||
"border_variant": "#cbcdffff",
|
||||
"border_focused": "#5b5bd6ff",
|
||||
"border_selected": "#5b5bd6ff",
|
||||
"border_transparent": "#00000000",
|
||||
"border_disabled": "#e6e7ffff",
|
||||
"ring": "#5151cdff",
|
||||
"text": "#1f2d5cff",
|
||||
"text_muted": "#5753c6ff",
|
||||
"text_placeholder": "#9b9ef0ff",
|
||||
"text_accent": "#5b5bd6ff",
|
||||
"text_danger": "#e54d2eff",
|
||||
"text_warning": "#f76b15ff",
|
||||
"icon": "#5753c6ff",
|
||||
"icon_muted": "#9b9ef0ff",
|
||||
"icon_accent": "#5151cdff",
|
||||
"element_foreground": "#ffffffff",
|
||||
"element_background": "#5b5bd6ff",
|
||||
"element_hover": "#5151cdff",
|
||||
"element_active": "#6e56cfff",
|
||||
"element_selected": "#654dc4ff",
|
||||
"element_disabled": "#5b5bd64d",
|
||||
"secondary_foreground": "#1f2d5cff",
|
||||
"secondary_background": "#f0f1feff",
|
||||
"secondary_hover": "#e6e7ffff",
|
||||
"secondary_active": "#dadcffff",
|
||||
"secondary_selected": "#dadcffff",
|
||||
"secondary_disabled": "#5b5bd64d",
|
||||
"danger_foreground": "#ffffffff",
|
||||
"danger_background": "#feebe7ff",
|
||||
"danger_hover": "#ffcdc2ff",
|
||||
"danger_active": "#fdbdafff",
|
||||
"danger_selected": "#fdbdafff",
|
||||
"danger_disabled": "#e54d2e4d",
|
||||
"warning_foreground": "#ffffffff",
|
||||
"warning_background": "#fff7edff",
|
||||
"warning_hover": "#ffd19aff",
|
||||
"warning_active": "#ffc182ff",
|
||||
"warning_selected": "#ffc182ff",
|
||||
"warning_disabled": "#f76b154d",
|
||||
"ghost_element_background": "#00000000",
|
||||
"ghost_element_background_alt": "#f0f1feff",
|
||||
"ghost_element_hover": "#211f430d",
|
||||
"ghost_element_active": "#211f431a",
|
||||
"ghost_element_selected": "#211f431a",
|
||||
"ghost_element_disabled": "#211f4305",
|
||||
"tab_background": "#f0f1feff",
|
||||
"tab_foreground": "#5753c6ff",
|
||||
"tab_hover_background": "#211f430d",
|
||||
"tab_active_background": "#fdfcfeff",
|
||||
"tab_active_foreground": "#1f2d5cff",
|
||||
"scrollbar_thumb_background": "#211f431a",
|
||||
"scrollbar_thumb_hover_background": "#211f4326",
|
||||
"scrollbar_thumb_border": "#00000000",
|
||||
"scrollbar_track_background": "#00000000",
|
||||
"scrollbar_track_border": "#00000000",
|
||||
"drop_target_background": "#5b5bd61a",
|
||||
"cursor": "#5b5bd6ff",
|
||||
"selection": "#5b5bd640"
|
||||
},
|
||||
"dark": {
|
||||
"background": "#14121fff",
|
||||
"surface_background": "#1b1525ff",
|
||||
"elevated_surface_background": "#291f43ff",
|
||||
"panel_background": "#14121fff",
|
||||
"overlay": "#baa7ff1a",
|
||||
"title_bar": "#291f43ff",
|
||||
"title_bar_inactive": "#14121fff",
|
||||
"window_border": "#473876ff",
|
||||
"border": "#473876ff",
|
||||
"border_variant": "#3c2e69ff",
|
||||
"border_focused": "#7d66d9ff",
|
||||
"border_selected": "#7d66d9ff",
|
||||
"border_transparent": "#00000000",
|
||||
"border_disabled": "#33255bff",
|
||||
"ring": "#6e56cfff",
|
||||
"text": "#e2ddfeff",
|
||||
"text_muted": "#baa7ffff",
|
||||
"text_placeholder": "#6958adff",
|
||||
"text_accent": "#baa7ffff",
|
||||
"text_danger": "#ff977dff",
|
||||
"text_warning": "#ffa057ff",
|
||||
"icon": "#baa7ffff",
|
||||
"icon_muted": "#6958adff",
|
||||
"icon_accent": "#6e56cfff",
|
||||
"element_foreground": "#14121fff",
|
||||
"element_background": "#7d66d9ff",
|
||||
"element_hover": "#baa7ffff",
|
||||
"element_active": "#6e56cfff",
|
||||
"element_selected": "#654dc4ff",
|
||||
"element_disabled": "#7d66d94d",
|
||||
"secondary_foreground": "#e2ddfeff",
|
||||
"secondary_background": "#291f43ff",
|
||||
"secondary_hover": "#33255bff",
|
||||
"secondary_active": "#3c2e69ff",
|
||||
"secondary_selected": "#3c2e69ff",
|
||||
"secondary_disabled": "#7d66d94d",
|
||||
"danger_foreground": "#181111ff",
|
||||
"danger_background": "#391714ff",
|
||||
"danger_hover": "#5e1c16ff",
|
||||
"danger_active": "#6e2920ff",
|
||||
"danger_selected": "#6e2920ff",
|
||||
"danger_disabled": "#ff977d4d",
|
||||
"warning_foreground": "#17120eff",
|
||||
"warning_background": "#331e0bff",
|
||||
"warning_hover": "#562800ff",
|
||||
"warning_active": "#66350cff",
|
||||
"warning_selected": "#66350cff",
|
||||
"warning_disabled": "#ffa0574d",
|
||||
"ghost_element_background": "#00000000",
|
||||
"ghost_element_background_alt": "#291f43ff",
|
||||
"ghost_element_hover": "#baa7ff0d",
|
||||
"ghost_element_active": "#baa7ff1a",
|
||||
"ghost_element_selected": "#baa7ff1a",
|
||||
"ghost_element_disabled": "#baa7ff05",
|
||||
"tab_background": "#291f43ff",
|
||||
"tab_foreground": "#baa7ffff",
|
||||
"tab_hover_background": "#baa7ff0d",
|
||||
"tab_active_background": "#14121fff",
|
||||
"tab_active_foreground": "#e2ddfeff",
|
||||
"scrollbar_thumb_background": "#baa7ff1a",
|
||||
"scrollbar_thumb_hover_background": "#baa7ff26",
|
||||
"scrollbar_thumb_border": "#00000000",
|
||||
"scrollbar_track_background": "#00000000",
|
||||
"scrollbar_track_border": "#00000000",
|
||||
"drop_target_background": "#baa7ff1a",
|
||||
"cursor": "#baa7ffff",
|
||||
"selection": "#baa7ff40"
|
||||
}
|
||||
}
|
||||
@@ -1,74 +1,76 @@
|
||||
{
|
||||
"id": "catppuccin-frappe",
|
||||
"name": "Catppuccin Frappé",
|
||||
"author": "Catppuccin",
|
||||
"url": "https://github.com/catppuccin/catppuccin",
|
||||
"author": "Catppuccin Org (ported by Coop)",
|
||||
"url": "https://catppuccin.com",
|
||||
"light": {
|
||||
"background": "#303446",
|
||||
"surface_background": "#292c3c",
|
||||
"elevated_surface_background": "#232634",
|
||||
"panel_background": "#303446",
|
||||
"overlay": "#c6d0f51a",
|
||||
"title_bar": "#292c3c",
|
||||
"title_bar_inactive": "#232634",
|
||||
"window_border": "#737994",
|
||||
"border": "#626880",
|
||||
"border_variant": "#51576d",
|
||||
"title_bar": "#232634",
|
||||
"title_bar_inactive": "#303446",
|
||||
"window_border": "#51576d",
|
||||
"border": "#51576d",
|
||||
"border_variant": "#414559",
|
||||
"border_focused": "#8caaee",
|
||||
"border_selected": "#8caaee",
|
||||
"border_transparent": "#00000000",
|
||||
"border_disabled": "#414559",
|
||||
"ring": "#8caaee",
|
||||
"border_transparent": "#c6d0f500",
|
||||
"border_disabled": "#292c3c",
|
||||
"ring": "#babbf1",
|
||||
"text": "#c6d0f5",
|
||||
"text_muted": "#b5bfe2",
|
||||
"text_placeholder": "#a5adce",
|
||||
"text_muted": "#a5adce",
|
||||
"text_placeholder": "#838ba7",
|
||||
"text_accent": "#8caaee",
|
||||
"icon": "#b5bfe2",
|
||||
"icon_muted": "#a5adce",
|
||||
"icon_accent": "#8caaee",
|
||||
"element_foreground": "#232634",
|
||||
"text_danger": "#e78284",
|
||||
"text_warning": "#ef9f76",
|
||||
"icon": "#a5adce",
|
||||
"icon_muted": "#838ba7",
|
||||
"icon_accent": "#babbf1",
|
||||
"element_foreground": "#303446",
|
||||
"element_background": "#8caaee",
|
||||
"element_hover": "#babbf1",
|
||||
"element_active": "#7e99d6",
|
||||
"element_selected": "#7088bf",
|
||||
"element_active": "#99d1db",
|
||||
"element_selected": "#85c1dc",
|
||||
"element_disabled": "#8caaee4d",
|
||||
"secondary_foreground": "#7088bf",
|
||||
"secondary_background": "#292c3c",
|
||||
"secondary_hover": "#8caaee33",
|
||||
"secondary_active": "#232634",
|
||||
"secondary_selected": "#232634",
|
||||
"secondary_foreground": "#c6d0f5",
|
||||
"secondary_background": "#414559",
|
||||
"secondary_hover": "#51576d",
|
||||
"secondary_active": "#626880",
|
||||
"secondary_selected": "#626880",
|
||||
"secondary_disabled": "#8caaee4d",
|
||||
"danger_foreground": "#232634",
|
||||
"danger_foreground": "#303446",
|
||||
"danger_background": "#e78284",
|
||||
"danger_hover": "#ea999c",
|
||||
"danger_active": "#d07576",
|
||||
"danger_selected": "#b96869",
|
||||
"danger_active": "#ef9f76",
|
||||
"danger_selected": "#e5c890",
|
||||
"danger_disabled": "#e782844d",
|
||||
"warning_foreground": "#232634",
|
||||
"warning_background": "#e5c890",
|
||||
"warning_hover": "#ef9f76",
|
||||
"warning_active": "#ceb482",
|
||||
"warning_selected": "#b7a074",
|
||||
"warning_disabled": "#e5c8904d",
|
||||
"ghost_element_background": "#00000000",
|
||||
"ghost_element_background_alt": "#414559",
|
||||
"ghost_element_hover": "#c6d0f533",
|
||||
"ghost_element_active": "#51576d",
|
||||
"ghost_element_selected": "#51576d",
|
||||
"ghost_element_disabled": "#c6d0f50d",
|
||||
"tab_inactive_background": "#292c3c",
|
||||
"tab_inactive_foreground": "#b5bfe2",
|
||||
"warning_foreground": "#303446",
|
||||
"warning_background": "#ef9f76",
|
||||
"warning_hover": "#e5c890",
|
||||
"warning_active": "#a6d189",
|
||||
"warning_selected": "#81c8be",
|
||||
"warning_disabled": "#ef9f764d",
|
||||
"ghost_element_background": "#c6d0f500",
|
||||
"ghost_element_background_alt": "#292c3c",
|
||||
"ghost_element_hover": "#c6d0f50d",
|
||||
"ghost_element_active": "#c6d0f51a",
|
||||
"ghost_element_selected": "#c6d0f51a",
|
||||
"ghost_element_disabled": "#c6d0f505",
|
||||
"tab_background": "#232634",
|
||||
"tab_foreground": "#a5adce",
|
||||
"tab_hover_background": "#c6d0f50d",
|
||||
"tab_active_background": "#303446",
|
||||
"tab_active_foreground": "#c6d0f5",
|
||||
"tab_hover_foreground": "#babbf1",
|
||||
"scrollbar_thumb_background": "#c6d0f533",
|
||||
"scrollbar_thumb_hover_background": "#c6d0f580",
|
||||
"scrollbar_thumb_border": "#00000000",
|
||||
"scrollbar_track_background": "#00000000",
|
||||
"scrollbar_track_border": "#51576d",
|
||||
"scrollbar_thumb_background": "#c6d0f51a",
|
||||
"scrollbar_thumb_hover_background": "#c6d0f526",
|
||||
"scrollbar_thumb_border": "#c6d0f500",
|
||||
"scrollbar_track_background": "#c6d0f500",
|
||||
"scrollbar_track_border": "#c6d0f500",
|
||||
"drop_target_background": "#8caaee1a",
|
||||
"cursor": "#f2d5cf",
|
||||
"selection": "#949cbb40"
|
||||
"cursor": "#8caaee",
|
||||
"selection": "#8caaee40"
|
||||
},
|
||||
"dark": {
|
||||
"background": "#303446",
|
||||
@@ -76,65 +78,67 @@
|
||||
"elevated_surface_background": "#232634",
|
||||
"panel_background": "#303446",
|
||||
"overlay": "#c6d0f51a",
|
||||
"title_bar": "#292c3c",
|
||||
"title_bar_inactive": "#232634",
|
||||
"window_border": "#737994",
|
||||
"border": "#626880",
|
||||
"border_variant": "#51576d",
|
||||
"title_bar": "#232634",
|
||||
"title_bar_inactive": "#303446",
|
||||
"window_border": "#51576d",
|
||||
"border": "#51576d",
|
||||
"border_variant": "#414559",
|
||||
"border_focused": "#8caaee",
|
||||
"border_selected": "#8caaee",
|
||||
"border_transparent": "#00000000",
|
||||
"border_disabled": "#414559",
|
||||
"ring": "#8caaee",
|
||||
"border_transparent": "#c6d0f500",
|
||||
"border_disabled": "#292c3c",
|
||||
"ring": "#babbf1",
|
||||
"text": "#c6d0f5",
|
||||
"text_muted": "#b5bfe2",
|
||||
"text_placeholder": "#a5adce",
|
||||
"text_muted": "#a5adce",
|
||||
"text_placeholder": "#838ba7",
|
||||
"text_accent": "#8caaee",
|
||||
"icon": "#b5bfe2",
|
||||
"icon_muted": "#a5adce",
|
||||
"icon_accent": "#8caaee",
|
||||
"element_foreground": "#232634",
|
||||
"text_danger": "#e78284",
|
||||
"text_warning": "#ef9f76",
|
||||
"icon": "#a5adce",
|
||||
"icon_muted": "#838ba7",
|
||||
"icon_accent": "#babbf1",
|
||||
"element_foreground": "#303446",
|
||||
"element_background": "#8caaee",
|
||||
"element_hover": "#babbf1",
|
||||
"element_active": "#7e99d6",
|
||||
"element_selected": "#7088bf",
|
||||
"element_active": "#99d1db",
|
||||
"element_selected": "#85c1dc",
|
||||
"element_disabled": "#8caaee4d",
|
||||
"secondary_foreground": "#7088bf",
|
||||
"secondary_background": "#292c3c",
|
||||
"secondary_hover": "#8caaee33",
|
||||
"secondary_active": "#232634",
|
||||
"secondary_selected": "#232634",
|
||||
"secondary_foreground": "#c6d0f5",
|
||||
"secondary_background": "#414559",
|
||||
"secondary_hover": "#51576d",
|
||||
"secondary_active": "#626880",
|
||||
"secondary_selected": "#626880",
|
||||
"secondary_disabled": "#8caaee4d",
|
||||
"danger_foreground": "#232634",
|
||||
"danger_foreground": "#303446",
|
||||
"danger_background": "#e78284",
|
||||
"danger_hover": "#ea999c",
|
||||
"danger_active": "#d07576",
|
||||
"danger_selected": "#b96869",
|
||||
"danger_active": "#ef9f76",
|
||||
"danger_selected": "#e5c890",
|
||||
"danger_disabled": "#e782844d",
|
||||
"warning_foreground": "#232634",
|
||||
"warning_background": "#e5c890",
|
||||
"warning_hover": "#ef9f76",
|
||||
"warning_active": "#ceb482",
|
||||
"warning_selected": "#b7a074",
|
||||
"warning_disabled": "#e5c8904d",
|
||||
"ghost_element_background": "#00000000",
|
||||
"ghost_element_background_alt": "#414559",
|
||||
"ghost_element_hover": "#c6d0f533",
|
||||
"ghost_element_active": "#51576d",
|
||||
"ghost_element_selected": "#51576d",
|
||||
"ghost_element_disabled": "#c6d0f50d",
|
||||
"tab_inactive_background": "#292c3c",
|
||||
"tab_inactive_foreground": "#b5bfe2",
|
||||
"warning_foreground": "#303446",
|
||||
"warning_background": "#ef9f76",
|
||||
"warning_hover": "#e5c890",
|
||||
"warning_active": "#a6d189",
|
||||
"warning_selected": "#81c8be",
|
||||
"warning_disabled": "#ef9f764d",
|
||||
"ghost_element_background": "#c6d0f500",
|
||||
"ghost_element_background_alt": "#292c3c",
|
||||
"ghost_element_hover": "#c6d0f50d",
|
||||
"ghost_element_active": "#c6d0f51a",
|
||||
"ghost_element_selected": "#c6d0f51a",
|
||||
"ghost_element_disabled": "#c6d0f505",
|
||||
"tab_background": "#232634",
|
||||
"tab_foreground": "#a5adce",
|
||||
"tab_hover_background": "#c6d0f50d",
|
||||
"tab_active_background": "#303446",
|
||||
"tab_active_foreground": "#c6d0f5",
|
||||
"tab_hover_foreground": "#babbf1",
|
||||
"scrollbar_thumb_background": "#c6d0f533",
|
||||
"scrollbar_thumb_hover_background": "#c6d0f580",
|
||||
"scrollbar_thumb_border": "#00000000",
|
||||
"scrollbar_track_background": "#00000000",
|
||||
"scrollbar_track_border": "#51576d",
|
||||
"scrollbar_thumb_background": "#c6d0f51a",
|
||||
"scrollbar_thumb_hover_background": "#c6d0f526",
|
||||
"scrollbar_thumb_border": "#c6d0f500",
|
||||
"scrollbar_track_background": "#c6d0f500",
|
||||
"scrollbar_track_border": "#c6d0f500",
|
||||
"drop_target_background": "#8caaee1a",
|
||||
"cursor": "#f2d5cf",
|
||||
"selection": "#949cbb40"
|
||||
"cursor": "#8caaee",
|
||||
"selection": "#8caaee40"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,74 +1,76 @@
|
||||
{
|
||||
"id": "catppuccin-latte",
|
||||
"name": "Catppuccin Latte",
|
||||
"author": "Catppuccin",
|
||||
"url": "https://github.com/catppuccin/catppuccin",
|
||||
"author": "Catppuccin Org (ported by Coop)",
|
||||
"url": "https://catppuccin.com",
|
||||
"light": {
|
||||
"background": "#eff1f5",
|
||||
"surface_background": "#e6e9ef",
|
||||
"elevated_surface_background": "#dce0e8",
|
||||
"panel_background": "#eff1f5",
|
||||
"overlay": "#4c4f691a",
|
||||
"title_bar": "#e6e9ef",
|
||||
"title_bar_inactive": "#dce0e8",
|
||||
"window_border": "#9ca0b0",
|
||||
"border": "#acb0be",
|
||||
"border_variant": "#bcc0cc",
|
||||
"title_bar": "#dce0e8",
|
||||
"title_bar_inactive": "#eff1f5",
|
||||
"window_border": "#bcc0cc",
|
||||
"border": "#bcc0cc",
|
||||
"border_variant": "#ccd0da",
|
||||
"border_focused": "#1e66f5",
|
||||
"border_selected": "#1e66f5",
|
||||
"border_transparent": "#00000000",
|
||||
"border_disabled": "#ccd0da",
|
||||
"ring": "#1e66f5",
|
||||
"border_transparent": "#4c4f6900",
|
||||
"border_disabled": "#e6e9ef",
|
||||
"ring": "#7287fd",
|
||||
"text": "#4c4f69",
|
||||
"text_muted": "#5c5f77",
|
||||
"text_placeholder": "#6c6f85",
|
||||
"text_muted": "#6c6f85",
|
||||
"text_placeholder": "#8c8fa1",
|
||||
"text_accent": "#1e66f5",
|
||||
"icon": "#5c5f77",
|
||||
"icon_muted": "#6c6f85",
|
||||
"icon_accent": "#1e66f5",
|
||||
"text_danger": "#d20f39",
|
||||
"text_warning": "#fe640b",
|
||||
"icon": "#6c6f85",
|
||||
"icon_muted": "#8c8fa1",
|
||||
"icon_accent": "#7287fd",
|
||||
"element_foreground": "#eff1f5",
|
||||
"element_background": "#1e66f5",
|
||||
"element_hover": "#8839ef",
|
||||
"element_active": "#1c5ce0",
|
||||
"element_selected": "#1a52cc",
|
||||
"element_hover": "#7287fd",
|
||||
"element_active": "#04a5e5",
|
||||
"element_selected": "#209fb5",
|
||||
"element_disabled": "#1e66f54d",
|
||||
"secondary_foreground": "#1a52cc",
|
||||
"secondary_background": "#e6e9ef",
|
||||
"secondary_hover": "#8839ef33",
|
||||
"secondary_active": "#dce0e8",
|
||||
"secondary_selected": "#dce0e8",
|
||||
"secondary_foreground": "#4c4f69",
|
||||
"secondary_background": "#ccd0da",
|
||||
"secondary_hover": "#bcc0cc",
|
||||
"secondary_active": "#acb0be",
|
||||
"secondary_selected": "#acb0be",
|
||||
"secondary_disabled": "#1e66f54d",
|
||||
"danger_foreground": "#eff1f5",
|
||||
"danger_background": "#d20f39",
|
||||
"danger_hover": "#e64553",
|
||||
"danger_active": "#bd0d33",
|
||||
"danger_selected": "#a80b2d",
|
||||
"danger_active": "#fe640b",
|
||||
"danger_selected": "#df8e1d",
|
||||
"danger_disabled": "#d20f394d",
|
||||
"warning_foreground": "#4c4f69",
|
||||
"warning_background": "#df8e1d",
|
||||
"warning_hover": "#fe640b",
|
||||
"warning_active": "#c9801a",
|
||||
"warning_selected": "#b47217",
|
||||
"warning_disabled": "#df8e1d4d",
|
||||
"ghost_element_background": "#00000000",
|
||||
"ghost_element_background_alt": "#ccd0da",
|
||||
"ghost_element_hover": "#4c4f6933",
|
||||
"ghost_element_active": "#bcc0cc",
|
||||
"ghost_element_selected": "#bcc0cc",
|
||||
"ghost_element_disabled": "#4c4f690d",
|
||||
"tab_inactive_background": "#e6e9ef",
|
||||
"tab_inactive_foreground": "#5c5f77",
|
||||
"warning_foreground": "#eff1f5",
|
||||
"warning_background": "#fe640b",
|
||||
"warning_hover": "#df8e1d",
|
||||
"warning_active": "#40a02b",
|
||||
"warning_selected": "#179299",
|
||||
"warning_disabled": "#fe640b4d",
|
||||
"ghost_element_background": "#4c4f6900",
|
||||
"ghost_element_background_alt": "#e6e9ef",
|
||||
"ghost_element_hover": "#4c4f690d",
|
||||
"ghost_element_active": "#4c4f691a",
|
||||
"ghost_element_selected": "#4c4f691a",
|
||||
"ghost_element_disabled": "#4c4f6905",
|
||||
"tab_background": "#e6e9ef",
|
||||
"tab_foreground": "#6c6f85",
|
||||
"tab_hover_background": "#4c4f690d",
|
||||
"tab_active_background": "#eff1f5",
|
||||
"tab_active_foreground": "#4c4f69",
|
||||
"tab_hover_foreground": "#8839ef",
|
||||
"scrollbar_thumb_background": "#4c4f6933",
|
||||
"scrollbar_thumb_hover_background": "#4c4f6980",
|
||||
"scrollbar_thumb_border": "#00000000",
|
||||
"scrollbar_track_background": "#00000000",
|
||||
"scrollbar_track_border": "#bcc0cc",
|
||||
"scrollbar_thumb_background": "#4c4f691a",
|
||||
"scrollbar_thumb_hover_background": "#4c4f6926",
|
||||
"scrollbar_thumb_border": "#4c4f6900",
|
||||
"scrollbar_track_background": "#4c4f6900",
|
||||
"scrollbar_track_border": "#4c4f6900",
|
||||
"drop_target_background": "#1e66f51a",
|
||||
"cursor": "#dc8a78",
|
||||
"selection": "#7c7f9340"
|
||||
"cursor": "#1e66f5",
|
||||
"selection": "#1e66f540"
|
||||
},
|
||||
"dark": {
|
||||
"background": "#eff1f5",
|
||||
@@ -76,65 +78,67 @@
|
||||
"elevated_surface_background": "#dce0e8",
|
||||
"panel_background": "#eff1f5",
|
||||
"overlay": "#4c4f691a",
|
||||
"title_bar": "#e6e9ef",
|
||||
"title_bar_inactive": "#dce0e8",
|
||||
"window_border": "#9ca0b0",
|
||||
"border": "#acb0be",
|
||||
"border_variant": "#bcc0cc",
|
||||
"title_bar": "#dce0e8",
|
||||
"title_bar_inactive": "#eff1f5",
|
||||
"window_border": "#bcc0cc",
|
||||
"border": "#bcc0cc",
|
||||
"border_variant": "#ccd0da",
|
||||
"border_focused": "#1e66f5",
|
||||
"border_selected": "#1e66f5",
|
||||
"border_transparent": "#00000000",
|
||||
"border_disabled": "#ccd0da",
|
||||
"ring": "#1e66f5",
|
||||
"border_transparent": "#4c4f6900",
|
||||
"border_disabled": "#e6e9ef",
|
||||
"ring": "#7287fd",
|
||||
"text": "#4c4f69",
|
||||
"text_muted": "#5c5f77",
|
||||
"text_placeholder": "#6c6f85",
|
||||
"text_muted": "#6c6f85",
|
||||
"text_placeholder": "#8c8fa1",
|
||||
"text_accent": "#1e66f5",
|
||||
"icon": "#5c5f77",
|
||||
"icon_muted": "#6c6f85",
|
||||
"icon_accent": "#1e66f5",
|
||||
"text_danger": "#d20f39",
|
||||
"text_warning": "#fe640b",
|
||||
"icon": "#6c6f85",
|
||||
"icon_muted": "#8c8fa1",
|
||||
"icon_accent": "#7287fd",
|
||||
"element_foreground": "#eff1f5",
|
||||
"element_background": "#1e66f5",
|
||||
"element_hover": "#8839ef",
|
||||
"element_active": "#1c5ce0",
|
||||
"element_selected": "#1a52cc",
|
||||
"element_hover": "#7287fd",
|
||||
"element_active": "#04a5e5",
|
||||
"element_selected": "#209fb5",
|
||||
"element_disabled": "#1e66f54d",
|
||||
"secondary_foreground": "#1a52cc",
|
||||
"secondary_background": "#e6e9ef",
|
||||
"secondary_hover": "#8839ef33",
|
||||
"secondary_active": "#dce0e8",
|
||||
"secondary_selected": "#dce0e8",
|
||||
"secondary_foreground": "#4c4f69",
|
||||
"secondary_background": "#ccd0da",
|
||||
"secondary_hover": "#bcc0cc",
|
||||
"secondary_active": "#acb0be",
|
||||
"secondary_selected": "#acb0be",
|
||||
"secondary_disabled": "#1e66f54d",
|
||||
"danger_foreground": "#eff1f5",
|
||||
"danger_background": "#d20f39",
|
||||
"danger_hover": "#e64553",
|
||||
"danger_active": "#bd0d33",
|
||||
"danger_selected": "#a80b2d",
|
||||
"danger_active": "#fe640b",
|
||||
"danger_selected": "#df8e1d",
|
||||
"danger_disabled": "#d20f394d",
|
||||
"warning_foreground": "#4c4f69",
|
||||
"warning_background": "#df8e1d",
|
||||
"warning_hover": "#fe640b",
|
||||
"warning_active": "#c9801a",
|
||||
"warning_selected": "#b47217",
|
||||
"warning_disabled": "#df8e1d4d",
|
||||
"ghost_element_background": "#00000000",
|
||||
"ghost_element_background_alt": "#ccd0da",
|
||||
"ghost_element_hover": "#4c4f6933",
|
||||
"ghost_element_active": "#bcc0cc",
|
||||
"ghost_element_selected": "#bcc0cc",
|
||||
"ghost_element_disabled": "#4c4f690d",
|
||||
"tab_inactive_background": "#e6e9ef",
|
||||
"tab_inactive_foreground": "#5c5f77",
|
||||
"warning_foreground": "#eff1f5",
|
||||
"warning_background": "#fe640b",
|
||||
"warning_hover": "#df8e1d",
|
||||
"warning_active": "#40a02b",
|
||||
"warning_selected": "#179299",
|
||||
"warning_disabled": "#fe640b4d",
|
||||
"ghost_element_background": "#4c4f6900",
|
||||
"ghost_element_background_alt": "#e6e9ef",
|
||||
"ghost_element_hover": "#4c4f690d",
|
||||
"ghost_element_active": "#4c4f691a",
|
||||
"ghost_element_selected": "#4c4f691a",
|
||||
"ghost_element_disabled": "#4c4f6905",
|
||||
"tab_background": "#e6e9ef",
|
||||
"tab_foreground": "#6c6f85",
|
||||
"tab_hover_background": "#4c4f690d",
|
||||
"tab_active_background": "#eff1f5",
|
||||
"tab_active_foreground": "#4c4f69",
|
||||
"tab_hover_foreground": "#8839ef",
|
||||
"scrollbar_thumb_background": "#4c4f6933",
|
||||
"scrollbar_thumb_hover_background": "#4c4f6980",
|
||||
"scrollbar_thumb_border": "#00000000",
|
||||
"scrollbar_track_background": "#00000000",
|
||||
"scrollbar_track_border": "#bcc0cc",
|
||||
"scrollbar_thumb_background": "#4c4f691a",
|
||||
"scrollbar_thumb_hover_background": "#4c4f6926",
|
||||
"scrollbar_thumb_border": "#4c4f6900",
|
||||
"scrollbar_track_background": "#4c4f6900",
|
||||
"scrollbar_track_border": "#4c4f6900",
|
||||
"drop_target_background": "#1e66f51a",
|
||||
"cursor": "#dc8a78",
|
||||
"selection": "#7c7f9340"
|
||||
"cursor": "#1e66f5",
|
||||
"selection": "#1e66f540"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,74 +1,76 @@
|
||||
{
|
||||
"id": "catppuccin-macchiato",
|
||||
"name": "Catppuccin Macchiato",
|
||||
"author": "Catppuccin",
|
||||
"url": "https://github.com/catppuccin/catppuccin",
|
||||
"author": "Catppuccin Org (ported by Coop)",
|
||||
"url": "https://catppuccin.com",
|
||||
"light": {
|
||||
"background": "#24273a",
|
||||
"surface_background": "#1e2030",
|
||||
"elevated_surface_background": "#181926",
|
||||
"panel_background": "#24273a",
|
||||
"overlay": "#cad3f51a",
|
||||
"title_bar": "#1e2030",
|
||||
"title_bar_inactive": "#181926",
|
||||
"window_border": "#6e738d",
|
||||
"border": "#5b6078",
|
||||
"border_variant": "#494d64",
|
||||
"title_bar": "#181926",
|
||||
"title_bar_inactive": "#24273a",
|
||||
"window_border": "#494d64",
|
||||
"border": "#494d64",
|
||||
"border_variant": "#363a4f",
|
||||
"border_focused": "#8aadf4",
|
||||
"border_selected": "#8aadf4",
|
||||
"border_transparent": "#00000000",
|
||||
"border_disabled": "#363a4f",
|
||||
"ring": "#8aadf4",
|
||||
"border_transparent": "#cad3f500",
|
||||
"border_disabled": "#1e2030",
|
||||
"ring": "#b7bdf8",
|
||||
"text": "#cad3f5",
|
||||
"text_muted": "#b8c0e0",
|
||||
"text_placeholder": "#a5adcb",
|
||||
"text_muted": "#a5adcb",
|
||||
"text_placeholder": "#8087a2",
|
||||
"text_accent": "#8aadf4",
|
||||
"icon": "#b8c0e0",
|
||||
"icon_muted": "#a5adcb",
|
||||
"icon_accent": "#8aadf4",
|
||||
"element_foreground": "#181926",
|
||||
"text_danger": "#ed8796",
|
||||
"text_warning": "#f5a97f",
|
||||
"icon": "#a5adcb",
|
||||
"icon_muted": "#8087a2",
|
||||
"icon_accent": "#b7bdf8",
|
||||
"element_foreground": "#24273a",
|
||||
"element_background": "#8aadf4",
|
||||
"element_hover": "#b7bdf8",
|
||||
"element_active": "#7c9cdc",
|
||||
"element_selected": "#6e8bc5",
|
||||
"element_active": "#91d7e3",
|
||||
"element_selected": "#7dc4e4",
|
||||
"element_disabled": "#8aadf44d",
|
||||
"secondary_foreground": "#6e8bc5",
|
||||
"secondary_background": "#1e2030",
|
||||
"secondary_hover": "#8aadf433",
|
||||
"secondary_active": "#181926",
|
||||
"secondary_selected": "#181926",
|
||||
"secondary_foreground": "#cad3f5",
|
||||
"secondary_background": "#363a4f",
|
||||
"secondary_hover": "#494d64",
|
||||
"secondary_active": "#5b6078",
|
||||
"secondary_selected": "#5b6078",
|
||||
"secondary_disabled": "#8aadf44d",
|
||||
"danger_foreground": "#181926",
|
||||
"danger_foreground": "#24273a",
|
||||
"danger_background": "#ed8796",
|
||||
"danger_hover": "#ee99a0",
|
||||
"danger_active": "#d57a87",
|
||||
"danger_selected": "#be6d78",
|
||||
"danger_active": "#f5a97f",
|
||||
"danger_selected": "#eed49f",
|
||||
"danger_disabled": "#ed87964d",
|
||||
"warning_foreground": "#181926",
|
||||
"warning_background": "#eed49f",
|
||||
"warning_hover": "#f5a97f",
|
||||
"warning_active": "#d6bf8f",
|
||||
"warning_selected": "#beaa7f",
|
||||
"warning_disabled": "#eed49f4d",
|
||||
"ghost_element_background": "#00000000",
|
||||
"ghost_element_background_alt": "#363a4f",
|
||||
"ghost_element_hover": "#cad3f533",
|
||||
"ghost_element_active": "#494d64",
|
||||
"ghost_element_selected": "#494d64",
|
||||
"ghost_element_disabled": "#cad3f50d",
|
||||
"tab_inactive_background": "#1e2030",
|
||||
"tab_inactive_foreground": "#b8c0e0",
|
||||
"warning_foreground": "#24273a",
|
||||
"warning_background": "#f5a97f",
|
||||
"warning_hover": "#eed49f",
|
||||
"warning_active": "#a6da95",
|
||||
"warning_selected": "#8bd5ca",
|
||||
"warning_disabled": "#f5a97f4d",
|
||||
"ghost_element_background": "#cad3f500",
|
||||
"ghost_element_background_alt": "#1e2030",
|
||||
"ghost_element_hover": "#cad3f50d",
|
||||
"ghost_element_active": "#cad3f51a",
|
||||
"ghost_element_selected": "#cad3f51a",
|
||||
"ghost_element_disabled": "#cad3f505",
|
||||
"tab_background": "#181926",
|
||||
"tab_foreground": "#a5adcb",
|
||||
"tab_hover_background": "#cad3f50d",
|
||||
"tab_active_background": "#24273a",
|
||||
"tab_active_foreground": "#cad3f5",
|
||||
"tab_hover_foreground": "#b7bdf8",
|
||||
"scrollbar_thumb_background": "#cad3f533",
|
||||
"scrollbar_thumb_hover_background": "#cad3f580",
|
||||
"scrollbar_thumb_border": "#00000000",
|
||||
"scrollbar_track_background": "#00000000",
|
||||
"scrollbar_track_border": "#494d64",
|
||||
"scrollbar_thumb_background": "#cad3f51a",
|
||||
"scrollbar_thumb_hover_background": "#cad3f526",
|
||||
"scrollbar_thumb_border": "#cad3f500",
|
||||
"scrollbar_track_background": "#cad3f500",
|
||||
"scrollbar_track_border": "#cad3f500",
|
||||
"drop_target_background": "#8aadf41a",
|
||||
"cursor": "#f4dbd6",
|
||||
"selection": "#939ab740"
|
||||
"cursor": "#8aadf4",
|
||||
"selection": "#8aadf440"
|
||||
},
|
||||
"dark": {
|
||||
"background": "#24273a",
|
||||
@@ -76,65 +78,67 @@
|
||||
"elevated_surface_background": "#181926",
|
||||
"panel_background": "#24273a",
|
||||
"overlay": "#cad3f51a",
|
||||
"title_bar": "#1e2030",
|
||||
"title_bar_inactive": "#181926",
|
||||
"window_border": "#6e738d",
|
||||
"border": "#5b6078",
|
||||
"border_variant": "#494d64",
|
||||
"title_bar": "#181926",
|
||||
"title_bar_inactive": "#24273a",
|
||||
"window_border": "#494d64",
|
||||
"border": "#494d64",
|
||||
"border_variant": "#363a4f",
|
||||
"border_focused": "#8aadf4",
|
||||
"border_selected": "#8aadf4",
|
||||
"border_transparent": "#00000000",
|
||||
"border_disabled": "#363a4f",
|
||||
"ring": "#8aadf4",
|
||||
"border_transparent": "#cad3f500",
|
||||
"border_disabled": "#1e2030",
|
||||
"ring": "#b7bdf8",
|
||||
"text": "#cad3f5",
|
||||
"text_muted": "#b8c0e0",
|
||||
"text_placeholder": "#a5adcb",
|
||||
"text_muted": "#a5adcb",
|
||||
"text_placeholder": "#8087a2",
|
||||
"text_accent": "#8aadf4",
|
||||
"icon": "#b8c0e0",
|
||||
"icon_muted": "#a5adcb",
|
||||
"icon_accent": "#8aadf4",
|
||||
"element_foreground": "#181926",
|
||||
"text_danger": "#ed8796",
|
||||
"text_warning": "#f5a97f",
|
||||
"icon": "#a5adcb",
|
||||
"icon_muted": "#8087a2",
|
||||
"icon_accent": "#b7bdf8",
|
||||
"element_foreground": "#24273a",
|
||||
"element_background": "#8aadf4",
|
||||
"element_hover": "#b7bdf8",
|
||||
"element_active": "#7c9cdc",
|
||||
"element_selected": "#6e8bc5",
|
||||
"element_active": "#91d7e3",
|
||||
"element_selected": "#7dc4e4",
|
||||
"element_disabled": "#8aadf44d",
|
||||
"secondary_foreground": "#6e8bc5",
|
||||
"secondary_background": "#1e2030",
|
||||
"secondary_hover": "#8aadf433",
|
||||
"secondary_active": "#181926",
|
||||
"secondary_selected": "#181926",
|
||||
"secondary_foreground": "#cad3f5",
|
||||
"secondary_background": "#363a4f",
|
||||
"secondary_hover": "#494d64",
|
||||
"secondary_active": "#5b6078",
|
||||
"secondary_selected": "#5b6078",
|
||||
"secondary_disabled": "#8aadf44d",
|
||||
"danger_foreground": "#181926",
|
||||
"danger_foreground": "#24273a",
|
||||
"danger_background": "#ed8796",
|
||||
"danger_hover": "#ee99a0",
|
||||
"danger_active": "#d57a87",
|
||||
"danger_selected": "#be6d78",
|
||||
"danger_active": "#f5a97f",
|
||||
"danger_selected": "#eed49f",
|
||||
"danger_disabled": "#ed87964d",
|
||||
"warning_foreground": "#181926",
|
||||
"warning_background": "#eed49f",
|
||||
"warning_hover": "#f5a97f",
|
||||
"warning_active": "#d6bf8f",
|
||||
"warning_selected": "#beaa7f",
|
||||
"warning_disabled": "#eed49f4d",
|
||||
"ghost_element_background": "#00000000",
|
||||
"ghost_element_background_alt": "#363a4f",
|
||||
"ghost_element_hover": "#cad3f533",
|
||||
"ghost_element_active": "#494d64",
|
||||
"ghost_element_selected": "#494d64",
|
||||
"ghost_element_disabled": "#cad3f50d",
|
||||
"tab_inactive_background": "#1e2030",
|
||||
"tab_inactive_foreground": "#b8c0e0",
|
||||
"warning_foreground": "#24273a",
|
||||
"warning_background": "#f5a97f",
|
||||
"warning_hover": "#eed49f",
|
||||
"warning_active": "#a6da95",
|
||||
"warning_selected": "#8bd5ca",
|
||||
"warning_disabled": "#f5a97f4d",
|
||||
"ghost_element_background": "#cad3f500",
|
||||
"ghost_element_background_alt": "#1e2030",
|
||||
"ghost_element_hover": "#cad3f50d",
|
||||
"ghost_element_active": "#cad3f51a",
|
||||
"ghost_element_selected": "#cad3f51a",
|
||||
"ghost_element_disabled": "#cad3f505",
|
||||
"tab_background": "#181926",
|
||||
"tab_foreground": "#a5adcb",
|
||||
"tab_hover_background": "#cad3f50d",
|
||||
"tab_active_background": "#24273a",
|
||||
"tab_active_foreground": "#cad3f5",
|
||||
"tab_hover_foreground": "#b7bdf8",
|
||||
"scrollbar_thumb_background": "#cad3f533",
|
||||
"scrollbar_thumb_hover_background": "#cad3f580",
|
||||
"scrollbar_thumb_border": "#00000000",
|
||||
"scrollbar_track_background": "#00000000",
|
||||
"scrollbar_track_border": "#494d64",
|
||||
"scrollbar_thumb_background": "#cad3f51a",
|
||||
"scrollbar_thumb_hover_background": "#cad3f526",
|
||||
"scrollbar_thumb_border": "#cad3f500",
|
||||
"scrollbar_track_background": "#cad3f500",
|
||||
"scrollbar_track_border": "#cad3f500",
|
||||
"drop_target_background": "#8aadf41a",
|
||||
"cursor": "#f4dbd6",
|
||||
"selection": "#939ab740"
|
||||
"cursor": "#8aadf4",
|
||||
"selection": "#8aadf440"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,74 +1,76 @@
|
||||
{
|
||||
"id": "catppuccin-mocha",
|
||||
"name": "Catppuccin Mocha",
|
||||
"author": "Catppuccin",
|
||||
"url": "https://github.com/catppuccin/catppuccin",
|
||||
"author": "Catppuccin Org (ported by Coop)",
|
||||
"url": "https://catppuccin.com",
|
||||
"light": {
|
||||
"background": "#1e1e2e",
|
||||
"surface_background": "#181825",
|
||||
"elevated_surface_background": "#11111b",
|
||||
"panel_background": "#1e1e2e",
|
||||
"overlay": "#cdd6f41a",
|
||||
"title_bar": "#181825",
|
||||
"title_bar_inactive": "#11111b",
|
||||
"window_border": "#6c7086",
|
||||
"border": "#585b70",
|
||||
"border_variant": "#45475a",
|
||||
"title_bar": "#11111b",
|
||||
"title_bar_inactive": "#1e1e2e",
|
||||
"window_border": "#45475a",
|
||||
"border": "#45475a",
|
||||
"border_variant": "#313244",
|
||||
"border_focused": "#89b4fa",
|
||||
"border_selected": "#89b4fa",
|
||||
"border_transparent": "#00000000",
|
||||
"border_disabled": "#313244",
|
||||
"ring": "#89b4fa",
|
||||
"border_transparent": "#cdd6f400",
|
||||
"border_disabled": "#181825",
|
||||
"ring": "#b4befe",
|
||||
"text": "#cdd6f4",
|
||||
"text_muted": "#bac2de",
|
||||
"text_placeholder": "#a6adc8",
|
||||
"text_muted": "#a6adc8",
|
||||
"text_placeholder": "#7f849c",
|
||||
"text_accent": "#89b4fa",
|
||||
"icon": "#bac2de",
|
||||
"icon_muted": "#a6adc8",
|
||||
"icon_accent": "#89b4fa",
|
||||
"element_foreground": "#11111b",
|
||||
"text_danger": "#f38ba8",
|
||||
"text_warning": "#fab387",
|
||||
"icon": "#a6adc8",
|
||||
"icon_muted": "#7f849c",
|
||||
"icon_accent": "#b4befe",
|
||||
"element_foreground": "#1e1e2e",
|
||||
"element_background": "#89b4fa",
|
||||
"element_hover": "#b4befe",
|
||||
"element_active": "#7ba2e1",
|
||||
"element_selected": "#6d90c9",
|
||||
"element_active": "#89dceb",
|
||||
"element_selected": "#74c7ec",
|
||||
"element_disabled": "#89b4fa4d",
|
||||
"secondary_foreground": "#6d90c9",
|
||||
"secondary_background": "#181825",
|
||||
"secondary_hover": "#89b4fa33",
|
||||
"secondary_active": "#11111b",
|
||||
"secondary_selected": "#11111b",
|
||||
"secondary_foreground": "#cdd6f4",
|
||||
"secondary_background": "#313244",
|
||||
"secondary_hover": "#45475a",
|
||||
"secondary_active": "#585b70",
|
||||
"secondary_selected": "#585b70",
|
||||
"secondary_disabled": "#89b4fa4d",
|
||||
"danger_foreground": "#11111b",
|
||||
"danger_foreground": "#1e1e2e",
|
||||
"danger_background": "#f38ba8",
|
||||
"danger_hover": "#eba0ac",
|
||||
"danger_active": "#db7d98",
|
||||
"danger_selected": "#c46f88",
|
||||
"danger_active": "#fab387",
|
||||
"danger_selected": "#f9e2af",
|
||||
"danger_disabled": "#f38ba84d",
|
||||
"warning_foreground": "#11111b",
|
||||
"warning_background": "#f9e2af",
|
||||
"warning_hover": "#fab387",
|
||||
"warning_active": "#e0cb9e",
|
||||
"warning_selected": "#c8b48d",
|
||||
"warning_disabled": "#f9e2af4d",
|
||||
"ghost_element_background": "#00000000",
|
||||
"ghost_element_background_alt": "#313244",
|
||||
"ghost_element_hover": "#cdd6f433",
|
||||
"ghost_element_active": "#45475a",
|
||||
"ghost_element_selected": "#45475a",
|
||||
"ghost_element_disabled": "#cdd6f40d",
|
||||
"tab_inactive_background": "#181825",
|
||||
"tab_inactive_foreground": "#bac2de",
|
||||
"warning_foreground": "#1e1e2e",
|
||||
"warning_background": "#fab387",
|
||||
"warning_hover": "#f9e2af",
|
||||
"warning_active": "#a6e3a1",
|
||||
"warning_selected": "#94e2d5",
|
||||
"warning_disabled": "#fab3874d",
|
||||
"ghost_element_background": "#cdd6f400",
|
||||
"ghost_element_background_alt": "#181825",
|
||||
"ghost_element_hover": "#cdd6f40d",
|
||||
"ghost_element_active": "#cdd6f41a",
|
||||
"ghost_element_selected": "#cdd6f41a",
|
||||
"ghost_element_disabled": "#cdd6f405",
|
||||
"tab_background": "#11111b",
|
||||
"tab_foreground": "#a6adc8",
|
||||
"tab_hover_background": "#cdd6f40d",
|
||||
"tab_active_background": "#1e1e2e",
|
||||
"tab_active_foreground": "#cdd6f4",
|
||||
"tab_hover_foreground": "#b4befe",
|
||||
"scrollbar_thumb_background": "#cdd6f433",
|
||||
"scrollbar_thumb_hover_background": "#cdd6f580",
|
||||
"scrollbar_thumb_border": "#00000000",
|
||||
"scrollbar_track_background": "#00000000",
|
||||
"scrollbar_track_border": "#45475a",
|
||||
"scrollbar_thumb_background": "#cdd6f41a",
|
||||
"scrollbar_thumb_hover_background": "#cdd6f426",
|
||||
"scrollbar_thumb_border": "#cdd6f400",
|
||||
"scrollbar_track_background": "#cdd6f400",
|
||||
"scrollbar_track_border": "#cdd6f400",
|
||||
"drop_target_background": "#89b4fa1a",
|
||||
"cursor": "#f5e0dc",
|
||||
"selection": "#9399b240"
|
||||
"cursor": "#89b4fa",
|
||||
"selection": "#89b4fa40"
|
||||
},
|
||||
"dark": {
|
||||
"background": "#1e1e2e",
|
||||
@@ -76,65 +78,67 @@
|
||||
"elevated_surface_background": "#11111b",
|
||||
"panel_background": "#1e1e2e",
|
||||
"overlay": "#cdd6f41a",
|
||||
"title_bar": "#181825",
|
||||
"title_bar_inactive": "#11111b",
|
||||
"window_border": "#6c7086",
|
||||
"border": "#585b70",
|
||||
"border_variant": "#45475a",
|
||||
"title_bar": "#11111b",
|
||||
"title_bar_inactive": "#1e1e2e",
|
||||
"window_border": "#45475a",
|
||||
"border": "#45475a",
|
||||
"border_variant": "#313244",
|
||||
"border_focused": "#89b4fa",
|
||||
"border_selected": "#89b4fa",
|
||||
"border_transparent": "#00000000",
|
||||
"border_disabled": "#313244",
|
||||
"ring": "#89b4fa",
|
||||
"border_transparent": "#cdd6f400",
|
||||
"border_disabled": "#181825",
|
||||
"ring": "#b4befe",
|
||||
"text": "#cdd6f4",
|
||||
"text_muted": "#bac2de",
|
||||
"text_placeholder": "#a6adc8",
|
||||
"text_muted": "#a6adc8",
|
||||
"text_placeholder": "#7f849c",
|
||||
"text_accent": "#89b4fa",
|
||||
"icon": "#bac2de",
|
||||
"icon_muted": "#a6adc8",
|
||||
"icon_accent": "#89b4fa",
|
||||
"element_foreground": "#11111b",
|
||||
"text_danger": "#f38ba8",
|
||||
"text_warning": "#fab387",
|
||||
"icon": "#a6adc8",
|
||||
"icon_muted": "#7f849c",
|
||||
"icon_accent": "#b4befe",
|
||||
"element_foreground": "#1e1e2e",
|
||||
"element_background": "#89b4fa",
|
||||
"element_hover": "#b4befe",
|
||||
"element_active": "#7ba2e1",
|
||||
"element_selected": "#6d90c9",
|
||||
"element_active": "#89dceb",
|
||||
"element_selected": "#74c7ec",
|
||||
"element_disabled": "#89b4fa4d",
|
||||
"secondary_foreground": "#6d90c9",
|
||||
"secondary_background": "#181825",
|
||||
"secondary_hover": "#89b4fa33",
|
||||
"secondary_active": "#11111b",
|
||||
"secondary_selected": "#11111b",
|
||||
"secondary_foreground": "#cdd6f4",
|
||||
"secondary_background": "#313244",
|
||||
"secondary_hover": "#45475a",
|
||||
"secondary_active": "#585b70",
|
||||
"secondary_selected": "#585b70",
|
||||
"secondary_disabled": "#89b4fa4d",
|
||||
"danger_foreground": "#11111b",
|
||||
"danger_foreground": "#1e1e2e",
|
||||
"danger_background": "#f38ba8",
|
||||
"danger_hover": "#eba0ac",
|
||||
"danger_active": "#db7d98",
|
||||
"danger_selected": "#c46f88",
|
||||
"danger_active": "#fab387",
|
||||
"danger_selected": "#f9e2af",
|
||||
"danger_disabled": "#f38ba84d",
|
||||
"warning_foreground": "#11111b",
|
||||
"warning_background": "#f9e2af",
|
||||
"warning_hover": "#fab387",
|
||||
"warning_active": "#e0cb9e",
|
||||
"warning_selected": "#c8b48d",
|
||||
"warning_disabled": "#f9e2af4d",
|
||||
"ghost_element_background": "#00000000",
|
||||
"ghost_element_background_alt": "#313244",
|
||||
"ghost_element_hover": "#cdd6f433",
|
||||
"ghost_element_active": "#45475a",
|
||||
"ghost_element_selected": "#45475a",
|
||||
"ghost_element_disabled": "#cdd6f40d",
|
||||
"tab_inactive_background": "#181825",
|
||||
"tab_inactive_foreground": "#bac2de",
|
||||
"warning_foreground": "#1e1e2e",
|
||||
"warning_background": "#fab387",
|
||||
"warning_hover": "#f9e2af",
|
||||
"warning_active": "#a6e3a1",
|
||||
"warning_selected": "#94e2d5",
|
||||
"warning_disabled": "#fab3874d",
|
||||
"ghost_element_background": "#cdd6f400",
|
||||
"ghost_element_background_alt": "#181825",
|
||||
"ghost_element_hover": "#cdd6f40d",
|
||||
"ghost_element_active": "#cdd6f41a",
|
||||
"ghost_element_selected": "#cdd6f41a",
|
||||
"ghost_element_disabled": "#cdd6f405",
|
||||
"tab_background": "#11111b",
|
||||
"tab_foreground": "#a6adc8",
|
||||
"tab_hover_background": "#cdd6f40d",
|
||||
"tab_active_background": "#1e1e2e",
|
||||
"tab_active_foreground": "#cdd6f4",
|
||||
"tab_hover_foreground": "#b4befe",
|
||||
"scrollbar_thumb_background": "#cdd6f433",
|
||||
"scrollbar_thumb_hover_background": "#cdd6f580",
|
||||
"scrollbar_thumb_border": "#00000000",
|
||||
"scrollbar_track_background": "#00000000",
|
||||
"scrollbar_track_border": "#45475a",
|
||||
"scrollbar_thumb_background": "#cdd6f41a",
|
||||
"scrollbar_thumb_hover_background": "#cdd6f426",
|
||||
"scrollbar_thumb_border": "#cdd6f400",
|
||||
"scrollbar_track_background": "#cdd6f400",
|
||||
"scrollbar_track_border": "#cdd6f400",
|
||||
"drop_target_background": "#89b4fa1a",
|
||||
"cursor": "#f5e0dc",
|
||||
"selection": "#9399b240"
|
||||
"cursor": "#89b4fa",
|
||||
"selection": "#89b4fa40"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,140 +1,144 @@
|
||||
{
|
||||
"id": "flexoki",
|
||||
"name": "Flexoki",
|
||||
"author": "Stephan Ango",
|
||||
"author": "Steph Ango (ported by Coop)",
|
||||
"url": "https://stephango.com/flexoki",
|
||||
"light": {
|
||||
"background": "#FFFCF0",
|
||||
"surface_background": "#F2F0E5",
|
||||
"elevated_surface_background": "#E6E4D9",
|
||||
"panel_background": "#FFFCF0",
|
||||
"overlay": "#100F0F1a",
|
||||
"title_bar": "#F2F0E5",
|
||||
"title_bar_inactive": "#E6E4D9",
|
||||
"window_border": "#B7B5AC",
|
||||
"overlay": "#100F0F1A",
|
||||
"title_bar": "#E6E4D9",
|
||||
"title_bar_inactive": "#FFFCF0",
|
||||
"window_border": "#CECDC3",
|
||||
"border": "#CECDC3",
|
||||
"border_variant": "#DAD8CE",
|
||||
"border_focused": "#205EA6",
|
||||
"border_selected": "#205EA6",
|
||||
"border_transparent": "#00000000",
|
||||
"border_focused": "#24837B",
|
||||
"border_selected": "#24837B",
|
||||
"border_transparent": "#100F0F00",
|
||||
"border_disabled": "#E6E4D9",
|
||||
"ring": "#205EA6",
|
||||
"ring": "#3AA99F",
|
||||
"text": "#100F0F",
|
||||
"text_muted": "#6F6E69",
|
||||
"text_placeholder": "#9F9D96",
|
||||
"text_accent": "#205EA6",
|
||||
"text_placeholder": "#B7B5AC",
|
||||
"text_accent": "#24837B",
|
||||
"text_danger": "#AF3029",
|
||||
"text_warning": "#BC5215",
|
||||
"icon": "#6F6E69",
|
||||
"icon_muted": "#9F9D96",
|
||||
"icon_accent": "#205EA6",
|
||||
"icon_muted": "#B7B5AC",
|
||||
"icon_accent": "#3AA99F",
|
||||
"element_foreground": "#FFFCF0",
|
||||
"element_background": "#205EA6",
|
||||
"element_hover": "#1A4F8C",
|
||||
"element_active": "#163B66",
|
||||
"element_selected": "#133051",
|
||||
"element_disabled": "#205EA64d",
|
||||
"secondary_foreground": "#163B66",
|
||||
"secondary_background": "#F2F0E5",
|
||||
"secondary_hover": "#205EA61a",
|
||||
"secondary_active": "#E6E4D9",
|
||||
"secondary_selected": "#E6E4D9",
|
||||
"secondary_disabled": "#205EA64d",
|
||||
"element_background": "#24837B",
|
||||
"element_hover": "#3AA99F",
|
||||
"element_active": "#1C1B1A",
|
||||
"element_selected": "#100F0F",
|
||||
"element_disabled": "#24837B4D",
|
||||
"secondary_foreground": "#100F0F",
|
||||
"secondary_background": "#E6E4D9",
|
||||
"secondary_hover": "#DAD8CE",
|
||||
"secondary_active": "#CECDC3",
|
||||
"secondary_selected": "#CECDC3",
|
||||
"secondary_disabled": "#24837B4D",
|
||||
"danger_foreground": "#FFFCF0",
|
||||
"danger_background": "#D14D41",
|
||||
"danger_hover": "#C03E35",
|
||||
"danger_active": "#AF3029",
|
||||
"danger_selected": "#942822",
|
||||
"danger_disabled": "#D14D414d",
|
||||
"warning_foreground": "#100F0F",
|
||||
"warning_background": "#D0A215",
|
||||
"warning_hover": "#BE9207",
|
||||
"warning_active": "#AD8301",
|
||||
"warning_selected": "#8E6B01",
|
||||
"warning_disabled": "#D0A2154d",
|
||||
"ghost_element_background": "#00000000",
|
||||
"ghost_element_background_alt": "#E6E4D9",
|
||||
"ghost_element_hover": "#100F0F1a",
|
||||
"ghost_element_active": "#DAD8CE",
|
||||
"ghost_element_selected": "#DAD8CE",
|
||||
"ghost_element_disabled": "#100F0F0d",
|
||||
"tab_inactive_background": "#F2F0E5",
|
||||
"tab_inactive_foreground": "#6F6E69",
|
||||
"danger_background": "#AF3029",
|
||||
"danger_hover": "#D14D41",
|
||||
"danger_active": "#1C1B1A",
|
||||
"danger_selected": "#100F0F",
|
||||
"danger_disabled": "#AF30294D",
|
||||
"warning_foreground": "#FFFCF0",
|
||||
"warning_background": "#BC5215",
|
||||
"warning_hover": "#DA702C",
|
||||
"warning_active": "#1C1B1A",
|
||||
"warning_selected": "#100F0F",
|
||||
"warning_disabled": "#BC52154D",
|
||||
"ghost_element_background": "#100F0F00",
|
||||
"ghost_element_background_alt": "#F2F0E5",
|
||||
"ghost_element_hover": "#100F0F0D",
|
||||
"ghost_element_active": "#100F0F1A",
|
||||
"ghost_element_selected": "#100F0F1A",
|
||||
"ghost_element_disabled": "#100F0F05",
|
||||
"tab_background": "#E6E4D9",
|
||||
"tab_foreground": "#6F6E69",
|
||||
"tab_hover_background": "#100F0F0D",
|
||||
"tab_active_background": "#FFFCF0",
|
||||
"tab_active_foreground": "#100F0F",
|
||||
"tab_hover_foreground": "#205EA6",
|
||||
"scrollbar_thumb_background": "#100F0F33",
|
||||
"scrollbar_thumb_hover_background": "#100F0F4d",
|
||||
"scrollbar_thumb_border": "#00000000",
|
||||
"scrollbar_track_background": "#00000000",
|
||||
"scrollbar_track_border": "#DAD8CE",
|
||||
"drop_target_background": "#205EA61a",
|
||||
"cursor": "#205EA6",
|
||||
"selection": "#205EA640"
|
||||
"scrollbar_thumb_background": "#100F0F1A",
|
||||
"scrollbar_thumb_hover_background": "#100F0F26",
|
||||
"scrollbar_thumb_border": "#100F0F00",
|
||||
"scrollbar_track_background": "#100F0F00",
|
||||
"scrollbar_track_border": "#100F0F00",
|
||||
"drop_target_background": "#24837B1A",
|
||||
"cursor": "#24837B",
|
||||
"selection": "#24837B40"
|
||||
},
|
||||
"dark": {
|
||||
"background": "#100F0F",
|
||||
"surface_background": "#1C1B1A",
|
||||
"elevated_surface_background": "#282726",
|
||||
"panel_background": "#100F0F",
|
||||
"overlay": "#FFFCF01a",
|
||||
"title_bar": "#1C1B1A",
|
||||
"title_bar_inactive": "#282726",
|
||||
"window_border": "#575653",
|
||||
"overlay": "#FFFCF01A",
|
||||
"title_bar": "#282726",
|
||||
"title_bar_inactive": "#100F0F",
|
||||
"window_border": "#403E3C",
|
||||
"border": "#403E3C",
|
||||
"border_variant": "#343331",
|
||||
"border_focused": "#4385BE",
|
||||
"border_selected": "#4385BE",
|
||||
"border_transparent": "#00000000",
|
||||
"border_focused": "#3AA99F",
|
||||
"border_selected": "#3AA99F",
|
||||
"border_transparent": "#100F0F00",
|
||||
"border_disabled": "#282726",
|
||||
"ring": "#4385BE",
|
||||
"text": "#FFFCF0",
|
||||
"ring": "#24837B",
|
||||
"text": "#CECDC3",
|
||||
"text_muted": "#878580",
|
||||
"text_placeholder": "#6F6E69",
|
||||
"text_accent": "#4385BE",
|
||||
"text_placeholder": "#575653",
|
||||
"text_accent": "#3AA99F",
|
||||
"text_danger": "#D14D41",
|
||||
"text_warning": "#DA702C",
|
||||
"icon": "#878580",
|
||||
"icon_muted": "#6F6E69",
|
||||
"icon_accent": "#4385BE",
|
||||
"icon_muted": "#575653",
|
||||
"icon_accent": "#24837B",
|
||||
"element_foreground": "#100F0F",
|
||||
"element_background": "#4385BE",
|
||||
"element_hover": "#3171B2",
|
||||
"element_active": "#205EA6",
|
||||
"element_selected": "#1A4F8C",
|
||||
"element_disabled": "#4385BE4d",
|
||||
"secondary_foreground": "#205EA6",
|
||||
"element_background": "#3AA99F",
|
||||
"element_hover": "#24837B",
|
||||
"element_active": "#CECDC3",
|
||||
"element_selected": "#F2F0E5",
|
||||
"element_disabled": "#3AA99F4D",
|
||||
"secondary_foreground": "#CECDC3",
|
||||
"secondary_background": "#1C1B1A",
|
||||
"secondary_hover": "#4385BE1a",
|
||||
"secondary_active": "#282726",
|
||||
"secondary_selected": "#282726",
|
||||
"secondary_disabled": "#4385BE4d",
|
||||
"secondary_hover": "#282726",
|
||||
"secondary_active": "#343331",
|
||||
"secondary_selected": "#343331",
|
||||
"secondary_disabled": "#3AA99F4D",
|
||||
"danger_foreground": "#100F0F",
|
||||
"danger_background": "#E8705F",
|
||||
"danger_hover": "#D14D41",
|
||||
"danger_active": "#C03E35",
|
||||
"danger_selected": "#AF3029",
|
||||
"danger_disabled": "#E8705F4d",
|
||||
"danger_background": "#D14D41",
|
||||
"danger_hover": "#AF3029",
|
||||
"danger_active": "#CECDC3",
|
||||
"danger_selected": "#F2F0E5",
|
||||
"danger_disabled": "#D14D414D",
|
||||
"warning_foreground": "#100F0F",
|
||||
"warning_background": "#DFB431",
|
||||
"warning_hover": "#D0A215",
|
||||
"warning_active": "#BE9207",
|
||||
"warning_selected": "#AD8301",
|
||||
"warning_disabled": "#DFB4314d",
|
||||
"ghost_element_background": "#00000000",
|
||||
"ghost_element_background_alt": "#282726",
|
||||
"ghost_element_hover": "#FFFCF01a",
|
||||
"ghost_element_active": "#343331",
|
||||
"ghost_element_selected": "#343331",
|
||||
"ghost_element_disabled": "#FFFCF00d",
|
||||
"tab_inactive_background": "#1C1B1A",
|
||||
"tab_inactive_foreground": "#878580",
|
||||
"warning_background": "#DA702C",
|
||||
"warning_hover": "#BC5215",
|
||||
"warning_active": "#CECDC3",
|
||||
"warning_selected": "#F2F0E5",
|
||||
"warning_disabled": "#DA702C4D",
|
||||
"ghost_element_background": "#100F0F00",
|
||||
"ghost_element_background_alt": "#1C1B1A",
|
||||
"ghost_element_hover": "#FFFCF00D",
|
||||
"ghost_element_active": "#FFFCF01A",
|
||||
"ghost_element_selected": "#FFFCF01A",
|
||||
"ghost_element_disabled": "#FFFCF005",
|
||||
"tab_background": "#282726",
|
||||
"tab_foreground": "#878580",
|
||||
"tab_hover_background": "#FFFCF00D",
|
||||
"tab_active_background": "#100F0F",
|
||||
"tab_active_foreground": "#FFFCF0",
|
||||
"tab_hover_foreground": "#4385BE",
|
||||
"scrollbar_thumb_background": "#FFFCF033",
|
||||
"scrollbar_thumb_hover_background": "#FFFCF04d",
|
||||
"scrollbar_thumb_border": "#00000000",
|
||||
"scrollbar_track_background": "#00000000",
|
||||
"scrollbar_track_border": "#343331",
|
||||
"drop_target_background": "#4385BE1a",
|
||||
"cursor": "#4385BE",
|
||||
"selection": "#4385BE40"
|
||||
"tab_active_foreground": "#CECDC3",
|
||||
"scrollbar_thumb_background": "#FFFCF01A",
|
||||
"scrollbar_thumb_hover_background": "#FFFCF026",
|
||||
"scrollbar_thumb_border": "#100F0F00",
|
||||
"scrollbar_track_background": "#100F0F00",
|
||||
"scrollbar_track_border": "#100F0F00",
|
||||
"drop_target_background": "#3AA99F1A",
|
||||
"cursor": "#3AA99F",
|
||||
"selection": "#3AA99F40"
|
||||
}
|
||||
}
|
||||
|
||||
144
assets/themes/forest.json
Normal file
@@ -0,0 +1,144 @@
|
||||
{
|
||||
"id": "forest",
|
||||
"name": "Forest",
|
||||
"author": "Coop",
|
||||
"url": "https://github.com/lumehq/coop",
|
||||
"light": {
|
||||
"background": "#fbfefcff",
|
||||
"surface_background": "#f4fbf6ff",
|
||||
"elevated_surface_background": "#e9f6e9ff",
|
||||
"panel_background": "#fbfefcff",
|
||||
"overlay": "#193b2d1a",
|
||||
"title_bar": "#e9f6e9ff",
|
||||
"title_bar_inactive": "#fbfefcff",
|
||||
"window_border": "#c4e8d1ff",
|
||||
"border": "#c4e8d1ff",
|
||||
"border_variant": "#b2ddb5ff",
|
||||
"border_focused": "#30a46cff",
|
||||
"border_selected": "#30a46cff",
|
||||
"border_transparent": "#00000000",
|
||||
"border_disabled": "#e0f3e6ff",
|
||||
"ring": "#2b9a66ff",
|
||||
"text": "#193b2dff",
|
||||
"text_muted": "#2f7c57ff",
|
||||
"text_placeholder": "#8eceaaff",
|
||||
"text_accent": "#30a46cff",
|
||||
"text_danger": "#e54d2eff",
|
||||
"text_warning": "#f76b15ff",
|
||||
"icon": "#2f7c57ff",
|
||||
"icon_muted": "#8eceaaff",
|
||||
"icon_accent": "#2b9a66ff",
|
||||
"element_foreground": "#ffffffff",
|
||||
"element_background": "#30a46cff",
|
||||
"element_hover": "#2b9a66ff",
|
||||
"element_active": "#2a7e3bff",
|
||||
"element_selected": "#218358ff",
|
||||
"element_disabled": "#30a46c4d",
|
||||
"secondary_foreground": "#193b2dff",
|
||||
"secondary_background": "#e9f6e9ff",
|
||||
"secondary_hover": "#daf1dbff",
|
||||
"secondary_active": "#c4e8d1ff",
|
||||
"secondary_selected": "#c4e8d1ff",
|
||||
"secondary_disabled": "#30a46c4d",
|
||||
"danger_foreground": "#ffffffff",
|
||||
"danger_background": "#feebe7ff",
|
||||
"danger_hover": "#ffcdc2ff",
|
||||
"danger_active": "#fdbdafff",
|
||||
"danger_selected": "#fdbdafff",
|
||||
"danger_disabled": "#e54d2e4d",
|
||||
"warning_foreground": "#ffffffff",
|
||||
"warning_background": "#fff7edff",
|
||||
"warning_hover": "#ffd19aff",
|
||||
"warning_active": "#ffc182ff",
|
||||
"warning_selected": "#ffc182ff",
|
||||
"warning_disabled": "#f76b154d",
|
||||
"ghost_element_background": "#00000000",
|
||||
"ghost_element_background_alt": "#e9f6e9ff",
|
||||
"ghost_element_hover": "#193b2d0d",
|
||||
"ghost_element_active": "#193b2d1a",
|
||||
"ghost_element_selected": "#193b2d1a",
|
||||
"ghost_element_disabled": "#193b2d05",
|
||||
"tab_background": "#e9f6e9ff",
|
||||
"tab_foreground": "#2f7c57ff",
|
||||
"tab_hover_background": "#193b2d0d",
|
||||
"tab_active_background": "#fbfefcff",
|
||||
"tab_active_foreground": "#193b2dff",
|
||||
"scrollbar_thumb_background": "#193b2d1a",
|
||||
"scrollbar_thumb_hover_background": "#193b2d26",
|
||||
"scrollbar_thumb_border": "#00000000",
|
||||
"scrollbar_track_background": "#00000000",
|
||||
"scrollbar_track_border": "#00000000",
|
||||
"drop_target_background": "#30a46c1a",
|
||||
"cursor": "#30a46cff",
|
||||
"selection": "#30a46c40"
|
||||
},
|
||||
"dark": {
|
||||
"background": "#0e1512ff",
|
||||
"surface_background": "#121b17ff",
|
||||
"elevated_surface_background": "#132d21ff",
|
||||
"panel_background": "#0e1512ff",
|
||||
"overlay": "#b1f1cb1a",
|
||||
"title_bar": "#132d21ff",
|
||||
"title_bar_inactive": "#0e1512ff",
|
||||
"window_border": "#20573eff",
|
||||
"border": "#20573eff",
|
||||
"border_variant": "#174933ff",
|
||||
"border_focused": "#33b074ff",
|
||||
"border_selected": "#33b074ff",
|
||||
"border_transparent": "#00000000",
|
||||
"border_disabled": "#113b29ff",
|
||||
"ring": "#33b074ff",
|
||||
"text": "#b1f1cbff",
|
||||
"text_muted": "#71d083ff",
|
||||
"text_placeholder": "#2f7c57ff",
|
||||
"text_accent": "#3dd68cff",
|
||||
"text_danger": "#ff977dff",
|
||||
"text_warning": "#ffa057ff",
|
||||
"icon": "#71d083ff",
|
||||
"icon_muted": "#2f7c57ff",
|
||||
"icon_accent": "#33b074ff",
|
||||
"element_foreground": "#0e1512ff",
|
||||
"element_background": "#3dd68cff",
|
||||
"element_hover": "#71d083ff",
|
||||
"element_active": "#33b074ff",
|
||||
"element_selected": "#30a46cff",
|
||||
"element_disabled": "#3dd68c4d",
|
||||
"secondary_foreground": "#b1f1cbff",
|
||||
"secondary_background": "#132d21ff",
|
||||
"secondary_hover": "#113b29ff",
|
||||
"secondary_active": "#174933ff",
|
||||
"secondary_selected": "#174933ff",
|
||||
"secondary_disabled": "#3dd68c4d",
|
||||
"danger_foreground": "#181111ff",
|
||||
"danger_background": "#391714ff",
|
||||
"danger_hover": "#5e1c16ff",
|
||||
"danger_active": "#6e2920ff",
|
||||
"danger_selected": "#6e2920ff",
|
||||
"danger_disabled": "#ff977d4d",
|
||||
"warning_foreground": "#17120eff",
|
||||
"warning_background": "#331e0bff",
|
||||
"warning_hover": "#562800ff",
|
||||
"warning_active": "#66350cff",
|
||||
"warning_selected": "#66350cff",
|
||||
"warning_disabled": "#ffa0574d",
|
||||
"ghost_element_background": "#00000000",
|
||||
"ghost_element_background_alt": "#132d21ff",
|
||||
"ghost_element_hover": "#b1f1cb0d",
|
||||
"ghost_element_active": "#b1f1cb1a",
|
||||
"ghost_element_selected": "#b1f1cb1a",
|
||||
"ghost_element_disabled": "#b1f1cb05",
|
||||
"tab_background": "#132d21ff",
|
||||
"tab_foreground": "#71d083ff",
|
||||
"tab_hover_background": "#b1f1cb0d",
|
||||
"tab_active_background": "#0e1512ff",
|
||||
"tab_active_foreground": "#b1f1cbff",
|
||||
"scrollbar_thumb_background": "#b1f1cb1a",
|
||||
"scrollbar_thumb_hover_background": "#b1f1cb26",
|
||||
"scrollbar_thumb_border": "#00000000",
|
||||
"scrollbar_track_background": "#00000000",
|
||||
"scrollbar_track_border": "#00000000",
|
||||
"drop_target_background": "#3dd68c1a",
|
||||
"cursor": "#3dd68cff",
|
||||
"selection": "#3dd68c40"
|
||||
}
|
||||
}
|
||||
144
assets/themes/ocean.json
Normal file
@@ -0,0 +1,144 @@
|
||||
{
|
||||
"id": "ocean",
|
||||
"name": "Ocean",
|
||||
"author": "Coop",
|
||||
"url": "https://github.com/lumehq/coop",
|
||||
"light": {
|
||||
"background": "#fafefeff",
|
||||
"surface_background": "#f2fbfaff",
|
||||
"elevated_surface_background": "#e6f7f7ff",
|
||||
"panel_background": "#fafefeff",
|
||||
"overlay": "#00333f1a",
|
||||
"title_bar": "#e6f7f7ff",
|
||||
"title_bar_inactive": "#fafefeff",
|
||||
"window_border": "#cce5e9ff",
|
||||
"border": "#cce5e9ff",
|
||||
"border_variant": "#b8dde3ff",
|
||||
"border_focused": "#00a2c7ff",
|
||||
"border_selected": "#00a2c7ff",
|
||||
"border_transparent": "#00000000",
|
||||
"border_disabled": "#e0f0f2ff",
|
||||
"ring": "#0797b9ff",
|
||||
"text": "#0d3c48ff",
|
||||
"text_muted": "#107d98ff",
|
||||
"text_placeholder": "#60b3d7ff",
|
||||
"text_accent": "#00a2c7ff",
|
||||
"text_danger": "#e54d2eff",
|
||||
"text_warning": "#f76b15ff",
|
||||
"icon": "#107d98ff",
|
||||
"icon_muted": "#60b3d7ff",
|
||||
"icon_accent": "#0797b9ff",
|
||||
"element_foreground": "#ffffffff",
|
||||
"element_background": "#00a2c7ff",
|
||||
"element_hover": "#0797b9ff",
|
||||
"element_active": "#12667eff",
|
||||
"element_selected": "#0d4a5cff",
|
||||
"element_disabled": "#00a2c74d",
|
||||
"secondary_foreground": "#0d4a5cff",
|
||||
"secondary_background": "#ddf9f2ff",
|
||||
"secondary_hover": "#c8f4e9ff",
|
||||
"secondary_active": "#b3ecdeff",
|
||||
"secondary_selected": "#b3ecdeff",
|
||||
"secondary_disabled": "#00a2c74d",
|
||||
"danger_foreground": "#ffffffff",
|
||||
"danger_background": "#feebe7ff",
|
||||
"danger_hover": "#ffcdc2ff",
|
||||
"danger_active": "#fdbdafff",
|
||||
"danger_selected": "#fdbdafff",
|
||||
"danger_disabled": "#e54d2e4d",
|
||||
"warning_foreground": "#ffffffff",
|
||||
"warning_background": "#fff7edff",
|
||||
"warning_hover": "#ffd19aff",
|
||||
"warning_active": "#ffc182ff",
|
||||
"warning_selected": "#ffc182ff",
|
||||
"warning_disabled": "#f76b154d",
|
||||
"ghost_element_background": "#00000000",
|
||||
"ghost_element_background_alt": "#e6f7f7ff",
|
||||
"ghost_element_hover": "#00333f0d",
|
||||
"ghost_element_active": "#00333f1a",
|
||||
"ghost_element_selected": "#00333f1a",
|
||||
"ghost_element_disabled": "#00333f05",
|
||||
"tab_background": "#e6f7f7ff",
|
||||
"tab_foreground": "#107d98ff",
|
||||
"tab_hover_background": "#00333f0d",
|
||||
"tab_active_background": "#fafefeff",
|
||||
"tab_active_foreground": "#0d3c48ff",
|
||||
"scrollbar_thumb_background": "#00333f1a",
|
||||
"scrollbar_thumb_hover_background": "#00333f26",
|
||||
"scrollbar_thumb_border": "#00000000",
|
||||
"scrollbar_track_background": "#00000000",
|
||||
"scrollbar_track_border": "#00000000",
|
||||
"drop_target_background": "#00a2c71a",
|
||||
"cursor": "#00a2c7ff",
|
||||
"selection": "#00a2c740"
|
||||
},
|
||||
"dark": {
|
||||
"background": "#0b161aff",
|
||||
"surface_background": "#101b20ff",
|
||||
"elevated_surface_background": "#082c36ff",
|
||||
"panel_background": "#0b161aff",
|
||||
"overlay": "#c2f3ff1a",
|
||||
"title_bar": "#082c36ff",
|
||||
"title_bar_inactive": "#0b161aff",
|
||||
"window_border": "#1b537bff",
|
||||
"border": "#1b537bff",
|
||||
"border_variant": "#154467ff",
|
||||
"border_focused": "#00a2c7ff",
|
||||
"border_selected": "#00a2c7ff",
|
||||
"border_transparent": "#00000000",
|
||||
"border_disabled": "#112840ff",
|
||||
"ring": "#23afd0ff",
|
||||
"text": "#b6ecf7ff",
|
||||
"text_muted": "#4ccce6ff",
|
||||
"text_placeholder": "#197caeff",
|
||||
"text_accent": "#7ce2feff",
|
||||
"text_danger": "#ff977dff",
|
||||
"text_warning": "#ffa057ff",
|
||||
"icon": "#4ccce6ff",
|
||||
"icon_muted": "#197caeff",
|
||||
"icon_accent": "#23afd0ff",
|
||||
"element_foreground": "#0b161aff",
|
||||
"element_background": "#7ce2feff",
|
||||
"element_hover": "#a8eeffff",
|
||||
"element_active": "#23afd0ff",
|
||||
"element_selected": "#00a2c7ff",
|
||||
"element_disabled": "#7ce2fe4d",
|
||||
"secondary_foreground": "#adf0ddff",
|
||||
"secondary_background": "#0d2d2aff",
|
||||
"secondary_hover": "#023b37ff",
|
||||
"secondary_active": "#084843ff",
|
||||
"secondary_selected": "#084843ff",
|
||||
"secondary_disabled": "#7ce2fe4d",
|
||||
"danger_foreground": "#181111ff",
|
||||
"danger_background": "#391714ff",
|
||||
"danger_hover": "#5e1c16ff",
|
||||
"danger_active": "#6e2920ff",
|
||||
"danger_selected": "#6e2920ff",
|
||||
"danger_disabled": "#ff977d4d",
|
||||
"warning_foreground": "#17120eff",
|
||||
"warning_background": "#331e0bff",
|
||||
"warning_hover": "#562800ff",
|
||||
"warning_active": "#66350cff",
|
||||
"warning_selected": "#66350cff",
|
||||
"warning_disabled": "#ffa0574d",
|
||||
"ghost_element_background": "#00000000",
|
||||
"ghost_element_background_alt": "#082c36ff",
|
||||
"ghost_element_hover": "#c2f3ff0d",
|
||||
"ghost_element_active": "#c2f3ff1a",
|
||||
"ghost_element_selected": "#c2f3ff1a",
|
||||
"ghost_element_disabled": "#c2f3ff05",
|
||||
"tab_background": "#082c36ff",
|
||||
"tab_foreground": "#4ccce6ff",
|
||||
"tab_hover_background": "#c2f3ff0d",
|
||||
"tab_active_background": "#0b161aff",
|
||||
"tab_active_foreground": "#b6ecf7ff",
|
||||
"scrollbar_thumb_background": "#c2f3ff1a",
|
||||
"scrollbar_thumb_hover_background": "#c2f3ff26",
|
||||
"scrollbar_thumb_border": "#00000000",
|
||||
"scrollbar_track_background": "#00000000",
|
||||
"scrollbar_track_border": "#00000000",
|
||||
"drop_target_background": "#7ce2fe1a",
|
||||
"cursor": "#7ce2feff",
|
||||
"selection": "#7ce2fe40"
|
||||
}
|
||||
}
|
||||
@@ -1,140 +0,0 @@
|
||||
{
|
||||
"id": "rose-pine-dawn",
|
||||
"name": "Rosé Pine Dawn",
|
||||
"author": "Rosé Pine",
|
||||
"url": "https://rosepinetheme.com/",
|
||||
"light": {
|
||||
"background": "#faf4ed",
|
||||
"surface_background": "#fffaf3",
|
||||
"elevated_surface_background": "#f2e9e1",
|
||||
"panel_background": "#faf4ed",
|
||||
"overlay": "#5752791a",
|
||||
"title_bar": "#fffaf3",
|
||||
"title_bar_inactive": "#f2e9e1",
|
||||
"window_border": "#cecacd",
|
||||
"border": "#dfdad9",
|
||||
"border_variant": "#f4ede8",
|
||||
"border_focused": "#907aa9",
|
||||
"border_selected": "#907aa9",
|
||||
"border_transparent": "#00000000",
|
||||
"border_disabled": "#f2e9e1",
|
||||
"ring": "#907aa9",
|
||||
"text": "#575279",
|
||||
"text_muted": "#797593",
|
||||
"text_placeholder": "#9893a5",
|
||||
"text_accent": "#907aa9",
|
||||
"icon": "#797593",
|
||||
"icon_muted": "#9893a5",
|
||||
"icon_accent": "#907aa9",
|
||||
"element_foreground": "#faf4ed",
|
||||
"element_background": "#907aa9",
|
||||
"element_hover": "#907aa9e6",
|
||||
"element_active": "#826b95",
|
||||
"element_selected": "#745c81",
|
||||
"element_disabled": "#907aa94d",
|
||||
"secondary_foreground": "#745c81",
|
||||
"secondary_background": "#fffaf3",
|
||||
"secondary_hover": "#907aa91a",
|
||||
"secondary_active": "#f2e9e1",
|
||||
"secondary_selected": "#f2e9e1",
|
||||
"secondary_disabled": "#907aa94d",
|
||||
"danger_foreground": "#faf4ed",
|
||||
"danger_background": "#b4637a",
|
||||
"danger_hover": "#a7586e",
|
||||
"danger_active": "#9a4d62",
|
||||
"danger_selected": "#8d4256",
|
||||
"danger_disabled": "#b4637a4d",
|
||||
"warning_foreground": "#575279",
|
||||
"warning_background": "#ea9d34",
|
||||
"warning_hover": "#d98e2f",
|
||||
"warning_active": "#c87f2a",
|
||||
"warning_selected": "#b77025",
|
||||
"warning_disabled": "#ea9d344d",
|
||||
"ghost_element_background": "#00000000",
|
||||
"ghost_element_background_alt": "#f2e9e1",
|
||||
"ghost_element_hover": "#5752791a",
|
||||
"ghost_element_active": "#dfdad9",
|
||||
"ghost_element_selected": "#dfdad9",
|
||||
"ghost_element_disabled": "#5752790d",
|
||||
"tab_inactive_background": "#fffaf3",
|
||||
"tab_inactive_foreground": "#797593",
|
||||
"tab_active_background": "#faf4ed",
|
||||
"tab_active_foreground": "#575279",
|
||||
"tab_hover_foreground": "#907aa9",
|
||||
"scrollbar_thumb_background": "#57527933",
|
||||
"scrollbar_thumb_hover_background": "#5752794d",
|
||||
"scrollbar_thumb_border": "#00000000",
|
||||
"scrollbar_track_background": "#00000000",
|
||||
"scrollbar_track_border": "#dfdad9",
|
||||
"drop_target_background": "#907aa91a",
|
||||
"cursor": "#907aa9",
|
||||
"selection": "#907aa940"
|
||||
},
|
||||
"dark": {
|
||||
"background": "#faf4ed",
|
||||
"surface_background": "#fffaf3",
|
||||
"elevated_surface_background": "#f2e9e1",
|
||||
"panel_background": "#faf4ed",
|
||||
"overlay": "#5752791a",
|
||||
"title_bar": "#fffaf3",
|
||||
"title_bar_inactive": "#f2e9e1",
|
||||
"window_border": "#cecacd",
|
||||
"border": "#dfdad9",
|
||||
"border_variant": "#f4ede8",
|
||||
"border_focused": "#907aa9",
|
||||
"border_selected": "#907aa9",
|
||||
"border_transparent": "#00000000",
|
||||
"border_disabled": "#f2e9e1",
|
||||
"ring": "#907aa9",
|
||||
"text": "#575279",
|
||||
"text_muted": "#797593",
|
||||
"text_placeholder": "#9893a5",
|
||||
"text_accent": "#907aa9",
|
||||
"icon": "#797593",
|
||||
"icon_muted": "#9893a5",
|
||||
"icon_accent": "#907aa9",
|
||||
"element_foreground": "#faf4ed",
|
||||
"element_background": "#907aa9",
|
||||
"element_hover": "#907aa9e6",
|
||||
"element_active": "#826b95",
|
||||
"element_selected": "#745c81",
|
||||
"element_disabled": "#907aa94d",
|
||||
"secondary_foreground": "#745c81",
|
||||
"secondary_background": "#fffaf3",
|
||||
"secondary_hover": "#907aa91a",
|
||||
"secondary_active": "#f2e9e1",
|
||||
"secondary_selected": "#f2e9e1",
|
||||
"secondary_disabled": "#907aa94d",
|
||||
"danger_foreground": "#faf4ed",
|
||||
"danger_background": "#b4637a",
|
||||
"danger_hover": "#a7586e",
|
||||
"danger_active": "#9a4d62",
|
||||
"danger_selected": "#8d4256",
|
||||
"danger_disabled": "#b4637a4d",
|
||||
"warning_foreground": "#575279",
|
||||
"warning_background": "#ea9d34",
|
||||
"warning_hover": "#d98e2f",
|
||||
"warning_active": "#c87f2a",
|
||||
"warning_selected": "#b77025",
|
||||
"warning_disabled": "#ea9d344d",
|
||||
"ghost_element_background": "#00000000",
|
||||
"ghost_element_background_alt": "#f2e9e1",
|
||||
"ghost_element_hover": "#5752791a",
|
||||
"ghost_element_active": "#dfdad9",
|
||||
"ghost_element_selected": "#dfdad9",
|
||||
"ghost_element_disabled": "#5752790d",
|
||||
"tab_inactive_background": "#fffaf3",
|
||||
"tab_inactive_foreground": "#797593",
|
||||
"tab_active_background": "#faf4ed",
|
||||
"tab_active_foreground": "#575279",
|
||||
"tab_hover_foreground": "#907aa9",
|
||||
"scrollbar_thumb_background": "#57527933",
|
||||
"scrollbar_thumb_hover_background": "#5752794d",
|
||||
"scrollbar_thumb_border": "#00000000",
|
||||
"scrollbar_track_background": "#00000000",
|
||||
"scrollbar_track_border": "#dfdad9",
|
||||
"drop_target_background": "#907aa91a",
|
||||
"cursor": "#907aa9",
|
||||
"selection": "#907aa940"
|
||||
}
|
||||
}
|
||||
@@ -1,140 +0,0 @@
|
||||
{
|
||||
"id": "rose-pine-moon",
|
||||
"name": "Rosé Pine Moon",
|
||||
"author": "Rosé Pine",
|
||||
"url": "https://rosepinetheme.com/",
|
||||
"light": {
|
||||
"background": "#232136",
|
||||
"surface_background": "#2a273f",
|
||||
"elevated_surface_background": "#393552",
|
||||
"panel_background": "#232136",
|
||||
"overlay": "#e0def41a",
|
||||
"title_bar": "#2a273f",
|
||||
"title_bar_inactive": "#393552",
|
||||
"window_border": "#56526e",
|
||||
"border": "#44415a",
|
||||
"border_variant": "#393552",
|
||||
"border_focused": "#c4a7e7",
|
||||
"border_selected": "#c4a7e7",
|
||||
"border_transparent": "#00000000",
|
||||
"border_disabled": "#393552",
|
||||
"ring": "#c4a7e7",
|
||||
"text": "#e0def4",
|
||||
"text_muted": "#908caa",
|
||||
"text_placeholder": "#6e6a86",
|
||||
"text_accent": "#c4a7e7",
|
||||
"icon": "#908caa",
|
||||
"icon_muted": "#6e6a86",
|
||||
"icon_accent": "#c4a7e7",
|
||||
"element_foreground": "#232136",
|
||||
"element_background": "#c4a7e7",
|
||||
"element_hover": "#c4a7e7e6",
|
||||
"element_active": "#b296d6",
|
||||
"element_selected": "#a085c5",
|
||||
"element_disabled": "#c4a7e74d",
|
||||
"secondary_foreground": "#a085c5",
|
||||
"secondary_background": "#393552",
|
||||
"secondary_hover": "#c4a7e71a",
|
||||
"secondary_active": "#44415a",
|
||||
"secondary_selected": "#44415a",
|
||||
"secondary_disabled": "#c4a7e74d",
|
||||
"danger_foreground": "#232136",
|
||||
"danger_background": "#eb6f92",
|
||||
"danger_hover": "#e55a82",
|
||||
"danger_active": "#df4572",
|
||||
"danger_selected": "#d93062",
|
||||
"danger_disabled": "#eb6f924d",
|
||||
"warning_foreground": "#232136",
|
||||
"warning_background": "#f6c177",
|
||||
"warning_hover": "#f4b35e",
|
||||
"warning_active": "#f2a545",
|
||||
"warning_selected": "#f0972c",
|
||||
"warning_disabled": "#f6c1774d",
|
||||
"ghost_element_background": "#00000000",
|
||||
"ghost_element_background_alt": "#393552",
|
||||
"ghost_element_hover": "#e0def41a",
|
||||
"ghost_element_active": "#44415a",
|
||||
"ghost_element_selected": "#44415a",
|
||||
"ghost_element_disabled": "#e0def40d",
|
||||
"tab_inactive_background": "#2a273f",
|
||||
"tab_inactive_foreground": "#908caa",
|
||||
"tab_active_background": "#232136",
|
||||
"tab_active_foreground": "#e0def4",
|
||||
"tab_hover_foreground": "#c4a7e7",
|
||||
"scrollbar_thumb_background": "#e0def433",
|
||||
"scrollbar_thumb_hover_background": "#e0def44d",
|
||||
"scrollbar_thumb_border": "#00000000",
|
||||
"scrollbar_track_background": "#00000000",
|
||||
"scrollbar_track_border": "#44415a",
|
||||
"drop_target_background": "#c4a7e71a",
|
||||
"cursor": "#c4a7e7",
|
||||
"selection": "#c4a7e740"
|
||||
},
|
||||
"dark": {
|
||||
"background": "#232136",
|
||||
"surface_background": "#2a273f",
|
||||
"elevated_surface_background": "#393552",
|
||||
"panel_background": "#232136",
|
||||
"overlay": "#e0def41a",
|
||||
"title_bar": "#2a273f",
|
||||
"title_bar_inactive": "#393552",
|
||||
"window_border": "#56526e",
|
||||
"border": "#44415a",
|
||||
"border_variant": "#393552",
|
||||
"border_focused": "#c4a7e7",
|
||||
"border_selected": "#c4a7e7",
|
||||
"border_transparent": "#00000000",
|
||||
"border_disabled": "#393552",
|
||||
"ring": "#c4a7e7",
|
||||
"text": "#e0def4",
|
||||
"text_muted": "#908caa",
|
||||
"text_placeholder": "#6e6a86",
|
||||
"text_accent": "#c4a7e7",
|
||||
"icon": "#908caa",
|
||||
"icon_muted": "#6e6a86",
|
||||
"icon_accent": "#c4a7e7",
|
||||
"element_foreground": "#232136",
|
||||
"element_background": "#c4a7e7",
|
||||
"element_hover": "#c4a7e7e6",
|
||||
"element_active": "#b296d6",
|
||||
"element_selected": "#a085c5",
|
||||
"element_disabled": "#c4a7e74d",
|
||||
"secondary_foreground": "#a085c5",
|
||||
"secondary_background": "#393552",
|
||||
"secondary_hover": "#c4a7e71a",
|
||||
"secondary_active": "#44415a",
|
||||
"secondary_selected": "#44415a",
|
||||
"secondary_disabled": "#c4a7e74d",
|
||||
"danger_foreground": "#232136",
|
||||
"danger_background": "#eb6f92",
|
||||
"danger_hover": "#e55a82",
|
||||
"danger_active": "#df4572",
|
||||
"danger_selected": "#d93062",
|
||||
"danger_disabled": "#eb6f924d",
|
||||
"warning_foreground": "#232136",
|
||||
"warning_background": "#f6c177",
|
||||
"warning_hover": "#f4b35e",
|
||||
"warning_active": "#f2a545",
|
||||
"warning_selected": "#f0972c",
|
||||
"warning_disabled": "#f6c1774d",
|
||||
"ghost_element_background": "#00000000",
|
||||
"ghost_element_background_alt": "#393552",
|
||||
"ghost_element_hover": "#e0def41a",
|
||||
"ghost_element_active": "#44415a",
|
||||
"ghost_element_selected": "#44415a",
|
||||
"ghost_element_disabled": "#e0def40d",
|
||||
"tab_inactive_background": "#2a273f",
|
||||
"tab_inactive_foreground": "#908caa",
|
||||
"tab_active_background": "#232136",
|
||||
"tab_active_foreground": "#e0def4",
|
||||
"tab_hover_foreground": "#c4a7e7",
|
||||
"scrollbar_thumb_background": "#e0def433",
|
||||
"scrollbar_thumb_hover_background": "#e0def44d",
|
||||
"scrollbar_thumb_border": "#00000000",
|
||||
"scrollbar_track_background": "#00000000",
|
||||
"scrollbar_track_border": "#44415a",
|
||||
"drop_target_background": "#c4a7e71a",
|
||||
"cursor": "#c4a7e7",
|
||||
"selection": "#c4a7e740"
|
||||
}
|
||||
}
|
||||
@@ -1,140 +0,0 @@
|
||||
{
|
||||
"id": "rose-pine",
|
||||
"name": "Rosé Pine",
|
||||
"author": "Rosé Pine",
|
||||
"url": "https://rosepinetheme.com/",
|
||||
"light": {
|
||||
"background": "#191724",
|
||||
"surface_background": "#1f1d2e",
|
||||
"elevated_surface_background": "#26233a",
|
||||
"panel_background": "#191724",
|
||||
"overlay": "#e0def41a",
|
||||
"title_bar": "#1f1d2e",
|
||||
"title_bar_inactive": "#26233a",
|
||||
"window_border": "#524f67",
|
||||
"border": "#403d52",
|
||||
"border_variant": "#26233a",
|
||||
"border_focused": "#c4a7e7",
|
||||
"border_selected": "#c4a7e7",
|
||||
"border_transparent": "#00000000",
|
||||
"border_disabled": "#26233a",
|
||||
"ring": "#c4a7e7",
|
||||
"text": "#e0def4",
|
||||
"text_muted": "#908caa",
|
||||
"text_placeholder": "#6e6a86",
|
||||
"text_accent": "#c4a7e7",
|
||||
"icon": "#908caa",
|
||||
"icon_muted": "#6e6a86",
|
||||
"icon_accent": "#c4a7e7",
|
||||
"element_foreground": "#191724",
|
||||
"element_background": "#c4a7e7",
|
||||
"element_hover": "#c4a7e7e6",
|
||||
"element_active": "#b296d6",
|
||||
"element_selected": "#a085c5",
|
||||
"element_disabled": "#c4a7e74d",
|
||||
"secondary_foreground": "#a085c5",
|
||||
"secondary_background": "#26233a",
|
||||
"secondary_hover": "#c4a7e71a",
|
||||
"secondary_active": "#403d52",
|
||||
"secondary_selected": "#403d52",
|
||||
"secondary_disabled": "#c4a7e74d",
|
||||
"danger_foreground": "#191724",
|
||||
"danger_background": "#eb6f92",
|
||||
"danger_hover": "#e55a82",
|
||||
"danger_active": "#df4572",
|
||||
"danger_selected": "#d93062",
|
||||
"danger_disabled": "#eb6f924d",
|
||||
"warning_foreground": "#191724",
|
||||
"warning_background": "#f6c177",
|
||||
"warning_hover": "#f4b35e",
|
||||
"warning_active": "#f2a545",
|
||||
"warning_selected": "#f0972c",
|
||||
"warning_disabled": "#f6c1774d",
|
||||
"ghost_element_background": "#00000000",
|
||||
"ghost_element_background_alt": "#26233a",
|
||||
"ghost_element_hover": "#e0def41a",
|
||||
"ghost_element_active": "#403d52",
|
||||
"ghost_element_selected": "#403d52",
|
||||
"ghost_element_disabled": "#e0def40d",
|
||||
"tab_inactive_background": "#1f1d2e",
|
||||
"tab_inactive_foreground": "#908caa",
|
||||
"tab_active_background": "#191724",
|
||||
"tab_active_foreground": "#e0def4",
|
||||
"tab_hover_foreground": "#c4a7e7",
|
||||
"scrollbar_thumb_background": "#e0def433",
|
||||
"scrollbar_thumb_hover_background": "#e0def44d",
|
||||
"scrollbar_thumb_border": "#00000000",
|
||||
"scrollbar_track_background": "#00000000",
|
||||
"scrollbar_track_border": "#403d52",
|
||||
"drop_target_background": "#c4a7e71a",
|
||||
"cursor": "#c4a7e7",
|
||||
"selection": "#c4a7e740"
|
||||
},
|
||||
"dark": {
|
||||
"background": "#191724",
|
||||
"surface_background": "#1f1d2e",
|
||||
"elevated_surface_background": "#26233a",
|
||||
"panel_background": "#191724",
|
||||
"overlay": "#e0def41a",
|
||||
"title_bar": "#1f1d2e",
|
||||
"title_bar_inactive": "#26233a",
|
||||
"window_border": "#524f67",
|
||||
"border": "#403d52",
|
||||
"border_variant": "#26233a",
|
||||
"border_focused": "#c4a7e7",
|
||||
"border_selected": "#c4a7e7",
|
||||
"border_transparent": "#00000000",
|
||||
"border_disabled": "#26233a",
|
||||
"ring": "#c4a7e7",
|
||||
"text": "#e0def4",
|
||||
"text_muted": "#908caa",
|
||||
"text_placeholder": "#6e6a86",
|
||||
"text_accent": "#c4a7e7",
|
||||
"icon": "#908caa",
|
||||
"icon_muted": "#6e6a86",
|
||||
"icon_accent": "#c4a7e7",
|
||||
"element_foreground": "#191724",
|
||||
"element_background": "#c4a7e7",
|
||||
"element_hover": "#c4a7e7e6",
|
||||
"element_active": "#b296d6",
|
||||
"element_selected": "#a085c5",
|
||||
"element_disabled": "#c4a7e74d",
|
||||
"secondary_foreground": "#a085c5",
|
||||
"secondary_background": "#26233a",
|
||||
"secondary_hover": "#c4a7e71a",
|
||||
"secondary_active": "#403d52",
|
||||
"secondary_selected": "#403d52",
|
||||
"secondary_disabled": "#c4a7e74d",
|
||||
"danger_foreground": "#191724",
|
||||
"danger_background": "#eb6f92",
|
||||
"danger_hover": "#e55a82",
|
||||
"danger_active": "#df4572",
|
||||
"danger_selected": "#d93062",
|
||||
"danger_disabled": "#eb6f924d",
|
||||
"warning_foreground": "#191724",
|
||||
"warning_background": "#f6c177",
|
||||
"warning_hover": "#f4b35e",
|
||||
"warning_active": "#f2a545",
|
||||
"warning_selected": "#f0972c",
|
||||
"warning_disabled": "#f6c1774d",
|
||||
"ghost_element_background": "#00000000",
|
||||
"ghost_element_background_alt": "#26233a",
|
||||
"ghost_element_hover": "#e0def41a",
|
||||
"ghost_element_active": "#403d52",
|
||||
"ghost_element_selected": "#403d52",
|
||||
"ghost_element_disabled": "#e0def40d",
|
||||
"tab_inactive_background": "#1f1d2e",
|
||||
"tab_inactive_foreground": "#908caa",
|
||||
"tab_active_background": "#191724",
|
||||
"tab_active_foreground": "#e0def4",
|
||||
"tab_hover_foreground": "#c4a7e7",
|
||||
"scrollbar_thumb_background": "#e0def433",
|
||||
"scrollbar_thumb_hover_background": "#e0def44d",
|
||||
"scrollbar_thumb_border": "#00000000",
|
||||
"scrollbar_track_background": "#00000000",
|
||||
"scrollbar_track_border": "#403d52",
|
||||
"drop_target_background": "#c4a7e71a",
|
||||
"cursor": "#c4a7e7",
|
||||
"selection": "#c4a7e740"
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,7 @@ use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
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::{
|
||||
App, AppContext, AsyncApp, BackgroundExecutor, Context, Entity, Global, Subscription, Task,
|
||||
@@ -11,7 +11,7 @@ use gpui::{
|
||||
};
|
||||
use semver::Version;
|
||||
use serde::Deserialize;
|
||||
use smallvec::{smallvec, SmallVec};
|
||||
use smallvec::{SmallVec, smallvec};
|
||||
use smol::fs::File;
|
||||
use smol::io::AsyncReadExt;
|
||||
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";
|
||||
|
||||
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 {
|
||||
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 {
|
||||
|
||||
@@ -1,20 +1,23 @@
|
||||
use std::cmp::Reverse;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::collections::{BTreeSet, HashMap, HashSet};
|
||||
use std::hash::{DefaultHasher, Hash, Hasher};
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::{anyhow, Context as AnyhowContext, Error};
|
||||
use common::EventUtils;
|
||||
use fuzzy_matcher::skim::SkimMatcherV2;
|
||||
use anyhow::{Context as AnyhowContext, Error, anyhow};
|
||||
use common::EventExt;
|
||||
use device::{DeviceEvent, DeviceRegistry};
|
||||
use fuzzy_matcher::FuzzyMatcher;
|
||||
use fuzzy_matcher::skim::SkimMatcherV2;
|
||||
use gpui::{
|
||||
App, AppContext, Context, Entity, EventEmitter, Global, Subscription, Task, WeakEntity, Window,
|
||||
App, AppContext, Context, Entity, EventEmitter, Global, SharedString, Subscription, Task,
|
||||
WeakEntity, Window,
|
||||
};
|
||||
use nostr_sdk::prelude::*;
|
||||
use smallvec::{smallvec, SmallVec};
|
||||
use state::{NostrRegistry, RelayState, DEVICE_GIFTWRAP, TIMEOUT, USER_GIFTWRAP};
|
||||
use smallvec::{SmallVec, smallvec};
|
||||
use smol::lock::RwLock;
|
||||
use state::{CoopSigner, DEVICE_GIFTWRAP, NostrRegistry, StateEvent, TIMEOUT, USER_GIFTWRAP};
|
||||
|
||||
mod message;
|
||||
mod room;
|
||||
@@ -39,6 +42,8 @@ pub enum ChatEvent {
|
||||
CloseRoom(u64),
|
||||
/// An event to notify UI about a new chat request
|
||||
Ping,
|
||||
/// An error occurred
|
||||
Error(SharedString),
|
||||
}
|
||||
|
||||
/// Channel signal.
|
||||
@@ -48,46 +53,62 @@ enum Signal {
|
||||
Message(NewMessage),
|
||||
/// Eose received from relay pool
|
||||
Eose,
|
||||
/// An error occurred
|
||||
Error(FailedMessage),
|
||||
}
|
||||
|
||||
/// 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)
|
||||
impl Signal {
|
||||
pub fn message(gift_wrap: EventId, rumor: UnsignedEvent) -> Self {
|
||||
Self::Message(NewMessage::new(gift_wrap, rumor))
|
||||
}
|
||||
|
||||
pub fn subscribing(&self) -> bool {
|
||||
matches!(self, InboxState::Subscribing)
|
||||
pub fn eose() -> Self {
|
||||
Self::Eose
|
||||
}
|
||||
|
||||
pub fn error<T>(event: &Event, reason: T) -> Self
|
||||
where
|
||||
T: Into<SharedString>,
|
||||
{
|
||||
Self::Error(FailedMessage::new(event, reason))
|
||||
}
|
||||
}
|
||||
|
||||
type Dekey = bool;
|
||||
type GiftWrapId = EventId;
|
||||
|
||||
/// Chat Registry
|
||||
#[derive(Debug)]
|
||||
pub struct ChatRegistry {
|
||||
/// Relay state for messaging relay list
|
||||
state: Entity<InboxState>,
|
||||
/// Whether the chat registry is currently initializing.
|
||||
pub initializing: bool,
|
||||
|
||||
/// Collection of all chat rooms
|
||||
/// Chat rooms
|
||||
rooms: Vec<Entity<Room>>,
|
||||
|
||||
/// Events that failed to unwrap for any reason
|
||||
trashes: Entity<BTreeSet<FailedMessage>>,
|
||||
|
||||
/// Tracking events seen on which relays in the current session
|
||||
seens: Arc<RwLock<HashMap<EventId, HashSet<RelayUrl>>>>,
|
||||
|
||||
/// Mapping of unwrapped event ids to their gift wrap event ids
|
||||
event_map: Arc<RwLock<HashMap<EventId, (GiftWrapId, Dekey)>>>,
|
||||
|
||||
/// Tracking the status of unwrapping gift wrap events.
|
||||
tracking_flag: Arc<AtomicBool>,
|
||||
|
||||
/// Channel for sending signals to the UI.
|
||||
signal_tx: flume::Sender<Signal>,
|
||||
|
||||
/// Channel for receiving signals from the UI.
|
||||
signal_rx: flume::Receiver<Signal>,
|
||||
|
||||
/// Async tasks
|
||||
tasks: SmallVec<[Task<Result<(), Error>>; 2]>,
|
||||
|
||||
/// Subscriptions
|
||||
_subscriptions: SmallVec<[Subscription; 1]>,
|
||||
_subscriptions: SmallVec<[Subscription; 2]>,
|
||||
}
|
||||
|
||||
impl EventEmitter<ChatEvent> for ChatRegistry {}
|
||||
@@ -105,52 +126,69 @@ impl ChatRegistry {
|
||||
|
||||
/// Create a new chat registry instance
|
||||
fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||
let state = cx.new(|_| InboxState::default());
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let device = DeviceRegistry::global(cx);
|
||||
|
||||
let (tx, rx) = flume::unbounded::<Signal>();
|
||||
let mut subscriptions = smallvec![];
|
||||
|
||||
subscriptions.push(
|
||||
// Observe the nip65 state and load chat rooms on every state change
|
||||
cx.observe(&nostr, |this, state, cx| {
|
||||
match state.read(cx).relay_list_state() {
|
||||
RelayState::Idle => {
|
||||
this.reset(cx);
|
||||
}
|
||||
RelayState::Configured => {
|
||||
this.ensure_messaging_relays(cx);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
// Subscribe to the signer event
|
||||
cx.subscribe_in(&nostr, window, |this, state, event, window, cx| {
|
||||
if event == &StateEvent::SignerSet {
|
||||
this.reset(cx);
|
||||
this.get_contact_list(cx);
|
||||
this.get_rooms(cx);
|
||||
|
||||
let signer = state.read(cx).signer();
|
||||
cx.spawn_in(window, async move |this, cx| {
|
||||
let user_signer = signer.get().await;
|
||||
this.update(cx, |this, cx| {
|
||||
this.get_messages(user_signer, cx);
|
||||
})
|
||||
.ok();
|
||||
})
|
||||
.detach();
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
subscriptions.push(
|
||||
// Observe the nip17 state and load chat rooms on every state change
|
||||
cx.observe(&state, |this, state, cx| {
|
||||
if let InboxState::RelayConfigured(event) = state.read(cx) {
|
||||
let relay_urls: Vec<_> = nip17::extract_relay_list(event).cloned().collect();
|
||||
this.get_messages(relay_urls, cx);
|
||||
}
|
||||
// Subscribe to the device event
|
||||
cx.subscribe_in(&device, window, |_this, _s, event, window, cx| {
|
||||
if event == &DeviceEvent::Set {
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let signer = nostr.read(cx).signer();
|
||||
|
||||
cx.spawn_in(window, async move |this, cx| {
|
||||
if let Some(device_signer) = signer.get_encryption_signer().await {
|
||||
this.update(cx, |this, cx| {
|
||||
this.get_messages(device_signer, cx);
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
// Run at the end of the current cycle
|
||||
cx.defer_in(window, |this, _window, cx| {
|
||||
// Load chat rooms
|
||||
this.get_rooms(cx);
|
||||
|
||||
// Handle nostr notifications
|
||||
this.handle_notifications(cx);
|
||||
|
||||
// Track unwrap gift wrap progress
|
||||
this.tracking(cx);
|
||||
});
|
||||
|
||||
Self {
|
||||
state,
|
||||
initializing: true,
|
||||
rooms: vec![],
|
||||
trashes: cx.new(|_| BTreeSet::default()),
|
||||
seens: Arc::new(RwLock::new(HashMap::default())),
|
||||
event_map: Arc::new(RwLock::new(HashMap::default())),
|
||||
tracking_flag: Arc::new(AtomicBool::new(false)),
|
||||
signal_rx: rx,
|
||||
signal_tx: tx,
|
||||
tasks: smallvec![],
|
||||
_subscriptions: subscriptions,
|
||||
}
|
||||
@@ -162,58 +200,92 @@ impl ChatRegistry {
|
||||
let client = nostr.read(cx).client();
|
||||
let signer = nostr.read(cx).signer();
|
||||
let status = self.tracking_flag.clone();
|
||||
let seens = self.seens.clone();
|
||||
let event_map = self.event_map.clone();
|
||||
let trashes = self.trashes.downgrade();
|
||||
|
||||
let initialized_at = Timestamp::now();
|
||||
let sub_id1 = SubscriptionId::new(DEVICE_GIFTWRAP);
|
||||
let sub_id2 = SubscriptionId::new(USER_GIFTWRAP);
|
||||
|
||||
// Channel for communication between nostr and gpui
|
||||
let (tx, rx) = flume::bounded::<Signal>(1024);
|
||||
let tx = self.signal_tx.clone();
|
||||
let rx = self.signal_rx.clone();
|
||||
|
||||
self.tasks.push(cx.background_spawn(async move {
|
||||
let device_signer = signer.get_encryption_signer().await;
|
||||
let mut notifications = client.notifications();
|
||||
let mut processed_events = HashSet::new();
|
||||
|
||||
while let Some(notification) = notifications.next().await {
|
||||
let ClientNotification::Message { message, .. } = notification else {
|
||||
let ClientNotification::Message { message, relay_url } = notification else {
|
||||
// Skip non-message notifications
|
||||
continue;
|
||||
};
|
||||
|
||||
match message {
|
||||
RelayMessage::Event { event, .. } => {
|
||||
match *message {
|
||||
RelayMessage::Event {
|
||||
event,
|
||||
subscription_id,
|
||||
} => {
|
||||
// Keep track of which relays have seen this event
|
||||
{
|
||||
let mut seens = seens.write().await;
|
||||
seens.entry(event.id).or_default().insert(relay_url);
|
||||
}
|
||||
|
||||
// De-duplicate events by their ID
|
||||
if !processed_events.insert(event.id) {
|
||||
// Skip if the event has already been processed
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip non-gift wrap events
|
||||
if event.kind != Kind::GiftWrap {
|
||||
// Skip non-gift wrap events
|
||||
continue;
|
||||
}
|
||||
|
||||
// Extract the rumor from the gift wrap event
|
||||
match extract_rumor(&client, &device_signer, event.as_ref()).await {
|
||||
Ok(rumor) => match rumor.created_at >= initialized_at {
|
||||
true => {
|
||||
let new_message = NewMessage::new(event.id, rumor);
|
||||
let signal = Signal::Message(new_message);
|
||||
match extract_rumor(&client, &signer, event.as_ref()).await {
|
||||
Ok(rumor) => {
|
||||
// Map the rumor id to the gift wrap event id for later lookup
|
||||
{
|
||||
let mut event_map = event_map.write().await;
|
||||
let dekey = subscription_id.as_ref() == &sub_id1;
|
||||
event_map.insert(rumor.id.unwrap(), (event.id, dekey));
|
||||
}
|
||||
|
||||
if rumor.kind != Kind::PrivateDirectMessage
|
||||
|| rumor.kind != Kind::Custom(15)
|
||||
{
|
||||
log::info!("Rumor is not releated to NIP17");
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if the rumor has a recipient
|
||||
if rumor.tags.is_empty() {
|
||||
let signal =
|
||||
Signal::error(event.as_ref(), "Recipient is missing");
|
||||
tx.send_async(signal).await?;
|
||||
}
|
||||
false => {
|
||||
|
||||
// Check if the rumor was created after the chat was initialized (for detecting new messages)
|
||||
if rumor.created_at >= initialized_at {
|
||||
let signal = Signal::message(event.id, rumor);
|
||||
tx.send_async(signal).await?;
|
||||
} else {
|
||||
// Mark the chat still processing new messages
|
||||
status.store(true, Ordering::Release);
|
||||
}
|
||||
},
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("Failed to unwrap the gift wrap event: {e}");
|
||||
let reason = format!("Failed to extract rumor: {e}");
|
||||
let signal = Signal::error(event.as_ref(), reason);
|
||||
tx.send_async(signal).await?;
|
||||
}
|
||||
}
|
||||
}
|
||||
RelayMessage::EndOfStoredEvents(id) => {
|
||||
if id.as_ref() == &sub_id1 || id.as_ref() == &sub_id2 {
|
||||
tx.send_async(Signal::Eose).await?;
|
||||
tx.send_async(Signal::eose()).await?;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
@@ -236,6 +308,12 @@ impl ChatRegistry {
|
||||
this.get_rooms(cx);
|
||||
})?;
|
||||
}
|
||||
Signal::Error(trash) => {
|
||||
trashes.update(cx, |this, cx| {
|
||||
this.insert(trash);
|
||||
cx.notify();
|
||||
})?;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -246,49 +324,87 @@ impl ChatRegistry {
|
||||
/// Tracking the status of unwrapping gift wrap events.
|
||||
fn tracking(&mut self, cx: &mut Context<Self>) {
|
||||
let status = self.tracking_flag.clone();
|
||||
let tx = self.signal_tx.clone();
|
||||
|
||||
self.tasks.push(cx.background_spawn(async move {
|
||||
let loop_duration = Duration::from_secs(10);
|
||||
let loop_duration = Duration::from_secs(15);
|
||||
|
||||
loop {
|
||||
if status.load(Ordering::Acquire) {
|
||||
_ = status.compare_exchange(true, false, Ordering::Release, Ordering::Relaxed);
|
||||
_ = tx.send_async(Signal::Eose).await;
|
||||
} else {
|
||||
_ = tx.send_async(Signal::Eose).await;
|
||||
}
|
||||
smol::Timer::after(loop_duration).await;
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
/// 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);
|
||||
/// Get contact list from relays
|
||||
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();
|
||||
|
||||
// Set state to checking
|
||||
self.set_state(InboxState::Checking, cx);
|
||||
let Some(public_key) = signer.public_key() else {
|
||||
return;
|
||||
};
|
||||
|
||||
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)));
|
||||
|
||||
// Construct filter for inbox relays
|
||||
let filter = Filter::new()
|
||||
.kind(Kind::ContactList)
|
||||
.author(public_key)
|
||||
.limit(1);
|
||||
|
||||
// Subscribe
|
||||
client.subscribe(filter).close_on(opts).with_id(id).await?;
|
||||
|
||||
Ok(())
|
||||
});
|
||||
|
||||
self.tasks.push(task);
|
||||
}
|
||||
|
||||
/// Get all messages for the provided signer
|
||||
fn get_messages<T>(&mut self, signer: T, cx: &mut Context<Self>)
|
||||
where
|
||||
T: NostrSigner + 'static,
|
||||
{
|
||||
let task = self.subscribe_gift_wrap_events(signer, cx);
|
||||
|
||||
self.tasks.push(cx.spawn(async move |this, cx| {
|
||||
let result = task.await?;
|
||||
|
||||
// Update state
|
||||
this.update(cx, |this, cx| {
|
||||
this.set_state(result, cx);
|
||||
})?;
|
||||
|
||||
match task.await {
|
||||
Ok(_) => {
|
||||
this.update(cx, |this, cx| {
|
||||
this.set_initializing(false, cx);
|
||||
})?;
|
||||
}
|
||||
Err(e) => {
|
||||
this.update(cx, |_this, cx| {
|
||||
cx.emit(ChatEvent::Error(SharedString::from(e.to_string())));
|
||||
})?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}));
|
||||
}
|
||||
|
||||
// Verify messaging relay list for current user
|
||||
fn verify_relays(&mut self, cx: &mut Context<Self>) -> Task<Result<InboxState, Error>> {
|
||||
// Get messaging relay list for current user
|
||||
fn get_messaging_relays(&self, cx: &App) -> Task<Result<Vec<RelayUrl>, Error>> {
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let client = nostr.read(cx).client();
|
||||
|
||||
let signer = nostr.read(cx).signer();
|
||||
let public_key = signer.public_key().unwrap();
|
||||
let write_relays = nostr.read(cx).write_relays(&public_key, cx);
|
||||
|
||||
cx.background_spawn(async move {
|
||||
let urls = write_relays.await;
|
||||
let public_key = signer.get_public_key().await?;
|
||||
let id = SubscriptionId::new("inbox-relay");
|
||||
|
||||
// Construct filter for inbox relays
|
||||
let filter = Filter::new()
|
||||
@@ -296,64 +412,38 @@ impl ChatRegistry {
|
||||
.author(public_key)
|
||||
.limit(1);
|
||||
|
||||
// Construct target for subscription
|
||||
let target: HashMap<&RelayUrl, Filter> =
|
||||
urls.iter().map(|relay| (relay, filter.clone())).collect();
|
||||
|
||||
// Stream events from user's write relays
|
||||
let mut stream = client
|
||||
.stream_events(target)
|
||||
.stream_events(filter)
|
||||
.with_id(id)
|
||||
.timeout(Duration::from_secs(TIMEOUT))
|
||||
.await?;
|
||||
|
||||
while let Some((_url, res)) = stream.next().await {
|
||||
match res {
|
||||
Ok(event) => {
|
||||
return Ok(InboxState::RelayConfigured(Box::new(event)));
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("Failed to receive relay list event: {e}");
|
||||
}
|
||||
if let Ok(event) = res {
|
||||
let urls: Vec<RelayUrl> = nip17::extract_owned_relay_list(event).collect();
|
||||
return Ok(urls);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(InboxState::RelayNotAvailable)
|
||||
Err(anyhow!("Messaging Relays not found"))
|
||||
})
|
||||
}
|
||||
|
||||
/// Get all messages for current user
|
||||
fn get_messages<I>(&mut self, relay_urls: I, cx: &mut Context<Self>)
|
||||
/// Continuously get gift wrap events for the signer
|
||||
fn subscribe_gift_wrap_events<T>(&self, signer: T, cx: &App) -> Task<Result<(), Error>>
|
||||
where
|
||||
I: IntoIterator<Item = RelayUrl>,
|
||||
{
|
||||
let task = self.subscribe(relay_urls, cx);
|
||||
|
||||
self.tasks.push(cx.spawn(async move |this, cx| {
|
||||
task.await?;
|
||||
|
||||
// Update state
|
||||
this.update(cx, |this, cx| {
|
||||
this.set_state(InboxState::Subscribing, cx);
|
||||
})?;
|
||||
|
||||
Ok(())
|
||||
}));
|
||||
}
|
||||
|
||||
/// Continuously get gift wrap events for the current user in their messaging relays
|
||||
fn subscribe<I>(&mut self, urls: I, cx: &mut Context<Self>) -> Task<Result<(), Error>>
|
||||
where
|
||||
I: IntoIterator<Item = RelayUrl>,
|
||||
T: NostrSigner + 'static,
|
||||
{
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let client = nostr.read(cx).client();
|
||||
let signer = nostr.read(cx).signer();
|
||||
let urls = urls.into_iter().collect::<Vec<_>>();
|
||||
let urls = self.get_messaging_relays(cx);
|
||||
|
||||
cx.background_spawn(async move {
|
||||
let urls = urls.await?;
|
||||
let public_key = signer.get_public_key().await?;
|
||||
let filter = Filter::new().kind(Kind::GiftWrap).pubkey(public_key);
|
||||
let id = SubscriptionId::new(USER_GIFTWRAP);
|
||||
let id = SubscriptionId::new(format!("{}-msg", public_key.to_hex()));
|
||||
|
||||
// Ensure relay connections
|
||||
for url in urls.iter() {
|
||||
@@ -377,17 +467,35 @@ 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();
|
||||
});
|
||||
/// Refresh the chat registry, fetching messages and contact list from relays.
|
||||
pub fn refresh(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
self.reset(cx);
|
||||
self.get_contact_list(cx);
|
||||
self.get_rooms(cx);
|
||||
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let signer = nostr.read(cx).signer();
|
||||
|
||||
cx.spawn_in(window, async move |this, cx| {
|
||||
let user_signer = signer.get().await;
|
||||
let device_signer = signer.get_encryption_signer().await;
|
||||
|
||||
this.update(cx, |this, cx| {
|
||||
this.get_messages(user_signer, cx);
|
||||
|
||||
if let Some(device_signer) = device_signer {
|
||||
this.get_messages(device_signer, cx);
|
||||
}
|
||||
})
|
||||
.ok();
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
/// Get the relay state
|
||||
pub fn state(&self, cx: &App) -> InboxState {
|
||||
self.state.read(cx).clone()
|
||||
/// Set the initializing status of the chat registry
|
||||
fn set_initializing(&mut self, initializing: bool, cx: &mut Context<Self>) {
|
||||
self.initializing = initializing;
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// Get the loading status of the chat registry
|
||||
@@ -420,6 +528,51 @@ impl ChatRegistry {
|
||||
.count()
|
||||
}
|
||||
|
||||
/// Count the number of messages seen by a given relay.
|
||||
pub fn count_messages(&self, relay_url: &RelayUrl) -> usize {
|
||||
self.seens
|
||||
.read_blocking()
|
||||
.values()
|
||||
.filter(|seen| seen.contains(relay_url))
|
||||
.count()
|
||||
}
|
||||
|
||||
/// Count the number of trash messages.
|
||||
pub fn count_trash_messages(&self, cx: &App) -> usize {
|
||||
self.trashes.read(cx).len()
|
||||
}
|
||||
|
||||
/// Get the trash messages entity.
|
||||
pub fn trashes(&self) -> Entity<BTreeSet<FailedMessage>> {
|
||||
self.trashes.clone()
|
||||
}
|
||||
|
||||
/// Get the relays that have seen a given rumor id.
|
||||
pub fn rumor_seen_on(&self, id: &EventId) -> Option<HashSet<RelayUrl>> {
|
||||
self.event_map
|
||||
.read_blocking()
|
||||
.get(id)
|
||||
.map(|(id, _dekey)| self.seen_on(id))
|
||||
}
|
||||
|
||||
/// Get the relays that have seen a given gift wrap id.
|
||||
pub fn seen_on(&self, id: &EventId) -> HashSet<RelayUrl> {
|
||||
self.seens
|
||||
.read_blocking()
|
||||
.get(id)
|
||||
.cloned()
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Check if a given rumor was encrypted by the dekey.
|
||||
pub fn encrypted_by_dekey(&self, id: &EventId) -> bool {
|
||||
self.event_map
|
||||
.read_blocking()
|
||||
.get(id)
|
||||
.map(|(_, dekey)| *dekey)
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Add a new room to the start of list.
|
||||
pub fn add_room<I>(&mut self, room: I, cx: &mut Context<Self>)
|
||||
where
|
||||
@@ -497,7 +650,12 @@ impl ChatRegistry {
|
||||
|
||||
/// Reset the registry.
|
||||
pub fn reset(&mut self, cx: &mut Context<Self>) {
|
||||
self.initializing = true;
|
||||
self.rooms.clear();
|
||||
self.trashes.update(cx, |this, cx| {
|
||||
this.clear();
|
||||
cx.notify();
|
||||
});
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
@@ -591,10 +749,10 @@ impl ChatRegistry {
|
||||
|
||||
// Process each event and group by room hash
|
||||
for raw in events.into_iter() {
|
||||
if let Ok(rumor) = UnsignedEvent::from_json(&raw.content) {
|
||||
if rumor.tags.public_keys().peekable().peek().is_some() {
|
||||
grouped.entry(rumor.uniq_id()).or_default().push(rumor);
|
||||
}
|
||||
if let Ok(rumor) = UnsignedEvent::from_json(&raw.content)
|
||||
&& rumor.tags.public_keys().peekable().peek().is_some()
|
||||
{
|
||||
grouped.entry(rumor.uniq_id()).or_default().push(rumor);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -634,9 +792,18 @@ impl ChatRegistry {
|
||||
/// If the room doesn't exist, it will be created.
|
||||
/// Updates room ordering based on the most recent messages.
|
||||
pub fn new_message(&mut self, message: NewMessage, cx: &mut Context<Self>) {
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let signer = nostr.read(cx).signer();
|
||||
|
||||
match self.rooms.iter().find(|e| e.read(cx).id == message.room) {
|
||||
Some(room) => {
|
||||
room.update(cx, |this, cx| {
|
||||
if this.kind == RoomKind::Request
|
||||
&& let Some(public_key) = signer.public_key()
|
||||
&& message.rumor.pubkey == public_key
|
||||
{
|
||||
this.set_ongoing(cx);
|
||||
}
|
||||
this.push_message(message, cx);
|
||||
});
|
||||
self.sort(cx);
|
||||
@@ -649,14 +816,12 @@ impl ChatRegistry {
|
||||
}
|
||||
|
||||
/// 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>) {
|
||||
if let Some(ids) = ids {
|
||||
for room in self.rooms.iter() {
|
||||
if ids.contains(&room.read(cx).id) {
|
||||
room.update(cx, |this, cx| {
|
||||
this.emit_refresh(cx);
|
||||
});
|
||||
}
|
||||
pub fn refresh_rooms(&mut self, ids: &[u64], cx: &mut Context<Self>) {
|
||||
for room in self.rooms.iter() {
|
||||
if ids.contains(&room.read(cx).id) {
|
||||
room.update(cx, |this, cx| {
|
||||
this.emit_refresh(cx);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -665,16 +830,16 @@ impl ChatRegistry {
|
||||
/// Unwraps a gift-wrapped event and processes its contents.
|
||||
async fn extract_rumor(
|
||||
client: &Client,
|
||||
device_signer: &Option<Arc<dyn NostrSigner>>,
|
||||
signer: &Arc<CoopSigner>,
|
||||
gift_wrap: &Event,
|
||||
) -> Result<UnsignedEvent, Error> {
|
||||
// Try to get cached rumor first
|
||||
if let Ok(event) = get_rumor(client, gift_wrap.id).await {
|
||||
return Ok(event);
|
||||
if let Ok(rumor) = get_rumor(client, gift_wrap.id).await {
|
||||
return Ok(rumor);
|
||||
}
|
||||
|
||||
// Try to unwrap with the available signer
|
||||
let unwrapped = try_unwrap(client, device_signer, gift_wrap).await?;
|
||||
let unwrapped = try_unwrap(signer, gift_wrap).await?;
|
||||
let mut rumor = unwrapped.rumor;
|
||||
|
||||
// Generate event id for the rumor if it doesn't have one
|
||||
@@ -689,30 +854,27 @@ async fn extract_rumor(
|
||||
}
|
||||
|
||||
/// Helper method to try unwrapping with different signers
|
||||
async fn try_unwrap(
|
||||
client: &Client,
|
||||
device_signer: &Option<Arc<dyn NostrSigner>>,
|
||||
gift_wrap: &Event,
|
||||
) -> Result<UnwrappedGift, Error> {
|
||||
async fn try_unwrap(signer: &Arc<CoopSigner>, gift_wrap: &Event) -> Result<UnwrappedGift, Error> {
|
||||
// Try with the device signer first
|
||||
if let Some(signer) = device_signer {
|
||||
if let Ok(unwrapped) = try_unwrap_with(gift_wrap, signer).await {
|
||||
if let Some(signer) = signer.get_encryption_signer().await {
|
||||
log::info!("trying with encryption key");
|
||||
if let Ok(unwrapped) = try_unwrap_with(gift_wrap, &signer).await {
|
||||
return Ok(unwrapped);
|
||||
};
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Try with the user's signer
|
||||
let user_signer = client.signer().context("Signer not found")?;
|
||||
let unwrapped = UnwrappedGift::from_gift_wrap(user_signer, gift_wrap).await?;
|
||||
// Fallback to the user's signer
|
||||
let user_signer = signer.get().await;
|
||||
let unwrapped = try_unwrap_with(gift_wrap, &user_signer).await?;
|
||||
|
||||
Ok(unwrapped)
|
||||
}
|
||||
|
||||
/// Attempts to unwrap a gift wrap event with a given signer.
|
||||
async fn try_unwrap_with(
|
||||
gift_wrap: &Event,
|
||||
signer: &Arc<dyn NostrSigner>,
|
||||
) -> Result<UnwrappedGift, Error> {
|
||||
async fn try_unwrap_with<T>(gift_wrap: &Event, signer: &T) -> Result<UnwrappedGift, Error>
|
||||
where
|
||||
T: NostrSigner + 'static,
|
||||
{
|
||||
// Get the sealed event
|
||||
let seal = signer
|
||||
.nip44_decrypt(&gift_wrap.pubkey, &gift_wrap.content)
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
use std::hash::Hash;
|
||||
use std::ops::Range;
|
||||
|
||||
use common::EventUtils;
|
||||
use common::{EventExt, NostrParser, extract_and_remove_media_urls};
|
||||
use gpui::{SharedString, SharedUri};
|
||||
use nostr_sdk::prelude::*;
|
||||
|
||||
/// New message.
|
||||
@@ -23,6 +25,25 @@ impl NewMessage {
|
||||
}
|
||||
}
|
||||
|
||||
/// Trash message.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub struct FailedMessage {
|
||||
pub raw_event: SharedString,
|
||||
pub reason: SharedString,
|
||||
}
|
||||
|
||||
impl FailedMessage {
|
||||
pub fn new<T>(event: &Event, reason: T) -> Self
|
||||
where
|
||||
T: Into<SharedString>,
|
||||
{
|
||||
Self {
|
||||
raw_event: SharedString::from(event.as_json()),
|
||||
reason: reason.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Message.
|
||||
#[derive(Debug, Clone, Hash, PartialEq, Eq)]
|
||||
pub enum Message {
|
||||
@@ -91,6 +112,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.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RenderedMessage {
|
||||
@@ -99,10 +132,12 @@ pub struct RenderedMessage {
|
||||
pub author: PublicKey,
|
||||
/// The content/text of the message
|
||||
pub content: String,
|
||||
/// List of media URLs in the message
|
||||
pub media: Vec<SharedUri>,
|
||||
/// Message created time as unix timestamp
|
||||
pub created_at: Timestamp,
|
||||
/// 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
|
||||
pub replies_to: Vec<EventId>,
|
||||
}
|
||||
@@ -111,11 +146,13 @@ impl From<&Event> for RenderedMessage {
|
||||
fn from(val: &Event) -> Self {
|
||||
let mentions = extract_mentions(&val.content);
|
||||
let replies_to = extract_reply_ids(&val.tags);
|
||||
let (media, string) = extract_and_remove_media_urls(&val.content);
|
||||
|
||||
Self {
|
||||
id: val.id,
|
||||
author: val.pubkey,
|
||||
content: val.content.clone(),
|
||||
content: string,
|
||||
media,
|
||||
created_at: val.created_at,
|
||||
mentions,
|
||||
replies_to,
|
||||
@@ -127,12 +164,14 @@ impl From<&UnsignedEvent> for RenderedMessage {
|
||||
fn from(val: &UnsignedEvent) -> Self {
|
||||
let mentions = extract_mentions(&val.content);
|
||||
let replies_to = extract_reply_ids(&val.tags);
|
||||
let (media, string) = extract_and_remove_media_urls(&val.content);
|
||||
|
||||
Self {
|
||||
// Event ID must be known
|
||||
id: val.id.unwrap(),
|
||||
author: val.pubkey,
|
||||
content: val.content.clone(),
|
||||
content: string,
|
||||
media,
|
||||
created_at: val.created_at,
|
||||
mentions,
|
||||
replies_to,
|
||||
@@ -144,12 +183,14 @@ impl From<&NewMessage> for RenderedMessage {
|
||||
fn from(val: &NewMessage) -> Self {
|
||||
let mentions = extract_mentions(&val.rumor.content);
|
||||
let replies_to = extract_reply_ids(&val.rumor.tags);
|
||||
let (media, string) = extract_and_remove_media_urls(&val.rumor.content);
|
||||
|
||||
Self {
|
||||
// Event ID must be known
|
||||
id: val.rumor.id.unwrap(),
|
||||
author: val.rumor.pubkey,
|
||||
content: val.rumor.content.clone(),
|
||||
content: string,
|
||||
media,
|
||||
created_at: val.rumor.created_at,
|
||||
mentions,
|
||||
replies_to,
|
||||
@@ -184,20 +225,17 @@ impl Hash for RenderedMessage {
|
||||
}
|
||||
|
||||
/// 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 tokens = parser.parse(content);
|
||||
|
||||
tokens
|
||||
.filter_map(|token| match token {
|
||||
Token::Nostr(nip21) => match nip21 {
|
||||
Nip21::Pubkey(pubkey) => Some(pubkey),
|
||||
Nip21::Profile(profile) => Some(profile.public_key),
|
||||
_ => None,
|
||||
},
|
||||
.filter_map(|token| match token.value {
|
||||
Nip21::Pubkey(public_key) => Some(Mention::new(public_key, token.range)),
|
||||
Nip21::Profile(profile) => Some(Mention::new(profile.public_key, token.range)),
|
||||
_ => None,
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Extracts all reply (ids) from the event tags.
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
use std::cmp::Ordering;
|
||||
use std::collections::HashMap;
|
||||
use std::hash::{Hash, Hasher};
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::{anyhow, Error};
|
||||
use common::EventUtils;
|
||||
use anyhow::{Error, anyhow};
|
||||
use common::EventExt;
|
||||
use gpui::{App, AppContext, Context, EventEmitter, SharedString, Task};
|
||||
use itertools::Itertools;
|
||||
use nostr_sdk::prelude::*;
|
||||
@@ -58,16 +57,44 @@ impl SendReport {
|
||||
|
||||
/// Returns true if the send is pending.
|
||||
pub fn pending(&self) -> bool {
|
||||
self.output.is_none() && self.error.is_none()
|
||||
self.error.is_none()
|
||||
&& self
|
||||
.output
|
||||
.as_ref()
|
||||
.is_some_and(|o| o.success.is_empty() && o.failed.is_empty())
|
||||
}
|
||||
|
||||
/// Returns true if the send was successful.
|
||||
pub fn success(&self) -> bool {
|
||||
if let Some(output) = self.output.as_ref() {
|
||||
!output.failed.is_empty()
|
||||
} else {
|
||||
false
|
||||
}
|
||||
self.error.is_none() && self.output.as_ref().is_some_and(|o| !o.success.is_empty())
|
||||
}
|
||||
|
||||
/// Returns true if the send failed.
|
||||
pub fn failed(&self) -> bool {
|
||||
self.error.is_some() && self.output.as_ref().is_some_and(|o| !o.failed.is_empty())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum SendStatus {
|
||||
Ok {
|
||||
id: EventId,
|
||||
relay: RelayUrl,
|
||||
},
|
||||
Failed {
|
||||
id: EventId,
|
||||
relay: RelayUrl,
|
||||
message: String,
|
||||
},
|
||||
}
|
||||
|
||||
impl SendStatus {
|
||||
pub fn ok(id: EventId, relay: RelayUrl) -> Self {
|
||||
Self::Ok { id, relay }
|
||||
}
|
||||
|
||||
pub fn failed(id: EventId, relay: RelayUrl, message: String) -> Self {
|
||||
Self::Failed { id, relay, message }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -153,7 +180,7 @@ impl From<&UnsignedEvent> for Room {
|
||||
subject,
|
||||
members,
|
||||
kind: RoomKind::default(),
|
||||
config: RoomConfig::default(),
|
||||
config: RoomConfig::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -205,10 +232,8 @@ impl Room {
|
||||
|
||||
/// Sets this room is ongoing conversation
|
||||
pub fn set_ongoing(&mut self, cx: &mut Context<Self>) {
|
||||
if self.kind != RoomKind::Ongoing {
|
||||
self.kind = RoomKind::Ongoing;
|
||||
cx.notify();
|
||||
}
|
||||
self.kind = RoomKind::Ongoing;
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// Updates the creation timestamp of the room
|
||||
@@ -232,6 +257,12 @@ impl Room {
|
||||
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
|
||||
@@ -319,88 +350,37 @@ impl Room {
|
||||
cx.emit(RoomEvent::Reload);
|
||||
}
|
||||
|
||||
#[allow(clippy::type_complexity)]
|
||||
/// Get gossip relays for each member
|
||||
pub fn connect(&self, cx: &App) -> HashMap<PublicKey, Task<Result<(bool, bool), Error>>> {
|
||||
pub fn connect(&self, cx: &App) -> Task<Result<(), Error>> {
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let signer = nostr.read(cx).signer();
|
||||
let public_key = signer.public_key().unwrap();
|
||||
|
||||
let client = nostr.read(cx).client();
|
||||
let members = self.members();
|
||||
let mut tasks = HashMap::new();
|
||||
|
||||
for member in members.into_iter() {
|
||||
// Skip if member is the current user
|
||||
if member == public_key {
|
||||
continue;
|
||||
cx.background_spawn(async move {
|
||||
let opts = SubscribeAutoCloseOptions::default()
|
||||
.exit_policy(ReqExitPolicy::ExitOnEOSE)
|
||||
.timeout(Some(Duration::from_secs(TIMEOUT)));
|
||||
|
||||
for public_key in members.into_iter() {
|
||||
let inbox = Filter::new()
|
||||
.author(public_key)
|
||||
.kind(Kind::InboxRelays)
|
||||
.limit(1);
|
||||
|
||||
let announcement = Filter::new()
|
||||
.author(public_key)
|
||||
.kind(Kind::Custom(10044))
|
||||
.limit(1);
|
||||
|
||||
// Subscribe to the target
|
||||
client
|
||||
.subscribe(vec![inbox, announcement])
|
||||
.close_on(opts)
|
||||
.await?;
|
||||
}
|
||||
|
||||
let client = nostr.read(cx).client();
|
||||
let write_relays = nostr.read(cx).write_relays(&member, cx);
|
||||
|
||||
tasks.insert(
|
||||
member,
|
||||
cx.background_spawn(async move {
|
||||
let urls = write_relays.await;
|
||||
|
||||
// Return if no relays are available
|
||||
if urls.is_empty() {
|
||||
return Err(anyhow!(
|
||||
"User has not set up any relays. You cannot send messages to them."
|
||||
));
|
||||
}
|
||||
|
||||
// Construct filters for inbox and announcement
|
||||
let inbox_filter = Filter::new()
|
||||
.kind(Kind::InboxRelays)
|
||||
.author(member)
|
||||
.limit(1);
|
||||
let announcement_filter = Filter::new()
|
||||
.kind(Kind::Custom(10044))
|
||||
.author(member)
|
||||
.limit(1);
|
||||
|
||||
// Create subscription targets
|
||||
let target = urls
|
||||
.into_iter()
|
||||
.map(|relay| {
|
||||
(
|
||||
relay,
|
||||
vec![inbox_filter.clone(), announcement_filter.clone()],
|
||||
)
|
||||
})
|
||||
.collect::<HashMap<_, _>>();
|
||||
|
||||
// Stream events from user's write relays
|
||||
let mut stream = client
|
||||
.stream_events(target)
|
||||
.timeout(Duration::from_secs(TIMEOUT))
|
||||
.await?;
|
||||
|
||||
let mut has_inbox = false;
|
||||
let mut has_announcement = false;
|
||||
|
||||
while let Some((_url, res)) = stream.next().await {
|
||||
let event = res?;
|
||||
|
||||
match event.kind {
|
||||
Kind::InboxRelays => has_inbox = true,
|
||||
Kind::Custom(10044) => has_announcement = true,
|
||||
_ => {}
|
||||
}
|
||||
|
||||
// Early exit if both flags are found
|
||||
if has_inbox && has_announcement {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
Ok((has_inbox, has_announcement))
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
tasks
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
/// Get all messages belonging to the room
|
||||
@@ -420,6 +400,10 @@ impl Room {
|
||||
.await?
|
||||
.into_iter()
|
||||
.filter_map(|event| UnsignedEvent::from_json(&event.content).ok())
|
||||
.filter(|event| {
|
||||
// Only process private direct messages and file messages
|
||||
event.kind == Kind::PrivateDirectMessage || event.kind == Kind::Custom(15)
|
||||
})
|
||||
.sorted_by_key(|message| message.created_at)
|
||||
.collect();
|
||||
|
||||
@@ -443,7 +427,7 @@ impl Room {
|
||||
// Get current user's public key
|
||||
let sender = nostr.read(cx).signer().public_key()?;
|
||||
|
||||
// Get all members
|
||||
// Get all members, excluding the sender
|
||||
let members: Vec<Person> = self
|
||||
.members
|
||||
.iter()
|
||||
@@ -520,70 +504,85 @@ impl Room {
|
||||
|
||||
// Process each member
|
||||
for member in members {
|
||||
let relays = member.messaging_relays();
|
||||
let announcement = member.announcement();
|
||||
let public_key = member.public_key();
|
||||
|
||||
if relays.is_empty() {
|
||||
reports.push(SendReport::new(public_key).error("No messaging relays"));
|
||||
continue;
|
||||
}
|
||||
|
||||
// Handle encryption signer requirements
|
||||
if signer_kind.encryption() {
|
||||
// Receiver didn't set up a decoupled encryption key
|
||||
if announcement.is_none() {
|
||||
reports.push(SendReport::new(public_key).error(NO_DEKEY));
|
||||
continue;
|
||||
}
|
||||
|
||||
// Sender didn't set up a decoupled encryption key
|
||||
if encryption_signer.is_none() {
|
||||
reports.push(SendReport::new(sender.public_key()).error(USER_NO_DEKEY));
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Determine receiver and signer
|
||||
let (receiver, signer) = match signer_kind {
|
||||
// Determine the signer to use
|
||||
let signer = match signer_kind {
|
||||
SignerKind::Auto => {
|
||||
if let Some(announcement) = announcement {
|
||||
if let Some(enc_signer) = encryption_signer.as_ref() {
|
||||
(announcement.public_key(), enc_signer.clone())
|
||||
} else {
|
||||
(member.public_key(), user_signer.clone())
|
||||
}
|
||||
if announcement.is_some()
|
||||
&& let Some(encryption_signer) = encryption_signer.clone()
|
||||
{
|
||||
// Safe to unwrap due to earlier checks
|
||||
encryption_signer
|
||||
} else {
|
||||
(member.public_key(), user_signer.clone())
|
||||
user_signer.clone()
|
||||
}
|
||||
}
|
||||
SignerKind::Encryption => {
|
||||
// Safe to unwrap due to earlier checks
|
||||
(
|
||||
announcement.unwrap().public_key(),
|
||||
encryption_signer.as_ref().unwrap().clone(),
|
||||
)
|
||||
encryption_signer.as_ref().unwrap().clone()
|
||||
}
|
||||
SignerKind::User => (member.public_key(), user_signer.clone()),
|
||||
SignerKind::User => user_signer.clone(),
|
||||
};
|
||||
|
||||
match send_gift_wrap(&client, &signer, &receiver, &rumor, relays, public_key).await
|
||||
{
|
||||
Ok((report, _)) => {
|
||||
// Send the gift wrap event and collect the report
|
||||
match send_gift_wrap(&client, &signer, &member, &rumor, signer_kind).await {
|
||||
Ok(report) => {
|
||||
reports.push(report);
|
||||
sents += 1;
|
||||
}
|
||||
Err(report) => reports.push(report),
|
||||
Err(error) => {
|
||||
let report = SendReport::new(public_key).error(error.to_string());
|
||||
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),
|
||||
// Determine the signer to use
|
||||
let signer = match signer_kind {
|
||||
SignerKind::Auto => {
|
||||
if sender.announcement().is_some()
|
||||
&& let Some(encryption_signer) = encryption_signer.clone()
|
||||
{
|
||||
// Safe to unwrap due to earlier checks
|
||||
encryption_signer
|
||||
} else {
|
||||
user_signer.clone()
|
||||
}
|
||||
}
|
||||
SignerKind::Encryption => {
|
||||
// Safe to unwrap due to earlier checks
|
||||
encryption_signer.as_ref().unwrap().clone()
|
||||
}
|
||||
SignerKind::User => user_signer.clone(),
|
||||
};
|
||||
|
||||
match send_gift_wrap(&client, &signer, &sender, &rumor, signer_kind).await {
|
||||
Ok(report) => reports.push(report),
|
||||
Err(error) => {
|
||||
let report = SendReport::new(public_key).error(error.to_string());
|
||||
reports.push(report);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -596,36 +595,51 @@ impl Room {
|
||||
async fn send_gift_wrap<T>(
|
||||
client: &Client,
|
||||
signer: &T,
|
||||
receiver: &PublicKey,
|
||||
receiver: &Person,
|
||||
rumor: &UnsignedEvent,
|
||||
relays: &[RelayUrl],
|
||||
public_key: PublicKey,
|
||||
) -> Result<(SendReport, bool), SendReport>
|
||||
config: &SignerKind,
|
||||
) -> Result<SendReport, Error>
|
||||
where
|
||||
T: NostrSigner + 'static,
|
||||
{
|
||||
// Ensure relay connections
|
||||
for url in relays {
|
||||
client.add_relay(url).and_connect().await.ok();
|
||||
}
|
||||
let k_tag = Tag::custom(TagKind::k(), vec!["14"]);
|
||||
let mut extra_tags = vec![k_tag];
|
||||
|
||||
match EventBuilder::gift_wrap(signer, receiver, rumor.clone(), []).await {
|
||||
Ok(event) => {
|
||||
match client
|
||||
.send_event(&event)
|
||||
.to(relays)
|
||||
.ack_policy(AckPolicy::none())
|
||||
.await
|
||||
{
|
||||
Ok(output) => Ok((
|
||||
SendReport::new(public_key)
|
||||
.gift_wrap_id(event.id)
|
||||
.output(output),
|
||||
true,
|
||||
)),
|
||||
Err(e) => Err(SendReport::new(public_key).error(e.to_string())),
|
||||
// Determine the receiver public key based on the config
|
||||
let receiver = match config {
|
||||
SignerKind::Auto => {
|
||||
if let Some(announcement) = receiver.announcement().as_ref() {
|
||||
extra_tags.push(Tag::public_key(receiver.public_key()));
|
||||
announcement.public_key()
|
||||
} else {
|
||||
receiver.public_key()
|
||||
}
|
||||
}
|
||||
Err(e) => Err(SendReport::new(public_key).error(e.to_string())),
|
||||
}
|
||||
SignerKind::Encryption => {
|
||||
if let Some(announcement) = receiver.announcement().as_ref() {
|
||||
extra_tags.push(Tag::public_key(receiver.public_key()));
|
||||
announcement.public_key()
|
||||
} else {
|
||||
return Err(anyhow!("User has no encryption announcement"));
|
||||
}
|
||||
}
|
||||
SignerKind::User => receiver.public_key(),
|
||||
};
|
||||
|
||||
// Construct the gift wrap event
|
||||
let event = EventBuilder::gift_wrap(signer, &receiver, rumor.clone(), extra_tags).await?;
|
||||
|
||||
// Send the gift wrap event and collect the report
|
||||
let report = client
|
||||
.send_event(&event)
|
||||
.to_nip17()
|
||||
.ack_policy(AckPolicy::none())
|
||||
.await
|
||||
.map(|output| {
|
||||
SendReport::new(receiver)
|
||||
.gift_wrap_id(event.id)
|
||||
.output(output)
|
||||
})?;
|
||||
|
||||
Ok(report)
|
||||
}
|
||||
|
||||
@@ -28,3 +28,5 @@ serde_json.workspace = true
|
||||
|
||||
once_cell = "1.19.0"
|
||||
regex = "1"
|
||||
linkify = "0.10.0"
|
||||
pulldown-cmark = "0.13.1"
|
||||
|
||||
@@ -7,20 +7,11 @@ use settings::SignerKind;
|
||||
#[action(namespace = chat, no_json)]
|
||||
pub enum Command {
|
||||
Insert(&'static str),
|
||||
ChangeSubject(&'static str),
|
||||
ChangeSubject(String),
|
||||
ChangeSigner(SignerKind),
|
||||
ToggleBackup,
|
||||
Copy(PublicKey),
|
||||
Relays(PublicKey),
|
||||
Njump(PublicKey),
|
||||
Trace(EventId),
|
||||
}
|
||||
|
||||
#[derive(Action, Clone, PartialEq, Eq, Deserialize)]
|
||||
#[action(namespace = chat, no_json)]
|
||||
pub struct SeenOn(pub EventId);
|
||||
|
||||
/// Define a open public key action
|
||||
#[derive(Action, Clone, PartialEq, Eq, Deserialize, Debug)]
|
||||
#[action(namespace = pubkey, no_json)]
|
||||
pub struct OpenPublicKey(pub PublicKey);
|
||||
|
||||
/// Define a copy inline public key action
|
||||
#[derive(Action, Clone, PartialEq, Eq, Deserialize, Debug)]
|
||||
#[action(namespace = pubkey, no_json)]
|
||||
pub struct CopyPublicKey(pub PublicKey);
|
||||
|
||||
@@ -1,60 +0,0 @@
|
||||
use gpui::{
|
||||
div, App, AppContext, Context, Entity, IntoElement, ParentElement, Render, SharedString,
|
||||
Styled, Window,
|
||||
};
|
||||
use theme::ActiveTheme;
|
||||
use ui::input::{InputState, TextInput};
|
||||
use ui::{v_flex, Sizable};
|
||||
|
||||
pub fn init(subject: Option<String>, window: &mut Window, cx: &mut App) -> Entity<Subject> {
|
||||
cx.new(|cx| Subject::new(subject, window, cx))
|
||||
}
|
||||
|
||||
pub struct Subject {
|
||||
input: Entity<InputState>,
|
||||
}
|
||||
|
||||
impl Subject {
|
||||
pub fn new(subject: Option<String>, window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||
let input = cx.new(|cx| InputState::new(window, cx).placeholder("Plan for holiday"));
|
||||
|
||||
if let Some(value) = subject {
|
||||
input.update(cx, |this, cx| {
|
||||
this.set_value(value, window, cx);
|
||||
});
|
||||
};
|
||||
|
||||
Self { input }
|
||||
}
|
||||
|
||||
pub fn new_subject(&self, cx: &App) -> SharedString {
|
||||
self.input.read(cx).value()
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for Subject {
|
||||
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
v_flex()
|
||||
.gap_2()
|
||||
.child(
|
||||
v_flex()
|
||||
.gap_1()
|
||||
.child(
|
||||
div()
|
||||
.text_sm()
|
||||
.text_color(cx.theme().text_muted)
|
||||
.child(SharedString::from("Subject:")),
|
||||
)
|
||||
.child(TextInput::new(&self.input).small()),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.text_xs()
|
||||
.italic()
|
||||
.text_color(cx.theme().text_placeholder)
|
||||
.child(SharedString::from(
|
||||
"Subject will be updated when you send a new message.",
|
||||
)),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,29 +1,29 @@
|
||||
use std::ops::Range;
|
||||
use std::sync::Arc;
|
||||
|
||||
use chat::Mention;
|
||||
use common::RangeExt;
|
||||
use gpui::{
|
||||
AnyElement, App, ElementId, HighlightStyle, InteractiveText, IntoElement, SharedString,
|
||||
StyledText, UnderlineStyle, Window,
|
||||
AnyElement, App, ElementId, Entity, FontStyle, FontWeight, HighlightStyle, InteractiveText,
|
||||
IntoElement, SharedString, StrikethroughStyle, StyledText, UnderlineStyle, Window,
|
||||
};
|
||||
use nostr_sdk::prelude::*;
|
||||
use once_cell::sync::Lazy;
|
||||
use person::PersonRegistry;
|
||||
use regex::Regex;
|
||||
use theme::ActiveTheme;
|
||||
|
||||
use crate::actions::OpenPublicKey;
|
||||
|
||||
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());
|
||||
|
||||
#[allow(clippy::enum_variant_names)]
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum Highlight {
|
||||
Link,
|
||||
Nostr,
|
||||
Code,
|
||||
InlineCode(bool),
|
||||
Highlight(HighlightStyle),
|
||||
Mention,
|
||||
}
|
||||
|
||||
impl From<HighlightStyle> for Highlight {
|
||||
fn from(style: HighlightStyle) -> Self {
|
||||
Self::Highlight(style)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
@@ -35,7 +35,12 @@ pub struct 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 highlights = Vec::new();
|
||||
let mut link_ranges = Vec::new();
|
||||
@@ -43,10 +48,12 @@ impl RenderedText {
|
||||
|
||||
render_plain_text_mut(
|
||||
content,
|
||||
mentions,
|
||||
&mut text,
|
||||
&mut highlights,
|
||||
&mut link_ranges,
|
||||
&mut link_urls,
|
||||
persons,
|
||||
cx,
|
||||
);
|
||||
|
||||
@@ -61,7 +68,8 @@ impl RenderedText {
|
||||
}
|
||||
|
||||
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;
|
||||
let color = cx.theme().text_accent;
|
||||
|
||||
InteractiveText::new(
|
||||
id,
|
||||
@@ -71,15 +79,36 @@ impl RenderedText {
|
||||
(
|
||||
range.clone(),
|
||||
match highlight {
|
||||
Highlight::Link => HighlightStyle {
|
||||
color: Some(link_color),
|
||||
underline: Some(UnderlineStyle::default()),
|
||||
Highlight::Code => HighlightStyle {
|
||||
background_color: Some(code_background),
|
||||
..Default::default()
|
||||
},
|
||||
Highlight::Nostr => HighlightStyle {
|
||||
color: Some(link_color),
|
||||
Highlight::InlineCode(link) => {
|
||||
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 {
|
||||
color: Some(color),
|
||||
underline: Some(UnderlineStyle {
|
||||
thickness: 1.0.into(),
|
||||
..Default::default()
|
||||
}),
|
||||
..Default::default()
|
||||
},
|
||||
Highlight::Highlight(highlight) => *highlight,
|
||||
},
|
||||
)
|
||||
}),
|
||||
@@ -87,22 +116,10 @@ impl RenderedText {
|
||||
)
|
||||
.on_click(self.link_ranges.clone(), {
|
||||
let link_urls = self.link_urls.clone();
|
||||
move |ix, window, cx| {
|
||||
let token = link_urls[ix].as_str();
|
||||
|
||||
if let Some(clean_url) = token.strip_prefix("nostr:") {
|
||||
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}")
|
||||
move |ix, _, cx| {
|
||||
let url = &link_urls[ix];
|
||||
if url.starts_with("http") {
|
||||
cx.open_url(url);
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -110,214 +127,273 @@ impl RenderedText {
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn render_plain_text_mut(
|
||||
content: &str,
|
||||
block: &str,
|
||||
mut mentions: &[Mention],
|
||||
text: &mut String,
|
||||
highlights: &mut Vec<(Range<usize>, Highlight)>,
|
||||
link_ranges: &mut Vec<Range<usize>>,
|
||||
link_urls: &mut Vec<String>,
|
||||
persons: &Entity<PersonRegistry>,
|
||||
cx: &App,
|
||||
) {
|
||||
// Copy the content directly
|
||||
text.push_str(content);
|
||||
use pulldown_cmark::{Event, Options, Parser, Tag, TagEnd};
|
||||
|
||||
// Collect all URLs
|
||||
let mut url_matches: Vec<(Range<usize>, String)> = Vec::new();
|
||||
let mut bold_depth = 0;
|
||||
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 range = link.start()..link.end();
|
||||
let url = link.as_str().to_string();
|
||||
let mut options = Options::all();
|
||||
options.remove(pulldown_cmark::Options::ENABLE_DEFINITION_LIST);
|
||||
|
||||
url_matches.push((range, url));
|
||||
}
|
||||
for (event, source_range) in Parser::new_ext(block, options).into_offset_iter() {
|
||||
let prev_len = text.len();
|
||||
|
||||
// Collect all nostr entities with nostr: prefix
|
||||
let mut nostr_matches: Vec<(Range<usize>, String)> = Vec::new();
|
||||
match event {
|
||||
Event::Text(t) => {
|
||||
// Process text with mention replacements
|
||||
let t_str = t.as_ref();
|
||||
let mut last_processed = 0;
|
||||
|
||||
for nostr_match in NOSTR_URI_REGEX.find_iter(content) {
|
||||
let range = nostr_match.start()..nostr_match.end();
|
||||
let nostr_uri = nostr_match.as_str().to_string();
|
||||
while let Some(mention) = mentions.first() {
|
||||
if !source_range.contains_inclusive(&mention.range) {
|
||||
break;
|
||||
}
|
||||
|
||||
// Check if this nostr URI overlaps with any already processed URL
|
||||
if !url_matches
|
||||
.iter()
|
||||
.any(|(url_range, _)| url_range.start < range.end && range.start < url_range.end)
|
||||
{
|
||||
nostr_matches.push((range, nostr_uri));
|
||||
}
|
||||
}
|
||||
// Calculate positions within the current text
|
||||
let mention_start_in_text = mention.range.start - source_range.start;
|
||||
let mention_end_in_text = mention.range.end - source_range.start;
|
||||
|
||||
// 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);
|
||||
// Add text before this mention
|
||||
if mention_start_in_text > last_processed {
|
||||
let before_mention = &t_str[last_processed..mention_start_in_text];
|
||||
process_text_segment(
|
||||
before_mention,
|
||||
prev_len + last_processed,
|
||||
bold_depth,
|
||||
italic_depth,
|
||||
strikethrough_depth,
|
||||
link_url.clone(),
|
||||
text,
|
||||
highlights,
|
||||
link_ranges,
|
||||
link_urls,
|
||||
);
|
||||
}
|
||||
|
||||
// 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 the mention replacement
|
||||
let profile = persons.read(cx).get(&mention.public_key, cx);
|
||||
let replacement_text = format!("@{}", profile.name());
|
||||
|
||||
// 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;
|
||||
};
|
||||
let replacement_start = text.len();
|
||||
text.push_str(&replacement_text);
|
||||
let replacement_end = text.len();
|
||||
|
||||
if let Ok(nip21) = Nip21::parse(&entity) {
|
||||
match nip21 {
|
||||
Nip21::Pubkey(public_key) => {
|
||||
render_pubkey(
|
||||
public_key,
|
||||
text,
|
||||
&range,
|
||||
highlights,
|
||||
link_ranges,
|
||||
link_urls,
|
||||
cx,
|
||||
);
|
||||
highlights.push((replacement_start..replacement_end, Highlight::Mention));
|
||||
|
||||
last_processed = mention_end_in_text;
|
||||
mentions = &mentions[1..];
|
||||
}
|
||||
Nip21::Profile(nip19_profile) => {
|
||||
render_pubkey(
|
||||
nip19_profile.public_key,
|
||||
|
||||
// Add any remaining text after the last mention
|
||||
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,
|
||||
&range,
|
||||
highlights,
|
||||
link_ranges,
|
||||
link_urls,
|
||||
cx,
|
||||
);
|
||||
}
|
||||
Nip21::EventId(event_id) => {
|
||||
render_bech32(
|
||||
event_id.to_bech32().unwrap(),
|
||||
text,
|
||||
&range,
|
||||
highlights,
|
||||
link_ranges,
|
||||
link_urls,
|
||||
);
|
||||
}
|
||||
Nip21::Event(nip19_event) => {
|
||||
render_bech32(
|
||||
nip19_event.to_bech32().unwrap(),
|
||||
text,
|
||||
&range,
|
||||
highlights,
|
||||
link_ranges,
|
||||
link_urls,
|
||||
);
|
||||
}
|
||||
Nip21::Coordinate(nip19_coordinate) => {
|
||||
render_bech32(
|
||||
nip19_coordinate.to_bech32().unwrap(),
|
||||
text,
|
||||
&range,
|
||||
highlights,
|
||||
link_ranges,
|
||||
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);
|
||||
}
|
||||
|
||||
highlights.push((prev_len..text.len(), Highlight::InlineCode(is_link)))
|
||||
}
|
||||
Event::Start(tag) => match tag {
|
||||
Tag::Paragraph => new_paragraph(text, &mut list_stack),
|
||||
Tag::Heading { .. } => {
|
||||
new_paragraph(text, &mut list_stack);
|
||||
bold_depth += 1;
|
||||
}
|
||||
Tag::CodeBlock(_kind) => {
|
||||
new_paragraph(text, &mut list_stack);
|
||||
}
|
||||
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 {
|
||||
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'),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if a string is a URL
|
||||
fn is_url(s: &str) -> bool {
|
||||
URL_REGEX.is_match(s)
|
||||
}
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
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,
|
||||
highlights: &mut Vec<(Range<usize>, Highlight)>,
|
||||
link_ranges: &mut Vec<Range<usize>>,
|
||||
link_urls: &mut Vec<String>,
|
||||
) {
|
||||
// Build the style for this segment
|
||||
let mut style = HighlightStyle::default();
|
||||
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()
|
||||
});
|
||||
}
|
||||
|
||||
/// Format a bech32 entity with ellipsis and last 4 characters
|
||||
fn format_shortened_entity(entity: &str) -> String {
|
||||
let prefix_end = entity.find('1').unwrap_or(0);
|
||||
// Add the text
|
||||
text.push_str(segment);
|
||||
let text_end = text.len();
|
||||
|
||||
if prefix_end > 0 && entity.len() > prefix_end + 5 {
|
||||
let prefix = &entity[0..=prefix_end]; // Include the '1'
|
||||
let suffix = &entity[entity.len() - 4..]; // Last 4 chars
|
||||
if let Some(link_url) = link_url {
|
||||
// Handle as a markdown link
|
||||
link_ranges.push(segment_start..text_end);
|
||||
link_urls.push(link_url);
|
||||
style.underline = Some(UnderlineStyle {
|
||||
thickness: 1.0.into(),
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
format!("{prefix}...{suffix}")
|
||||
// Add highlight for the entire linked segment
|
||||
if style != HighlightStyle::default() {
|
||||
highlights.push((segment_start..text_end, Highlight::Highlight(style)));
|
||||
}
|
||||
} else {
|
||||
entity.to_string()
|
||||
}
|
||||
}
|
||||
// Handle link detection within the segment
|
||||
let mut finder = linkify::LinkFinder::new();
|
||||
finder.kinds(&[linkify::LinkKind::Url]);
|
||||
let mut last_link_pos = 0;
|
||||
|
||||
fn render_pubkey(
|
||||
public_key: PublicKey,
|
||||
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());
|
||||
for link in finder.links(segment) {
|
||||
let start = link.start();
|
||||
let end = link.end();
|
||||
|
||||
text.replace_range(range.clone(), &display_name);
|
||||
// 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;
|
||||
|
||||
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);
|
||||
if style != HighlightStyle::default() {
|
||||
highlights.push((non_link_start..non_link_end, Highlight::Highlight(style)));
|
||||
}
|
||||
}
|
||||
|
||||
highlights.push((new_range.clone(), Highlight::Nostr));
|
||||
link_ranges.push(new_range);
|
||||
link_urls.push(format!("nostr:{}", profile.public_key().to_hex()));
|
||||
// Add the link
|
||||
let range = (segment_start + start)..(segment_start + end);
|
||||
link_ranges.push(range.clone());
|
||||
link_urls.push(link.as_str().to_string());
|
||||
|
||||
if length_diff != 0 {
|
||||
adjust_ranges(highlights, link_ranges, range.end, length_diff);
|
||||
}
|
||||
}
|
||||
// Apply link styling (underline + existing style)
|
||||
let mut link_style = style;
|
||||
link_style.underline = Some(UnderlineStyle {
|
||||
thickness: 1.0.into(),
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
fn render_bech32(
|
||||
bech32: String,
|
||||
text: &mut String,
|
||||
range: &Range<usize>,
|
||||
highlights: &mut Vec<(Range<usize>, Highlight)>,
|
||||
link_ranges: &mut Vec<Range<usize>>,
|
||||
link_urls: &mut Vec<String>,
|
||||
) {
|
||||
let njump_url = format!("https://njump.me/{bech32}");
|
||||
let shortened_entity = format_shortened_entity(&bech32);
|
||||
let display_text = format!("https://njump.me/{shortened_entity}");
|
||||
highlights.push((range, Highlight::Highlight(link_style)));
|
||||
|
||||
text.replace_range(range.clone(), &display_text);
|
||||
|
||||
let new_length = display_text.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::Link));
|
||||
link_ranges.push(new_range);
|
||||
link_urls.push(njump_url);
|
||||
|
||||
if length_diff != 0 {
|
||||
adjust_ranges(highlights, link_ranges, range.end, length_diff);
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to adjust ranges when text length changes
|
||||
fn adjust_ranges(
|
||||
highlights: &mut [(Range<usize>, Highlight)],
|
||||
link_ranges: &mut [Range<usize>],
|
||||
position: usize,
|
||||
length_diff: isize,
|
||||
) {
|
||||
// Adjust highlight ranges
|
||||
for (range, _) in highlights.iter_mut() {
|
||||
if range.start > position {
|
||||
range.start = (range.start as isize + length_diff) as usize;
|
||||
range.end = (range.end as isize + length_diff) as usize;
|
||||
last_link_pos = end;
|
||||
}
|
||||
}
|
||||
|
||||
// Adjust link ranges
|
||||
for range in link_ranges.iter_mut() {
|
||||
if range.start > position {
|
||||
range.start = (range.start as isize + length_diff) as usize;
|
||||
range.end = (range.end as isize + length_diff) as usize;
|
||||
// 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)));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn new_paragraph(text: &mut String, list_stack: &mut [(Option<u64>, bool)]) {
|
||||
let mut is_subsequent_paragraph_of_list = false;
|
||||
if let Some((_, has_content)) = list_stack.last_mut() {
|
||||
if *has_content {
|
||||
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,5 @@ log.workspace = true
|
||||
|
||||
dirs = "5.0"
|
||||
qrcode = "0.14.1"
|
||||
bech32 = "0.11.1"
|
||||
regex = "1.10"
|
||||
|
||||
135
crates/common/src/caching.rs
Normal file
@@ -0,0 +1,135 @@
|
||||
use std::collections::{HashMap, VecDeque};
|
||||
use std::mem::take;
|
||||
|
||||
use futures::FutureExt;
|
||||
use gpui::{
|
||||
App, AppContext, Asset, AssetLogger, ElementId, Entity, ImageAssetLoader, ImageCache,
|
||||
ImageCacheItem, ImageCacheProvider, ImageSource, Resource, hash,
|
||||
};
|
||||
|
||||
pub fn coop_cache(id: impl Into<ElementId>, max_items: usize) -> CoopImageCacheProvider {
|
||||
CoopImageCacheProvider {
|
||||
id: id.into(),
|
||||
max_items,
|
||||
}
|
||||
}
|
||||
|
||||
pub struct CoopImageCacheProvider {
|
||||
id: ElementId,
|
||||
max_items: usize,
|
||||
}
|
||||
|
||||
impl ImageCacheProvider for CoopImageCacheProvider {
|
||||
fn provide(&mut self, window: &mut gpui::Window, cx: &mut App) -> gpui::AnyImageCache {
|
||||
window
|
||||
.with_global_id(self.id.clone(), |id, window| {
|
||||
window.with_element_state(id, |cache, _| {
|
||||
let cache = cache.unwrap_or_else(|| CoopImageCache::new(self.max_items, cx));
|
||||
(cache.clone(), cache)
|
||||
})
|
||||
})
|
||||
.into()
|
||||
}
|
||||
}
|
||||
|
||||
pub struct CoopImageCache {
|
||||
max_items: usize,
|
||||
usage_list: VecDeque<u64>,
|
||||
cache: HashMap<u64, (ImageCacheItem, Resource)>,
|
||||
}
|
||||
|
||||
impl CoopImageCache {
|
||||
pub fn new(max_items: usize, cx: &mut App) -> Entity<Self> {
|
||||
cx.new(|cx| {
|
||||
log::info!("Creating CoopImageCache");
|
||||
cx.on_release(|this: &mut Self, cx| {
|
||||
for (ix, (mut image, resource)) in take(&mut this.cache) {
|
||||
if let Some(Ok(image)) = image.get() {
|
||||
log::info!("Dropping image {ix}");
|
||||
cx.drop_image(image, None);
|
||||
}
|
||||
ImageSource::Resource(resource).remove_asset(cx);
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
|
||||
CoopImageCache {
|
||||
max_items,
|
||||
usage_list: VecDeque::with_capacity(max_items),
|
||||
cache: HashMap::with_capacity(max_items),
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl ImageCache for CoopImageCache {
|
||||
fn load(
|
||||
&mut self,
|
||||
resource: &Resource,
|
||||
window: &mut gpui::Window,
|
||||
cx: &mut gpui::App,
|
||||
) -> Option<Result<std::sync::Arc<gpui::RenderImage>, gpui::ImageCacheError>> {
|
||||
let hash = hash(resource);
|
||||
|
||||
if let Some(item) = self.cache.get_mut(&hash) {
|
||||
let current_idx = self
|
||||
.usage_list
|
||||
.iter()
|
||||
.position(|item| *item == hash)
|
||||
.expect("cache has an item usage_list doesn't");
|
||||
|
||||
self.usage_list.remove(current_idx);
|
||||
self.usage_list.push_front(hash);
|
||||
|
||||
return item.0.get();
|
||||
}
|
||||
|
||||
let load_future = AssetLogger::<ImageAssetLoader>::load(resource.clone(), cx);
|
||||
let task = cx.background_executor().spawn(load_future).shared();
|
||||
|
||||
if self.usage_list.len() >= self.max_items {
|
||||
log::info!("Image cache is full, evicting oldest item");
|
||||
|
||||
if let Some(oldest) = self.usage_list.pop_back() {
|
||||
let mut image = self
|
||||
.cache
|
||||
.remove(&oldest)
|
||||
.expect("usage_list has an item cache doesn't");
|
||||
|
||||
if let Some(Ok(image)) = image.0.get() {
|
||||
log::info!("requesting image to be dropped");
|
||||
cx.drop_image(image, Some(window));
|
||||
}
|
||||
|
||||
ImageSource::Resource(image.1).remove_asset(cx);
|
||||
}
|
||||
}
|
||||
|
||||
self.cache.insert(
|
||||
hash,
|
||||
(
|
||||
gpui::ImageCacheItem::Loading(task.clone()),
|
||||
resource.clone(),
|
||||
),
|
||||
);
|
||||
self.usage_list.push_front(hash);
|
||||
|
||||
let entity = window.current_view();
|
||||
|
||||
window
|
||||
.spawn(cx, async move |cx| {
|
||||
let result = task.await;
|
||||
|
||||
if let Err(err) = result {
|
||||
log::error!("error loading image into cache: {:?}", err);
|
||||
}
|
||||
|
||||
cx.on_next_frame(move |_, cx| {
|
||||
cx.notify(entity);
|
||||
});
|
||||
})
|
||||
.detach();
|
||||
|
||||
None
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,10 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::{anyhow, Error};
|
||||
use chrono::{Local, TimeZone};
|
||||
use gpui::{Image, ImageFormat, SharedString};
|
||||
use nostr_sdk::prelude::*;
|
||||
use qrcode::render::svg;
|
||||
use qrcode::QrCode;
|
||||
use qrcode::render::svg;
|
||||
|
||||
const NOW: &str = "now";
|
||||
const SECONDS_IN_MINUTE: i64 = 60;
|
||||
@@ -13,12 +12,12 @@ const MINUTES_IN_HOUR: i64 = 60;
|
||||
const HOURS_IN_DAY: i64 = 24;
|
||||
const DAYS_IN_MONTH: i64 = 30;
|
||||
|
||||
pub trait RenderedTimestamp {
|
||||
pub trait TimestampExt {
|
||||
fn to_human_time(&self) -> SharedString;
|
||||
fn to_ago(&self) -> SharedString;
|
||||
}
|
||||
|
||||
impl RenderedTimestamp for Timestamp {
|
||||
impl TimestampExt for Timestamp {
|
||||
fn to_human_time(&self) -> SharedString {
|
||||
let input_time = match Local.timestamp_opt(self.as_secs() as i64, 0) {
|
||||
chrono::LocalResult::Single(time) => time,
|
||||
@@ -61,23 +60,11 @@ impl RenderedTimestamp for Timestamp {
|
||||
}
|
||||
}
|
||||
|
||||
pub trait TextUtils {
|
||||
fn to_public_key(&self) -> Result<PublicKey, Error>;
|
||||
pub trait StringExt {
|
||||
fn to_qr(&self) -> Option<Arc<Image>>;
|
||||
}
|
||||
|
||||
impl<T: AsRef<str>> TextUtils for T {
|
||||
fn to_public_key(&self) -> Result<PublicKey, Error> {
|
||||
let s = self.as_ref();
|
||||
if s.starts_with("nprofile1") {
|
||||
Ok(Nip19Profile::from_bech32(s)?.public_key)
|
||||
} else if s.starts_with("npub1") {
|
||||
Ok(PublicKey::parse(s)?)
|
||||
} else {
|
||||
Err(anyhow!("Invalid public key"))
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: AsRef<str>> StringExt for T {
|
||||
fn to_qr(&self) -> Option<Arc<Image>> {
|
||||
let s = self.as_ref();
|
||||
let code = QrCode::new(s).unwrap();
|
||||
|
||||
@@ -3,12 +3,12 @@ use std::hash::{DefaultHasher, Hash, Hasher};
|
||||
use itertools::Itertools;
|
||||
use nostr_sdk::prelude::*;
|
||||
|
||||
pub trait EventUtils {
|
||||
pub trait EventExt {
|
||||
fn uniq_id(&self) -> u64;
|
||||
fn extract_public_keys(&self) -> Vec<PublicKey>;
|
||||
}
|
||||
|
||||
impl EventUtils for Event {
|
||||
impl EventExt for Event {
|
||||
fn uniq_id(&self) -> u64 {
|
||||
let mut hasher = DefaultHasher::new();
|
||||
let mut pubkeys: Vec<PublicKey> = self.extract_public_keys();
|
||||
@@ -25,7 +25,7 @@ impl EventUtils for Event {
|
||||
}
|
||||
}
|
||||
|
||||
impl EventUtils for UnsignedEvent {
|
||||
impl EventExt for UnsignedEvent {
|
||||
fn uniq_id(&self) -> u64 {
|
||||
let mut hasher = DefaultHasher::new();
|
||||
let mut pubkeys: Vec<PublicKey> = vec![];
|
||||
|
||||
@@ -1,9 +1,17 @@
|
||||
pub use caching::*;
|
||||
pub use debounced_delay::*;
|
||||
pub use display::*;
|
||||
pub use event::*;
|
||||
pub use media_extractor::*;
|
||||
pub use parser::*;
|
||||
pub use paths::*;
|
||||
pub use range::*;
|
||||
|
||||
mod caching;
|
||||
mod debounced_delay;
|
||||
mod display;
|
||||
mod event;
|
||||
mod media_extractor;
|
||||
mod parser;
|
||||
mod paths;
|
||||
mod range;
|
||||
|
||||
117
crates/common/src/media_extractor.rs
Normal file
@@ -0,0 +1,117 @@
|
||||
use gpui::SharedUri;
|
||||
use regex::Regex;
|
||||
|
||||
/// Extracts media URLs from a string and returns both the extracted URLs
|
||||
/// and the string with media URLs removed
|
||||
pub struct MediaExtractor {
|
||||
image_regex: Regex,
|
||||
video_regex: Regex,
|
||||
}
|
||||
|
||||
impl MediaExtractor {
|
||||
/// Creates a new MediaExtractor with compiled regex patterns
|
||||
pub fn new() -> Self {
|
||||
MediaExtractor {
|
||||
// Match common image extensions
|
||||
image_regex: Regex::new(
|
||||
r#"(?i)\bhttps?://[^\s<>"']+\.(?:jpg|jpeg|png|gif|bmp|webp|svg|ico)(?:\?[^\s<>"']*)?\b"#,
|
||||
).unwrap(),
|
||||
// Match common video extensions
|
||||
video_regex: Regex::new(
|
||||
r#"(?i)\bhttps?://[^\s<>"']+\.(?:mp4|mov|avi|mkv|webm|flv|wmv|m4v|3gp)(?:\?[^\s<>"']*)?\b"#,
|
||||
).unwrap(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Extracts all media URLs from a string
|
||||
pub fn extract_media_urls(&self, text: &str) -> Vec<SharedUri> {
|
||||
let mut urls = Vec::new();
|
||||
|
||||
// Extract image URLs
|
||||
for capture in self.image_regex.find_iter(text) {
|
||||
urls.push(capture.as_str().to_string().into());
|
||||
}
|
||||
|
||||
// Extract video URLs
|
||||
// for capture in self.video_regex.find_iter(text) {
|
||||
// urls.push(capture.as_str().to_string().into());
|
||||
// }
|
||||
|
||||
urls
|
||||
}
|
||||
|
||||
/// Removes all media URLs from a string and returns the cleaned text
|
||||
pub fn remove_media_urls(&self, text: &str) -> String {
|
||||
let mut result = text.to_string();
|
||||
|
||||
// Remove image URLs
|
||||
result = self.image_regex.replace_all(&result, "").to_string();
|
||||
|
||||
// Remove video URLs
|
||||
// result = self.video_regex.replace_all(&result, "").to_string();
|
||||
|
||||
// Clean up extra whitespace that might result from removal
|
||||
self.cleanup_text(&result)
|
||||
}
|
||||
|
||||
/// Extracts media URLs and removes them from the string, returning both
|
||||
pub fn extract_and_remove(&self, text: &str) -> (Vec<SharedUri>, String) {
|
||||
let urls = self.extract_media_urls(text);
|
||||
let cleaned_text = self.remove_media_urls(text);
|
||||
(urls, cleaned_text)
|
||||
}
|
||||
|
||||
/// Helper function to clean up text after URL removal
|
||||
fn cleanup_text(&self, text: &str) -> String {
|
||||
let text = text.trim();
|
||||
|
||||
// Remove multiple consecutive spaces
|
||||
let re = Regex::new(r"\s+").unwrap();
|
||||
re.replace_all(text, " ").trim().to_string()
|
||||
}
|
||||
|
||||
/// Validates if a URL is a valid media URL
|
||||
pub fn is_media_url(&self, url: &str) -> bool {
|
||||
self.image_regex.is_match(url) || self.video_regex.is_match(url)
|
||||
}
|
||||
|
||||
/// Categorizes extracted URLs into images and videos
|
||||
pub fn categorize_urls(&self, urls: &[SharedUri]) -> (Vec<SharedUri>, Vec<SharedUri>) {
|
||||
let mut images = Vec::new();
|
||||
let mut videos = Vec::new();
|
||||
|
||||
for url in urls {
|
||||
if self.image_regex.is_match(url) {
|
||||
images.push(url.clone());
|
||||
} else if self.video_regex.is_match(url) {
|
||||
videos.push(url.clone());
|
||||
}
|
||||
}
|
||||
|
||||
(images, videos)
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for MediaExtractor {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// Convenience function for one-time extraction and removal
|
||||
pub fn extract_and_remove_media_urls(text: &str) -> (Vec<SharedUri>, String) {
|
||||
let extractor = MediaExtractor::new();
|
||||
extractor.extract_and_remove(text)
|
||||
}
|
||||
|
||||
/// Convenience function for just extracting media URLs
|
||||
pub fn extract_media_urls(text: &str) -> Vec<SharedUri> {
|
||||
let extractor = MediaExtractor::new();
|
||||
extractor.extract_media_urls(text)
|
||||
}
|
||||
|
||||
/// Convenience function for just removing media URLs
|
||||
pub fn remove_media_urls(text: &str) -> String {
|
||||
let extractor = MediaExtractor::new();
|
||||
extractor.remove_media_urls(text)
|
||||
}
|
||||
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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,13 @@ pub fn home_dir() -> &'static PathBuf {
|
||||
HOME_DIR.get_or_init(|| dirs::home_dir().expect("failed to determine home directory"))
|
||||
}
|
||||
|
||||
/// Returns the path to the user's download directory.
|
||||
pub fn download_dir() -> &'static PathBuf {
|
||||
static DOWNLOAD_DIR: OnceLock<PathBuf> = OnceLock::new();
|
||||
DOWNLOAD_DIR
|
||||
.get_or_init(|| dirs::download_dir().expect("failed to determine download directory"))
|
||||
}
|
||||
|
||||
/// Returns the path to the configuration directory used by Coop.
|
||||
pub fn config_dir() -> &'static PathBuf {
|
||||
static CONFIG_DIR: OnceLock<PathBuf> = OnceLock::new();
|
||||
@@ -56,9 +63,3 @@ pub fn support_dir() -> &'static PathBuf {
|
||||
config_dir().clone()
|
||||
})
|
||||
}
|
||||
|
||||
/// Returns the path to the `nostr` file.
|
||||
pub fn nostr_file() -> &'static PathBuf {
|
||||
static NOSTR_FILE: OnceLock<PathBuf> = OnceLock::new();
|
||||
NOSTR_FILE.get_or_init(|| support_dir().join("nostr-db"))
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
pub mod screening;
|
||||
pub mod settings;
|
||||
@@ -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),
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,294 +0,0 @@
|
||||
use anyhow::Error;
|
||||
use device::DeviceRegistry;
|
||||
use gpui::prelude::FluentBuilder;
|
||||
use gpui::{
|
||||
div, px, AnyElement, App, AppContext, Context, Entity, EventEmitter, FocusHandle, Focusable,
|
||||
IntoElement, ParentElement, Render, SharedString, Styled, Task, Window,
|
||||
};
|
||||
use nostr_sdk::prelude::*;
|
||||
use person::{shorten_pubkey, PersonRegistry};
|
||||
use state::Announcement;
|
||||
use theme::ActiveTheme;
|
||||
use ui::button::{Button, ButtonVariants};
|
||||
use ui::dock_area::panel::{Panel, PanelEvent};
|
||||
use ui::notification::Notification;
|
||||
use ui::{divider, h_flex, v_flex, Disableable, IconName, Sizable, StyledExt, WindowExtension};
|
||||
|
||||
const 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 NOTICE: &str = "By resetting your encryption key, you will lose access to \
|
||||
all your encrypted messages before. This action cannot be undone.";
|
||||
|
||||
pub fn init(public_key: PublicKey, window: &mut Window, cx: &mut App) -> Entity<EncryptionPanel> {
|
||||
cx.new(|cx| EncryptionPanel::new(public_key, window, cx))
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct EncryptionPanel {
|
||||
name: SharedString,
|
||||
focus_handle: FocusHandle,
|
||||
|
||||
/// User's public key
|
||||
public_key: PublicKey,
|
||||
|
||||
/// Whether the panel is loading
|
||||
loading: bool,
|
||||
|
||||
/// Tasks
|
||||
tasks: Vec<Task<Result<(), Error>>>,
|
||||
}
|
||||
|
||||
impl EncryptionPanel {
|
||||
fn new(public_key: PublicKey, _window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||
Self {
|
||||
name: "Encryption".into(),
|
||||
focus_handle: cx.focus_handle(),
|
||||
public_key,
|
||||
loading: false,
|
||||
tasks: vec![],
|
||||
}
|
||||
}
|
||||
|
||||
fn set_loading(&mut self, status: bool, cx: &mut Context<Self>) {
|
||||
self.loading = status;
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn approve(&mut self, event: &Event, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let device = DeviceRegistry::global(cx);
|
||||
let task = device.read(cx).approve(event, cx);
|
||||
let id = event.id;
|
||||
|
||||
// Update loading status
|
||||
self.set_loading(true, cx);
|
||||
|
||||
self.tasks.push(cx.spawn_in(window, async move |this, cx| {
|
||||
match task.await {
|
||||
Ok(_) => {
|
||||
this.update_in(cx, |this, window, cx| {
|
||||
// Reset loading status
|
||||
this.set_loading(false, cx);
|
||||
|
||||
// Remove request
|
||||
device.update(cx, |this, cx| {
|
||||
this.remove_request(&id, cx);
|
||||
});
|
||||
|
||||
window.push_notification("Approved", cx);
|
||||
})?;
|
||||
}
|
||||
Err(e) => {
|
||||
this.update_in(cx, |this, window, cx| {
|
||||
this.set_loading(false, cx);
|
||||
window.push_notification(Notification::error(e.to_string()), cx);
|
||||
})?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}));
|
||||
}
|
||||
|
||||
fn render_requests(&mut self, cx: &mut Context<Self>) -> Vec<impl IntoElement> {
|
||||
const TITLE: &str = "You've requested for the Encryption Key from:";
|
||||
|
||||
let device = DeviceRegistry::global(cx);
|
||||
let requests = device.read(cx).requests.clone();
|
||||
let mut items = Vec::new();
|
||||
|
||||
for event in requests.into_iter() {
|
||||
let request = Announcement::from(&event);
|
||||
let client_name = request.client_name();
|
||||
let target = request.public_key();
|
||||
|
||||
items.push(
|
||||
v_flex()
|
||||
.gap_2()
|
||||
.text_sm()
|
||||
.child(SharedString::from(TITLE))
|
||||
.child(
|
||||
v_flex()
|
||||
.h_12()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.px_2()
|
||||
.rounded(cx.theme().radius)
|
||||
.bg(cx.theme().warning_background)
|
||||
.text_color(cx.theme().warning_foreground)
|
||||
.child(client_name.clone()),
|
||||
)
|
||||
.child(
|
||||
h_flex()
|
||||
.h_7()
|
||||
.w_full()
|
||||
.px_2()
|
||||
.rounded(cx.theme().radius)
|
||||
.bg(cx.theme().elevated_surface_background)
|
||||
.child(SharedString::from(target.to_hex())),
|
||||
)
|
||||
.child(
|
||||
h_flex().justify_end().gap_2().child(
|
||||
Button::new("approve")
|
||||
.label("Approve")
|
||||
.ghost()
|
||||
.small()
|
||||
.disabled(self.loading)
|
||||
.loading(self.loading)
|
||||
.on_click(cx.listener(move |this, _ev, window, cx| {
|
||||
this.approve(&event, window, cx);
|
||||
})),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
items
|
||||
}
|
||||
}
|
||||
|
||||
impl Panel for EncryptionPanel {
|
||||
fn panel_id(&self) -> SharedString {
|
||||
self.name.clone()
|
||||
}
|
||||
|
||||
fn title(&self, _cx: &App) -> AnyElement {
|
||||
self.name.clone().into_any_element()
|
||||
}
|
||||
}
|
||||
|
||||
impl EventEmitter<PanelEvent> for EncryptionPanel {}
|
||||
|
||||
impl Focusable for EncryptionPanel {
|
||||
fn focus_handle(&self, _: &App) -> gpui::FocusHandle {
|
||||
self.focus_handle.clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for EncryptionPanel {
|
||||
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let device = DeviceRegistry::global(cx);
|
||||
let state = device.read(cx).state();
|
||||
let has_requests = device.read(cx).has_requests();
|
||||
|
||||
let persons = PersonRegistry::global(cx);
|
||||
let profile = persons.read(cx).get(&self.public_key, cx);
|
||||
|
||||
let Some(announcement) = profile.announcement() else {
|
||||
return div();
|
||||
};
|
||||
|
||||
let pubkey = SharedString::from(shorten_pubkey(announcement.public_key(), 16));
|
||||
let client_name = announcement.client_name();
|
||||
|
||||
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_3()
|
||||
.text_sm()
|
||||
.child(
|
||||
v_flex()
|
||||
.gap_1p5()
|
||||
.child(
|
||||
div()
|
||||
.text_color(cx.theme().text_muted)
|
||||
.child(SharedString::from("Device Name:")),
|
||||
)
|
||||
.child(
|
||||
h_flex()
|
||||
.h_12()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.rounded(cx.theme().radius)
|
||||
.bg(cx.theme().elevated_surface_background)
|
||||
.child(client_name.clone()),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
v_flex()
|
||||
.gap_1p5()
|
||||
.child(
|
||||
div()
|
||||
.text_color(cx.theme().text_muted)
|
||||
.child(SharedString::from("Encryption Public Key:")),
|
||||
)
|
||||
.child(
|
||||
h_flex()
|
||||
.h_7()
|
||||
.w_full()
|
||||
.px_2()
|
||||
.rounded(cx.theme().radius)
|
||||
.bg(cx.theme().elevated_surface_background)
|
||||
.child(pubkey),
|
||||
),
|
||||
),
|
||||
)
|
||||
.when(has_requests, |this| {
|
||||
this.child(divider(cx)).child(
|
||||
v_flex()
|
||||
.gap_1p5()
|
||||
.w_full()
|
||||
.child(
|
||||
div()
|
||||
.text_color(cx.theme().text_muted)
|
||||
.child(SharedString::from("Requests:")),
|
||||
)
|
||||
.child(
|
||||
v_flex()
|
||||
.gap_2()
|
||||
.flex_1()
|
||||
.w_full()
|
||||
.children(self.render_requests(cx)),
|
||||
),
|
||||
)
|
||||
})
|
||||
.child(divider(cx))
|
||||
.when(state.requesting(), |this| {
|
||||
this.child(
|
||||
h_flex()
|
||||
.h_8()
|
||||
.justify_center()
|
||||
.text_xs()
|
||||
.text_center()
|
||||
.text_color(cx.theme().text_accent)
|
||||
.bg(cx.theme().elevated_surface_background)
|
||||
.rounded(cx.theme().radius)
|
||||
.child(SharedString::from(
|
||||
"Please open other device and approve the request",
|
||||
)),
|
||||
)
|
||||
})
|
||||
.when(state.set(), |this| {
|
||||
this.child(
|
||||
v_flex()
|
||||
.gap_1()
|
||||
.child(
|
||||
Button::new("reset")
|
||||
.icon(IconName::Reset)
|
||||
.label("Reset")
|
||||
.warning()
|
||||
.small()
|
||||
.font_semibold(),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.italic()
|
||||
.text_size(px(10.))
|
||||
.text_color(cx.theme().text_muted)
|
||||
.child(SharedString::from(NOTICE)),
|
||||
),
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,289 +0,0 @@
|
||||
use chat::{ChatRegistry, InboxState};
|
||||
use gpui::prelude::FluentBuilder;
|
||||
use gpui::{
|
||||
div, svg, AnyElement, App, AppContext, Context, Entity, EventEmitter, FocusHandle, Focusable,
|
||||
IntoElement, ParentElement, Render, SharedString, Styled, Window,
|
||||
};
|
||||
use state::{NostrRegistry, RelayState};
|
||||
use theme::ActiveTheme;
|
||||
use ui::button::{Button, ButtonVariants};
|
||||
use ui::dock_area::dock::DockPlacement;
|
||||
use ui::dock_area::panel::{Panel, PanelEvent};
|
||||
use ui::{h_flex, v_flex, Icon, IconName, Sizable, StyledExt};
|
||||
|
||||
use crate::panels::{connect, import, messaging_relays, profile, relay_list};
|
||||
use crate::workspace::Workspace;
|
||||
|
||||
pub fn init(window: &mut Window, cx: &mut App) -> Entity<GreeterPanel> {
|
||||
cx.new(|cx| GreeterPanel::new(window, cx))
|
||||
}
|
||||
|
||||
pub struct GreeterPanel {
|
||||
name: SharedString,
|
||||
focus_handle: FocusHandle,
|
||||
}
|
||||
|
||||
impl GreeterPanel {
|
||||
fn new(_window: &mut Window, cx: &mut App) -> Self {
|
||||
Self {
|
||||
name: "Onboarding".into(),
|
||||
focus_handle: cx.focus_handle(),
|
||||
}
|
||||
}
|
||||
|
||||
fn add_profile_panel(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let signer = nostr.read(cx).signer();
|
||||
|
||||
if let Some(public_key) = signer.public_key() {
|
||||
cx.spawn_in(window, async move |_this, cx| {
|
||||
cx.update(|window, cx| {
|
||||
Workspace::add_panel(
|
||||
profile::init(public_key, window, cx),
|
||||
DockPlacement::Right,
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
})
|
||||
.ok();
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Panel for GreeterPanel {
|
||||
fn panel_id(&self) -> SharedString {
|
||||
self.name.clone()
|
||||
}
|
||||
|
||||
fn title(&self, cx: &App) -> AnyElement {
|
||||
div()
|
||||
.child(
|
||||
svg()
|
||||
.path("brand/coop.svg")
|
||||
.size_4()
|
||||
.text_color(cx.theme().text_muted),
|
||||
)
|
||||
.into_any_element()
|
||||
}
|
||||
}
|
||||
|
||||
impl EventEmitter<PanelEvent> for GreeterPanel {}
|
||||
|
||||
impl Focusable for GreeterPanel {
|
||||
fn focus_handle(&self, _: &App) -> gpui::FocusHandle {
|
||||
self.focus_handle.clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for GreeterPanel {
|
||||
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
const TITLE: &str = "Welcome to Coop!";
|
||||
const DESCRIPTION: &str = "Chat Freely, Stay Private on Nostr.";
|
||||
|
||||
let chat = ChatRegistry::global(cx);
|
||||
let nip17 = chat.read(cx).state(cx);
|
||||
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let nip65 = nostr.read(cx).relay_list_state();
|
||||
|
||||
let signer = nostr.read(cx).signer();
|
||||
let owned = signer.owned();
|
||||
|
||||
let required_actions =
|
||||
nip65 == RelayState::NotConfigured || nip17 == InboxState::RelayNotAvailable;
|
||||
|
||||
h_flex()
|
||||
.size_full()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.p_2()
|
||||
.child(
|
||||
v_flex()
|
||||
.h_full()
|
||||
.w_112()
|
||||
.gap_6()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.child(
|
||||
h_flex()
|
||||
.mb_4()
|
||||
.gap_2()
|
||||
.w_full()
|
||||
.child(
|
||||
svg()
|
||||
.path("brand/coop.svg")
|
||||
.size_12()
|
||||
.text_color(cx.theme().icon_muted),
|
||||
)
|
||||
.child(
|
||||
v_flex()
|
||||
.child(
|
||||
div()
|
||||
.font_semibold()
|
||||
.text_color(cx.theme().text)
|
||||
.child(SharedString::from(TITLE)),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.text_xs()
|
||||
.text_color(cx.theme().text_muted)
|
||||
.child(SharedString::from(DESCRIPTION)),
|
||||
),
|
||||
),
|
||||
)
|
||||
.when(required_actions, |this| {
|
||||
this.child(
|
||||
v_flex()
|
||||
.gap_2()
|
||||
.w_full()
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_2()
|
||||
.w_full()
|
||||
.text_xs()
|
||||
.font_semibold()
|
||||
.text_color(cx.theme().text_muted)
|
||||
.child(SharedString::from("Required Actions"))
|
||||
.child(div().flex_1().h_px().bg(cx.theme().border)),
|
||||
)
|
||||
.child(
|
||||
v_flex()
|
||||
.gap_2()
|
||||
.w_full()
|
||||
.when(nip65.not_configured(), |this| {
|
||||
this.child(
|
||||
Button::new("relaylist")
|
||||
.icon(Icon::new(IconName::Relay))
|
||||
.label("Set up relay list")
|
||||
.ghost()
|
||||
.small()
|
||||
.justify_start()
|
||||
.on_click(move |_ev, window, cx| {
|
||||
Workspace::add_panel(
|
||||
relay_list::init(window, cx),
|
||||
DockPlacement::Center,
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
}),
|
||||
)
|
||||
})
|
||||
.when(nip17.not_configured(), |this| {
|
||||
this.child(
|
||||
Button::new("import")
|
||||
.icon(Icon::new(IconName::Relay))
|
||||
.label("Set up messaging relays")
|
||||
.ghost()
|
||||
.small()
|
||||
.justify_start()
|
||||
.on_click(move |_ev, window, cx| {
|
||||
Workspace::add_panel(
|
||||
messaging_relays::init(window, cx),
|
||||
DockPlacement::Center,
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
}),
|
||||
)
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
.when(!owned, |this| {
|
||||
this.child(
|
||||
v_flex()
|
||||
.gap_2()
|
||||
.w_full()
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_2()
|
||||
.w_full()
|
||||
.text_xs()
|
||||
.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(
|
||||
v_flex()
|
||||
.gap_2()
|
||||
.w_full()
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_2()
|
||||
.w_full()
|
||||
.text_xs()
|
||||
.font_semibold()
|
||||
.text_color(cx.theme().text_muted)
|
||||
.child(SharedString::from("Get Started"))
|
||||
.child(div().flex_1().h_px().bg(cx.theme().border)),
|
||||
)
|
||||
.child(
|
||||
v_flex()
|
||||
.gap_2()
|
||||
.w_full()
|
||||
.child(
|
||||
Button::new("profile")
|
||||
.icon(Icon::new(IconName::Profile))
|
||||
.label("Update profile")
|
||||
.ghost()
|
||||
.small()
|
||||
.justify_start()
|
||||
.on_click(cx.listener(move |this, _ev, window, cx| {
|
||||
this.add_profile_panel(window, cx)
|
||||
})),
|
||||
)
|
||||
.child(
|
||||
Button::new("invite")
|
||||
.icon(Icon::new(IconName::Invite))
|
||||
.label("Invite friends")
|
||||
.ghost()
|
||||
.small()
|
||||
.justify_start(),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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)),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,657 +0,0 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use ::settings::AppSettings;
|
||||
use chat::{ChatEvent, ChatRegistry, InboxState};
|
||||
use gpui::prelude::FluentBuilder;
|
||||
use gpui::{
|
||||
div, px, rems, Action, App, AppContext, Axis, Context, Entity, InteractiveElement, IntoElement,
|
||||
ParentElement, Render, SharedString, Styled, Subscription, Window,
|
||||
};
|
||||
use person::PersonRegistry;
|
||||
use serde::Deserialize;
|
||||
use smallvec::{smallvec, SmallVec};
|
||||
use state::{NostrRegistry, RelayState};
|
||||
use theme::{ActiveTheme, Theme, ThemeRegistry, SIDEBAR_WIDTH};
|
||||
use title_bar::TitleBar;
|
||||
use ui::avatar::Avatar;
|
||||
use ui::button::{Button, ButtonVariants};
|
||||
use ui::dock_area::dock::DockPlacement;
|
||||
use ui::dock_area::panel::PanelView;
|
||||
use ui::dock_area::{ClosePanel, DockArea, DockItem};
|
||||
use ui::menu::DropdownMenu;
|
||||
use ui::{h_flex, v_flex, IconName, Root, Sizable, WindowExtension};
|
||||
|
||||
use crate::dialogs::settings;
|
||||
use crate::panels::{backup, encryption_key, greeter, messaging_relays, profile, relay_list};
|
||||
use crate::sidebar;
|
||||
|
||||
pub fn init(window: &mut Window, cx: &mut App) -> Entity<Workspace> {
|
||||
cx.new(|cx| Workspace::new(window, cx))
|
||||
}
|
||||
|
||||
#[derive(Action, Clone, PartialEq, Eq, Deserialize)]
|
||||
#[action(namespace = workspace, no_json)]
|
||||
enum Command {
|
||||
ToggleTheme,
|
||||
|
||||
RefreshRelayList,
|
||||
RefreshMessagingRelays,
|
||||
|
||||
ShowRelayList,
|
||||
ShowMessaging,
|
||||
ShowEncryption,
|
||||
ShowProfile,
|
||||
ShowSettings,
|
||||
ShowBackup,
|
||||
}
|
||||
|
||||
pub struct Workspace {
|
||||
/// App's Title Bar
|
||||
titlebar: Entity<TitleBar>,
|
||||
|
||||
/// App's Dock Area
|
||||
dock: Entity<DockArea>,
|
||||
|
||||
/// Event subscriptions
|
||||
_subscriptions: SmallVec<[Subscription; 3]>,
|
||||
}
|
||||
|
||||
impl Workspace {
|
||||
fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||
let chat = ChatRegistry::global(cx);
|
||||
let titlebar = cx.new(|_| TitleBar::new());
|
||||
let dock = cx.new(|cx| DockArea::new(window, cx));
|
||||
|
||||
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 all events emitted by the chat registry
|
||||
cx.subscribe_in(&chat, window, move |this, chat, ev, window, cx| {
|
||||
match ev {
|
||||
ChatEvent::OpenRoom(id) => {
|
||||
if let Some(room) = chat.read(cx).room(id, cx) {
|
||||
this.dock.update(cx, |this, cx| {
|
||||
this.add_panel(
|
||||
Arc::new(chat_ui::init(room, window, cx)),
|
||||
DockPlacement::Center,
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
ChatEvent::CloseRoom(..) => {
|
||||
this.dock.update(cx, |this, cx| {
|
||||
// Force focus to the tab panel
|
||||
this.focus_tab_panel(window, cx);
|
||||
|
||||
// Dispatch the close panel action
|
||||
cx.defer_in(window, |_, window, cx| {
|
||||
window.dispatch_action(Box::new(ClosePanel), cx);
|
||||
window.close_all_modals(cx);
|
||||
});
|
||||
});
|
||||
}
|
||||
_ => {}
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
subscriptions.push(
|
||||
// Observe the chat registry
|
||||
cx.observe(&chat, move |this, chat, cx| {
|
||||
let ids = this.panel_ids(cx);
|
||||
|
||||
chat.update(cx, |this, cx| {
|
||||
this.refresh_rooms(ids, cx);
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
// Set the default layout for app's dock
|
||||
cx.defer_in(window, |this, window, cx| {
|
||||
this.set_layout(window, cx);
|
||||
});
|
||||
|
||||
Self {
|
||||
titlebar,
|
||||
dock,
|
||||
_subscriptions: subscriptions,
|
||||
}
|
||||
}
|
||||
|
||||
/// Add panel to the dock
|
||||
pub fn add_panel<P>(panel: P, placement: DockPlacement, window: &mut Window, cx: &mut App)
|
||||
where
|
||||
P: PanelView,
|
||||
{
|
||||
if let Some(root) = window.root::<Root>().flatten() {
|
||||
if let Ok(workspace) = root.read(cx).view().clone().downcast::<Self>() {
|
||||
workspace.update(cx, |this, cx| {
|
||||
this.dock.update(cx, |this, cx| {
|
||||
this.add_panel(Arc::new(panel), placement, window, cx);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Get all panel ids
|
||||
fn panel_ids(&self, cx: &App) -> Option<Vec<u64>> {
|
||||
let ids: Vec<u64> = self
|
||||
.dock
|
||||
.read(cx)
|
||||
.items
|
||||
.panel_ids(cx)
|
||||
.into_iter()
|
||||
.filter_map(|panel| panel.parse::<u64>().ok())
|
||||
.collect();
|
||||
|
||||
Some(ids)
|
||||
}
|
||||
|
||||
/// Set the dock layout
|
||||
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)));
|
||||
|
||||
// Main workspace
|
||||
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| {
|
||||
this.set_left_dock(left, Some(SIDEBAR_WIDTH), true, window, cx);
|
||||
this.set_center(center, window, cx);
|
||||
});
|
||||
}
|
||||
|
||||
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_4()
|
||||
.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::ShowBackup => {
|
||||
self.dock.update(cx, |this, cx| {
|
||||
this.add_panel(
|
||||
Arc::new(backup::init(window, cx)),
|
||||
DockPlacement::Right,
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
});
|
||||
}
|
||||
Command::ShowEncryption => {
|
||||
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(encryption_key::init(public_key, 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::RefreshRelayList => {
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
nostr.update(cx, |this, cx| {
|
||||
this.ensure_relay_list(cx);
|
||||
});
|
||||
}
|
||||
Command::RefreshMessagingRelays => {
|
||||
let chat = ChatRegistry::global(cx);
|
||||
chat.update(cx, |this, cx| {
|
||||
this.ensure_messaging_relays(cx);
|
||||
});
|
||||
}
|
||||
Command::ToggleTheme => {
|
||||
self.theme_selector(window, cx);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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_4()
|
||||
.child(v_flex().gap_2().w_full().children({
|
||||
let mut items = vec![];
|
||||
|
||||
for (ix, (path, theme)) in themes.iter().enumerate() {
|
||||
items.push(
|
||||
h_flex()
|
||||
.group("")
|
||||
.px_2()
|
||||
.h_8()
|
||||
.w_full()
|
||||
.justify_between()
|
||||
.rounded(cx.theme().radius)
|
||||
.hover(|this| this.bg(cx.theme().elevated_surface_background))
|
||||
.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 signer = nostr.read(cx).signer();
|
||||
let current_user = signer.public_key();
|
||||
|
||||
h_flex()
|
||||
.flex_shrink_0()
|
||||
.justify_between()
|
||||
.gap_2()
|
||||
.when_some(current_user.as_ref(), |this, public_key| {
|
||||
let persons = PersonRegistry::global(cx);
|
||||
let profile = persons.read(cx).get(public_key, cx);
|
||||
|
||||
this.child(
|
||||
Button::new("current-user")
|
||||
.child(Avatar::new(profile.avatar()).size(rems(1.25)))
|
||||
.small()
|
||||
.caret()
|
||||
.compact()
|
||||
.transparent()
|
||||
.dropdown_menu(move |this, _window, _cx| {
|
||||
this.min_w(px(256.))
|
||||
.label(profile.name())
|
||||
.separator()
|
||||
.menu_with_icon(
|
||||
"Profile",
|
||||
IconName::Profile,
|
||||
Box::new(Command::ShowProfile),
|
||||
)
|
||||
.menu_with_icon(
|
||||
"Backup",
|
||||
IconName::UserKey,
|
||||
Box::new(Command::ShowBackup),
|
||||
)
|
||||
.menu_with_icon(
|
||||
"Themes",
|
||||
IconName::Sun,
|
||||
Box::new(Command::ToggleTheme),
|
||||
)
|
||||
.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...")),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn titlebar_right(&mut self, _window: &mut Window, cx: &Context<Self>) -> impl IntoElement {
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let signer = nostr.read(cx).signer();
|
||||
let relay_list = nostr.read(cx).relay_list_state();
|
||||
|
||||
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()
|
||||
.on_click(|_ev, window, cx| {
|
||||
window.dispatch_action(Box::new(Command::ShowEncryption), cx);
|
||||
}),
|
||||
)
|
||||
.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| {
|
||||
this.min_w(px(260.))
|
||||
.label("Messaging Relays")
|
||||
.menu_element_with_disabled(
|
||||
Box::new(Command::ShowRelayList),
|
||||
true,
|
||||
move |_window, cx| {
|
||||
let persons = PersonRegistry::global(cx);
|
||||
let profile = persons.read(cx).get(&pkey, cx);
|
||||
let urls = profile.messaging_relays();
|
||||
|
||||
v_flex()
|
||||
.gap_1()
|
||||
.w_full()
|
||||
.items_start()
|
||||
.justify_start()
|
||||
.children({
|
||||
let mut items = vec![];
|
||||
|
||||
for url in urls.iter() {
|
||||
items.push(
|
||||
h_flex()
|
||||
.h_6()
|
||||
.w_full()
|
||||
.gap_2()
|
||||
.px_2()
|
||||
.text_xs()
|
||||
.bg(cx
|
||||
.theme()
|
||||
.elevated_surface_background)
|
||||
.rounded(cx.theme().radius)
|
||||
.child(
|
||||
div()
|
||||
.size_1()
|
||||
.rounded_full()
|
||||
.bg(gpui::green()),
|
||||
)
|
||||
.child(SharedString::from(
|
||||
url.to_string(),
|
||||
)),
|
||||
);
|
||||
}
|
||||
|
||||
items
|
||||
})
|
||||
},
|
||||
)
|
||||
.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 relay_list {
|
||||
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(relay_list.configured(), |this| this.indicator())
|
||||
.dropdown_menu(move |this, _window, _cx| {
|
||||
this.min_w(px(260.))
|
||||
.label("Relays")
|
||||
.menu_element_with_disabled(
|
||||
Box::new(Command::ShowRelayList),
|
||||
true,
|
||||
move |_window, cx| {
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let urls = nostr.read(cx).read_only_relays(&pkey, cx);
|
||||
|
||||
v_flex()
|
||||
.gap_1()
|
||||
.w_full()
|
||||
.items_start()
|
||||
.justify_start()
|
||||
.children({
|
||||
let mut items = vec![];
|
||||
|
||||
for url in urls.into_iter() {
|
||||
items.push(
|
||||
h_flex()
|
||||
.h_6()
|
||||
.w_full()
|
||||
.gap_2()
|
||||
.px_2()
|
||||
.text_xs()
|
||||
.bg(cx
|
||||
.theme()
|
||||
.elevated_surface_background)
|
||||
.rounded(cx.theme().radius)
|
||||
.child(
|
||||
div()
|
||||
.size_1()
|
||||
.rounded_full()
|
||||
.bg(gpui::green()),
|
||||
)
|
||||
.child(url),
|
||||
);
|
||||
}
|
||||
|
||||
items
|
||||
})
|
||||
},
|
||||
)
|
||||
.separator()
|
||||
.menu_with_icon(
|
||||
"Reload",
|
||||
IconName::Refresh,
|
||||
Box::new(Command::RefreshRelayList),
|
||||
)
|
||||
.menu_with_icon(
|
||||
"Update relay list",
|
||||
IconName::Settings,
|
||||
Box::new(Command::ShowRelayList),
|
||||
)
|
||||
}),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for Workspace {
|
||||
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let modal_layer = Root::render_modal_layer(window, cx);
|
||||
let notification_layer = Root::render_notification_layer(window, cx);
|
||||
|
||||
// Titlebar elements
|
||||
let left = self.titlebar_left(window, cx).into_any_element();
|
||||
let right = self.titlebar_right(window, cx).into_any_element();
|
||||
|
||||
// Update title bar children
|
||||
self.titlebar.update(cx, |this, _cx| {
|
||||
this.set_children(vec![left, right]);
|
||||
});
|
||||
|
||||
div()
|
||||
.id(SharedString::from("workspace"))
|
||||
.on_action(cx.listener(Self::on_command))
|
||||
.relative()
|
||||
.size_full()
|
||||
.child(
|
||||
v_flex()
|
||||
.relative()
|
||||
.size_full()
|
||||
// Title Bar
|
||||
.child(self.titlebar.clone())
|
||||
// Dock
|
||||
.child(self.dock.clone()),
|
||||
)
|
||||
// Notifications
|
||||
.children(notification_layer)
|
||||
// Modals
|
||||
.children(modal_layer)
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,8 @@ publish.workspace = true
|
||||
common = { path = "../common" }
|
||||
state = { path = "../state" }
|
||||
person = { path = "../person" }
|
||||
ui = { path = "../ui" }
|
||||
theme = { path = "../theme" }
|
||||
|
||||
gpui.workspace = true
|
||||
nostr-sdk.workspace = true
|
||||
|
||||
@@ -1,16 +1,26 @@
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::cell::Cell;
|
||||
use std::collections::HashSet;
|
||||
use std::path::PathBuf;
|
||||
use std::rc::Rc;
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::{anyhow, Context as AnyhowContext, Error};
|
||||
use gpui::{App, AppContext, Context, Entity, Global, Subscription, Task, Window};
|
||||
use anyhow::{Context as AnyhowContext, Error, anyhow};
|
||||
use gpui::{
|
||||
App, AppContext, Context, Entity, EventEmitter, Global, IntoElement, ParentElement,
|
||||
SharedString, Styled, Subscription, Task, Window, div, relative,
|
||||
};
|
||||
use nostr_sdk::prelude::*;
|
||||
use person::PersonRegistry;
|
||||
use smallvec::{smallvec, SmallVec};
|
||||
use state::{
|
||||
app_name, Announcement, DeviceState, NostrRegistry, RelayState, DEVICE_GIFTWRAP, TIMEOUT,
|
||||
};
|
||||
use state::{Announcement, CLIENT_NAME, NostrRegistry, StateEvent, TIMEOUT};
|
||||
use theme::ActiveTheme;
|
||||
use ui::avatar::Avatar;
|
||||
use ui::button::Button;
|
||||
use ui::notification::{Notification, NotificationKind};
|
||||
use ui::{Disableable, Sizable, StyledExt, WindowExtension, h_flex, v_flex};
|
||||
|
||||
const IDENTIFIER: &str = "coop:device";
|
||||
const MSG: &str = "You've requested an encryption key from another device. \
|
||||
Approve to allow Coop to share with it.";
|
||||
|
||||
pub fn init(window: &mut Window, cx: &mut App) {
|
||||
DeviceRegistry::set_global(cx.new(|cx| DeviceRegistry::new(window, cx)), cx);
|
||||
@@ -20,24 +30,48 @@ struct GlobalDeviceRegistry(Entity<DeviceRegistry>);
|
||||
|
||||
impl Global for GlobalDeviceRegistry {}
|
||||
|
||||
/// Device event.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub enum DeviceEvent {
|
||||
/// A new encryption signer has been set
|
||||
Set,
|
||||
/// The device is requesting an encryption key
|
||||
Requesting,
|
||||
/// The device is creating a new encryption key
|
||||
Creating,
|
||||
/// An error occurred
|
||||
Error(SharedString),
|
||||
}
|
||||
|
||||
impl DeviceEvent {
|
||||
pub fn error<T>(error: T) -> Self
|
||||
where
|
||||
T: Into<SharedString>,
|
||||
{
|
||||
Self::Error(error.into())
|
||||
}
|
||||
}
|
||||
|
||||
/// Device Registry
|
||||
///
|
||||
/// NIP-4e: https://github.com/nostr-protocol/nips/blob/per-device-keys/4e.md
|
||||
#[derive(Debug)]
|
||||
pub struct DeviceRegistry {
|
||||
/// Request for encryption key from other devices
|
||||
pub requests: Vec<Event>,
|
||||
/// Whether the registry is currently initializing
|
||||
pub initializing: bool,
|
||||
|
||||
/// Device state
|
||||
state: DeviceState,
|
||||
/// Whether there is a pending request for encryption key approval
|
||||
pub pending_request: bool,
|
||||
|
||||
/// Async tasks
|
||||
tasks: Vec<Task<Result<(), Error>>>,
|
||||
|
||||
/// Subscriptions
|
||||
_subscriptions: SmallVec<[Subscription; 1]>,
|
||||
/// Event subscription
|
||||
_subscription: Option<Subscription>,
|
||||
}
|
||||
|
||||
impl EventEmitter<DeviceEvent> for DeviceRegistry {}
|
||||
|
||||
impl DeviceRegistry {
|
||||
/// Retrieve the global device registry state
|
||||
pub fn global(cx: &App) -> Entity<Self> {
|
||||
@@ -52,37 +86,28 @@ impl DeviceRegistry {
|
||||
/// Create a new device registry instance
|
||||
fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let mut subscriptions = smallvec![];
|
||||
|
||||
subscriptions.push(
|
||||
// Observe the NIP-65 state
|
||||
cx.observe(&nostr, |this, state, cx| {
|
||||
match state.read(cx).relay_list_state() {
|
||||
RelayState::Idle => {
|
||||
this.reset(cx);
|
||||
}
|
||||
RelayState::Configured => {
|
||||
this.get_announcement(cx);
|
||||
}
|
||||
_ => {}
|
||||
};
|
||||
}),
|
||||
);
|
||||
// Subscribe to nostr state events
|
||||
let subscription = cx.subscribe_in(&nostr, window, |this, _e, event, _window, cx| {
|
||||
if event == &StateEvent::SignerSet {
|
||||
this.set_initializing(true, cx);
|
||||
this.get_announcement(cx);
|
||||
};
|
||||
});
|
||||
|
||||
// Run at the end of current cycle
|
||||
cx.defer_in(window, |this, _window, cx| {
|
||||
this.handle_notifications(cx);
|
||||
cx.defer_in(window, |this, window, cx| {
|
||||
this.handle_notifications(window, cx);
|
||||
});
|
||||
|
||||
Self {
|
||||
requests: vec![],
|
||||
state: DeviceState::default(),
|
||||
initializing: true,
|
||||
pending_request: false,
|
||||
tasks: vec![],
|
||||
_subscriptions: subscriptions,
|
||||
_subscription: Some(subscription),
|
||||
}
|
||||
}
|
||||
|
||||
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 client = nostr.read(cx).client();
|
||||
let (tx, rx) = flume::bounded::<Event>(100);
|
||||
@@ -92,10 +117,8 @@ impl DeviceRegistry {
|
||||
let mut processed_events = HashSet::new();
|
||||
|
||||
while let Some(notification) = notifications.next().await {
|
||||
if let ClientNotification::Message {
|
||||
message: RelayMessage::Event { event, .. },
|
||||
..
|
||||
} = notification
|
||||
if let ClientNotification::Message { message, .. } = notification
|
||||
&& let RelayMessage::Event { event, .. } = *message
|
||||
{
|
||||
if !processed_events.insert(event.id) {
|
||||
// Skip if the event has already been processed
|
||||
@@ -121,38 +144,37 @@ impl DeviceRegistry {
|
||||
Ok(())
|
||||
}));
|
||||
|
||||
self.tasks.push(
|
||||
// Update GPUI states
|
||||
cx.spawn(async move |this, cx| {
|
||||
while let Ok(event) = rx.recv_async().await {
|
||||
match event.kind {
|
||||
Kind::Custom(4454) => {
|
||||
this.update(cx, |this, cx| {
|
||||
this.add_request(event, cx);
|
||||
})?;
|
||||
}
|
||||
Kind::Custom(4455) => {
|
||||
this.update(cx, |this, cx| {
|
||||
this.parse_response(event, cx);
|
||||
})?;
|
||||
}
|
||||
_ => {}
|
||||
self.tasks.push(cx.spawn_in(window, async move |this, cx| {
|
||||
while let Ok(event) = rx.recv_async().await {
|
||||
match event.kind {
|
||||
// New request event from other device
|
||||
Kind::Custom(4454) => {
|
||||
this.update_in(cx, |this, window, cx| {
|
||||
this.ask_for_approval(event, window, cx);
|
||||
})?;
|
||||
}
|
||||
// New response event from the master device
|
||||
Kind::Custom(4455) => {
|
||||
this.update(cx, |this, cx| {
|
||||
this.extract_encryption(event, cx);
|
||||
})?;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}),
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}));
|
||||
}
|
||||
|
||||
/// Get the device state
|
||||
pub fn state(&self) -> DeviceState {
|
||||
self.state.clone()
|
||||
/// Set whether the registry is currently initializing
|
||||
fn set_initializing(&mut self, initializing: bool, cx: &mut Context<Self>) {
|
||||
self.initializing = initializing;
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// Set the device state
|
||||
fn set_state(&mut self, state: DeviceState, cx: &mut Context<Self>) {
|
||||
self.state = state;
|
||||
/// Set whether there is a pending request for encryption key approval
|
||||
fn set_pending_request(&mut self, pending: bool, cx: &mut Context<Self>) {
|
||||
self.pending_request = pending;
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
@@ -169,96 +191,37 @@ impl DeviceRegistry {
|
||||
|
||||
// Update state
|
||||
this.update(cx, |this, cx| {
|
||||
this.set_state(DeviceState::Set, cx);
|
||||
this.get_messages(cx);
|
||||
this.set_initializing(false, cx);
|
||||
cx.emit(DeviceEvent::Set);
|
||||
})?;
|
||||
|
||||
Ok(())
|
||||
}));
|
||||
}
|
||||
|
||||
/// Reset the device state
|
||||
fn reset(&mut self, cx: &mut Context<Self>) {
|
||||
self.state = DeviceState::Idle;
|
||||
self.requests.clear();
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// Add a request for device keys
|
||||
fn add_request(&mut self, request: Event, cx: &mut Context<Self>) {
|
||||
self.requests.push(request);
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// Remove a request for device keys
|
||||
pub fn remove_request(&mut self, id: &EventId, cx: &mut Context<Self>) {
|
||||
self.requests.retain(|r| r.id != *id);
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// Check if there are any pending requests
|
||||
pub fn has_requests(&self) -> bool {
|
||||
!self.requests.is_empty()
|
||||
}
|
||||
|
||||
/// Get all messages for encryption keys
|
||||
fn get_messages(&mut self, cx: &mut Context<Self>) {
|
||||
let task = self.subscribe_to_giftwrap_events(cx);
|
||||
|
||||
self.tasks.push(cx.spawn(async move |_this, _cx| {
|
||||
task.await?;
|
||||
|
||||
// Update state
|
||||
|
||||
Ok(())
|
||||
}));
|
||||
}
|
||||
|
||||
/// 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>> {
|
||||
/// Backup the encryption's secret key to a file
|
||||
pub fn backup(&self, path: PathBuf, cx: &App) -> Task<Result<(), Error>> {
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let client = nostr.read(cx).client();
|
||||
|
||||
let signer = nostr.read(cx).signer();
|
||||
let public_key = signer.public_key().unwrap();
|
||||
|
||||
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 {
|
||||
let filter = Filter::new().kind(Kind::GiftWrap).pubkey(public_key);
|
||||
let id = SubscriptionId::new(DEVICE_GIFTWRAP);
|
||||
let keys = get_keys(&client).await?;
|
||||
let content = keys.secret_key().to_bech32()?;
|
||||
|
||||
// Construct target for subscription
|
||||
let target: HashMap<RelayUrl, Filter> = relay_urls
|
||||
.into_iter()
|
||||
.map(|relay| (relay, filter.clone()))
|
||||
.collect();
|
||||
|
||||
let output = client.subscribe(target).with_id(id).await?;
|
||||
|
||||
log::info!(
|
||||
"Successfully subscribed to encryption gift-wrap messages on: {:?}",
|
||||
output.success
|
||||
);
|
||||
smol::fs::write(path, &content).await?;
|
||||
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
/// 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 client = nostr.read(cx).client();
|
||||
|
||||
let signer = nostr.read(cx).signer();
|
||||
let public_key = signer.public_key().unwrap();
|
||||
|
||||
let write_relays = nostr.read(cx).write_relays(&public_key, cx);
|
||||
|
||||
let task: Task<Result<Event, Error>> = cx.background_spawn(async move {
|
||||
let urls = write_relays.await;
|
||||
let signer = client.signer().context("Signer not found")?;
|
||||
let public_key = signer.get_public_key().await?;
|
||||
|
||||
// Construct the filter for the device announcement event
|
||||
let filter = Filter::new()
|
||||
@@ -266,41 +229,33 @@ impl DeviceRegistry {
|
||||
.author(public_key)
|
||||
.limit(1);
|
||||
|
||||
// Construct target for subscription
|
||||
let target: HashMap<&RelayUrl, Filter> =
|
||||
urls.iter().map(|relay| (relay, filter.clone())).collect();
|
||||
|
||||
// Stream events from user's write relays
|
||||
let mut stream = client
|
||||
.stream_events(target)
|
||||
.stream_events(filter)
|
||||
.timeout(Duration::from_secs(TIMEOUT))
|
||||
.await?;
|
||||
|
||||
while let Some((_url, res)) = stream.next().await {
|
||||
match res {
|
||||
Ok(event) => {
|
||||
log::info!("Received device announcement event: {event:?}");
|
||||
return Ok(event);
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("Failed to receive device announcement event: {e}");
|
||||
}
|
||||
if let Ok(event) = res {
|
||||
return Ok(event);
|
||||
}
|
||||
}
|
||||
|
||||
Err(anyhow!("Device announcement not found"))
|
||||
Err(anyhow!("Announcement not found"))
|
||||
});
|
||||
|
||||
self.tasks.push(cx.spawn(async move |this, cx| {
|
||||
match task.await {
|
||||
Ok(event) => {
|
||||
// Set encryption key from the announcement event
|
||||
this.update(cx, |this, cx| {
|
||||
this.new_signer(&event, cx);
|
||||
this.set_encryption(&event, cx);
|
||||
})?;
|
||||
}
|
||||
Err(_) => {
|
||||
// User has no announcement, create a new one
|
||||
this.update(cx, |this, cx| {
|
||||
this.announce(cx);
|
||||
this.set_announcement(Keys::generate(), cx);
|
||||
})?;
|
||||
}
|
||||
}
|
||||
@@ -309,175 +264,134 @@ impl DeviceRegistry {
|
||||
}));
|
||||
}
|
||||
|
||||
/// Create a new device signer and announce it
|
||||
fn announce(&mut self, cx: &mut Context<Self>) {
|
||||
/// Create a new device signer and announce it to user's relay list
|
||||
pub fn set_announcement(&mut self, keys: Keys, cx: &mut Context<Self>) {
|
||||
let task = self.create_encryption(keys, cx);
|
||||
|
||||
// Notify that we're creating a new encryption key
|
||||
cx.emit(DeviceEvent::Creating);
|
||||
|
||||
self.tasks.push(cx.spawn(async move |this, cx| {
|
||||
match task.await {
|
||||
Ok(keys) => {
|
||||
this.update(cx, |this, cx| {
|
||||
this.set_signer(keys, cx);
|
||||
this.wait_for_request(cx);
|
||||
})?;
|
||||
}
|
||||
Err(e) => {
|
||||
this.update(cx, |_this, cx| {
|
||||
cx.emit(DeviceEvent::error(e.to_string()));
|
||||
})?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}));
|
||||
}
|
||||
|
||||
/// Create new encryption key and announce it to user's relay list
|
||||
fn create_encryption(&self, keys: Keys, cx: &App) -> Task<Result<Keys, Error>> {
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let client = nostr.read(cx).client();
|
||||
|
||||
// Get current user
|
||||
let signer = nostr.read(cx).signer();
|
||||
let public_key = signer.public_key().unwrap();
|
||||
|
||||
// Get user's write relays
|
||||
let write_relays = nostr.read(cx).write_relays(&public_key, cx);
|
||||
|
||||
let keys = Keys::generate();
|
||||
let secret = keys.secret_key().to_secret_hex();
|
||||
let n = keys.public_key();
|
||||
|
||||
let task: Task<Result<(), Error>> = cx.background_spawn(async move {
|
||||
let urls = write_relays.await;
|
||||
|
||||
cx.background_spawn(async move {
|
||||
// Construct an announcement event
|
||||
let event = client
|
||||
.sign_event_builder(EventBuilder::new(Kind::Custom(10044), "").tags(vec![
|
||||
Tag::custom(TagKind::custom("n"), vec![n]),
|
||||
Tag::client(app_name()),
|
||||
]))
|
||||
.await?;
|
||||
let builder = EventBuilder::new(Kind::Custom(10044), "").tags(vec![
|
||||
Tag::custom(TagKind::custom("n"), vec![n]),
|
||||
Tag::client(CLIENT_NAME),
|
||||
]);
|
||||
|
||||
// Sign the event with user's signer
|
||||
let event = client.sign_event_builder(builder).await?;
|
||||
|
||||
// Publish announcement
|
||||
client.send_event(&event).to(urls).await?;
|
||||
client
|
||||
.send_event(&event)
|
||||
.to_nip65()
|
||||
.ack_policy(AckPolicy::none())
|
||||
.await?;
|
||||
|
||||
// Save device keys to the database
|
||||
set_keys(&client, &secret).await?;
|
||||
|
||||
Ok(())
|
||||
});
|
||||
|
||||
self.tasks.push(cx.spawn(async move |this, cx| {
|
||||
if task.await.is_ok() {
|
||||
this.update(cx, |this, cx| {
|
||||
this.set_signer(keys, cx);
|
||||
this.listen_request(cx);
|
||||
})?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}));
|
||||
Ok(keys)
|
||||
})
|
||||
}
|
||||
|
||||
/// Initialize device signer (decoupled encryption key) for the current user
|
||||
fn new_signer(&mut self, event: &Event, cx: &mut Context<Self>) {
|
||||
/// Set encryption key from the announcement event
|
||||
fn set_encryption(&mut self, event: &Event, cx: &mut Context<Self>) {
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let client = nostr.read(cx).client();
|
||||
|
||||
let announcement = Announcement::from(event);
|
||||
let device_pubkey = announcement.public_key();
|
||||
|
||||
// Get encryption key from the database and compare with the announcement
|
||||
let task: Task<Result<Keys, Error>> = cx.background_spawn(async move {
|
||||
if let Ok(keys) = get_keys(&client).await {
|
||||
if keys.public_key() != device_pubkey {
|
||||
return Err(anyhow!("Key mismatch"));
|
||||
};
|
||||
let keys = get_keys(&client).await?;
|
||||
|
||||
Ok(keys)
|
||||
} else {
|
||||
Err(anyhow!("Key not found"))
|
||||
}
|
||||
// Compare the public key from the announcement with the one from the database
|
||||
if keys.public_key() != device_pubkey {
|
||||
return Err(anyhow!("Encryption Key doesn't match the announcement"));
|
||||
};
|
||||
|
||||
Ok(keys)
|
||||
});
|
||||
|
||||
cx.spawn(async move |this, cx| {
|
||||
match task.await {
|
||||
Ok(keys) => {
|
||||
this.update(cx, |this, cx| {
|
||||
this.set_signer(keys, cx);
|
||||
this.listen_request(cx);
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
Err(e) => {
|
||||
this.update(cx, |this, cx| {
|
||||
this.request(cx);
|
||||
this.listen_approval(cx);
|
||||
})
|
||||
.ok();
|
||||
|
||||
log::warn!("Failed to initialize device signer: {e}");
|
||||
}
|
||||
};
|
||||
})
|
||||
.detach();
|
||||
self.tasks.push(cx.spawn(async move |this, cx| {
|
||||
if let Ok(keys) = task.await {
|
||||
this.update(cx, |this, cx| {
|
||||
this.set_signer(keys, cx);
|
||||
this.wait_for_request(cx);
|
||||
})?;
|
||||
} else {
|
||||
this.update(cx, |this, cx| {
|
||||
this.request(cx);
|
||||
})?;
|
||||
}
|
||||
Ok(())
|
||||
}));
|
||||
}
|
||||
|
||||
/// Listen for device key requests on user's write relays
|
||||
fn listen_request(&mut self, cx: &mut Context<Self>) {
|
||||
/// Wait for encryption key requests from now on
|
||||
fn wait_for_request(&mut self, cx: &mut Context<Self>) {
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let client = nostr.read(cx).client();
|
||||
|
||||
let signer = nostr.read(cx).signer();
|
||||
let public_key = signer.public_key().unwrap();
|
||||
|
||||
let write_relays = nostr.read(cx).write_relays(&public_key, cx);
|
||||
self.tasks.push(cx.background_spawn(async move {
|
||||
let public_key = signer.get_public_key().await?;
|
||||
let id = SubscriptionId::new("dekey-requests");
|
||||
|
||||
let task: Task<Result<(), Error>> = cx.background_spawn(async move {
|
||||
let urls = write_relays.await;
|
||||
|
||||
// Construct a filter for device key requests
|
||||
// Construct a filter for encryption key requests
|
||||
let filter = Filter::new()
|
||||
.kind(Kind::Custom(4454))
|
||||
.author(public_key)
|
||||
.since(Timestamp::now());
|
||||
|
||||
// Construct target for subscription
|
||||
let target: HashMap<&RelayUrl, Filter> =
|
||||
urls.iter().map(|relay| (relay, filter.clone())).collect();
|
||||
|
||||
// Subscribe to the device key requests on user's write relays
|
||||
client.subscribe(target).await?;
|
||||
|
||||
Ok(())
|
||||
});
|
||||
|
||||
task.detach();
|
||||
}
|
||||
|
||||
/// Listen for device key approvals on user's write relays
|
||||
fn listen_approval(&mut self, cx: &mut Context<Self>) {
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let client = nostr.read(cx).client();
|
||||
|
||||
let signer = nostr.read(cx).signer();
|
||||
let public_key = signer.public_key().unwrap();
|
||||
|
||||
let write_relays = nostr.read(cx).write_relays(&public_key, cx);
|
||||
|
||||
self.tasks.push(cx.background_spawn(async move {
|
||||
let urls = write_relays.await;
|
||||
|
||||
// Construct a filter for device key requests
|
||||
let filter = Filter::new()
|
||||
.kind(Kind::Custom(4455))
|
||||
.author(public_key)
|
||||
.since(Timestamp::now());
|
||||
|
||||
// Construct target for subscription
|
||||
let target: HashMap<&RelayUrl, Filter> =
|
||||
urls.iter().map(|relay| (relay, filter.clone())).collect();
|
||||
|
||||
// Subscribe to the device key requests on user's write relays
|
||||
client.subscribe(target).await?;
|
||||
client.subscribe(vec![filter]).with_id(id).await?;
|
||||
|
||||
Ok(())
|
||||
}));
|
||||
}
|
||||
|
||||
/// Request encryption keys from other device
|
||||
fn request(&mut self, cx: &mut Context<Self>) {
|
||||
pub fn request(&mut self, cx: &mut Context<Self>) {
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let client = nostr.read(cx).client();
|
||||
|
||||
let signer = nostr.read(cx).signer();
|
||||
let public_key = signer.public_key().unwrap();
|
||||
|
||||
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).keys();
|
||||
let app_pubkey = app_keys.public_key();
|
||||
|
||||
let task: Task<Result<Option<Keys>, Error>> = cx.background_spawn(async move {
|
||||
let task: Task<Result<Option<Event>, Error>> = cx.background_spawn(async move {
|
||||
let public_key = signer.get_public_key().await?;
|
||||
|
||||
// Construct a filter to get the latest approval event
|
||||
let filter = Filter::new()
|
||||
.kind(Kind::Custom(4455))
|
||||
.author(public_key)
|
||||
@@ -485,70 +399,81 @@ impl DeviceRegistry {
|
||||
.limit(1);
|
||||
|
||||
match client.database().query(filter).await?.first_owned() {
|
||||
Some(event) => {
|
||||
let root_device = event
|
||||
.tags
|
||||
.find(TagKind::custom("P"))
|
||||
.and_then(|tag| tag.content())
|
||||
.and_then(|content| PublicKey::parse(content).ok())
|
||||
.context("Invalid event's tags")?;
|
||||
|
||||
let payload = event.content.as_str();
|
||||
let decrypted = app_keys.nip44_decrypt(&root_device, payload).await?;
|
||||
|
||||
let secret = SecretKey::from_hex(&decrypted)?;
|
||||
let keys = Keys::new(secret);
|
||||
|
||||
Ok(Some(keys))
|
||||
}
|
||||
// Found an approval event
|
||||
Some(event) => Ok(Some(event)),
|
||||
// No approval event found, construct a request event
|
||||
None => {
|
||||
let urls = write_relays.await;
|
||||
|
||||
// Construct an event for device key request
|
||||
let event = client
|
||||
.sign_event_builder(EventBuilder::new(Kind::Custom(4454), "").tags(vec![
|
||||
Tag::client(app_name()),
|
||||
Tag::custom(TagKind::custom("P"), vec![app_pubkey]),
|
||||
]))
|
||||
.await?;
|
||||
let builder = EventBuilder::new(Kind::Custom(4454), "").tags(vec![
|
||||
Tag::custom(TagKind::custom("P"), vec![app_pubkey]),
|
||||
Tag::client(CLIENT_NAME),
|
||||
]);
|
||||
|
||||
// Sign the event with user's signer
|
||||
let event = client.sign_event_builder(builder).await?;
|
||||
|
||||
// Send the event to write relays
|
||||
client.send_event(&event).to(urls).await?;
|
||||
client.send_event(&event).to_nip65().await?;
|
||||
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
cx.spawn(async move |this, cx| {
|
||||
self.tasks.push(cx.spawn(async move |this, cx| {
|
||||
match task.await {
|
||||
Ok(Some(keys)) => {
|
||||
Ok(Some(event)) => {
|
||||
this.update(cx, |this, cx| {
|
||||
this.set_signer(keys, cx);
|
||||
})
|
||||
.ok();
|
||||
this.extract_encryption(event, cx);
|
||||
})?;
|
||||
}
|
||||
Ok(None) => {
|
||||
this.update(cx, |this, cx| {
|
||||
this.set_state(DeviceState::Requesting, cx);
|
||||
})
|
||||
.ok();
|
||||
this.set_initializing(false, cx);
|
||||
this.wait_for_approval(cx);
|
||||
|
||||
cx.emit(DeviceEvent::Requesting);
|
||||
})?;
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("Failed to request the encryption key: {e}");
|
||||
this.update(cx, |_this, cx| {
|
||||
cx.emit(DeviceEvent::error(e.to_string()));
|
||||
})?;
|
||||
}
|
||||
};
|
||||
})
|
||||
.detach();
|
||||
Ok(())
|
||||
}));
|
||||
}
|
||||
|
||||
/// Parse the response event for device keys from other devices
|
||||
fn parse_response(&mut self, event: Event, cx: &mut Context<Self>) {
|
||||
/// Wait for encryption key approvals
|
||||
fn wait_for_approval(&mut self, cx: &mut Context<Self>) {
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let app_keys = nostr.read(cx).app_keys().clone();
|
||||
let client = nostr.read(cx).client();
|
||||
let signer = nostr.read(cx).signer();
|
||||
|
||||
self.tasks.push(cx.background_spawn(async move {
|
||||
let public_key = signer.get_public_key().await?;
|
||||
|
||||
// Construct a filter for device key requests
|
||||
let filter = Filter::new()
|
||||
.kind(Kind::Custom(4455))
|
||||
.author(public_key)
|
||||
.since(Timestamp::now());
|
||||
|
||||
// Subscribe to the device key requests on user's write relays
|
||||
client.subscribe(filter).await?;
|
||||
|
||||
Ok(())
|
||||
}));
|
||||
}
|
||||
|
||||
/// Parse the approval event to get encryption key then set it
|
||||
fn extract_encryption(&mut self, event: Event, cx: &mut Context<Self>) {
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let app_keys = nostr.read(cx).keys();
|
||||
|
||||
let task: Task<Result<Keys, Error>> = cx.background_spawn(async move {
|
||||
let root_device = event
|
||||
let master = event
|
||||
.tags
|
||||
.find(TagKind::custom("P"))
|
||||
.and_then(|tag| tag.content())
|
||||
@@ -556,7 +481,7 @@ impl DeviceRegistry {
|
||||
.context("Invalid event's tags")?;
|
||||
|
||||
let payload = event.content.as_str();
|
||||
let decrypted = app_keys.nip44_decrypt(&root_device, payload).await?;
|
||||
let decrypted = app_keys.nip44_decrypt(&master, payload).await?;
|
||||
|
||||
let secret = SecretKey::from_hex(&decrypted)?;
|
||||
let keys = Keys::new(secret);
|
||||
@@ -565,33 +490,32 @@ impl DeviceRegistry {
|
||||
});
|
||||
|
||||
self.tasks.push(cx.spawn(async move |this, cx| {
|
||||
let keys = task.await?;
|
||||
|
||||
// Update signer
|
||||
this.update(cx, |this, cx| {
|
||||
this.set_signer(keys, cx);
|
||||
})?;
|
||||
|
||||
match task.await {
|
||||
Ok(keys) => {
|
||||
this.update(cx, |this, cx| {
|
||||
this.set_signer(keys, cx);
|
||||
})?;
|
||||
}
|
||||
Err(e) => {
|
||||
this.update(cx, |_this, cx| {
|
||||
cx.emit(DeviceEvent::error(e.to_string()));
|
||||
})?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}));
|
||||
}
|
||||
|
||||
/// Approve requests for device keys from other devices
|
||||
pub fn approve(&self, event: &Event, cx: &App) -> Task<Result<(), Error>> {
|
||||
fn approve(&mut self, event: &Event, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let client = nostr.read(cx).client();
|
||||
|
||||
// Get current user
|
||||
let signer = nostr.read(cx).signer();
|
||||
let public_key = signer.public_key().unwrap();
|
||||
|
||||
// Get user's write relays
|
||||
let write_relays = nostr.read(cx).write_relays(&public_key, cx);
|
||||
let event = event.clone();
|
||||
let id: SharedString = event.id.to_hex().into();
|
||||
|
||||
cx.background_spawn(async move {
|
||||
let urls = write_relays.await;
|
||||
|
||||
let task: Task<Result<(), Error>> = cx.background_spawn(async move {
|
||||
// Get device keys
|
||||
let keys = get_keys(&client).await?;
|
||||
let secret = keys.secret_key().to_secret_hex();
|
||||
@@ -605,33 +529,171 @@ impl DeviceRegistry {
|
||||
.context("Target is not a valid public key")?;
|
||||
|
||||
// Encrypt the device keys with the user's signer
|
||||
let payload = signer.nip44_encrypt(&target, &secret).await?;
|
||||
let payload = keys.nip44_encrypt(&target, &secret).await?;
|
||||
|
||||
// Construct the response event
|
||||
//
|
||||
// P tag: the current device's public key
|
||||
// p tag: the requester's public key
|
||||
let event = client
|
||||
.sign_event_builder(EventBuilder::new(Kind::Custom(4455), payload).tags(vec![
|
||||
Tag::custom(TagKind::custom("P"), vec![keys.public_key()]),
|
||||
Tag::public_key(target),
|
||||
]))
|
||||
.await?;
|
||||
let builder = EventBuilder::new(Kind::Custom(4455), payload).tags(vec![
|
||||
Tag::custom(TagKind::custom("P"), vec![keys.public_key().to_hex()]),
|
||||
Tag::public_key(target),
|
||||
]);
|
||||
|
||||
// Sign the builder
|
||||
let event = client.sign_event_builder(builder).await?;
|
||||
|
||||
// Send the response event to the user's relay list
|
||||
client.send_event(&event).to(urls).await?;
|
||||
client.send_event(&event).to_nip65().await?;
|
||||
|
||||
Ok(())
|
||||
});
|
||||
|
||||
cx.spawn_in(window, async move |_this, cx| {
|
||||
match task.await {
|
||||
Ok(_) => {
|
||||
cx.update(|window, cx| {
|
||||
window.clear_notification_by_id::<DeviceNotification>(id, cx);
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
Err(e) => {
|
||||
cx.update(|window, cx| {
|
||||
window.push_notification(
|
||||
Notification::error(e.to_string()).autohide(false),
|
||||
cx,
|
||||
);
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
};
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
/// Handle encryption request
|
||||
fn ask_for_approval(&mut self, event: Event, window: &mut Window, cx: &mut Context<Self>) {
|
||||
// Ignore if there is already a pending request
|
||||
if self.pending_request {
|
||||
return;
|
||||
}
|
||||
self.set_pending_request(true, cx);
|
||||
|
||||
// Show notification
|
||||
let notification = self.notification(event, cx);
|
||||
window.push_notification(notification, cx);
|
||||
}
|
||||
|
||||
/// 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));
|
||||
let key = SharedString::from(event.id.to_hex());
|
||||
|
||||
Notification::new()
|
||||
.type_id::<DeviceNotification>(key)
|
||||
.autohide(false)
|
||||
.with_kind(NotificationKind::Info)
|
||||
.title("Encryption Key Request")
|
||||
.content(move |_this, _window, cx| {
|
||||
v_flex()
|
||||
.gap_2()
|
||||
.text_sm()
|
||||
.child(
|
||||
div()
|
||||
.text_sm()
|
||||
.line_height(relative(1.25))
|
||||
.child(SharedString::from(MSG)),
|
||||
)
|
||||
.child(
|
||||
v_flex()
|
||||
.gap_2()
|
||||
.child(
|
||||
v_flex()
|
||||
.gap_1()
|
||||
.text_sm()
|
||||
.child(
|
||||
div()
|
||||
.font_semibold()
|
||||
.text_xs()
|
||||
.text_color(cx.theme().text_muted)
|
||||
.child(SharedString::from("From:")),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.h_8()
|
||||
.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()
|
||||
.font_semibold()
|
||||
.text_xs()
|
||||
.text_color(cx.theme().text_muted)
|
||||
.child(SharedString::from("Client:")),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.h_8()
|
||||
.w_full()
|
||||
.px_2()
|
||||
.rounded(cx.theme().radius)
|
||||
.bg(cx.theme().elevated_surface_background)
|
||||
.child(request.client_name()),
|
||||
),
|
||||
),
|
||||
)
|
||||
.into_any_element()
|
||||
})
|
||||
.action(move |_this, _window, _cx| {
|
||||
let view = entity.clone();
|
||||
let event = event.clone();
|
||||
|
||||
Button::new("approve")
|
||||
.label("Approve")
|
||||
.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();
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
struct DeviceNotification;
|
||||
|
||||
/// Verify the author of an event
|
||||
async fn verify_author(client: &Client, event: &Event) -> bool {
|
||||
if let Some(signer) = client.signer() {
|
||||
if let Ok(public_key) = signer.get_public_key().await {
|
||||
return public_key == event.pubkey;
|
||||
}
|
||||
if let Some(signer) = client.signer()
|
||||
&& let Ok(public_key) = signer.get_public_key().await
|
||||
{
|
||||
return public_key == event.pubkey;
|
||||
}
|
||||
false
|
||||
}
|
||||
@@ -664,7 +726,8 @@ async fn get_keys(client: &Client) -> Result<Keys, Error> {
|
||||
|
||||
let filter = Filter::new()
|
||||
.kind(Kind::ApplicationSpecificData)
|
||||
.identifier(IDENTIFIER);
|
||||
.identifier(IDENTIFIER)
|
||||
.author(public_key);
|
||||
|
||||
if let Some(event) = client.database().query(filter).await?.first() {
|
||||
let content = signer.nip44_decrypt(&public_key, &event.content).await?;
|
||||
|
||||
@@ -15,3 +15,4 @@ smallvec.workspace = true
|
||||
smol.workspace = true
|
||||
flume.workspace = true
|
||||
log.workspace = true
|
||||
urlencoding = "2.1.3"
|
||||
|
||||
@@ -3,19 +3,19 @@ use std::collections::{HashMap, HashSet};
|
||||
use std::rc::Rc;
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::{anyhow, Error};
|
||||
use common::EventUtils;
|
||||
use gpui::{App, AppContext, Context, Entity, Global, Task};
|
||||
use anyhow::{Error, anyhow};
|
||||
use common::EventExt;
|
||||
use gpui::{App, AppContext, Context, Entity, Global, Task, Window};
|
||||
use nostr_sdk::prelude::*;
|
||||
use smallvec::{smallvec, SmallVec};
|
||||
use state::{Announcement, NostrRegistry, BOOTSTRAP_RELAYS, TIMEOUT};
|
||||
use smallvec::{SmallVec, smallvec};
|
||||
use state::{Announcement, BOOTSTRAP_RELAYS, NostrRegistry, TIMEOUT};
|
||||
|
||||
mod person;
|
||||
|
||||
pub use person::*;
|
||||
|
||||
pub fn init(cx: &mut App) {
|
||||
PersonRegistry::set_global(cx.new(PersonRegistry::new), cx);
|
||||
pub fn init(window: &mut Window, cx: &mut App) {
|
||||
PersonRegistry::set_global(cx.new(|cx| PersonRegistry::new(window, cx)), cx);
|
||||
}
|
||||
|
||||
struct GlobalPersonRegistry(Entity<PersonRegistry>);
|
||||
@@ -36,13 +36,13 @@ pub struct PersonRegistry {
|
||||
persons: HashMap<PublicKey, Entity<Person>>,
|
||||
|
||||
/// Set of public keys that have been seen
|
||||
seen: Rc<RefCell<HashSet<PublicKey>>>,
|
||||
seens: Rc<RefCell<HashSet<PublicKey>>>,
|
||||
|
||||
/// Sender for requesting metadata
|
||||
sender: flume::Sender<PublicKey>,
|
||||
|
||||
/// Tasks for asynchronous operations
|
||||
_tasks: SmallVec<[Task<()>; 4]>,
|
||||
tasks: SmallVec<[Task<()>; 4]>,
|
||||
}
|
||||
|
||||
impl PersonRegistry {
|
||||
@@ -57,13 +57,13 @@ impl PersonRegistry {
|
||||
}
|
||||
|
||||
/// Create a new person registry instance
|
||||
fn new(cx: &mut Context<Self>) -> Self {
|
||||
fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let client = nostr.read(cx).client();
|
||||
|
||||
// Channel for communication between nostr and gpui
|
||||
let (tx, rx) = flume::bounded::<Dispatch>(100);
|
||||
let (mta_tx, mta_rx) = flume::bounded::<PublicKey>(100);
|
||||
let (mta_tx, mta_rx) = flume::unbounded::<PublicKey>();
|
||||
|
||||
let mut tasks = smallvec![];
|
||||
|
||||
@@ -111,33 +111,16 @@ impl PersonRegistry {
|
||||
}),
|
||||
);
|
||||
|
||||
tasks.push(
|
||||
// Load all user profiles from the database
|
||||
cx.spawn(async move |this, cx| {
|
||||
let result = cx
|
||||
.background_executor()
|
||||
.await_on_background(async move { load_persons(&client).await })
|
||||
.await;
|
||||
|
||||
match result {
|
||||
Ok(persons) => {
|
||||
this.update(cx, |this, cx| {
|
||||
this.bulk_inserts(persons, cx);
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("Failed to load all persons from the database: {e}");
|
||||
}
|
||||
};
|
||||
}),
|
||||
);
|
||||
// Load all user profiles from the database
|
||||
cx.defer_in(window, |this, _window, cx| {
|
||||
this.load(cx);
|
||||
});
|
||||
|
||||
Self {
|
||||
persons: HashMap::new(),
|
||||
seen: Rc::new(RefCell::new(HashSet::new())),
|
||||
seens: Rc::new(RefCell::new(HashSet::new())),
|
||||
sender: mta_tx,
|
||||
_tasks: tasks,
|
||||
tasks,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -152,7 +135,7 @@ impl PersonRegistry {
|
||||
continue;
|
||||
};
|
||||
|
||||
if let RelayMessage::Event { event, .. } = message {
|
||||
if let RelayMessage::Event { event, .. } = *message {
|
||||
// Skip if the event has already been processed
|
||||
if !processed.insert(event.id) {
|
||||
continue;
|
||||
@@ -163,25 +146,21 @@ impl PersonRegistry {
|
||||
let metadata = Metadata::from_json(&event.content).unwrap_or_default();
|
||||
let person = Person::new(event.pubkey, metadata);
|
||||
let val = Box::new(person);
|
||||
|
||||
// Send
|
||||
tx.send_async(Dispatch::Person(val)).await.ok();
|
||||
}
|
||||
Kind::ContactList => {
|
||||
let public_keys = event.extract_public_keys();
|
||||
|
||||
// Get metadata for all public keys
|
||||
get_metadata(client, public_keys).await.ok();
|
||||
}
|
||||
Kind::InboxRelays => {
|
||||
let val = Box::new(event.into_owned());
|
||||
|
||||
// Send
|
||||
tx.send_async(Dispatch::Relays(val)).await.ok();
|
||||
}
|
||||
Kind::Custom(10044) => {
|
||||
let val = Box::new(event.into_owned());
|
||||
|
||||
// Send
|
||||
tx.send_async(Dispatch::Announcement(val)).await.ok();
|
||||
}
|
||||
@@ -198,7 +177,7 @@ impl PersonRegistry {
|
||||
loop {
|
||||
match flume::Selector::new()
|
||||
.recv(rx, |result| result.ok())
|
||||
.wait_timeout(Duration::from_secs(2))
|
||||
.wait_timeout(Duration::from_secs(TIMEOUT))
|
||||
{
|
||||
Ok(Some(public_key)) => {
|
||||
batch.insert(public_key);
|
||||
@@ -208,40 +187,81 @@ impl PersonRegistry {
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
get_metadata(client, std::mem::take(&mut batch)).await.ok();
|
||||
if !batch.is_empty() {
|
||||
get_metadata(client, std::mem::take(&mut batch)).await.ok();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Load all user profiles from the database
|
||||
fn load(&mut self, cx: &mut Context<Self>) {
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let client = nostr.read(cx).client();
|
||||
|
||||
let task: Task<Result<Vec<Person>, Error>> = cx.background_spawn(async move {
|
||||
let filter = Filter::new().kind(Kind::Metadata).limit(200);
|
||||
let events = client.database().query(filter).await?;
|
||||
let persons = events
|
||||
.into_iter()
|
||||
.map(|event| {
|
||||
let metadata = Metadata::from_json(event.content).unwrap_or_default();
|
||||
Person::new(event.pubkey, metadata)
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(persons)
|
||||
});
|
||||
|
||||
self.tasks.push(cx.spawn(async move |this, cx| {
|
||||
if let Ok(persons) = task.await {
|
||||
this.update(cx, |this, cx| {
|
||||
this.bulk_inserts(persons, cx);
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
/// Set profile encryption keys announcement
|
||||
fn set_announcement(&mut self, event: &Event, cx: &mut App) {
|
||||
if let Some(person) = self.persons.get(&event.pubkey) {
|
||||
let announcement = Announcement::from(event);
|
||||
let announcement = Announcement::from(event);
|
||||
|
||||
if let Some(person) = self.persons.get(&event.pubkey) {
|
||||
person.update(cx, |person, cx| {
|
||||
person.set_announcement(announcement);
|
||||
cx.notify();
|
||||
});
|
||||
} else {
|
||||
let person =
|
||||
Person::new(event.pubkey, Metadata::default()).with_announcement(announcement);
|
||||
self.insert(person, cx);
|
||||
}
|
||||
}
|
||||
|
||||
/// Set messaging relays for a person
|
||||
fn set_messaging_relays(&mut self, event: &Event, cx: &mut App) {
|
||||
if let Some(person) = self.persons.get(&event.pubkey) {
|
||||
let urls: Vec<RelayUrl> = nip17::extract_relay_list(event).cloned().collect();
|
||||
let urls: Vec<RelayUrl> = nip17::extract_relay_list(event).cloned().collect();
|
||||
|
||||
if let Some(person) = self.persons.get(&event.pubkey) {
|
||||
person.update(cx, |person, cx| {
|
||||
person.set_messaging_relays(urls);
|
||||
cx.notify();
|
||||
});
|
||||
} else {
|
||||
let person = Person::new(event.pubkey, Metadata::default()).with_messaging_relays(urls);
|
||||
self.insert(person, cx);
|
||||
}
|
||||
}
|
||||
|
||||
/// Insert batch of persons
|
||||
fn bulk_inserts(&mut self, persons: Vec<Person>, cx: &mut Context<Self>) {
|
||||
for person in persons.into_iter() {
|
||||
self.persons.insert(person.public_key(), cx.new(|_| person));
|
||||
let public_key = person.public_key();
|
||||
self.persons
|
||||
.entry(public_key)
|
||||
.or_insert_with(|| cx.new(|_| person));
|
||||
}
|
||||
cx.notify();
|
||||
}
|
||||
@@ -253,7 +273,7 @@ impl PersonRegistry {
|
||||
match self.persons.get(&public_key) {
|
||||
Some(this) => {
|
||||
this.update(cx, |this, cx| {
|
||||
*this = person;
|
||||
this.set_metadata(person.metadata());
|
||||
cx.notify();
|
||||
});
|
||||
}
|
||||
@@ -270,7 +290,7 @@ impl PersonRegistry {
|
||||
}
|
||||
|
||||
let public_key = *public_key;
|
||||
let mut seen = self.seen.borrow_mut();
|
||||
let mut seen = self.seens.borrow_mut();
|
||||
|
||||
if seen.insert(public_key) {
|
||||
let sender = self.sender.clone();
|
||||
@@ -313,28 +333,12 @@ where
|
||||
.limit(limit);
|
||||
|
||||
// Construct target for subscription
|
||||
let target = BOOTSTRAP_RELAYS
|
||||
let target: HashMap<&str, Vec<Filter>> = BOOTSTRAP_RELAYS
|
||||
.into_iter()
|
||||
.map(|relay| (relay, vec![filter.clone()]))
|
||||
.collect::<HashMap<_, _>>();
|
||||
.collect();
|
||||
|
||||
client.subscribe(target).close_on(opts).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Load all user profiles from the database
|
||||
async fn load_persons(client: &Client) -> Result<Vec<Person>, Error> {
|
||||
let filter = Filter::new().kind(Kind::Metadata).limit(200);
|
||||
let events = client.database().query(filter).await?;
|
||||
|
||||
let mut persons = vec![];
|
||||
|
||||
for event in events.into_iter() {
|
||||
let metadata = Metadata::from_json(event.content).unwrap_or_default();
|
||||
let person = Person::new(event.pubkey, metadata);
|
||||
persons.push(person);
|
||||
}
|
||||
|
||||
Ok(persons)
|
||||
}
|
||||
|
||||
@@ -65,6 +65,21 @@ impl Person {
|
||||
}
|
||||
}
|
||||
|
||||
/// Build profile encryption keys announcement
|
||||
pub fn with_announcement(mut self, announcement: Announcement) -> Self {
|
||||
self.announcement = Some(announcement);
|
||||
self
|
||||
}
|
||||
|
||||
/// Build profile messaging relays
|
||||
pub fn with_messaging_relays<I>(mut self, relays: I) -> Self
|
||||
where
|
||||
I: IntoIterator<Item = RelayUrl>,
|
||||
{
|
||||
self.messaging_relays = relays.into_iter().collect();
|
||||
self
|
||||
}
|
||||
|
||||
/// Get profile public key
|
||||
pub fn public_key(&self) -> PublicKey {
|
||||
self.public_key
|
||||
@@ -80,12 +95,6 @@ impl Person {
|
||||
self.announcement.clone()
|
||||
}
|
||||
|
||||
/// Set profile encryption keys announcement
|
||||
pub fn set_announcement(&mut self, announcement: Announcement) {
|
||||
self.announcement = Some(announcement);
|
||||
log::info!("Updated announcement for: {}", self.public_key());
|
||||
}
|
||||
|
||||
/// Get profile messaging relays
|
||||
pub fn messaging_relays(&self) -> &Vec<RelayUrl> {
|
||||
&self.messaging_relays
|
||||
@@ -96,15 +105,6 @@ impl Person {
|
||||
self.messaging_relays.first().cloned()
|
||||
}
|
||||
|
||||
/// Set profile messaging relays
|
||||
pub fn set_messaging_relays<I>(&mut self, relays: I)
|
||||
where
|
||||
I: IntoIterator<Item = RelayUrl>,
|
||||
{
|
||||
self.messaging_relays = relays.into_iter().collect();
|
||||
log::info!("Updated messaging relays for: {}", self.public_key());
|
||||
}
|
||||
|
||||
/// Get profile avatar
|
||||
pub fn avatar(&self) -> SharedString {
|
||||
self.metadata()
|
||||
@@ -112,8 +112,9 @@ impl Person {
|
||||
.as_ref()
|
||||
.filter(|picture| !picture.is_empty())
|
||||
.map(|picture| {
|
||||
let encoded_picture = urlencoding::encode(picture);
|
||||
let url = format!(
|
||||
"{IMAGE_RESIZER}/?url={picture}&w=100&h=100&fit=cover&mask=circle&n=-1"
|
||||
"{IMAGE_RESIZER}/?url={encoded_picture}&w=100&h=100&fit=cover&mask=circle&n=-1"
|
||||
);
|
||||
url.into()
|
||||
})
|
||||
@@ -122,20 +123,38 @@ impl Person {
|
||||
|
||||
/// Get profile name
|
||||
pub fn 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(display_name) = self.metadata().display_name.as_ref()
|
||||
&& !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);
|
||||
}
|
||||
if let Some(name) = self.metadata().name.as_ref()
|
||||
&& !name.is_empty()
|
||||
{
|
||||
return SharedString::from(name);
|
||||
}
|
||||
|
||||
SharedString::from(shorten_pubkey(self.public_key(), 4))
|
||||
}
|
||||
|
||||
/// Set profile metadata
|
||||
pub fn set_metadata(&mut self, metadata: Metadata) {
|
||||
self.metadata = metadata;
|
||||
}
|
||||
|
||||
/// Set profile encryption keys announcement
|
||||
pub fn set_announcement(&mut self, announcement: Announcement) {
|
||||
self.announcement = Some(announcement);
|
||||
}
|
||||
|
||||
/// Set profile messaging relays
|
||||
pub fn set_messaging_relays<I>(&mut self, relays: I)
|
||||
where
|
||||
I: IntoIterator<Item = RelayUrl>,
|
||||
{
|
||||
self.messaging_relays = relays.into_iter().collect();
|
||||
}
|
||||
}
|
||||
|
||||
/// Shorten a [`PublicKey`] to a string with the first and last `len` characters
|
||||
@@ -145,7 +164,7 @@ 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..]
|
||||
)
|
||||
|
||||
@@ -5,19 +5,19 @@ use std::hash::Hash;
|
||||
use std::rc::Rc;
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::{anyhow, Context as AnyhowContext, Error};
|
||||
use anyhow::{Context as AnyhowContext, Error, anyhow};
|
||||
use gpui::{
|
||||
App, AppContext, Context, Entity, Global, IntoElement, ParentElement, SharedString, Styled,
|
||||
Task, Window,
|
||||
Task, Window, div, relative,
|
||||
};
|
||||
use nostr_sdk::prelude::*;
|
||||
use settings::{AppSettings, AuthMode};
|
||||
use smallvec::{smallvec, SmallVec};
|
||||
use smallvec::{SmallVec, smallvec};
|
||||
use state::NostrRegistry;
|
||||
use theme::ActiveTheme;
|
||||
use ui::button::{Button, ButtonVariants};
|
||||
use ui::notification::Notification;
|
||||
use ui::{v_flex, Disableable, IconName, Sizable, WindowExtension};
|
||||
use ui::button::Button;
|
||||
use ui::notification::{Notification, NotificationKind};
|
||||
use ui::{Disableable, WindowExtension, v_flex};
|
||||
|
||||
const AUTH_MESSAGE: &str =
|
||||
"Approve the authentication request to allow Coop to continue sending or receiving events.";
|
||||
@@ -34,7 +34,10 @@ struct AuthRequest {
|
||||
}
|
||||
|
||||
impl AuthRequest {
|
||||
pub fn new(challenge: impl Into<String>, url: RelayUrl) -> Self {
|
||||
pub fn new<S>(challenge: S, url: RelayUrl) -> Self
|
||||
where
|
||||
S: Into<String>,
|
||||
{
|
||||
Self {
|
||||
challenge: challenge.into(),
|
||||
url,
|
||||
@@ -67,7 +70,7 @@ pub struct RelayAuth {
|
||||
pending_events: HashSet<(EventId, RelayUrl)>,
|
||||
|
||||
/// Tasks for asynchronous operations
|
||||
tasks: SmallVec<[Task<()>; 2]>,
|
||||
_tasks: SmallVec<[Task<()>; 2]>,
|
||||
}
|
||||
|
||||
impl RelayAuth {
|
||||
@@ -83,32 +86,21 @@ impl RelayAuth {
|
||||
|
||||
/// Create a new relay auth instance
|
||||
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 client = nostr.read(cx).client();
|
||||
|
||||
let mut tasks = smallvec![];
|
||||
|
||||
// Channel for communication between nostr and gpui
|
||||
let (tx, rx) = flume::bounded::<Signal>(256);
|
||||
|
||||
self.tasks.push(cx.background_spawn(async move {
|
||||
log::info!("Started handling nostr notifications");
|
||||
tasks.push(cx.background_spawn(async move {
|
||||
let mut notifications = client.notifications();
|
||||
let mut challenges: HashSet<Cow<'_, str>> = HashSet::default();
|
||||
|
||||
while let Some(notification) = notifications.next().await {
|
||||
if let ClientNotification::Message { relay_url, message } = notification {
|
||||
match message {
|
||||
match *message {
|
||||
RelayMessage::Auth { challenge } => {
|
||||
if challenges.insert(challenge.clone()) {
|
||||
let request = Arc::new(AuthRequest::new(challenge, relay_url));
|
||||
@@ -134,7 +126,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 {
|
||||
match signal {
|
||||
Signal::Auth(req) => {
|
||||
@@ -152,6 +144,11 @@ impl RelayAuth {
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
||||
Self {
|
||||
pending_events: HashSet::default(),
|
||||
_tasks: tasks,
|
||||
}
|
||||
}
|
||||
|
||||
/// Insert a pending event waiting for resend after authentication
|
||||
@@ -162,15 +159,12 @@ impl RelayAuth {
|
||||
|
||||
/// Get all pending events for a specific relay,
|
||||
fn get_pending_events(&self, relay: &RelayUrl, _cx: &App) -> Vec<EventId> {
|
||||
let pending_events: Vec<EventId> = self
|
||||
.pending_events
|
||||
self.pending_events
|
||||
.iter()
|
||||
.filter(|(_, pending_relay)| pending_relay == relay)
|
||||
.map(|(id, _relay)| id)
|
||||
.cloned()
|
||||
.collect();
|
||||
|
||||
pending_events
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Clear all pending events for a specific relay,
|
||||
@@ -227,31 +221,31 @@ impl RelayAuth {
|
||||
|
||||
while let Some(notification) = notifications.next().await {
|
||||
match notification {
|
||||
RelayNotification::Message {
|
||||
message: RelayMessage::Ok { event_id, .. },
|
||||
} => {
|
||||
if id != event_id {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Get all subscriptions
|
||||
let subscriptions = relay.subscriptions().await;
|
||||
|
||||
// Re-subscribe to previous subscriptions
|
||||
for (id, filters) in subscriptions.into_iter() {
|
||||
if !filters.is_empty() {
|
||||
relay.send_msg(ClientMessage::req(id, filters)).await?;
|
||||
RelayNotification::Message { message } => {
|
||||
if let RelayMessage::Ok { event_id, .. } = *message {
|
||||
if id != event_id {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Re-send pending events
|
||||
for id in pending_events {
|
||||
if let Some(event) = client.database().event_by_id(&id).await? {
|
||||
relay.send_event(&event).await?;
|
||||
// Get all subscriptions
|
||||
let subscriptions = relay.subscriptions().await;
|
||||
|
||||
// Re-subscribe to previous subscriptions
|
||||
for (id, filters) in subscriptions.into_iter() {
|
||||
if !filters.is_empty() {
|
||||
relay.send_msg(ClientMessage::req(id, filters)).await?;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Ok(());
|
||||
// Re-send pending events
|
||||
for id in pending_events {
|
||||
if let Some(event) = client.database().event_by_id(&id).await? {
|
||||
relay.send_event(&event).await?;
|
||||
}
|
||||
}
|
||||
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
RelayNotification::AuthenticationFailed => break,
|
||||
_ => {}
|
||||
@@ -266,7 +260,7 @@ impl RelayAuth {
|
||||
fn response(&self, req: &Arc<AuthRequest>, window: &Window, cx: &Context<Self>) {
|
||||
let settings = AppSettings::global(cx);
|
||||
let req = req.clone();
|
||||
let challenge = req.challenge().to_string();
|
||||
let challenge = SharedString::from(req.challenge().to_string());
|
||||
|
||||
// Create a task for authentication
|
||||
let task = self.auth(&req, cx);
|
||||
@@ -276,20 +270,31 @@ impl RelayAuth {
|
||||
let url = req.url();
|
||||
|
||||
this.update_in(cx, |this, window, cx| {
|
||||
window.clear_notification(challenge, cx);
|
||||
window.clear_notification_by_id::<AuthNotification>(challenge, cx);
|
||||
|
||||
match result {
|
||||
Ok(_) => {
|
||||
// Clear pending events for the authenticated relay
|
||||
this.clear_pending_events(url, cx);
|
||||
|
||||
// Save the authenticated relay to automatically authenticate future requests
|
||||
settings.update(cx, |this, cx| {
|
||||
this.add_trusted_relay(url, cx);
|
||||
});
|
||||
window.push_notification(format!("{} has been authenticated", url), cx);
|
||||
|
||||
window.push_notification(
|
||||
Notification::success(format!(
|
||||
"Relay {} has been authenticated",
|
||||
url.domain().unwrap_or_default()
|
||||
)),
|
||||
cx,
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
window.push_notification(Notification::error(e.to_string()), cx);
|
||||
window.push_notification(
|
||||
Notification::error(e.to_string()).autohide(false),
|
||||
cx,
|
||||
);
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -314,49 +319,50 @@ impl RelayAuth {
|
||||
/// Build a notification for the authentication request.
|
||||
fn notification(&self, req: &Arc<AuthRequest>, cx: &Context<Self>) -> Notification {
|
||||
let req = req.clone();
|
||||
let challenge = SharedString::from(req.challenge.clone());
|
||||
let url = SharedString::from(req.url().to_string());
|
||||
let entity = cx.entity().downgrade();
|
||||
let loading = Rc::new(Cell::new(false));
|
||||
|
||||
Notification::new()
|
||||
.custom_id(SharedString::from(&req.challenge))
|
||||
.type_id::<AuthNotification>(challenge)
|
||||
.autohide(false)
|
||||
.icon(IconName::Info)
|
||||
.title(SharedString::from("Authentication Required"))
|
||||
.content(move |_window, cx| {
|
||||
.with_kind(NotificationKind::Info)
|
||||
.title("Authentication Required")
|
||||
.content(move |_this, _window, cx| {
|
||||
v_flex()
|
||||
.gap_2()
|
||||
.text_sm()
|
||||
.child(SharedString::from(AUTH_MESSAGE))
|
||||
.child(
|
||||
div()
|
||||
.text_sm()
|
||||
.line_height(relative(1.25))
|
||||
.child(SharedString::from(AUTH_MESSAGE)),
|
||||
)
|
||||
.child(
|
||||
v_flex()
|
||||
.py_1()
|
||||
.px_1p5()
|
||||
.rounded_sm()
|
||||
.text_xs()
|
||||
.bg(cx.theme().warning_background)
|
||||
.text_color(cx.theme().warning_foreground)
|
||||
.bg(cx.theme().elevated_surface_background)
|
||||
.text_color(cx.theme().text)
|
||||
.child(url.clone()),
|
||||
)
|
||||
.into_any_element()
|
||||
})
|
||||
.action(move |_window, _cx| {
|
||||
.action(move |_this, _window, _cx| {
|
||||
let view = entity.clone();
|
||||
let req = req.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.response(&req, window, cx);
|
||||
@@ -367,3 +373,5 @@ impl RelayAuth {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
struct AuthNotification;
|
||||
|
||||
@@ -2,12 +2,12 @@ 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 gpui::{App, AppContext, Context, Entity, Global, Subscription, Task, Window};
|
||||
use nostr_sdk::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use smallvec::{smallvec, SmallVec};
|
||||
use smallvec::{SmallVec, smallvec};
|
||||
use theme::{Theme, ThemeFamily, ThemeMode};
|
||||
|
||||
pub fn init(window: &mut Window, cx: &mut App) {
|
||||
@@ -94,21 +94,28 @@ pub struct RoomConfig {
|
||||
}
|
||||
|
||||
impl RoomConfig {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
backup: true,
|
||||
signer_kind: SignerKind::Auto,
|
||||
}
|
||||
}
|
||||
|
||||
/// Get backup config
|
||||
pub fn backup(&self) -> bool {
|
||||
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 {
|
||||
&self.signer_kind
|
||||
}
|
||||
|
||||
/// Set backup config
|
||||
pub fn set_backup(&mut self, backup: bool) {
|
||||
self.backup = backup;
|
||||
}
|
||||
|
||||
/// Set signer kind config
|
||||
pub fn set_signer_kind(&mut self, kind: &SignerKind) {
|
||||
self.signer_kind = kind.to_owned();
|
||||
@@ -270,23 +277,30 @@ impl AppSettings {
|
||||
self.apply_theme(window, cx);
|
||||
}
|
||||
|
||||
/// Reset theme
|
||||
pub fn reset_theme(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
self.values.theme = None;
|
||||
cx.notify();
|
||||
|
||||
self.apply_theme(window, cx);
|
||||
}
|
||||
|
||||
/// Apply theme
|
||||
pub fn apply_theme(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
if let Some(name) = self.values.theme.as_ref() {
|
||||
let mode = self.values.theme_mode;
|
||||
|
||||
if let Ok(new_theme) = ThemeFamily::from_assets(name) {
|
||||
Theme::apply_theme(Rc::new(new_theme), Some(window), cx);
|
||||
Theme::change(mode, Some(window), cx);
|
||||
} else {
|
||||
log::info!("Failed to load theme: {name}");
|
||||
}
|
||||
} else {
|
||||
Theme::apply_theme(Rc::new(ThemeFamily::default()), Some(window), cx);
|
||||
}
|
||||
}
|
||||
|
||||
/// Reset theme
|
||||
pub fn reset_theme(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
self.values.theme = None;
|
||||
self.apply_theme(window, cx);
|
||||
}
|
||||
|
||||
/// Check if the given relay is already authenticated
|
||||
pub fn trusted_relay(&self, url: &RelayUrl, _cx: &App) -> bool {
|
||||
self.values.trusted_relays.iter().any(|relay| {
|
||||
|
||||
@@ -10,6 +10,7 @@ common = { path = "../common" }
|
||||
nostr.workspace = true
|
||||
nostr-sdk.workspace = true
|
||||
nostr-lmdb.workspace = true
|
||||
nostr-gossip-memory.workspace = true
|
||||
nostr-connect.workspace = true
|
||||
nostr-blossom.workspace = true
|
||||
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
use std::sync::OnceLock;
|
||||
|
||||
/// Client name (Application name)
|
||||
pub const CLIENT_NAME: &str = "Coop";
|
||||
|
||||
@@ -13,7 +11,10 @@ pub const APP_ID: &str = "su.reya.coop";
|
||||
pub const KEYRING: &str = "Coop Safe Storage";
|
||||
|
||||
/// Default timeout for subscription
|
||||
pub const TIMEOUT: u64 = 3;
|
||||
pub const TIMEOUT: u64 = 2;
|
||||
|
||||
/// Default image cache size
|
||||
pub const IMAGE_CACHE_SIZE: usize = 20;
|
||||
|
||||
/// Default delay for searching
|
||||
pub const FIND_DELAY: u64 = 600;
|
||||
@@ -21,39 +22,30 @@ pub const FIND_DELAY: u64 = 600;
|
||||
/// Default limit for searching
|
||||
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
|
||||
pub const DEVICE_GIFTWRAP: &str = "device-gift-wraps";
|
||||
|
||||
/// Default subscription id for user gift wrap events
|
||||
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
|
||||
pub const WOT_RELAYS: [&str; 1] = ["wss://relay.vertexlab.io"];
|
||||
|
||||
/// Default search relays
|
||||
pub const SEARCH_RELAYS: [&str; 1] = ["wss://antiprimal.net"];
|
||||
pub const INDEXER_RELAYS: [&str; 1] = ["wss://indexer.coracle.social"];
|
||||
|
||||
/// Default search relays
|
||||
pub const SEARCH_RELAYS: [&str; 2] = ["wss://antiprimal.net", "wss://search.nos.today"];
|
||||
|
||||
/// Default bootstrap relays
|
||||
pub const BOOTSTRAP_RELAYS: [&str; 3] = [
|
||||
"wss://relay.damus.io",
|
||||
"wss://nos.lol",
|
||||
"wss://relay.primal.net",
|
||||
"wss://user.kindpag.es",
|
||||
];
|
||||
|
||||
static APP_NAME: OnceLock<String> = OnceLock::new();
|
||||
|
||||
/// Get the app name
|
||||
pub fn app_name() -> &'static String {
|
||||
APP_NAME.get_or_init(|| {
|
||||
let devicename = whoami::devicename();
|
||||
let platform = whoami::platform();
|
||||
|
||||
format!("{CLIENT_NAME} on {platform} ({devicename})")
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,28 +1,6 @@
|
||||
use gpui::SharedString;
|
||||
use nostr_sdk::prelude::*;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Default)]
|
||||
pub enum DeviceState {
|
||||
#[default]
|
||||
Idle,
|
||||
Requesting,
|
||||
Set,
|
||||
}
|
||||
|
||||
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
|
||||
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
pub struct Announcement {
|
||||
|
||||
@@ -1,83 +0,0 @@
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
use gpui::SharedString;
|
||||
use nostr_sdk::prelude::*;
|
||||
|
||||
/// Gossip
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct Gossip {
|
||||
relays: HashMap<PublicKey, HashSet<(RelayUrl, Option<RelayMetadata>)>>,
|
||||
}
|
||||
|
||||
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
|
||||
pub fn read_relays(&self, public_key: &PublicKey) -> Vec<RelayUrl> {
|
||||
self.relays
|
||||
.get(public_key)
|
||||
.map(|relays| {
|
||||
relays
|
||||
.iter()
|
||||
.filter_map(|(url, metadata)| {
|
||||
if metadata.is_none() || metadata == &Some(RelayMetadata::Read) {
|
||||
Some(url.to_owned())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Get write relays for a given public key
|
||||
pub fn write_relays(&self, public_key: &PublicKey) -> Vec<RelayUrl> {
|
||||
self.relays
|
||||
.get(public_key)
|
||||
.map(|relays| {
|
||||
relays
|
||||
.iter()
|
||||
.filter_map(|(url, metadata)| {
|
||||
if metadata.is_none() || metadata == &Some(RelayMetadata::Write) {
|
||||
Some(url.to_owned())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Insert gossip relays for a public key
|
||||
pub fn insert_relays(&mut self, event: &Event) {
|
||||
self.relays.entry(event.pubkey).or_default().extend(
|
||||
event
|
||||
.tags
|
||||
.iter()
|
||||
.filter_map(|tag| {
|
||||
if let Some(TagStandard::RelayMetadata {
|
||||
relay_url,
|
||||
metadata,
|
||||
}) = tag.clone().to_standardized()
|
||||
{
|
||||
Some((relay_url, metadata))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.take(3),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
use std::borrow::Cow;
|
||||
use std::result::Result;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::Arc;
|
||||
|
||||
use nostr_sdk::prelude::*;
|
||||
@@ -16,11 +15,6 @@ pub struct CoopSigner {
|
||||
|
||||
/// Specific signer for encryption purposes
|
||||
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 {
|
||||
@@ -32,7 +26,6 @@ impl CoopSigner {
|
||||
signer: RwLock::new(signer.into_nostr_signer()),
|
||||
signer_pkey: RwLock::new(None),
|
||||
encryption_signer: RwLock::new(None),
|
||||
owned: AtomicBool::new(false),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,17 +40,15 @@ impl CoopSigner {
|
||||
}
|
||||
|
||||
/// Get public key
|
||||
///
|
||||
/// Ensure to call this method after the signer has been initialized.
|
||||
/// Otherwise, it will panic.
|
||||
pub fn public_key(&self) -> Option<PublicKey> {
|
||||
self.signer_pkey.read_blocking().to_owned()
|
||||
}
|
||||
|
||||
/// Get the flag indicating whether the signer is user-owned.
|
||||
pub fn owned(&self) -> bool {
|
||||
self.owned.load(Ordering::SeqCst)
|
||||
*self.signer_pkey.read_blocking()
|
||||
}
|
||||
|
||||
/// 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
|
||||
T: IntoNostrSigner,
|
||||
{
|
||||
@@ -75,9 +66,6 @@ impl CoopSigner {
|
||||
|
||||
// Reset the encryption signer
|
||||
*encryption_signer = None;
|
||||
|
||||
// Update the owned flag
|
||||
self.owned.store(owned, Ordering::SeqCst);
|
||||
}
|
||||
|
||||
/// Set the encryption signer.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use gpui::{hsla, Hsla, Rgba};
|
||||
use gpui::{Hsla, Rgba, hsla};
|
||||
use schemars::JsonSchema;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
@@ -30,6 +30,8 @@ pub struct ThemeColors {
|
||||
pub text_muted: Hsla,
|
||||
pub text_placeholder: Hsla,
|
||||
pub text_accent: Hsla,
|
||||
pub text_danger: Hsla,
|
||||
pub text_warning: Hsla,
|
||||
|
||||
// Icon colors
|
||||
pub icon: Hsla,
|
||||
@@ -77,11 +79,11 @@ pub struct ThemeColors {
|
||||
pub ghost_element_disabled: Hsla,
|
||||
|
||||
// Tab colors
|
||||
pub tab_inactive_background: Hsla,
|
||||
pub tab_inactive_foreground: Hsla,
|
||||
pub tab_background: Hsla,
|
||||
pub tab_foreground: Hsla,
|
||||
pub tab_hover_background: Hsla,
|
||||
pub tab_active_background: Hsla,
|
||||
pub tab_active_foreground: Hsla,
|
||||
pub tab_hover_foreground: Hsla,
|
||||
|
||||
// Scrollbar colors
|
||||
pub scrollbar_thumb_background: Hsla,
|
||||
@@ -110,8 +112,8 @@ impl ThemeColors {
|
||||
elevated_surface_background: neutral().light().step_3(),
|
||||
panel_background: neutral().light().step_1(),
|
||||
overlay: neutral().light_alpha().step_3(),
|
||||
title_bar: neutral().light().step_2(),
|
||||
title_bar_inactive: neutral().light().step_3(),
|
||||
title_bar: neutral().light().step_3(),
|
||||
title_bar_inactive: neutral().light().step_1(),
|
||||
window_border: hsl(240.0, 5.9, 78.0),
|
||||
|
||||
border: neutral().light().step_6(),
|
||||
@@ -125,7 +127,9 @@ impl ThemeColors {
|
||||
text: neutral().light().step_12(),
|
||||
text_muted: neutral().light().step_11(),
|
||||
text_placeholder: neutral().light().step_10(),
|
||||
text_accent: brand().light().step_11(),
|
||||
text_accent: brand().light().step_9(),
|
||||
text_danger: danger().light().step_9(),
|
||||
text_warning: warning().light().step_9(),
|
||||
|
||||
icon: neutral().light().step_11(),
|
||||
icon_muted: neutral().light().step_10(),
|
||||
@@ -166,17 +170,17 @@ impl ThemeColors {
|
||||
ghost_element_selected: neutral().light().step_5(),
|
||||
ghost_element_disabled: neutral().light_alpha().step_2(),
|
||||
|
||||
tab_inactive_background: neutral().light().step_2(),
|
||||
tab_inactive_foreground: neutral().light().step_11(),
|
||||
tab_background: neutral().light().step_3(),
|
||||
tab_foreground: neutral().light().step_11(),
|
||||
tab_hover_background: neutral().light_alpha().step_4(),
|
||||
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_hover_background: neutral().light_alpha().step_4(),
|
||||
scrollbar_thumb_border: gpui::transparent_black(),
|
||||
scrollbar_track_background: gpui::transparent_black(),
|
||||
scrollbar_track_border: neutral().light().step_5(),
|
||||
scrollbar_track_border: gpui::transparent_black(),
|
||||
|
||||
drop_target_background: brand().light_alpha().step_2(),
|
||||
cursor: hsl(200., 100., 50.),
|
||||
@@ -192,9 +196,9 @@ impl ThemeColors {
|
||||
background: neutral().dark().step_1(),
|
||||
surface_background: neutral().dark().step_2(),
|
||||
elevated_surface_background: neutral().dark().step_3(),
|
||||
panel_background: gpui::black(),
|
||||
panel_background: neutral().dark().step_1(),
|
||||
overlay: neutral().dark_alpha().step_3(),
|
||||
title_bar: gpui::transparent_black(),
|
||||
title_bar: neutral().dark().step_3(),
|
||||
title_bar_inactive: neutral().dark().step_1(),
|
||||
window_border: hsl(240.0, 3.7, 28.0),
|
||||
|
||||
@@ -209,7 +213,9 @@ impl ThemeColors {
|
||||
text: neutral().dark().step_12(),
|
||||
text_muted: neutral().dark().step_11(),
|
||||
text_placeholder: neutral().dark().step_10(),
|
||||
text_accent: brand().dark().step_11(),
|
||||
text_accent: brand().dark().step_9(),
|
||||
text_danger: danger().dark().step_9(),
|
||||
text_warning: warning().dark().step_9(),
|
||||
|
||||
icon: neutral().dark().step_11(),
|
||||
icon_muted: neutral().dark().step_10(),
|
||||
@@ -250,17 +256,17 @@ impl ThemeColors {
|
||||
ghost_element_selected: neutral().dark().step_5(),
|
||||
ghost_element_disabled: neutral().dark_alpha().step_2(),
|
||||
|
||||
tab_inactive_background: neutral().dark().step_2(),
|
||||
tab_inactive_foreground: neutral().dark().step_11(),
|
||||
tab_active_background: neutral().dark().step_3(),
|
||||
tab_background: neutral().dark().step_3(),
|
||||
tab_foreground: neutral().dark().step_11(),
|
||||
tab_hover_background: neutral().dark_alpha().step_4(),
|
||||
tab_active_background: neutral().dark().step_1(),
|
||||
tab_active_foreground: neutral().dark().step_12(),
|
||||
tab_hover_foreground: brand().dark().step_9(),
|
||||
|
||||
scrollbar_thumb_background: neutral().dark_alpha().step_3(),
|
||||
scrollbar_thumb_hover_background: neutral().dark_alpha().step_4(),
|
||||
scrollbar_thumb_border: gpui::transparent_black(),
|
||||
scrollbar_track_background: gpui::transparent_black(),
|
||||
scrollbar_track_border: neutral().dark().step_5(),
|
||||
scrollbar_track_border: gpui::transparent_black(),
|
||||
|
||||
drop_target_background: brand().dark_alpha().step_2(),
|
||||
cursor: hsl(200., 100., 50.),
|
||||
|
||||
159
crates/theme/src/geometry.rs
Normal file
@@ -0,0 +1,159 @@
|
||||
use std::fmt::{self, Debug, Display, Formatter};
|
||||
|
||||
use gpui::{AbsoluteLength, Axis, Length, Pixels};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// A enum for defining the placement of the element.
|
||||
///
|
||||
/// See also: [`Side`] if you need to define the left, right side.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum Placement {
|
||||
#[serde(rename = "top")]
|
||||
Top,
|
||||
#[serde(rename = "bottom")]
|
||||
Bottom,
|
||||
#[serde(rename = "left")]
|
||||
Left,
|
||||
#[serde(rename = "right")]
|
||||
Right,
|
||||
}
|
||||
|
||||
impl Display for Placement {
|
||||
fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Placement::Top => write!(f, "Top"),
|
||||
Placement::Bottom => write!(f, "Bottom"),
|
||||
Placement::Left => write!(f, "Left"),
|
||||
Placement::Right => write!(f, "Right"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Placement {
|
||||
#[inline]
|
||||
pub fn is_horizontal(&self) -> bool {
|
||||
matches!(self, Placement::Left | Placement::Right)
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub fn is_vertical(&self) -> bool {
|
||||
matches!(self, Placement::Top | Placement::Bottom)
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub fn axis(&self) -> Axis {
|
||||
match self {
|
||||
Placement::Top | Placement::Bottom => Axis::Vertical,
|
||||
Placement::Left | Placement::Right => Axis::Horizontal,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A enum for defining the side of the element.
|
||||
///
|
||||
/// See also: [`Placement`] if you need to define the 4 edges.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum Side {
|
||||
#[serde(rename = "left")]
|
||||
Left,
|
||||
#[serde(rename = "right")]
|
||||
Right,
|
||||
}
|
||||
|
||||
impl Side {
|
||||
/// Returns true if the side is left.
|
||||
#[inline]
|
||||
pub fn is_left(&self) -> bool {
|
||||
matches!(self, Self::Left)
|
||||
}
|
||||
|
||||
/// Returns true if the side is right.
|
||||
#[inline]
|
||||
pub fn is_right(&self) -> bool {
|
||||
matches!(self, Self::Right)
|
||||
}
|
||||
}
|
||||
|
||||
/// A trait to extend the [`Axis`] enum with utility methods.
|
||||
pub trait AxisExt {
|
||||
#[allow(clippy::wrong_self_convention)]
|
||||
fn is_horizontal(self) -> bool;
|
||||
#[allow(clippy::wrong_self_convention)]
|
||||
fn is_vertical(self) -> bool;
|
||||
}
|
||||
|
||||
impl AxisExt for Axis {
|
||||
#[inline]
|
||||
fn is_horizontal(self) -> bool {
|
||||
self == Axis::Horizontal
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn is_vertical(self) -> bool {
|
||||
self == Axis::Vertical
|
||||
}
|
||||
}
|
||||
|
||||
/// A trait for converting [`Pixels`] to `f32` and `f64`.
|
||||
pub trait PixelsExt {
|
||||
fn as_f32(&self) -> f32;
|
||||
#[allow(clippy::wrong_self_convention)]
|
||||
fn as_f64(self) -> f64;
|
||||
}
|
||||
impl PixelsExt for Pixels {
|
||||
fn as_f32(&self) -> f32 {
|
||||
f32::from(self)
|
||||
}
|
||||
|
||||
fn as_f64(self) -> f64 {
|
||||
f64::from(self)
|
||||
}
|
||||
}
|
||||
|
||||
/// A trait to extend the [`Length`] enum with utility methods.
|
||||
pub trait LengthExt {
|
||||
/// Converts the [`Length`] to [`Pixels`] based on a given `base_size` and `rem_size`.
|
||||
///
|
||||
/// If the [`Length`] is [`Length::Auto`], it returns `None`.
|
||||
fn to_pixels(&self, base_size: AbsoluteLength, rem_size: Pixels) -> Option<Pixels>;
|
||||
}
|
||||
|
||||
impl LengthExt for Length {
|
||||
fn to_pixels(&self, base_size: AbsoluteLength, rem_size: Pixels) -> Option<Pixels> {
|
||||
match self {
|
||||
Length::Auto => None,
|
||||
Length::Definite(len) => Some(len.to_pixels(base_size, rem_size)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A struct for defining the edges of an element.
|
||||
///
|
||||
/// A extend version of [`gpui::Edges`] to serialize/deserialize.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize, Eq, PartialEq)]
|
||||
#[repr(C)]
|
||||
pub struct Edges<T: Clone + Debug + Default + PartialEq> {
|
||||
/// The size of the top edge.
|
||||
pub top: T,
|
||||
/// The size of the right edge.
|
||||
pub right: T,
|
||||
/// The size of the bottom edge.
|
||||
pub bottom: T,
|
||||
/// The size of the left edge.
|
||||
pub left: T,
|
||||
}
|
||||
|
||||
impl<T> Edges<T>
|
||||
where
|
||||
T: Clone + Debug + Default + PartialEq,
|
||||
{
|
||||
/// Creates a new `Edges` instance with all edges set to the same value.
|
||||
pub fn all(value: T) -> Self {
|
||||
Self {
|
||||
top: value.clone(),
|
||||
right: value.clone(),
|
||||
bottom: value.clone(),
|
||||
left: value,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,11 @@
|
||||
use std::ops::{Deref, DerefMut};
|
||||
use std::rc::Rc;
|
||||
|
||||
use gpui::{px, App, Global, Pixels, SharedString, Window};
|
||||
use gpui::{App, Global, Pixels, SharedString, Window, px};
|
||||
|
||||
mod colors;
|
||||
mod geometry;
|
||||
mod notification;
|
||||
mod platform_kind;
|
||||
mod registry;
|
||||
mod scale;
|
||||
@@ -11,6 +13,8 @@ mod scrollbar_mode;
|
||||
mod theme;
|
||||
|
||||
pub use colors::*;
|
||||
pub use geometry::*;
|
||||
pub use notification::*;
|
||||
pub use platform_kind::PlatformKind;
|
||||
pub use registry::*;
|
||||
pub use scale::*;
|
||||
@@ -82,6 +86,9 @@ pub struct Theme {
|
||||
/// Show the scrollbar mode, default: scrolling
|
||||
pub scrollbar_mode: ScrollbarMode,
|
||||
|
||||
/// Notification settings
|
||||
pub notification: NotificationSettings,
|
||||
|
||||
/// Platform kind
|
||||
pub platform: PlatformKind,
|
||||
}
|
||||
@@ -200,10 +207,11 @@ impl From<ThemeFamily> for Theme {
|
||||
Theme {
|
||||
font_size: px(15.),
|
||||
font_family: font_family.into(),
|
||||
radius: px(5.),
|
||||
radius: px(6.),
|
||||
radius_lg: px(10.),
|
||||
shadow: true,
|
||||
scrollbar_mode: ScrollbarMode::default(),
|
||||
notification: NotificationSettings::default(),
|
||||
mode,
|
||||
colors: *colors,
|
||||
theme: Rc::new(family),
|
||||
|
||||
30
crates/theme/src/notification.rs
Normal file
@@ -0,0 +1,30 @@
|
||||
use gpui::{Anchor, Pixels, px};
|
||||
|
||||
use crate::{Edges, TITLEBAR_HEIGHT};
|
||||
|
||||
/// The settings for notifications.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct NotificationSettings {
|
||||
/// The placement of the notification, default: [`Anchor::TopRight`]
|
||||
pub placement: Anchor,
|
||||
/// The margins of the notification with respect to the window edges.
|
||||
pub margins: Edges<Pixels>,
|
||||
/// The maximum number of notifications to show at once, default: 10
|
||||
pub max_items: usize,
|
||||
}
|
||||
|
||||
impl Default for NotificationSettings {
|
||||
fn default() -> Self {
|
||||
let offset = px(16.);
|
||||
Self {
|
||||
placement: Anchor::TopRight,
|
||||
margins: Edges {
|
||||
top: TITLEBAR_HEIGHT + offset, // avoid overlap with title bar
|
||||
right: offset,
|
||||
bottom: offset,
|
||||
left: offset,
|
||||
},
|
||||
max_items: 10,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,12 @@
|
||||
use gpui::prelude::FluentBuilder;
|
||||
#[cfg(target_os = "linux")]
|
||||
use gpui::MouseButton;
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
use gpui::Pixels;
|
||||
use gpui::prelude::FluentBuilder;
|
||||
use gpui::{
|
||||
px, AnyElement, Context, Decorations, Hsla, InteractiveElement as _, IntoElement,
|
||||
ParentElement, Render, StatefulInteractiveElement as _, Styled, Window, WindowControlArea,
|
||||
AnyElement, Context, Decorations, Hsla, InteractiveElement as _, IntoElement, ParentElement,
|
||||
Pixels, Render, StatefulInteractiveElement as _, Styled, Window, WindowControlArea, px,
|
||||
};
|
||||
use smallvec::{smallvec, SmallVec};
|
||||
use theme::{ActiveTheme, PlatformKind, CLIENT_SIDE_DECORATION_ROUNDING};
|
||||
use smallvec::{SmallVec, smallvec};
|
||||
use theme::{ActiveTheme, CLIENT_SIDE_DECORATION_ROUNDING, PlatformKind};
|
||||
use ui::h_flex;
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
|
||||
@@ -1,333 +0,0 @@
|
||||
//! This is a fork of gpui's anchored element that adds support for offsetting
|
||||
//! https://github.com/zed-industries/zed/blob/b06f4088a3565c5e30663106ff79c1ced645d87a/crates/gpui/src/elements/anchored.rs
|
||||
use gpui::{
|
||||
point, px, AnyElement, App, Axis, Bounds, Display, Edges, Element, GlobalElementId, Half,
|
||||
InspectorElementId, IntoElement, LayoutId, ParentElement, Pixels, Point, Position, Size, Style,
|
||||
Window,
|
||||
};
|
||||
use smallvec::SmallVec;
|
||||
|
||||
use crate::Anchor;
|
||||
|
||||
/// The state that the anchored element element uses to track its children.
|
||||
pub struct AnchoredState {
|
||||
child_layout_ids: SmallVec<[LayoutId; 4]>,
|
||||
}
|
||||
|
||||
/// An anchored element that can be used to display UI that
|
||||
/// will avoid overflowing the window bounds.
|
||||
pub(crate) struct Anchored {
|
||||
children: SmallVec<[AnyElement; 2]>,
|
||||
anchor_corner: Anchor,
|
||||
fit_mode: AnchoredFitMode,
|
||||
anchor_position: Option<Point<Pixels>>,
|
||||
position_mode: AnchoredPositionMode,
|
||||
offset: Option<Point<Pixels>>,
|
||||
}
|
||||
|
||||
/// anchored gives you an element that will avoid overflowing the window bounds.
|
||||
/// Its children should have no margin to avoid measurement issues.
|
||||
pub(crate) fn anchored() -> Anchored {
|
||||
Anchored {
|
||||
children: SmallVec::new(),
|
||||
anchor_corner: Anchor::TopLeft,
|
||||
fit_mode: AnchoredFitMode::SwitchAnchor,
|
||||
anchor_position: None,
|
||||
position_mode: AnchoredPositionMode::Window,
|
||||
offset: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
impl Anchored {
|
||||
/// Sets which corner of the anchored element should be anchored to the current position.
|
||||
pub fn anchor(mut self, anchor: Anchor) -> Self {
|
||||
self.anchor_corner = anchor;
|
||||
self
|
||||
}
|
||||
|
||||
/// Sets the position in window coordinates
|
||||
/// (otherwise the location the anchored element is rendered is used)
|
||||
pub fn position(mut self, anchor: Point<Pixels>) -> Self {
|
||||
self.anchor_position = Some(anchor);
|
||||
self
|
||||
}
|
||||
|
||||
/// Offset the final position by this amount.
|
||||
/// Useful when you want to anchor to an element but offset from it, such as in PopoverMenu.
|
||||
pub fn offset(mut self, offset: Point<Pixels>) -> Self {
|
||||
self.offset = Some(offset);
|
||||
self
|
||||
}
|
||||
|
||||
/// Sets the position mode for this anchored element. Local will have this
|
||||
/// interpret its [`Anchored::position`] as relative to the parent element.
|
||||
/// While Window will have it interpret the position as relative to the window.
|
||||
pub fn position_mode(mut self, mode: AnchoredPositionMode) -> Self {
|
||||
self.position_mode = mode;
|
||||
self
|
||||
}
|
||||
|
||||
/// Snap to window edge instead of switching anchor corner when an overflow would occur.
|
||||
pub fn snap_to_window(mut self) -> Self {
|
||||
self.fit_mode = AnchoredFitMode::SnapToWindow;
|
||||
self
|
||||
}
|
||||
|
||||
/// Snap to window edge and leave some margins.
|
||||
pub fn snap_to_window_with_margin(mut self, edges: impl Into<Edges<Pixels>>) -> Self {
|
||||
self.fit_mode = AnchoredFitMode::SnapToWindowWithMargin(edges.into());
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl ParentElement for Anchored {
|
||||
fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
|
||||
self.children.extend(elements)
|
||||
}
|
||||
}
|
||||
|
||||
impl Element for Anchored {
|
||||
type PrepaintState = ();
|
||||
type RequestLayoutState = AnchoredState;
|
||||
|
||||
fn id(&self) -> Option<gpui::ElementId> {
|
||||
None
|
||||
}
|
||||
|
||||
fn source_location(&self) -> Option<&'static core::panic::Location<'static>> {
|
||||
None
|
||||
}
|
||||
|
||||
fn request_layout(
|
||||
&mut self,
|
||||
_id: Option<&GlobalElementId>,
|
||||
_inspector_id: Option<&InspectorElementId>,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) -> (gpui::LayoutId, Self::RequestLayoutState) {
|
||||
let child_layout_ids = self
|
||||
.children
|
||||
.iter_mut()
|
||||
.map(|child| child.request_layout(window, cx))
|
||||
.collect::<SmallVec<_>>();
|
||||
|
||||
let anchored_style = Style {
|
||||
position: Position::Absolute,
|
||||
display: Display::Flex,
|
||||
..Style::default()
|
||||
};
|
||||
|
||||
let layout_id = window.request_layout(anchored_style, child_layout_ids.iter().copied(), cx);
|
||||
|
||||
(layout_id, AnchoredState { child_layout_ids })
|
||||
}
|
||||
|
||||
fn prepaint(
|
||||
&mut self,
|
||||
_id: Option<&GlobalElementId>,
|
||||
_inspector_id: Option<&InspectorElementId>,
|
||||
bounds: Bounds<Pixels>,
|
||||
request_layout: &mut Self::RequestLayoutState,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) {
|
||||
if request_layout.child_layout_ids.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
let mut child_min = point(Pixels::MAX, Pixels::MAX);
|
||||
let mut child_max = Point::default();
|
||||
for child_layout_id in &request_layout.child_layout_ids {
|
||||
let child_bounds = window.layout_bounds(*child_layout_id);
|
||||
child_min = child_min.min(&child_bounds.origin);
|
||||
child_max = child_max.max(&child_bounds.bottom_right());
|
||||
}
|
||||
let size: Size<Pixels> = (child_max - child_min).into();
|
||||
|
||||
let (origin, mut desired) = self.position_mode.get_position_and_bounds(
|
||||
self.anchor_position,
|
||||
self.anchor_corner,
|
||||
size,
|
||||
bounds,
|
||||
self.offset,
|
||||
);
|
||||
|
||||
let limits = Bounds {
|
||||
origin: Point::default(),
|
||||
size: window.viewport_size(),
|
||||
};
|
||||
|
||||
if self.fit_mode == AnchoredFitMode::SwitchAnchor {
|
||||
let mut anchor_corner = self.anchor_corner;
|
||||
|
||||
if desired.left() < limits.left() || desired.right() > limits.right() {
|
||||
let switched = Bounds::from_corner_and_size(
|
||||
anchor_corner
|
||||
.other_side_corner_along(Axis::Horizontal)
|
||||
.into(),
|
||||
origin,
|
||||
size,
|
||||
);
|
||||
if !(switched.left() < limits.left() || switched.right() > limits.right()) {
|
||||
anchor_corner = anchor_corner.other_side_corner_along(Axis::Horizontal);
|
||||
desired = switched
|
||||
}
|
||||
}
|
||||
|
||||
if desired.top() < limits.top() || desired.bottom() > limits.bottom() {
|
||||
let switched = Bounds::from_corner_and_size(
|
||||
anchor_corner.other_side_corner_along(Axis::Vertical).into(),
|
||||
origin,
|
||||
size,
|
||||
);
|
||||
if !(switched.top() < limits.top() || switched.bottom() > limits.bottom()) {
|
||||
desired = switched;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let client_inset = window.client_inset().unwrap_or(px(0.));
|
||||
let edges = match self.fit_mode {
|
||||
AnchoredFitMode::SnapToWindowWithMargin(edges) => edges,
|
||||
_ => Edges::default(),
|
||||
}
|
||||
.map(|edge| *edge + client_inset);
|
||||
|
||||
// Snap the horizontal edges of the anchored element to the horizontal edges of the window if
|
||||
// its horizontal bounds overflow, aligning to the left if it is wider than the limits.
|
||||
if desired.right() > limits.right() {
|
||||
desired.origin.x -= desired.right() - limits.right() + edges.right;
|
||||
}
|
||||
if desired.left() < limits.left() {
|
||||
desired.origin.x = limits.origin.x + edges.left;
|
||||
}
|
||||
|
||||
// Snap the vertical edges of the anchored element to the vertical edges of the window if
|
||||
// its vertical bounds overflow, aligning to the top if it is taller than the limits.
|
||||
if desired.bottom() > limits.bottom() {
|
||||
desired.origin.y -= desired.bottom() - limits.bottom() + edges.bottom;
|
||||
}
|
||||
if desired.top() < limits.top() {
|
||||
desired.origin.y = limits.origin.y + edges.top;
|
||||
}
|
||||
|
||||
let offset = desired.origin - bounds.origin;
|
||||
let offset = point(offset.x.round(), offset.y.round());
|
||||
|
||||
window.with_element_offset(offset, |window| {
|
||||
for child in &mut self.children {
|
||||
child.prepaint(window, cx);
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn paint(
|
||||
&mut self,
|
||||
_id: Option<&GlobalElementId>,
|
||||
_inspector_id: Option<&InspectorElementId>,
|
||||
_bounds: Bounds<Pixels>,
|
||||
_request_layout: &mut Self::RequestLayoutState,
|
||||
_prepaint: &mut Self::PrepaintState,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) {
|
||||
for child in &mut self.children {
|
||||
child.paint(window, cx);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl IntoElement for Anchored {
|
||||
type Element = Self;
|
||||
|
||||
fn into_element(self) -> Self::Element {
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// Which algorithm to use when fitting the anchored element to be inside the window.
|
||||
#[allow(dead_code)]
|
||||
#[derive(Copy, Clone, PartialEq)]
|
||||
pub enum AnchoredFitMode {
|
||||
/// Snap the anchored element to the window edge.
|
||||
SnapToWindow,
|
||||
/// Snap to window edge and leave some margins.
|
||||
SnapToWindowWithMargin(Edges<Pixels>),
|
||||
/// Switch which corner anchor this anchored element is attached to.
|
||||
SwitchAnchor,
|
||||
}
|
||||
|
||||
/// Which algorithm to use when positioning the anchored element.
|
||||
#[allow(dead_code)]
|
||||
#[derive(Copy, Clone, PartialEq)]
|
||||
pub enum AnchoredPositionMode {
|
||||
/// Position the anchored element relative to the window.
|
||||
Window,
|
||||
/// Position the anchored element relative to its parent.
|
||||
Local,
|
||||
}
|
||||
|
||||
impl AnchoredPositionMode {
|
||||
fn get_position_and_bounds(
|
||||
&self,
|
||||
anchor_position: Option<Point<Pixels>>,
|
||||
anchor_corner: Anchor,
|
||||
size: Size<Pixels>,
|
||||
bounds: Bounds<Pixels>,
|
||||
offset: Option<Point<Pixels>>,
|
||||
) -> (Point<Pixels>, Bounds<Pixels>) {
|
||||
let offset = offset.unwrap_or_default();
|
||||
|
||||
match self {
|
||||
AnchoredPositionMode::Window => {
|
||||
let anchor_position = anchor_position.unwrap_or(bounds.origin);
|
||||
let bounds =
|
||||
Self::from_corner_and_size(anchor_corner, anchor_position + offset, size);
|
||||
(anchor_position, bounds)
|
||||
}
|
||||
AnchoredPositionMode::Local => {
|
||||
let anchor_position = anchor_position.unwrap_or_default();
|
||||
let bounds = Self::from_corner_and_size(
|
||||
anchor_corner,
|
||||
bounds.origin + anchor_position + offset,
|
||||
size,
|
||||
);
|
||||
(anchor_position, bounds)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Ref https://github.com/zed-industries/zed/blob/b06f4088a3565c5e30663106ff79c1ced645d87a/crates/gpui/src/geometry.rs#L863
|
||||
fn from_corner_and_size(
|
||||
anchor: Anchor,
|
||||
origin: Point<Pixels>,
|
||||
size: Size<Pixels>,
|
||||
) -> Bounds<Pixels> {
|
||||
let origin = match anchor {
|
||||
Anchor::TopLeft => origin,
|
||||
Anchor::TopCenter => Point {
|
||||
x: origin.x - size.width.half(),
|
||||
y: origin.y,
|
||||
},
|
||||
Anchor::TopRight => Point {
|
||||
x: origin.x - size.width,
|
||||
y: origin.y,
|
||||
},
|
||||
Anchor::BottomLeft => Point {
|
||||
x: origin.x,
|
||||
y: origin.y - size.height,
|
||||
},
|
||||
Anchor::BottomCenter => Point {
|
||||
x: origin.x - size.width.half(),
|
||||
y: origin.y - size.height,
|
||||
},
|
||||
Anchor::BottomRight => Point {
|
||||
x: origin.x - size.width,
|
||||
y: origin.y - size.height,
|
||||
},
|
||||
};
|
||||
|
||||
Bounds { origin, size }
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,24 @@
|
||||
use gpui::prelude::FluentBuilder;
|
||||
use gpui::{
|
||||
div, img, px, rems, AbsoluteLength, App, Hsla, ImageSource, Img, IntoElement, ParentElement,
|
||||
RenderOnce, Styled, StyledImage, Window,
|
||||
AbsoluteLength, App, Div, Hsla, ImageSource, Img, InteractiveElement, Interactivity,
|
||||
IntoElement, ParentElement, RenderOnce, StyleRefinement, Styled, StyledImage, Window, div, img,
|
||||
px,
|
||||
};
|
||||
use theme::ActiveTheme;
|
||||
|
||||
use crate::{Selectable, Sizable, Size};
|
||||
|
||||
/// Returns the size of the avatar based on the given [`Size`].
|
||||
pub(super) fn avatar_size(size: Size) -> AbsoluteLength {
|
||||
match size {
|
||||
Size::Large => px(64.).into(),
|
||||
Size::Medium => px(32.).into(),
|
||||
Size::Small => px(24.).into(),
|
||||
Size::XSmall => px(20.).into(),
|
||||
Size::Size(size) => size.into(),
|
||||
}
|
||||
}
|
||||
|
||||
/// An element that renders a user avatar with customizable appearance options.
|
||||
///
|
||||
/// # Examples
|
||||
@@ -18,18 +32,24 @@ use theme::ActiveTheme;
|
||||
/// ```
|
||||
#[derive(IntoElement)]
|
||||
pub struct Avatar {
|
||||
base: Div,
|
||||
image: Img,
|
||||
size: Option<AbsoluteLength>,
|
||||
style: StyleRefinement,
|
||||
size: Size,
|
||||
border_color: Option<Hsla>,
|
||||
selected: bool,
|
||||
}
|
||||
|
||||
impl Avatar {
|
||||
/// Creates a new avatar element with the specified image source.
|
||||
pub fn new(src: impl Into<ImageSource>) -> Self {
|
||||
Avatar {
|
||||
base: div(),
|
||||
image: img(src),
|
||||
size: None,
|
||||
style: StyleRefinement::default(),
|
||||
size: Size::Medium,
|
||||
border_color: None,
|
||||
selected: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,14 +76,38 @@ impl Avatar {
|
||||
self.border_color = Some(color.into());
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// Size overrides the avatar size. By default they are 1rem.
|
||||
pub fn size<L: Into<AbsoluteLength>>(mut self, size: impl Into<Option<L>>) -> Self {
|
||||
self.size = size.into().map(Into::into);
|
||||
impl Sizable for Avatar {
|
||||
fn with_size(mut self, size: impl Into<Size>) -> Self {
|
||||
self.size = size.into();
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl Styled for Avatar {
|
||||
fn style(&mut self) -> &mut StyleRefinement {
|
||||
&mut self.style
|
||||
}
|
||||
}
|
||||
|
||||
impl Selectable for Avatar {
|
||||
fn is_selected(&self) -> bool {
|
||||
self.selected
|
||||
}
|
||||
|
||||
fn selected(mut self, selected: bool) -> Self {
|
||||
self.selected = selected;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl InteractiveElement for Avatar {
|
||||
fn interactivity(&mut self) -> &mut Interactivity {
|
||||
self.base.interactivity()
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for Avatar {
|
||||
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let border_width = if self.border_color.is_some() {
|
||||
@@ -71,8 +115,7 @@ impl RenderOnce for Avatar {
|
||||
} else {
|
||||
px(0.)
|
||||
};
|
||||
|
||||
let image_size = self.size.unwrap_or_else(|| rems(1.).into());
|
||||
let image_size = avatar_size(self.size);
|
||||
let container_size = image_size.to_pixels(window.rem_size()) + border_width * 2.;
|
||||
|
||||
div()
|
||||
|
||||
@@ -3,20 +3,26 @@ use std::sync::Arc;
|
||||
|
||||
use gpui::prelude::FluentBuilder as _;
|
||||
use gpui::{
|
||||
div, px, App, AppContext, Axis, Context, Element, Entity, IntoElement, MouseMoveEvent,
|
||||
MouseUpEvent, ParentElement as _, Pixels, Point, Render, Style, Styled as _, WeakEntity,
|
||||
Window,
|
||||
App, AppContext, Axis, Context, Element, Empty, Entity, IntoElement, MouseMoveEvent,
|
||||
MouseUpEvent, ParentElement as _, Pixels, Point, Render, Style, StyleRefinement, Styled as _,
|
||||
WeakEntity, Window, div, px,
|
||||
};
|
||||
|
||||
use super::{DockArea, DockItem};
|
||||
use crate::dock_area::panel::PanelView;
|
||||
use crate::dock_area::tab_panel::TabPanel;
|
||||
use crate::resizable::{resize_handle, PANEL_MIN_SIZE};
|
||||
use crate::StyledExt;
|
||||
use crate::dock::panel::PanelView;
|
||||
use crate::dock::tab_panel::TabPanel;
|
||||
use crate::resizable::{PANEL_MIN_SIZE, resize_handle};
|
||||
|
||||
#[derive(Clone, Render)]
|
||||
#[derive(Clone)]
|
||||
struct ResizePanel;
|
||||
|
||||
impl Render for ResizePanel {
|
||||
fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
|
||||
Empty
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum DockPlacement {
|
||||
Center,
|
||||
@@ -265,22 +271,22 @@ impl Dock {
|
||||
let mut right_dock_size = px(0.0);
|
||||
|
||||
// Get the size of the left dock if it's open and not the current dock
|
||||
if let Some(left_dock) = &dock_area.left_dock {
|
||||
if left_dock.entity_id() != cx.entity().entity_id() {
|
||||
let left_dock_read = left_dock.read(cx);
|
||||
if left_dock_read.is_open() {
|
||||
left_dock_size = left_dock_read.size;
|
||||
}
|
||||
if let Some(left_dock) = &dock_area.left_dock
|
||||
&& left_dock.entity_id() != cx.entity().entity_id()
|
||||
{
|
||||
let left_dock_read = left_dock.read(cx);
|
||||
if left_dock_read.is_open() {
|
||||
left_dock_size = left_dock_read.size;
|
||||
}
|
||||
}
|
||||
|
||||
// Get the size of the right dock if it's open and not the current dock
|
||||
if let Some(right_dock) = &dock_area.right_dock {
|
||||
if right_dock.entity_id() != cx.entity().entity_id() {
|
||||
let right_dock_read = right_dock.read(cx);
|
||||
if right_dock_read.is_open() {
|
||||
right_dock_size = right_dock_read.size;
|
||||
}
|
||||
if let Some(right_dock) = &dock_area.right_dock
|
||||
&& right_dock.entity_id() != cx.entity().entity_id()
|
||||
{
|
||||
let right_dock_read = right_dock.read(cx);
|
||||
if right_dock_read.is_open() {
|
||||
right_dock_size = right_dock_read.size;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -321,6 +327,8 @@ impl Render for Dock {
|
||||
return div();
|
||||
}
|
||||
|
||||
let cache_style = StyleRefinement::default().absolute().size_full();
|
||||
|
||||
div()
|
||||
.relative()
|
||||
.overflow_hidden()
|
||||
@@ -336,7 +344,7 @@ impl Render for Dock {
|
||||
.map(|this| match &self.panel {
|
||||
DockItem::Split { view, .. } => this.child(view.clone()),
|
||||
DockItem::Tabs { view, .. } => this.child(view.clone()),
|
||||
DockItem::Panel { view, .. } => this.child(view.clone().view()),
|
||||
DockItem::Panel { view, .. } => this.child(view.clone().view().cached(cache_style)),
|
||||
})
|
||||
.child(self.render_resize_handle(window, cx))
|
||||
.child(DockElement {
|
||||
@@ -2,21 +2,24 @@ use std::sync::Arc;
|
||||
|
||||
use gpui::prelude::FluentBuilder;
|
||||
use gpui::{
|
||||
actions, div, px, AnyElement, AnyView, App, AppContext, Axis, Bounds, Context, Edges, Entity,
|
||||
AnyElement, AnyView, App, AppContext, Axis, Bounds, Context, Decorations, Edges, Entity,
|
||||
EntityId, EventEmitter, Focusable, InteractiveElement as _, IntoElement, ParentElement as _,
|
||||
Pixels, Render, SharedString, Styled, Subscription, WeakEntity, Window,
|
||||
Pixels, Render, SharedString, Styled, Subscription, WeakEntity, Window, actions, div, px,
|
||||
};
|
||||
use theme::CLIENT_SIDE_DECORATION_ROUNDING;
|
||||
|
||||
use crate::dock_area::dock::{Dock, DockPlacement};
|
||||
use crate::dock_area::panel::{Panel, PanelEvent, PanelStyle, PanelView};
|
||||
use crate::dock_area::stack_panel::StackPanel;
|
||||
use crate::dock_area::tab_panel::TabPanel;
|
||||
use crate::ElementExt;
|
||||
|
||||
pub mod dock;
|
||||
pub mod panel;
|
||||
pub mod stack_panel;
|
||||
pub mod tab_panel;
|
||||
#[allow(clippy::module_inception)]
|
||||
mod dock;
|
||||
mod panel;
|
||||
mod stack_panel;
|
||||
mod tab_panel;
|
||||
|
||||
pub use dock::*;
|
||||
pub use panel::*;
|
||||
pub use stack_panel::*;
|
||||
pub use tab_panel::*;
|
||||
|
||||
actions!(dock, [ToggleZoom, ClosePanel]);
|
||||
|
||||
@@ -202,19 +205,16 @@ impl DockItem {
|
||||
/// Returns all panel ids
|
||||
pub fn panel_ids(&self, cx: &App) -> Vec<SharedString> {
|
||||
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::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(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -745,6 +745,7 @@ impl EventEmitter<DockEvent> for DockArea {}
|
||||
impl Render for DockArea {
|
||||
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let view = cx.entity().clone();
|
||||
let decorations = window.window_decorations();
|
||||
|
||||
div()
|
||||
.id("dock-area")
|
||||
@@ -754,7 +755,17 @@ impl Render for DockArea {
|
||||
.on_prepaint(move |bounds, _, cx| view.update(cx, |r, _| r.bounds = bounds))
|
||||
.map(|this| {
|
||||
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 {
|
||||
// render dock
|
||||
this.child(
|
||||
@@ -1,5 +1,5 @@
|
||||
use gpui::{
|
||||
AnyElement, AnyView, App, Element, Entity, EventEmitter, FocusHandle, Focusable, Hsla, Render,
|
||||
AnyElement, AnyView, App, Element, Entity, EventEmitter, FocusHandle, Focusable, Render,
|
||||
SharedString, Window,
|
||||
};
|
||||
|
||||
@@ -21,12 +21,6 @@ pub enum PanelStyle {
|
||||
TabBar,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct TitleStyle {
|
||||
pub background: Hsla,
|
||||
pub foreground: Hsla,
|
||||
}
|
||||
|
||||
pub trait Panel: EventEmitter<PanelEvent> + Render + Focusable {
|
||||
/// The name of the panel used to serialize, deserialize and identify the panel.
|
||||
///
|
||||
@@ -7,16 +7,16 @@ use gpui::{
|
||||
Window,
|
||||
};
|
||||
use smallvec::SmallVec;
|
||||
use theme::{ActiveTheme, CLIENT_SIDE_DECORATION_ROUNDING};
|
||||
use theme::{ActiveTheme, AxisExt as _, CLIENT_SIDE_DECORATION_ROUNDING, Placement};
|
||||
|
||||
use super::{DockArea, PanelEvent};
|
||||
use crate::dock_area::panel::{Panel, PanelView};
|
||||
use crate::dock_area::tab_panel::TabPanel;
|
||||
use crate::dock::panel::{Panel, PanelView};
|
||||
use crate::dock::tab_panel::TabPanel;
|
||||
use crate::h_flex;
|
||||
use crate::resizable::{
|
||||
resizable_panel, ResizablePanelEvent, ResizablePanelGroup, ResizablePanelState, ResizableState,
|
||||
PANEL_MIN_SIZE,
|
||||
PANEL_MIN_SIZE, ResizablePanelEvent, ResizablePanelGroup, ResizablePanelState, ResizableState,
|
||||
resizable_panel,
|
||||
};
|
||||
use crate::{h_flex, AxisExt as _, Placement};
|
||||
|
||||
pub struct StackPanel {
|
||||
pub(super) parent: Option<WeakEntity<StackPanel>>,
|
||||
@@ -70,10 +70,10 @@ impl StackPanel {
|
||||
return false;
|
||||
}
|
||||
|
||||
if let Some(parent) = &self.parent {
|
||||
if let Some(parent) = parent.upgrade() {
|
||||
return parent.read(cx).is_last_panel(cx);
|
||||
}
|
||||
if let Some(parent) = &self.parent
|
||||
&& let Some(parent) = parent.upgrade()
|
||||
{
|
||||
return parent.read(cx).is_last_panel(cx);
|
||||
}
|
||||
|
||||
true
|
||||
@@ -297,12 +297,11 @@ impl StackPanel {
|
||||
|
||||
/// Find the first top left in the stack.
|
||||
pub fn left_top_tab_panel(&self, check_parent: bool, cx: &App) -> Option<Entity<TabPanel>> {
|
||||
if check_parent {
|
||||
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) {
|
||||
return Some(panel);
|
||||
}
|
||||
}
|
||||
if check_parent
|
||||
&& let Some(parent) = self.parent.as_ref().and_then(|parent| parent.upgrade())
|
||||
&& let Some(panel) = parent.read(cx).left_top_tab_panel(true, cx)
|
||||
{
|
||||
return Some(panel);
|
||||
}
|
||||
|
||||
let first_panel = self.panels.first();
|
||||
@@ -321,12 +320,11 @@ impl StackPanel {
|
||||
|
||||
/// Find the first top right in the stack.
|
||||
pub fn right_top_tab_panel(&self, check_parent: bool, cx: &App) -> Option<Entity<TabPanel>> {
|
||||
if check_parent {
|
||||
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) {
|
||||
return Some(panel);
|
||||
}
|
||||
}
|
||||
if check_parent
|
||||
&& let Some(parent) = self.parent.as_ref().and_then(|parent| parent.upgrade())
|
||||
&& let Some(panel) = parent.read(cx).right_top_tab_panel(true, cx)
|
||||
{
|
||||
return Some(panel);
|
||||
}
|
||||
|
||||
let panel = if self.axis.is_vertical() {
|
||||
@@ -2,22 +2,22 @@ use std::sync::Arc;
|
||||
|
||||
use gpui::prelude::FluentBuilder;
|
||||
use gpui::{
|
||||
div, px, rems, App, AppContext, Context, Corner, DefiniteLength, DismissEvent, DragMoveEvent,
|
||||
Empty, Entity, EventEmitter, FocusHandle, Focusable, InteractiveElement as _, IntoElement,
|
||||
MouseButton, ParentElement, Pixels, Render, ScrollHandle, SharedString,
|
||||
StatefulInteractiveElement, Styled, WeakEntity, Window,
|
||||
Anchor, App, AppContext, Context, DefiniteLength, DismissEvent, DragMoveEvent, Empty, Entity,
|
||||
EventEmitter, FocusHandle, Focusable, InteractiveElement as _, IntoElement, MouseButton,
|
||||
ParentElement, Pixels, Render, ScrollHandle, SharedString, StatefulInteractiveElement, Styled,
|
||||
WeakEntity, Window, div, px, rems,
|
||||
};
|
||||
use theme::{ActiveTheme, CLIENT_SIDE_DECORATION_ROUNDING, TABBAR_HEIGHT};
|
||||
use theme::{ActiveTheme, AxisExt, CLIENT_SIDE_DECORATION_ROUNDING, Placement, TABBAR_HEIGHT};
|
||||
|
||||
use crate::button::{Button, ButtonVariants as _};
|
||||
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::dock::dock::DockPlacement;
|
||||
use crate::dock::panel::{Panel, PanelView};
|
||||
use crate::dock::stack_panel::StackPanel;
|
||||
use crate::dock::{ClosePanel, DockArea, PanelEvent, PanelStyle, ToggleZoom};
|
||||
use crate::menu::{DropdownMenu, PopupMenu};
|
||||
use crate::tab::tab_bar::TabBar;
|
||||
use crate::tab::Tab;
|
||||
use crate::{h_flex, v_flex, AxisExt, IconName, Placement, Selectable, Sizable, StyledExt};
|
||||
use crate::tab::tab_bar::TabBar;
|
||||
use crate::{IconName, Selectable, Sizable, StyledExt, h_flex, v_flex};
|
||||
|
||||
#[derive(Clone)]
|
||||
struct TabState {
|
||||
@@ -42,22 +42,20 @@ impl DragPanel {
|
||||
|
||||
impl Render for DragPanel {
|
||||
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
div()
|
||||
h_flex()
|
||||
.id("drag-panel")
|
||||
.cursor_grab()
|
||||
.py_1()
|
||||
.px_2()
|
||||
.w_24()
|
||||
.flex()
|
||||
.items_center()
|
||||
.p_2()
|
||||
.min_w_24()
|
||||
.justify_center()
|
||||
.overflow_hidden()
|
||||
.whitespace_nowrap()
|
||||
.rounded(cx.theme().radius_lg)
|
||||
.rounded(cx.theme().radius)
|
||||
.text_xs()
|
||||
.when(cx.theme().shadow, |this| this.shadow_lg())
|
||||
.text_color(cx.theme().text)
|
||||
.text_ellipsis()
|
||||
.when(cx.theme().shadow, |this| this.shadow_xs())
|
||||
.bg(cx.theme().background)
|
||||
.text_color(cx.theme().text_accent)
|
||||
.child(self.panel.title(cx))
|
||||
}
|
||||
}
|
||||
@@ -234,14 +232,13 @@ impl TabPanel {
|
||||
.any(|p| p.panel_id(cx) == panel.panel_id(cx))
|
||||
{
|
||||
// Set the active panel to the matched panel
|
||||
if active {
|
||||
if let Some(ix) = self
|
||||
if active
|
||||
&& let Some(ix) = self
|
||||
.panels
|
||||
.iter()
|
||||
.position(|p| p.panel_id(cx) == panel.panel_id(cx))
|
||||
{
|
||||
self.set_active_ix(ix, window, cx);
|
||||
}
|
||||
{
|
||||
self.set_active_ix(ix, window, cx);
|
||||
}
|
||||
|
||||
return;
|
||||
@@ -374,12 +371,11 @@ impl TabPanel {
|
||||
|
||||
/// Return true if self or parent only have last panel.
|
||||
fn is_last_panel(&self, cx: &App) -> bool {
|
||||
if let Some(parent) = &self.stack_panel {
|
||||
if let Some(stack_panel) = parent.upgrade() {
|
||||
if !stack_panel.read(cx).is_last_panel(cx) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if let Some(parent) = &self.stack_panel
|
||||
&& let Some(stack_panel) = parent.upgrade()
|
||||
&& !stack_panel.read(cx).is_last_panel(cx)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
self.panels.len() <= 1
|
||||
@@ -425,14 +421,13 @@ impl TabPanel {
|
||||
let view = cx.entity().clone();
|
||||
let build_popup_menu = move |this, cx: &App| view.read(cx).popup_menu(this, cx);
|
||||
let toolbar = self.toolbar_buttons(window, cx);
|
||||
let has_toolbar = !toolbar.is_empty();
|
||||
|
||||
h_flex()
|
||||
.p_0p5()
|
||||
.gap_1()
|
||||
.gap_1p5()
|
||||
.occlude()
|
||||
.rounded_full()
|
||||
.children(toolbar.into_iter().map(|btn| btn.small().ghost().rounded()))
|
||||
.children(toolbar.into_iter().map(|btn| btn.small().ghost()))
|
||||
.when(self.zoomed, |this| {
|
||||
this.child(
|
||||
Button::new("zoom")
|
||||
@@ -445,15 +440,11 @@ impl TabPanel {
|
||||
})),
|
||||
)
|
||||
})
|
||||
.when(has_toolbar, |this| {
|
||||
this.child(div().flex_shrink_0().h_4().w_px().bg(cx.theme().border))
|
||||
})
|
||||
.child(
|
||||
Button::new("menu")
|
||||
.icon(IconName::Ellipsis)
|
||||
.small()
|
||||
.ghost()
|
||||
.rounded()
|
||||
.dropdown_menu({
|
||||
let zoomable = state.zoomable;
|
||||
let closable = state.closable;
|
||||
@@ -469,7 +460,7 @@ impl TabPanel {
|
||||
})
|
||||
}
|
||||
})
|
||||
.anchor(Corner::TopRight),
|
||||
.anchor(Anchor::TopRight),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -578,6 +569,7 @@ impl TabPanel {
|
||||
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();
|
||||
let is_bottom_dock = bottom_dock_button.is_some();
|
||||
|
||||
if tabs_count == 1 && dock_area.read(cx).panel_style == PanelStyle::Default {
|
||||
let panel = self.panels.first().unwrap();
|
||||
@@ -646,7 +638,7 @@ impl TabPanel {
|
||||
.into_any_element();
|
||||
}
|
||||
|
||||
TabBar::new()
|
||||
TabBar::new("tab-bar")
|
||||
.track_scroll(&self.tab_bar_scroll_handle)
|
||||
.h(TABBAR_HEIGHT)
|
||||
.when(has_extend_dock_button, |this| {
|
||||
@@ -659,8 +651,9 @@ impl TabPanel {
|
||||
.border_b_1()
|
||||
.h_full()
|
||||
.border_color(cx.theme().border)
|
||||
.bg(cx.theme().surface_background)
|
||||
.px_2()
|
||||
.bg(cx.theme().tab_background)
|
||||
.pl_0p5()
|
||||
.pr_1()
|
||||
.children(left_dock_button)
|
||||
.children(bottom_dock_button),
|
||||
)
|
||||
@@ -682,16 +675,43 @@ impl TabPanel {
|
||||
Some(
|
||||
Tab::new()
|
||||
.ix(ix)
|
||||
.label(panel.title(cx))
|
||||
.py_2()
|
||||
.tab_bar_prefix(has_extend_dock_button)
|
||||
.child(panel.title(cx))
|
||||
.selected(active)
|
||||
.disabled(disabled)
|
||||
.suffix(
|
||||
Button::new("close-{ix}")
|
||||
.icon(IconName::Close)
|
||||
.tooltip("Close panel")
|
||||
.ghost()
|
||||
.xsmall()
|
||||
.on_click(cx.listener({
|
||||
let panel = panel.clone();
|
||||
move |view, _ev, window, cx| {
|
||||
view.remove_panel(&panel, window, cx);
|
||||
}
|
||||
})),
|
||||
)
|
||||
.on_click(cx.listener({
|
||||
let is_collapsed = self.collapsed;
|
||||
let dock_area = self.dock_area.clone();
|
||||
move |view, _, window, cx| {
|
||||
view.set_active_ix(ix, window, cx);
|
||||
|
||||
// Open dock if clicked on the collapsed bottom dock
|
||||
if is_bottom_dock && is_collapsed {
|
||||
_ = dock_area.update(cx, |dock_area, cx| {
|
||||
dock_area.toggle_dock(DockPlacement::Bottom, window, cx);
|
||||
});
|
||||
}
|
||||
}
|
||||
}))
|
||||
.when(!disabled, |this| {
|
||||
this.on_mouse_down(
|
||||
MouseButton::Middle,
|
||||
cx.listener({
|
||||
let panel = panel.clone();
|
||||
move |view, _, window, cx| {
|
||||
move |view, _ev, window, cx| {
|
||||
view.remove_panel(&panel, window, cx);
|
||||
}
|
||||
}),
|
||||
@@ -757,14 +777,15 @@ impl TabPanel {
|
||||
this.suffix(
|
||||
h_flex()
|
||||
.items_center()
|
||||
.px_2()
|
||||
.gap_1()
|
||||
.top_0()
|
||||
.right_0()
|
||||
.h_full()
|
||||
.border_color(cx.theme().border)
|
||||
.border_l_1()
|
||||
.border_b_1()
|
||||
.px_0p5()
|
||||
.gap_1()
|
||||
.border_color(cx.theme().border)
|
||||
.bg(cx.theme().tab_background)
|
||||
.child(self.render_toolbar(state, window, cx))
|
||||
.when_some(right_dock_button, |this, btn| this.child(btn)),
|
||||
)
|
||||
@@ -1080,7 +1101,9 @@ impl TabPanel {
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
if let Some(panel) = self.active_panel(cx) {
|
||||
if self.panels.len() > 1
|
||||
&& let Some(panel) = self.active_panel(cx)
|
||||
{
|
||||
self.remove_panel(&panel, window, cx);
|
||||
}
|
||||
}
|
||||
@@ -1097,6 +1120,7 @@ impl Focusable for TabPanel {
|
||||
}
|
||||
|
||||
impl EventEmitter<DismissEvent> for TabPanel {}
|
||||
|
||||
impl EventEmitter<PanelEvent> for TabPanel {}
|
||||
|
||||
impl Render for TabPanel {
|
||||
@@ -1,294 +0,0 @@
|
||||
use std::fmt::{self, Debug, Display, Formatter};
|
||||
|
||||
use gpui::{AbsoluteLength, Axis, Corner, Length, Pixels};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// A enum for defining the placement of the element.
|
||||
///
|
||||
/// See also: [`Side`] if you need to define the left, right side.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum Placement {
|
||||
#[serde(rename = "top")]
|
||||
Top,
|
||||
#[serde(rename = "bottom")]
|
||||
Bottom,
|
||||
#[serde(rename = "left")]
|
||||
Left,
|
||||
#[serde(rename = "right")]
|
||||
Right,
|
||||
}
|
||||
|
||||
impl Display for Placement {
|
||||
fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Placement::Top => write!(f, "Top"),
|
||||
Placement::Bottom => write!(f, "Bottom"),
|
||||
Placement::Left => write!(f, "Left"),
|
||||
Placement::Right => write!(f, "Right"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Placement {
|
||||
#[inline]
|
||||
pub fn is_horizontal(&self) -> bool {
|
||||
matches!(self, Placement::Left | Placement::Right)
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub fn is_vertical(&self) -> bool {
|
||||
matches!(self, Placement::Top | Placement::Bottom)
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub fn axis(&self) -> Axis {
|
||||
match self {
|
||||
Placement::Top | Placement::Bottom => Axis::Vertical,
|
||||
Placement::Left | Placement::Right => Axis::Horizontal,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The anchor position of an element.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
|
||||
pub enum Anchor {
|
||||
#[default]
|
||||
#[serde(rename = "top-left")]
|
||||
TopLeft,
|
||||
#[serde(rename = "top-center")]
|
||||
TopCenter,
|
||||
#[serde(rename = "top-right")]
|
||||
TopRight,
|
||||
#[serde(rename = "bottom-left")]
|
||||
BottomLeft,
|
||||
#[serde(rename = "bottom-center")]
|
||||
BottomCenter,
|
||||
#[serde(rename = "bottom-right")]
|
||||
BottomRight,
|
||||
}
|
||||
|
||||
impl Display for Anchor {
|
||||
fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Anchor::TopLeft => write!(f, "TopLeft"),
|
||||
Anchor::TopCenter => write!(f, "TopCenter"),
|
||||
Anchor::TopRight => write!(f, "TopRight"),
|
||||
Anchor::BottomLeft => write!(f, "BottomLeft"),
|
||||
Anchor::BottomCenter => write!(f, "BottomCenter"),
|
||||
Anchor::BottomRight => write!(f, "BottomRight"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Anchor {
|
||||
/// Returns true if the anchor is at the top.
|
||||
#[inline]
|
||||
pub fn is_top(&self) -> bool {
|
||||
matches!(self, Self::TopLeft | Self::TopCenter | Self::TopRight)
|
||||
}
|
||||
|
||||
/// Returns true if the anchor is at the bottom.
|
||||
#[inline]
|
||||
pub fn is_bottom(&self) -> bool {
|
||||
matches!(
|
||||
self,
|
||||
Self::BottomLeft | Self::BottomCenter | Self::BottomRight
|
||||
)
|
||||
}
|
||||
|
||||
/// Returns true if the anchor is at the left.
|
||||
#[inline]
|
||||
pub fn is_left(&self) -> bool {
|
||||
matches!(self, Self::TopLeft | Self::BottomLeft)
|
||||
}
|
||||
|
||||
/// Returns true if the anchor is at the right.
|
||||
#[inline]
|
||||
pub fn is_right(&self) -> bool {
|
||||
matches!(self, Self::TopRight | Self::BottomRight)
|
||||
}
|
||||
|
||||
/// Returns true if the anchor is at the center.
|
||||
#[inline]
|
||||
pub fn is_center(&self) -> bool {
|
||||
matches!(self, Self::TopCenter | Self::BottomCenter)
|
||||
}
|
||||
|
||||
/// Swaps the vertical position of the anchor.
|
||||
pub fn swap_vertical(&self) -> Self {
|
||||
match self {
|
||||
Anchor::TopLeft => Anchor::BottomLeft,
|
||||
Anchor::TopCenter => Anchor::BottomCenter,
|
||||
Anchor::TopRight => Anchor::BottomRight,
|
||||
Anchor::BottomLeft => Anchor::TopLeft,
|
||||
Anchor::BottomCenter => Anchor::TopCenter,
|
||||
Anchor::BottomRight => Anchor::TopRight,
|
||||
}
|
||||
}
|
||||
|
||||
/// Swaps the horizontal position of the anchor.
|
||||
pub fn swap_horizontal(&self) -> Self {
|
||||
match self {
|
||||
Anchor::TopLeft => Anchor::TopRight,
|
||||
Anchor::TopCenter => Anchor::TopCenter,
|
||||
Anchor::TopRight => Anchor::TopLeft,
|
||||
Anchor::BottomLeft => Anchor::BottomRight,
|
||||
Anchor::BottomCenter => Anchor::BottomCenter,
|
||||
Anchor::BottomRight => Anchor::BottomLeft,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn other_side_corner_along(&self, axis: Axis) -> Anchor {
|
||||
match axis {
|
||||
Axis::Vertical => match self {
|
||||
Self::TopLeft => Self::BottomLeft,
|
||||
Self::TopCenter => Self::BottomCenter,
|
||||
Self::TopRight => Self::BottomRight,
|
||||
Self::BottomLeft => Self::TopLeft,
|
||||
Self::BottomCenter => Self::TopCenter,
|
||||
Self::BottomRight => Self::TopRight,
|
||||
},
|
||||
Axis::Horizontal => match self {
|
||||
Self::TopLeft => Self::TopRight,
|
||||
Self::TopCenter => Self::TopCenter,
|
||||
Self::TopRight => Self::TopLeft,
|
||||
Self::BottomLeft => Self::BottomRight,
|
||||
Self::BottomCenter => Self::BottomCenter,
|
||||
Self::BottomRight => Self::BottomLeft,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Corner> for Anchor {
|
||||
fn from(corner: Corner) -> Self {
|
||||
match corner {
|
||||
Corner::TopLeft => Anchor::TopLeft,
|
||||
Corner::TopRight => Anchor::TopRight,
|
||||
Corner::BottomLeft => Anchor::BottomLeft,
|
||||
Corner::BottomRight => Anchor::BottomRight,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Anchor> for Corner {
|
||||
fn from(anchor: Anchor) -> Self {
|
||||
match anchor {
|
||||
Anchor::TopLeft => Corner::TopLeft,
|
||||
Anchor::TopRight => Corner::TopRight,
|
||||
Anchor::BottomLeft => Corner::BottomLeft,
|
||||
Anchor::BottomRight => Corner::BottomRight,
|
||||
Anchor::TopCenter => Corner::TopLeft,
|
||||
Anchor::BottomCenter => Corner::BottomLeft,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A enum for defining the side of the element.
|
||||
///
|
||||
/// See also: [`Placement`] if you need to define the 4 edges.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum Side {
|
||||
#[serde(rename = "left")]
|
||||
Left,
|
||||
#[serde(rename = "right")]
|
||||
Right,
|
||||
}
|
||||
|
||||
impl Side {
|
||||
/// Returns true if the side is left.
|
||||
#[inline]
|
||||
pub fn is_left(&self) -> bool {
|
||||
matches!(self, Self::Left)
|
||||
}
|
||||
|
||||
/// Returns true if the side is right.
|
||||
#[inline]
|
||||
pub fn is_right(&self) -> bool {
|
||||
matches!(self, Self::Right)
|
||||
}
|
||||
}
|
||||
|
||||
/// A trait to extend the [`Axis`] enum with utility methods.
|
||||
pub trait AxisExt {
|
||||
#[allow(clippy::wrong_self_convention)]
|
||||
fn is_horizontal(self) -> bool;
|
||||
#[allow(clippy::wrong_self_convention)]
|
||||
fn is_vertical(self) -> bool;
|
||||
}
|
||||
|
||||
impl AxisExt for Axis {
|
||||
#[inline]
|
||||
fn is_horizontal(self) -> bool {
|
||||
self == Axis::Horizontal
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn is_vertical(self) -> bool {
|
||||
self == Axis::Vertical
|
||||
}
|
||||
}
|
||||
|
||||
/// A trait for converting [`Pixels`] to `f32` and `f64`.
|
||||
pub trait PixelsExt {
|
||||
fn as_f32(&self) -> f32;
|
||||
#[allow(clippy::wrong_self_convention)]
|
||||
fn as_f64(self) -> f64;
|
||||
}
|
||||
impl PixelsExt for Pixels {
|
||||
fn as_f32(&self) -> f32 {
|
||||
f32::from(self)
|
||||
}
|
||||
|
||||
fn as_f64(self) -> f64 {
|
||||
f64::from(self)
|
||||
}
|
||||
}
|
||||
|
||||
/// A trait to extend the [`Length`] enum with utility methods.
|
||||
pub trait LengthExt {
|
||||
/// Converts the [`Length`] to [`Pixels`] based on a given `base_size` and `rem_size`.
|
||||
///
|
||||
/// If the [`Length`] is [`Length::Auto`], it returns `None`.
|
||||
fn to_pixels(&self, base_size: AbsoluteLength, rem_size: Pixels) -> Option<Pixels>;
|
||||
}
|
||||
|
||||
impl LengthExt for Length {
|
||||
fn to_pixels(&self, base_size: AbsoluteLength, rem_size: Pixels) -> Option<Pixels> {
|
||||
match self {
|
||||
Length::Auto => None,
|
||||
Length::Definite(len) => Some(len.to_pixels(base_size, rem_size)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A struct for defining the edges of an element.
|
||||
///
|
||||
/// A extend version of [`gpui::Edges`] to serialize/deserialize.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize, Eq, PartialEq)]
|
||||
#[repr(C)]
|
||||
pub struct Edges<T: Clone + Debug + Default + PartialEq> {
|
||||
/// The size of the top edge.
|
||||
pub top: T,
|
||||
/// The size of the right edge.
|
||||
pub right: T,
|
||||
/// The size of the bottom edge.
|
||||
pub bottom: T,
|
||||
/// The size of the left edge.
|
||||
pub left: T,
|
||||
}
|
||||
|
||||
impl<T> Edges<T>
|
||||
where
|
||||
T: Clone + Debug + Default + PartialEq,
|
||||
{
|
||||
/// Creates a new `Edges` instance with all edges set to the same value.
|
||||
pub fn all(value: T) -> Self {
|
||||
Self {
|
||||
top: value.clone(),
|
||||
right: value.clone(),
|
||||
bottom: value.clone(),
|
||||
left: value,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
use gpui::prelude::FluentBuilder as _;
|
||||
use gpui::{
|
||||
svg, AnyElement, App, AppContext, Context, Entity, Hsla, IntoElement, Radians, Render,
|
||||
RenderOnce, SharedString, StyleRefinement, Styled, Svg, Transformation, Window,
|
||||
AnyElement, App, AppContext, Context, Entity, Hsla, IntoElement, Radians, Render, RenderOnce,
|
||||
SharedString, StyleRefinement, Styled, Svg, Transformation, Window, svg,
|
||||
};
|
||||
use theme::ActiveTheme;
|
||||
|
||||
@@ -23,6 +23,7 @@ pub enum IconName {
|
||||
ArrowLeft,
|
||||
ArrowRight,
|
||||
Boom,
|
||||
Book,
|
||||
ChevronDown,
|
||||
CaretDown,
|
||||
CaretRight,
|
||||
@@ -33,10 +34,12 @@ pub enum IconName {
|
||||
CloseCircle,
|
||||
CloseCircleFill,
|
||||
Copy,
|
||||
Device,
|
||||
Door,
|
||||
Ellipsis,
|
||||
Emoji,
|
||||
Eye,
|
||||
Input,
|
||||
Info,
|
||||
Invite,
|
||||
Inbox,
|
||||
@@ -51,11 +54,14 @@ pub enum IconName {
|
||||
Relay,
|
||||
Reply,
|
||||
Refresh,
|
||||
Scan,
|
||||
Search,
|
||||
Settings,
|
||||
Settings2,
|
||||
Sun,
|
||||
Ship,
|
||||
Shield,
|
||||
Group,
|
||||
UserKey,
|
||||
Upload,
|
||||
Usb,
|
||||
@@ -89,6 +95,7 @@ impl IconNamed for IconName {
|
||||
Self::ArrowLeft => "icons/arrow-left.svg",
|
||||
Self::ArrowRight => "icons/arrow-right.svg",
|
||||
Self::Boom => "icons/boom.svg",
|
||||
Self::Book => "icons/book.svg",
|
||||
Self::ChevronDown => "icons/chevron-down.svg",
|
||||
Self::CaretDown => "icons/caret-down.svg",
|
||||
Self::CaretRight => "icons/caret-right.svg",
|
||||
@@ -99,10 +106,12 @@ impl IconNamed for IconName {
|
||||
Self::CloseCircle => "icons/close-circle.svg",
|
||||
Self::CloseCircleFill => "icons/close-circle-fill.svg",
|
||||
Self::Copy => "icons/copy.svg",
|
||||
Self::Device => "icons/device.svg",
|
||||
Self::Door => "icons/door.svg",
|
||||
Self::Ellipsis => "icons/ellipsis.svg",
|
||||
Self::Emoji => "icons/emoji.svg",
|
||||
Self::Eye => "icons/eye.svg",
|
||||
Self::Input => "icons/input.svg",
|
||||
Self::Info => "icons/info.svg",
|
||||
Self::Invite => "icons/invite.svg",
|
||||
Self::Inbox => "icons/inbox.svg",
|
||||
@@ -117,14 +126,17 @@ impl IconNamed for IconName {
|
||||
Self::Relay => "icons/relay.svg",
|
||||
Self::Reply => "icons/reply.svg",
|
||||
Self::Refresh => "icons/refresh.svg",
|
||||
Self::Scan => "icons/scan.svg",
|
||||
Self::Search => "icons/search.svg",
|
||||
Self::Settings => "icons/settings.svg",
|
||||
Self::Settings2 => "icons/settings2.svg",
|
||||
Self::Sun => "icons/sun.svg",
|
||||
Self::Ship => "icons/ship.svg",
|
||||
Self::Shield => "icons/shield.svg",
|
||||
Self::UserKey => "icons/user-key.svg",
|
||||
Self::Upload => "icons/upload.svg",
|
||||
Self::Usb => "icons/usb.svg",
|
||||
Self::Group => "icons/group.svg",
|
||||
Self::PanelLeft => "icons/panel-left.svg",
|
||||
Self::PanelLeftOpen => "icons/panel-left-open.svg",
|
||||
Self::PanelRight => "icons/panel-right.svg",
|
||||
|
||||
@@ -2,10 +2,10 @@ use std::ops::Range;
|
||||
use std::rc::Rc;
|
||||
|
||||
use gpui::{
|
||||
fill, point, px, relative, size, App, Bounds, Corners, Element, ElementId, ElementInputHandler,
|
||||
Entity, GlobalElementId, Hitbox, IntoElement, LayoutId, MouseButton, MouseMoveEvent, Path,
|
||||
Pixels, Point, ShapedLine, SharedString, Size, Style, TextAlign, TextRun, UnderlineStyle,
|
||||
Window,
|
||||
App, Bounds, Corners, Element, ElementId, ElementInputHandler, Entity, GlobalElementId, Hitbox,
|
||||
IntoElement, LayoutId, MouseButton, MouseMoveEvent, Path, Pixels, Point, ShapedLine,
|
||||
SharedString, Size, Style, TextAlign, TextRun, UnderlineStyle, Window, fill, point, px,
|
||||
relative, size,
|
||||
};
|
||||
use rope::Rope;
|
||||
use smallvec::SmallVec;
|
||||
@@ -348,10 +348,10 @@ impl TextElement {
|
||||
let mut rev_line_corners = line_corners.iter().rev().peekable();
|
||||
while let Some(corners) = rev_line_corners.next() {
|
||||
points.push(corners.top_left);
|
||||
if let Some(next) = rev_line_corners.peek() {
|
||||
if next.top_left.x > corners.top_left.x {
|
||||
points.push(point(next.top_left.x, corners.top_left.y));
|
||||
}
|
||||
if let Some(next) = rev_line_corners.peek()
|
||||
&& next.top_left.x > corners.top_left.x
|
||||
{
|
||||
points.push(point(next.top_left.x, corners.top_left.y));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -376,10 +376,10 @@ impl TextElement {
|
||||
) -> Option<Path<Pixels>> {
|
||||
let state = self.state.read(cx);
|
||||
let mut selected_range = state.selected_range;
|
||||
if let Some(ime_marked_range) = &state.ime_marked_range {
|
||||
if !ime_marked_range.is_empty() {
|
||||
selected_range = (ime_marked_range.end..ime_marked_range.end).into();
|
||||
}
|
||||
if let Some(ime_marked_range) = &state.ime_marked_range
|
||||
&& !ime_marked_range.is_empty()
|
||||
{
|
||||
selected_range = (ime_marked_range.end..ime_marked_range.end).into();
|
||||
}
|
||||
if selected_range.is_empty() {
|
||||
return None;
|
||||
@@ -830,11 +830,12 @@ impl Element for TextElement {
|
||||
}
|
||||
|
||||
// Paint blinking cursor
|
||||
if focused && show_cursor {
|
||||
if let Some(mut cursor_bounds) = prepaint.cursor_bounds.take() {
|
||||
cursor_bounds.origin.y += prepaint.cursor_scroll_offset.y;
|
||||
window.paint_quad(fill(cursor_bounds, cx.theme().cursor));
|
||||
}
|
||||
if focused
|
||||
&& show_cursor
|
||||
&& let Some(mut cursor_bounds) = prepaint.cursor_bounds.take()
|
||||
{
|
||||
cursor_bounds.origin.y += prepaint.cursor_scroll_offset.y;
|
||||
window.paint_quad(fill(cursor_bounds, cx.theme().cursor));
|
||||
}
|
||||
|
||||
// Paint line numbers
|
||||
|
||||
@@ -225,13 +225,12 @@ impl MaskPattern {
|
||||
}
|
||||
|
||||
// check if the fraction part is valid
|
||||
if let Some(frac) = frac_part {
|
||||
if !frac
|
||||
if let Some(frac) = frac_part
|
||||
&& !frac
|
||||
.chars()
|
||||
.all(|ch| ch.is_ascii_digit() || Some(ch) == *separator)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
true
|
||||
@@ -255,10 +254,10 @@ impl MaskPattern {
|
||||
|
||||
if token.is_sep() {
|
||||
// If next token is match, it's valid
|
||||
if let Some(next_token) = tokens.get(pos + 1) {
|
||||
if next_token.is_match(ch) {
|
||||
return true;
|
||||
}
|
||||
if let Some(next_token) = tokens.get(pos + 1)
|
||||
&& next_token.is_match(ch)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,11 +4,11 @@ use std::time::Duration;
|
||||
|
||||
use gpui::prelude::FluentBuilder as _;
|
||||
use gpui::{
|
||||
actions, div, point, px, Action, App, AppContext, Bounds, ClipboardItem, Context, Entity,
|
||||
EntityInputHandler, EventEmitter, FocusHandle, Focusable, InteractiveElement as _, IntoElement,
|
||||
KeyBinding, KeyDownEvent, MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent,
|
||||
ParentElement as _, Pixels, Point, Render, ScrollHandle, ScrollWheelEvent, SharedString,
|
||||
Styled as _, Subscription, UTF16Selection, Window, WrappedLine,
|
||||
Action, App, AppContext, Bounds, ClipboardItem, Context, Entity, EntityInputHandler,
|
||||
EventEmitter, FocusHandle, Focusable, InteractiveElement as _, IntoElement, KeyBinding,
|
||||
KeyDownEvent, MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent, ParentElement as _,
|
||||
Pixels, Point, Render, ScrollHandle, ScrollWheelEvent, SharedString, Styled as _, Subscription,
|
||||
UTF16Selection, Window, WrappedLine, actions, div, point, px,
|
||||
};
|
||||
use lsp_types::Position;
|
||||
use rope::{OffsetUtf16, Rope};
|
||||
@@ -25,9 +25,9 @@ use super::mask_pattern::MaskPattern;
|
||||
use super::mode::{InputMode, TabSize};
|
||||
use super::rope_ext::RopeExt;
|
||||
use super::text_wrapper::{LineItem, TextWrapper};
|
||||
use crate::Root;
|
||||
use crate::history::History;
|
||||
use crate::input::element::RIGHT_MARGIN;
|
||||
use crate::Root;
|
||||
|
||||
#[derive(Action, Clone, PartialEq, Eq, Deserialize)]
|
||||
#[action(namespace = input, no_json)]
|
||||
@@ -521,16 +521,16 @@ impl InputState {
|
||||
let new_row = new_row as usize;
|
||||
if new_row >= last_layout.visible_range.start {
|
||||
let visible_row = new_row.saturating_sub(last_layout.visible_range.start);
|
||||
if let Some(line) = last_layout.lines.get(visible_row) {
|
||||
if let Ok(x) = line.closest_index_for_position(
|
||||
if let Some(line) = last_layout.lines.get(visible_row)
|
||||
&& let Ok(x) = line.closest_index_for_position(
|
||||
Point {
|
||||
x: preferred_x,
|
||||
y: px(0.),
|
||||
},
|
||||
last_layout.line_height,
|
||||
) {
|
||||
new_offset = line_start_offset + x;
|
||||
}
|
||||
)
|
||||
{
|
||||
new_offset = line_start_offset + x;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1355,10 +1355,10 @@ impl InputState {
|
||||
) {
|
||||
// If there have IME marked range and is empty (Means pressed Esc to abort IME typing)
|
||||
// Clear the marked range.
|
||||
if let Some(ime_marked_range) = &self.ime_marked_range {
|
||||
if ime_marked_range.is_empty() {
|
||||
self.ime_marked_range = None;
|
||||
}
|
||||
if let Some(ime_marked_range) = &self.ime_marked_range
|
||||
&& ime_marked_range.is_empty()
|
||||
{
|
||||
self.ime_marked_range = None;
|
||||
}
|
||||
|
||||
self.selecting = true;
|
||||
@@ -1842,23 +1842,21 @@ impl InputState {
|
||||
|
||||
fn previous_boundary(&self, offset: usize) -> usize {
|
||||
let mut offset = self.text.clip_offset(offset.saturating_sub(1), Bias::Left);
|
||||
if let Some(ch) = self.text.char_at(offset) {
|
||||
if ch == '\r' {
|
||||
offset -= 1;
|
||||
}
|
||||
if let Some(ch) = self.text.char_at(offset)
|
||||
&& ch == '\r'
|
||||
{
|
||||
offset -= 1;
|
||||
}
|
||||
|
||||
offset
|
||||
}
|
||||
|
||||
fn next_boundary(&self, offset: usize) -> usize {
|
||||
let mut offset = self.text.clip_offset(offset + 1, Bias::Right);
|
||||
if let Some(ch) = self.text.char_at(offset) {
|
||||
if ch == '\r' {
|
||||
offset += 1;
|
||||
}
|
||||
if let Some(ch) = self.text.char_at(offset)
|
||||
&& ch == '\r'
|
||||
{
|
||||
offset += 1;
|
||||
}
|
||||
|
||||
offset
|
||||
}
|
||||
|
||||
@@ -1927,10 +1925,10 @@ impl InputState {
|
||||
return true;
|
||||
}
|
||||
|
||||
if let Some(validate) = &self.validate {
|
||||
if !validate(new_text, cx) {
|
||||
return false;
|
||||
}
|
||||
if let Some(validate) = &self.validate
|
||||
&& !validate(new_text, cx)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if !self.mask_pattern.is_valid(new_text) {
|
||||
@@ -1979,19 +1977,19 @@ impl InputState {
|
||||
self.input_bounds = new_bounds;
|
||||
|
||||
// Update text_wrapper wrap_width if changed.
|
||||
if let Some(last_layout) = self.last_layout.as_ref() {
|
||||
if wrap_width_changed {
|
||||
let wrap_width = if !self.soft_wrap {
|
||||
// None to disable wrapping (will use Pixels::MAX)
|
||||
None
|
||||
} else {
|
||||
last_layout.wrap_width
|
||||
};
|
||||
if let Some(last_layout) = self.last_layout.as_ref()
|
||||
&& wrap_width_changed
|
||||
{
|
||||
let wrap_width = if !self.soft_wrap {
|
||||
// None to disable wrapping (will use Pixels::MAX)
|
||||
None
|
||||
} else {
|
||||
last_layout.wrap_width
|
||||
};
|
||||
|
||||
self.text_wrapper.set_wrap_width(wrap_width, cx);
|
||||
self.mode.update_auto_grow(&self.text_wrapper);
|
||||
cx.notify();
|
||||
}
|
||||
self.text_wrapper.set_wrap_width(wrap_width, cx);
|
||||
self.mode.update_auto_grow(&self.text_wrapper);
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2209,20 +2207,18 @@ impl EntityInputHandler for InputState {
|
||||
break;
|
||||
}
|
||||
|
||||
if start_origin.is_none() {
|
||||
if let Some(p) =
|
||||
if start_origin.is_none()
|
||||
&& let Some(p) =
|
||||
line.position_for_index(range.start.saturating_sub(index_offset), line_height)
|
||||
{
|
||||
start_origin = Some(p + point(px(0.), y_offset));
|
||||
}
|
||||
{
|
||||
start_origin = Some(p + point(px(0.), y_offset));
|
||||
}
|
||||
|
||||
if end_origin.is_none() {
|
||||
if let Some(p) =
|
||||
if end_origin.is_none()
|
||||
&& let Some(p) =
|
||||
line.position_for_index(range.end.saturating_sub(index_offset), line_height)
|
||||
{
|
||||
end_origin = Some(p + point(px(0.), y_offset));
|
||||
}
|
||||
{
|
||||
end_origin = Some(p + point(px(0.), y_offset));
|
||||
}
|
||||
|
||||
index_offset += line.len() + 1;
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
pub use anchored::*;
|
||||
pub use element_ext::ElementExt;
|
||||
pub use event::InteractiveElementExt;
|
||||
pub use focusable::FocusableCycle;
|
||||
pub use geometry::*;
|
||||
pub use icon::*;
|
||||
pub use index_path::IndexPath;
|
||||
pub use kbd::*;
|
||||
pub use root::{window_paddings, Root};
|
||||
pub use root::{Root, window_paddings};
|
||||
pub use styled::*;
|
||||
pub use window_ext::*;
|
||||
|
||||
@@ -18,7 +16,7 @@ pub mod avatar;
|
||||
pub mod button;
|
||||
pub mod checkbox;
|
||||
pub mod divider;
|
||||
pub mod dock_area;
|
||||
pub mod dock;
|
||||
pub mod group_box;
|
||||
pub mod history;
|
||||
pub mod indicator;
|
||||
@@ -35,11 +33,9 @@ pub mod switch;
|
||||
pub mod tab;
|
||||
pub mod tooltip;
|
||||
|
||||
mod anchored;
|
||||
mod element_ext;
|
||||
mod event;
|
||||
mod focusable;
|
||||
mod geometry;
|
||||
mod icon;
|
||||
mod index_path;
|
||||
mod kbd;
|
||||
|
||||
@@ -3,21 +3,21 @@ use std::time::Duration;
|
||||
|
||||
use gpui::prelude::FluentBuilder;
|
||||
use gpui::{
|
||||
div, px, size, uniform_list, App, AppContext, AvailableSpace, ClickEvent, Context,
|
||||
DefiniteLength, EdgesRefinement, Entity, EventEmitter, FocusHandle, Focusable,
|
||||
InteractiveElement, IntoElement, KeyBinding, Length, ListSizingBehavior, MouseButton,
|
||||
ParentElement, Render, RenderOnce, ScrollStrategy, SharedString, StatefulInteractiveElement,
|
||||
StyleRefinement, Styled, Subscription, Task, UniformListScrollHandle, Window,
|
||||
App, AppContext, AvailableSpace, ClickEvent, Context, DefiniteLength, EdgesRefinement, Entity,
|
||||
EventEmitter, FocusHandle, Focusable, InteractiveElement, IntoElement, KeyBinding, Length,
|
||||
ListSizingBehavior, MouseButton, ParentElement, Render, RenderOnce, ScrollStrategy,
|
||||
SharedString, StatefulInteractiveElement, StyleRefinement, Styled, Subscription, Task,
|
||||
UniformListScrollHandle, Window, div, px, size, uniform_list,
|
||||
};
|
||||
use smol::Timer;
|
||||
use theme::ActiveTheme;
|
||||
|
||||
use crate::actions::{Cancel, Confirm, SelectDown, SelectUp};
|
||||
use crate::input::{InputEvent, InputState, TextInput};
|
||||
use crate::list::cache::{MeasuredEntrySize, RowEntry, RowsCache};
|
||||
use crate::list::ListDelegate;
|
||||
use crate::list::cache::{MeasuredEntrySize, RowEntry, RowsCache};
|
||||
use crate::scroll::{Scrollbar, ScrollbarHandle};
|
||||
use crate::{v_flex, Icon, IconName, IndexPath, Selectable, Sizable, Size, StyledExt};
|
||||
use crate::{Icon, IconName, IndexPath, Selectable, Sizable, Size, StyledExt, v_flex};
|
||||
|
||||
pub(crate) fn init(cx: &mut App) {
|
||||
let context: Option<&str> = Some("List");
|
||||
@@ -578,10 +578,10 @@ where
|
||||
self.prepare_items_if_needed(window, cx);
|
||||
|
||||
// Scroll to the selected item if it is set.
|
||||
if let Some((ix, strategy)) = self.deferred_scroll_to_index.take() {
|
||||
if let Some(item_ix) = self.rows_cache.position_of(&ix) {
|
||||
self.scroll_handle.scroll_to_item(item_ix, strategy);
|
||||
}
|
||||
if let Some((ix, strategy)) = self.deferred_scroll_to_index.take()
|
||||
&& let Some(item_ix) = self.rows_cache.position_of(&ix)
|
||||
{
|
||||
self.scroll_handle.scroll_to_item(item_ix, strategy);
|
||||
}
|
||||
|
||||
let loading = self.delegate().loading(cx);
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
use gpui::prelude::FluentBuilder;
|
||||
use gpui::{
|
||||
anchored, deferred, div, px, App, AppContext as _, ClickEvent, Context, DismissEvent, Entity,
|
||||
Focusable, InteractiveElement as _, IntoElement, KeyBinding, MouseButton, OwnedMenu,
|
||||
ParentElement, Render, SharedString, StatefulInteractiveElement, Styled, Subscription, Window,
|
||||
App, AppContext as _, ClickEvent, Context, DismissEvent, Entity, Focusable,
|
||||
InteractiveElement as _, IntoElement, KeyBinding, MouseButton, OwnedMenu, ParentElement,
|
||||
Render, SharedString, StatefulInteractiveElement, Styled, Subscription, Window, anchored,
|
||||
deferred, div, px,
|
||||
};
|
||||
|
||||
use crate::actions::{Cancel, SelectLeft, SelectRight};
|
||||
use crate::button::{Button, ButtonVariants};
|
||||
use crate::menu::PopupMenu;
|
||||
use crate::{h_flex, Selectable, Sizable};
|
||||
use crate::{Selectable, Sizable, h_flex};
|
||||
|
||||
const CONTEXT: &str = "AppMenuBar";
|
||||
|
||||
@@ -241,7 +242,7 @@ impl Render for AppMenu {
|
||||
.when(is_selected, |this| {
|
||||
this.child(deferred(
|
||||
anchored()
|
||||
.anchor(gpui::Corner::TopLeft)
|
||||
.anchor(gpui::Anchor::TopLeft)
|
||||
.snap_to_window_with_margin(px(8.))
|
||||
.child(
|
||||
div()
|
||||
|
||||
@@ -3,10 +3,10 @@ use std::rc::Rc;
|
||||
|
||||
use gpui::prelude::FluentBuilder;
|
||||
use gpui::{
|
||||
anchored, deferred, div, px, AnyElement, App, Context, Corner, DismissEvent, Element,
|
||||
ElementId, Entity, Focusable, GlobalElementId, Hitbox, HitboxBehavior, InspectorElementId,
|
||||
InteractiveElement, IntoElement, MouseButton, MouseDownEvent, ParentElement, Pixels, Point,
|
||||
StyleRefinement, Styled, Subscription, Window,
|
||||
Anchor, AnyElement, App, Context, DismissEvent, Element, ElementId, Entity, Focusable,
|
||||
GlobalElementId, Hitbox, HitboxBehavior, InspectorElementId, InteractiveElement, IntoElement,
|
||||
MouseButton, MouseDownEvent, ParentElement, Pixels, Point, StyleRefinement, Styled,
|
||||
Subscription, Window, anchored, deferred, div, px,
|
||||
};
|
||||
|
||||
use crate::menu::PopupMenu;
|
||||
@@ -41,7 +41,7 @@ pub struct ContextMenu<E: ParentElement + Styled + Sized> {
|
||||
menu: Option<Rc<dyn Fn(PopupMenu, &mut Window, &mut Context<PopupMenu>) -> PopupMenu>>,
|
||||
// This is not in use, just for style refinement forwarding.
|
||||
_ignore_style: StyleRefinement,
|
||||
anchor: Corner,
|
||||
anchor: Anchor,
|
||||
}
|
||||
|
||||
impl<E: ParentElement + Styled> ContextMenu<E> {
|
||||
@@ -51,7 +51,7 @@ impl<E: ParentElement + Styled> ContextMenu<E> {
|
||||
id: id.into(),
|
||||
element: Some(element),
|
||||
menu: None,
|
||||
anchor: Corner::TopLeft,
|
||||
anchor: Anchor::TopLeft,
|
||||
_ignore_style: StyleRefinement::default(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
use std::rc::Rc;
|
||||
|
||||
use gpui::{
|
||||
Context, Corner, DismissEvent, ElementId, Entity, Focusable, InteractiveElement, IntoElement,
|
||||
Anchor, Context, DismissEvent, ElementId, Entity, Focusable, InteractiveElement, IntoElement,
|
||||
RenderOnce, SharedString, StyleRefinement, Styled, Window,
|
||||
};
|
||||
|
||||
use crate::Selectable;
|
||||
use crate::avatar::Avatar;
|
||||
use crate::button::Button;
|
||||
use crate::menu::PopupMenu;
|
||||
use crate::popover::Popover;
|
||||
use crate::Selectable;
|
||||
|
||||
/// A dropdown menu trait for buttons and other interactive elements
|
||||
pub trait DropdownMenu: Styled + Selectable + InteractiveElement + IntoElement + 'static {
|
||||
@@ -17,13 +18,13 @@ pub trait DropdownMenu: Styled + Selectable + InteractiveElement + IntoElement +
|
||||
self,
|
||||
f: impl Fn(PopupMenu, &mut Window, &mut Context<PopupMenu>) -> PopupMenu + 'static,
|
||||
) -> DropdownMenuPopover<Self> {
|
||||
self.dropdown_menu_with_anchor(Corner::TopLeft, f)
|
||||
self.dropdown_menu_with_anchor(Anchor::TopLeft, f)
|
||||
}
|
||||
|
||||
/// Create a dropdown menu with the given items, anchored to the given corner
|
||||
fn dropdown_menu_with_anchor(
|
||||
mut self,
|
||||
anchor: impl Into<Corner>,
|
||||
anchor: impl Into<Anchor>,
|
||||
f: impl Fn(PopupMenu, &mut Window, &mut Context<PopupMenu>) -> PopupMenu + 'static,
|
||||
) -> DropdownMenuPopover<Self> {
|
||||
let style = self.style().clone();
|
||||
@@ -35,11 +36,13 @@ pub trait DropdownMenu: Styled + Selectable + InteractiveElement + IntoElement +
|
||||
|
||||
impl DropdownMenu for Button {}
|
||||
|
||||
impl DropdownMenu for Avatar {}
|
||||
|
||||
#[derive(IntoElement)]
|
||||
pub struct DropdownMenuPopover<T: Selectable + IntoElement + 'static> {
|
||||
id: ElementId,
|
||||
style: StyleRefinement,
|
||||
anchor: Corner,
|
||||
anchor: Anchor,
|
||||
trigger: T,
|
||||
#[allow(clippy::type_complexity)]
|
||||
builder: Rc<dyn Fn(PopupMenu, &mut Window, &mut Context<PopupMenu>) -> PopupMenu>,
|
||||
@@ -51,7 +54,7 @@ where
|
||||
{
|
||||
fn new(
|
||||
id: ElementId,
|
||||
anchor: impl Into<Corner>,
|
||||
anchor: impl Into<Anchor>,
|
||||
trigger: T,
|
||||
builder: impl Fn(PopupMenu, &mut Window, &mut Context<PopupMenu>) -> PopupMenu + 'static,
|
||||
) -> Self {
|
||||
@@ -65,7 +68,7 @@ where
|
||||
}
|
||||
|
||||
/// Set the anchor corner for the dropdown menu popover.
|
||||
pub fn anchor(mut self, anchor: impl Into<Corner>) -> Self {
|
||||
pub fn anchor(mut self, anchor: impl Into<Anchor>) -> Self {
|
||||
self.anchor = anchor.into();
|
||||
self
|
||||
}
|
||||
|
||||
@@ -2,19 +2,19 @@ use std::rc::Rc;
|
||||
|
||||
use gpui::prelude::FluentBuilder;
|
||||
use gpui::{
|
||||
anchored, div, px, rems, Action, AnyElement, App, AppContext, Axis, Bounds, ClickEvent,
|
||||
Context, Corner, DismissEvent, Edges, Entity, EventEmitter, FocusHandle, Focusable, Half,
|
||||
InteractiveElement, IntoElement, KeyBinding, MouseDownEvent, OwnedMenuItem, ParentElement,
|
||||
Pixels, Point, Render, ScrollHandle, SharedString, StatefulInteractiveElement, Styled,
|
||||
Subscription, WeakEntity, Window,
|
||||
Action, Anchor, AnyElement, App, AppContext, Axis, Bounds, ClickEvent, Context, DismissEvent,
|
||||
Edges, Entity, EventEmitter, FocusHandle, Focusable, Half, InteractiveElement, IntoElement,
|
||||
KeyBinding, MouseDownEvent, OwnedMenuItem, ParentElement, Pixels, Point, Render, ScrollHandle,
|
||||
SharedString, StatefulInteractiveElement, Styled, Subscription, WeakEntity, Window, anchored,
|
||||
div, px, rems,
|
||||
};
|
||||
use theme::ActiveTheme;
|
||||
use theme::{ActiveTheme, Side};
|
||||
|
||||
use crate::actions::{Cancel, Confirm, SelectDown, SelectLeft, SelectRight, SelectUp};
|
||||
use crate::kbd::Kbd;
|
||||
use crate::menu::menu_item::MenuItemElement;
|
||||
use crate::scroll::ScrollableElement;
|
||||
use crate::{h_flex, v_flex, ElementExt, Icon, IconName, Side, Sizable as _, Size, StyledExt};
|
||||
use crate::{ElementExt, Icon, IconName, Sizable as _, Size, StyledExt, h_flex, v_flex};
|
||||
|
||||
const CONTEXT: &str = "PopupMenu";
|
||||
|
||||
@@ -299,7 +299,7 @@ pub struct PopupMenu {
|
||||
scroll_handle: ScrollHandle,
|
||||
|
||||
/// This will update on render
|
||||
submenu_anchor: (Corner, Pixels),
|
||||
submenu_anchor: (Anchor, Pixels),
|
||||
|
||||
_subscriptions: Vec<Subscription>,
|
||||
}
|
||||
@@ -322,7 +322,7 @@ impl PopupMenu {
|
||||
scroll_handle: ScrollHandle::default(),
|
||||
external_link_icon: true,
|
||||
size: Size::default(),
|
||||
submenu_anchor: (Corner::TopLeft, Pixels::ZERO),
|
||||
submenu_anchor: (Anchor::TopLeft, Pixels::ZERO),
|
||||
_subscriptions: vec![],
|
||||
}
|
||||
}
|
||||
@@ -719,13 +719,13 @@ impl PopupMenu {
|
||||
}
|
||||
|
||||
pub(crate) fn active_submenu(&self) -> Option<Entity<PopupMenu>> {
|
||||
if let Some(ix) = self.selected_index {
|
||||
if let Some(item) = self.menu_items.get(ix) {
|
||||
return match item {
|
||||
PopupMenuItem::Submenu { menu, .. } => Some(menu.clone()),
|
||||
_ => None,
|
||||
};
|
||||
}
|
||||
if let Some(ix) = self.selected_index
|
||||
&& let Some(item) = self.menu_items.get(ix)
|
||||
{
|
||||
return match item {
|
||||
PopupMenuItem::Submenu { menu, .. } => Some(menu.clone()),
|
||||
_ => None,
|
||||
};
|
||||
}
|
||||
|
||||
None
|
||||
@@ -840,7 +840,7 @@ impl PopupMenu {
|
||||
}
|
||||
|
||||
fn select_left(&mut self, _: &SelectLeft, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let handled = if matches!(self.submenu_anchor.0, Corner::TopLeft | Corner::BottomLeft) {
|
||||
let handled = if matches!(self.submenu_anchor.0, Anchor::TopLeft | Anchor::BottomLeft) {
|
||||
self._unselect_submenu(window, cx)
|
||||
} else {
|
||||
self._select_submenu(window, cx)
|
||||
@@ -861,7 +861,7 @@ impl PopupMenu {
|
||||
}
|
||||
|
||||
fn select_right(&mut self, _: &SelectRight, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let handled = if matches!(self.submenu_anchor.0, Corner::TopLeft | Corner::BottomLeft) {
|
||||
let handled = if matches!(self.submenu_anchor.0, Anchor::TopLeft | Anchor::BottomLeft) {
|
||||
self._select_submenu(window, cx)
|
||||
} else {
|
||||
self._unselect_submenu(window, cx)
|
||||
@@ -930,8 +930,9 @@ impl PopupMenu {
|
||||
};
|
||||
|
||||
match parent.read(cx).submenu_anchor.0 {
|
||||
Corner::TopLeft | Corner::BottomLeft => Side::Left,
|
||||
Corner::TopRight | Corner::BottomRight => Side::Right,
|
||||
Anchor::TopLeft | Anchor::BottomLeft => Side::Left,
|
||||
Anchor::TopRight | Anchor::BottomRight => Side::Right,
|
||||
_ => Side::Left,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -965,12 +966,11 @@ impl PopupMenu {
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
// Do not dismiss, if click inside the parent menu
|
||||
if let Some(parent) = self.parent_menu.as_ref() {
|
||||
if let Some(parent) = parent.upgrade() {
|
||||
if parent.read(cx).bounds.contains(position) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
if let Some(parent) = self.parent_menu.as_ref()
|
||||
&& let Some(parent) = parent.upgrade()
|
||||
&& parent.read(cx).bounds.contains(position)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
self.dismiss(&Cancel, window, cx);
|
||||
@@ -1026,7 +1026,7 @@ impl PopupMenu {
|
||||
} else if checked {
|
||||
Icon::new(IconName::Check)
|
||||
} else {
|
||||
return None;
|
||||
Icon::empty()
|
||||
};
|
||||
|
||||
Some(icon.small())
|
||||
@@ -1042,14 +1042,14 @@ impl PopupMenu {
|
||||
let bounds = self.bounds;
|
||||
let max_width = self.max_width();
|
||||
let (anchor, left) = if max_width + bounds.origin.x > window.bounds().size.width {
|
||||
(Corner::TopRight, -px(16.))
|
||||
(Anchor::TopRight, -px(16.))
|
||||
} else {
|
||||
(Corner::TopLeft, bounds.size.width - px(8.))
|
||||
(Anchor::TopLeft, bounds.size.width - px(8.))
|
||||
};
|
||||
|
||||
let is_bottom_pos = bounds.origin.y + bounds.size.height > window.bounds().size.height;
|
||||
self.submenu_anchor = if is_bottom_pos {
|
||||
(anchor.other_side_corner_along(gpui::Axis::Vertical), left)
|
||||
(anchor.other_side_along(gpui::Axis::Vertical), left)
|
||||
} else {
|
||||
(anchor, left)
|
||||
};
|
||||
@@ -1112,25 +1112,17 @@ impl PopupMenu {
|
||||
.border_color(cx.theme().border)
|
||||
.disabled(true),
|
||||
PopupMenuItem::Label(label) => this.disabled(true).cursor_default().child(
|
||||
h_flex()
|
||||
.cursor_default()
|
||||
.items_center()
|
||||
.gap_x_1()
|
||||
.children(Self::render_icon(has_left_icon, false, None, window, cx))
|
||||
.child(
|
||||
div()
|
||||
.flex_1()
|
||||
.text_xs()
|
||||
.font_semibold()
|
||||
.text_color(cx.theme().text_muted)
|
||||
.child(label.clone()),
|
||||
),
|
||||
h_flex().cursor_default().items_center().gap_x_1().child(
|
||||
div()
|
||||
.flex_1()
|
||||
.text_xs()
|
||||
.font_semibold()
|
||||
.text_color(cx.theme().text_muted)
|
||||
.child(label.clone()),
|
||||
),
|
||||
),
|
||||
PopupMenuItem::ElementItem {
|
||||
render,
|
||||
icon,
|
||||
disabled,
|
||||
..
|
||||
render, disabled, ..
|
||||
} => this
|
||||
.when(!disabled, |this| {
|
||||
this.on_click(
|
||||
@@ -1144,13 +1136,6 @@ impl PopupMenu {
|
||||
.min_h(item_height)
|
||||
.items_center()
|
||||
.gap_x_2()
|
||||
.children(Self::render_icon(
|
||||
has_left_icon,
|
||||
is_left_check,
|
||||
icon.clone(),
|
||||
window,
|
||||
cx,
|
||||
))
|
||||
.child((render)(window, cx))
|
||||
.children(right_check_icon.map(|icon| icon.ml_3())),
|
||||
),
|
||||
@@ -1246,7 +1231,7 @@ impl PopupMenu {
|
||||
this.child({
|
||||
let (anchor, left) = self.submenu_anchor;
|
||||
let is_bottom_pos =
|
||||
matches!(anchor, Corner::BottomLeft | Corner::BottomRight);
|
||||
matches!(anchor, Anchor::BottomLeft | Anchor::BottomRight);
|
||||
anchored()
|
||||
.anchor(anchor)
|
||||
.child(
|
||||
|
||||
@@ -3,10 +3,9 @@ use std::time::Duration;
|
||||
|
||||
use gpui::prelude::FluentBuilder;
|
||||
use gpui::{
|
||||
anchored, div, hsla, point, px, Animation, AnimationExt as _, AnyElement, App, Bounds,
|
||||
BoxShadow, ClickEvent, Div, FocusHandle, InteractiveElement, IntoElement, KeyBinding,
|
||||
MouseButton, ParentElement, Pixels, Point, RenderOnce, SharedString, StyleRefinement, Styled,
|
||||
Window,
|
||||
Animation, AnimationExt as _, AnyElement, App, Bounds, BoxShadow, ClickEvent, Div, FocusHandle,
|
||||
InteractiveElement, IntoElement, KeyBinding, MouseButton, ParentElement, Pixels, Point,
|
||||
RenderOnce, SharedString, StyleRefinement, Styled, Window, anchored, div, hsla, point, px,
|
||||
};
|
||||
use theme::ActiveTheme;
|
||||
|
||||
@@ -14,7 +13,7 @@ use crate::actions::{Cancel, Confirm};
|
||||
use crate::animation::cubic_bezier;
|
||||
use crate::button::{Button, ButtonCustomVariant, ButtonVariant, ButtonVariants as _};
|
||||
use crate::scroll::ScrollableElement;
|
||||
use crate::{h_flex, v_flex, IconName, Root, Sizable, StyledExt, WindowExtension};
|
||||
use crate::{IconName, Root, Sizable, StyledExt, WindowExtension, h_flex, v_flex};
|
||||
|
||||
const CONTEXT: &str = "Modal";
|
||||
|
||||
@@ -297,10 +296,10 @@ impl RenderOnce for Modal {
|
||||
let on_close = on_close.clone();
|
||||
|
||||
move |_, window, cx| {
|
||||
if let Some(on_ok) = &on_ok {
|
||||
if !on_ok(&ClickEvent::default(), window, cx) {
|
||||
return;
|
||||
}
|
||||
if let Some(on_ok) = &on_ok
|
||||
&& !on_ok(&ClickEvent::default(), window, cx)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
on_close(&ClickEvent::default(), window, cx);
|
||||
@@ -343,7 +342,7 @@ impl RenderOnce for Modal {
|
||||
});
|
||||
|
||||
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()
|
||||
- gpui::size(
|
||||
@@ -360,8 +359,8 @@ impl RenderOnce for Modal {
|
||||
let y = self.margin_top.unwrap_or(view_size.height / 10.) + offset_top;
|
||||
let x = bounds.center().x - self.width / 2.;
|
||||
|
||||
let mut padding_right = px(16.);
|
||||
let mut padding_left = px(16.);
|
||||
let mut padding_right = px(8.);
|
||||
let mut padding_left = px(8.);
|
||||
|
||||
if let Some(pl) = self.style.padding.left {
|
||||
padding_left = pl.to_pixels(self.width.into(), window.rem_size());
|
||||
@@ -500,6 +499,7 @@ impl RenderOnce for Modal {
|
||||
.child(self.content),
|
||||
),
|
||||
)
|
||||
.when_none(&self.footer, |this| this.child(div().pt(padding_left)))
|
||||
.when_some(self.footer, |this, footer| {
|
||||
this.child(
|
||||
h_flex()
|
||||
|
||||
@@ -1,25 +1,23 @@
|
||||
use std::any::TypeId;
|
||||
use std::borrow::Cow;
|
||||
use std::collections::{HashMap, VecDeque};
|
||||
use std::rc::Rc;
|
||||
use std::time::Duration;
|
||||
|
||||
use gpui::prelude::FluentBuilder;
|
||||
use gpui::{
|
||||
div, px, Animation, AnimationExt, AnyElement, App, AppContext, ClickEvent, Context,
|
||||
Anchor, Animation, AnimationExt, AnyElement, App, AppContext, ClickEvent, Context,
|
||||
DismissEvent, ElementId, Entity, EventEmitter, InteractiveElement as _, IntoElement,
|
||||
ParentElement as _, Render, SharedString, StatefulInteractiveElement, StyleRefinement, Styled,
|
||||
Subscription, Window,
|
||||
Subscription, Window, div, px, relative,
|
||||
};
|
||||
use smol::Timer;
|
||||
use theme::ActiveTheme;
|
||||
|
||||
use crate::animation::cubic_bezier;
|
||||
use crate::button::{Button, ButtonVariants as _};
|
||||
use crate::{h_flex, v_flex, Icon, IconName, Sizable as _, StyledExt};
|
||||
use crate::{Icon, IconName, Sizable as _, Size, StyledExt, h_flex, v_flex};
|
||||
|
||||
#[derive(Debug, Clone, Copy, Default)]
|
||||
pub enum NotificationType {
|
||||
pub enum NotificationKind {
|
||||
#[default]
|
||||
Info,
|
||||
Success,
|
||||
@@ -27,13 +25,21 @@ pub enum NotificationType {
|
||||
Error,
|
||||
}
|
||||
|
||||
impl NotificationType {
|
||||
impl NotificationKind {
|
||||
fn icon(&self, cx: &App) -> Icon {
|
||||
match self {
|
||||
Self::Info => Icon::new(IconName::Info).text_color(cx.theme().element_foreground),
|
||||
Self::Success => Icon::new(IconName::Info).text_color(cx.theme().secondary_foreground),
|
||||
Self::Warning => Icon::new(IconName::Warning).text_color(cx.theme().warning_foreground),
|
||||
Self::Error => Icon::new(IconName::Warning).text_color(cx.theme().danger_foreground),
|
||||
Self::Info => Icon::new(IconName::Info)
|
||||
.with_size(Size::Medium)
|
||||
.text_color(cx.theme().icon),
|
||||
Self::Success => Icon::new(IconName::CheckCircle)
|
||||
.with_size(Size::Medium)
|
||||
.text_color(cx.theme().icon_accent),
|
||||
Self::Warning => Icon::new(IconName::Warning)
|
||||
.with_size(Size::Medium)
|
||||
.text_color(cx.theme().text_warning),
|
||||
Self::Error => Icon::new(IconName::CloseCircle)
|
||||
.with_size(Size::Medium)
|
||||
.text_color(cx.theme().danger_foreground),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -56,6 +62,7 @@ impl From<(TypeId, ElementId)> for NotificationId {
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::type_complexity)]
|
||||
/// A notification element.
|
||||
pub struct Notification {
|
||||
/// The id is used make the notification unique.
|
||||
@@ -64,16 +71,13 @@ pub struct Notification {
|
||||
/// None means the notification will be added to the end of the list.
|
||||
id: NotificationId,
|
||||
style: StyleRefinement,
|
||||
type_: Option<NotificationType>,
|
||||
kind: Option<NotificationKind>,
|
||||
title: Option<SharedString>,
|
||||
message: Option<SharedString>,
|
||||
icon: Option<Icon>,
|
||||
autohide: bool,
|
||||
#[allow(clippy::type_complexity)]
|
||||
action_builder: Option<Rc<dyn Fn(&mut Window, &mut Context<Self>) -> Button>>,
|
||||
#[allow(clippy::type_complexity)]
|
||||
content_builder: Option<Rc<dyn Fn(&mut Window, &mut Context<Self>) -> AnyElement>>,
|
||||
#[allow(clippy::type_complexity)]
|
||||
action_builder: Option<Rc<dyn Fn(&mut Self, &mut Window, &mut Context<Self>) -> Button>>,
|
||||
content_builder: Option<Rc<dyn Fn(&mut Self, &mut Window, &mut Context<Self>) -> AnyElement>>,
|
||||
on_click: Option<Rc<dyn Fn(&ClickEvent, &mut Window, &mut App)>>,
|
||||
closing: bool,
|
||||
}
|
||||
@@ -84,12 +88,6 @@ impl From<String> for Notification {
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Cow<'static, str>> for Notification {
|
||||
fn from(s: Cow<'static, str>) -> Self {
|
||||
Self::new().message(s)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<SharedString> for Notification {
|
||||
fn from(s: SharedString) -> Self {
|
||||
Self::new().message(s)
|
||||
@@ -102,24 +100,24 @@ impl From<&'static str> for Notification {
|
||||
}
|
||||
}
|
||||
|
||||
impl From<(NotificationType, &'static str)> for Notification {
|
||||
fn from((type_, content): (NotificationType, &'static str)) -> Self {
|
||||
Self::new().message(content).with_type(type_)
|
||||
impl From<(NotificationKind, &'static str)> for Notification {
|
||||
fn from((kind, content): (NotificationKind, &'static str)) -> Self {
|
||||
Self::new().message(content).with_kind(kind)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<(NotificationType, SharedString)> for Notification {
|
||||
fn from((type_, content): (NotificationType, SharedString)) -> Self {
|
||||
Self::new().message(content).with_type(type_)
|
||||
impl From<(NotificationKind, SharedString)> for Notification {
|
||||
fn from((kind, content): (NotificationKind, SharedString)) -> Self {
|
||||
Self::new().message(content).with_kind(kind)
|
||||
}
|
||||
}
|
||||
|
||||
struct DefaultIdType;
|
||||
|
||||
impl Notification {
|
||||
/// Create a new notification with the given content.
|
||||
/// Create a new notification.
|
||||
///
|
||||
/// default width is 320px.
|
||||
/// The default id is a random UUID.
|
||||
pub fn new() -> Self {
|
||||
let id: SharedString = uuid::Uuid::new_v4().to_string().into();
|
||||
let id = (TypeId::of::<DefaultIdType>(), id.into());
|
||||
@@ -129,7 +127,7 @@ impl Notification {
|
||||
style: StyleRefinement::default(),
|
||||
title: None,
|
||||
message: None,
|
||||
type_: None,
|
||||
kind: None,
|
||||
icon: None,
|
||||
autohide: true,
|
||||
action_builder: None,
|
||||
@@ -139,33 +137,38 @@ impl Notification {
|
||||
}
|
||||
}
|
||||
|
||||
/// Set the message of the notification, default is None.
|
||||
pub fn message(mut self, message: impl Into<SharedString>) -> Self {
|
||||
self.message = Some(message.into());
|
||||
self
|
||||
}
|
||||
|
||||
/// Create an info notification with the given message.
|
||||
pub fn info(message: impl Into<SharedString>) -> Self {
|
||||
Self::new()
|
||||
.message(message)
|
||||
.with_type(NotificationType::Info)
|
||||
.with_kind(NotificationKind::Info)
|
||||
}
|
||||
|
||||
/// Create a success notification with the given message.
|
||||
pub fn success(message: impl Into<SharedString>) -> Self {
|
||||
Self::new()
|
||||
.message(message)
|
||||
.with_type(NotificationType::Success)
|
||||
.with_kind(NotificationKind::Success)
|
||||
}
|
||||
|
||||
/// Create a warning notification with the given message.
|
||||
pub fn warning(message: impl Into<SharedString>) -> Self {
|
||||
Self::new()
|
||||
.message(message)
|
||||
.with_type(NotificationType::Warning)
|
||||
.with_kind(NotificationKind::Warning)
|
||||
}
|
||||
|
||||
/// Create an error notification with the given message.
|
||||
pub fn error(message: impl Into<SharedString>) -> Self {
|
||||
Self::new()
|
||||
.message(message)
|
||||
.with_type(NotificationType::Error)
|
||||
.with_kind(NotificationKind::Error)
|
||||
}
|
||||
|
||||
/// Set the type for unique identification of the notification.
|
||||
@@ -180,8 +183,8 @@ impl Notification {
|
||||
}
|
||||
|
||||
/// Set the type and id of the notification, used to uniquely identify the notification.
|
||||
pub fn custom_id(mut self, key: impl Into<ElementId>) -> Self {
|
||||
self.id = (TypeId::of::<DefaultIdType>(), key.into()).into();
|
||||
pub fn type_id<T: Sized + 'static>(mut self, key: impl Into<ElementId>) -> Self {
|
||||
self.id = (TypeId::of::<T>(), key.into()).into();
|
||||
self
|
||||
}
|
||||
|
||||
@@ -202,8 +205,8 @@ impl Notification {
|
||||
}
|
||||
|
||||
/// Set the type of the notification, default is NotificationType::Info.
|
||||
pub fn with_type(mut self, type_: NotificationType) -> Self {
|
||||
self.type_ = Some(type_);
|
||||
pub fn with_kind(mut self, kind: NotificationKind) -> Self {
|
||||
self.kind = Some(kind);
|
||||
self
|
||||
}
|
||||
|
||||
@@ -223,22 +226,31 @@ impl Notification {
|
||||
}
|
||||
|
||||
/// Set the action button of the notification.
|
||||
///
|
||||
/// When an action is set, the notification will not autohide.
|
||||
pub fn action<F>(mut self, action: F) -> Self
|
||||
where
|
||||
F: Fn(&mut Window, &mut Context<Self>) -> Button + 'static,
|
||||
F: Fn(&mut Self, &mut Window, &mut Context<Self>) -> Button + 'static,
|
||||
{
|
||||
self.action_builder = Some(Rc::new(action));
|
||||
self.autohide = false;
|
||||
self
|
||||
}
|
||||
|
||||
/// Dismiss the notification.
|
||||
pub fn dismiss(&mut self, _: &mut Window, cx: &mut Context<Self>) {
|
||||
if self.closing {
|
||||
return;
|
||||
}
|
||||
self.closing = true;
|
||||
cx.notify();
|
||||
|
||||
// Dismiss the notification after 0.15s to show the animation.
|
||||
cx.spawn(async move |view, cx| {
|
||||
Timer::after(Duration::from_secs_f32(0.15)).await;
|
||||
cx.background_executor()
|
||||
.timer(Duration::from_secs_f32(0.15))
|
||||
.await;
|
||||
|
||||
cx.update(|cx| {
|
||||
if let Some(view) = view.upgrade() {
|
||||
view.update(cx, |view, cx| {
|
||||
@@ -248,13 +260,13 @@ impl Notification {
|
||||
}
|
||||
})
|
||||
})
|
||||
.detach()
|
||||
.detach();
|
||||
}
|
||||
|
||||
/// Set the content of the notification.
|
||||
pub fn content(
|
||||
mut self,
|
||||
content: impl Fn(&mut Window, &mut Context<Self>) -> AnyElement + 'static,
|
||||
content: impl Fn(&mut Self, &mut Window, &mut Context<Self>) -> AnyElement + 'static,
|
||||
) -> Self {
|
||||
self.content_builder = Some(Rc::new(content));
|
||||
self
|
||||
@@ -279,30 +291,59 @@ impl Styled for Notification {
|
||||
|
||||
impl Render for Notification {
|
||||
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let closing = self.closing;
|
||||
let icon = match self.type_ {
|
||||
let content = self
|
||||
.content_builder
|
||||
.clone()
|
||||
.map(|builder| builder(self, window, cx));
|
||||
|
||||
let action = self.action_builder.clone().map(|builder| {
|
||||
builder(self, window, cx)
|
||||
.xsmall()
|
||||
.primary()
|
||||
.px_3()
|
||||
.font_semibold()
|
||||
});
|
||||
|
||||
let icon = match self.kind {
|
||||
None => self.icon.clone(),
|
||||
Some(type_) => Some(type_.icon(cx)),
|
||||
Some(kind) => Some(kind.icon(cx)),
|
||||
};
|
||||
|
||||
let background = match self.kind {
|
||||
Some(NotificationKind::Error) => cx.theme().danger_background,
|
||||
_ => cx.theme().surface_background,
|
||||
};
|
||||
|
||||
let text_color = match self.kind {
|
||||
Some(NotificationKind::Error) => cx.theme().danger_foreground,
|
||||
_ => cx.theme().text,
|
||||
};
|
||||
|
||||
let closing = self.closing;
|
||||
let has_title = self.title.is_some();
|
||||
let only_message = !has_title && content.is_none() && action.is_none();
|
||||
let placement = cx.theme().notification.placement;
|
||||
|
||||
h_flex()
|
||||
.id("notification")
|
||||
.refine_style(&self.style)
|
||||
.group("")
|
||||
.occlude()
|
||||
.relative()
|
||||
.w_96()
|
||||
.w_112()
|
||||
.border_1()
|
||||
.border_color(cx.theme().border)
|
||||
.bg(cx.theme().surface_background)
|
||||
.bg(background)
|
||||
.text_color(text_color)
|
||||
.rounded(cx.theme().radius_lg)
|
||||
.when(cx.theme().shadow, |this| this.shadow_md())
|
||||
.p_2()
|
||||
.gap_3()
|
||||
.gap_2()
|
||||
.justify_start()
|
||||
.items_start()
|
||||
.when(only_message, |this| this.items_center())
|
||||
.refine_style(&self.style)
|
||||
.when_some(icon, |this, icon| {
|
||||
this.child(div().flex_shrink_0().pt_1().child(icon))
|
||||
this.child(div().flex_shrink_0().size_5().child(icon))
|
||||
})
|
||||
.child(
|
||||
v_flex()
|
||||
@@ -310,23 +351,28 @@ impl Render for Notification {
|
||||
.gap_1()
|
||||
.overflow_hidden()
|
||||
.when_some(self.title.clone(), |this, title| {
|
||||
this.child(div().text_sm().font_semibold().child(title))
|
||||
this.child(h_flex().h_5().text_sm().font_semibold().child(title))
|
||||
})
|
||||
.when_some(self.message.clone(), |this, message| {
|
||||
this.child(div().text_sm().child(message))
|
||||
this.child(
|
||||
div()
|
||||
.text_sm()
|
||||
.when(has_title, |this| this.text_color(cx.theme().text_muted))
|
||||
.line_height(relative(1.3))
|
||||
.child(message),
|
||||
)
|
||||
})
|
||||
.when_some(self.content_builder.clone(), |this, child_builder| {
|
||||
this.child(child_builder(window, cx))
|
||||
})
|
||||
.when_some(self.action_builder.clone(), |this, action_builder| {
|
||||
this.child(action_builder(window, cx).small().w_full().my_2())
|
||||
.when_some(content, |this, content| this.child(content))
|
||||
.when_some(action, |this, action| {
|
||||
this.gap_2()
|
||||
.child(h_flex().w_full().flex_1().justify_end().child(action))
|
||||
}),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.absolute()
|
||||
.top_2p5()
|
||||
.right_2p5()
|
||||
.top(px(6.5))
|
||||
.right(px(6.5))
|
||||
.invisible()
|
||||
.group_hover("", |this| this.visible())
|
||||
.child(
|
||||
@@ -334,7 +380,7 @@ impl Render for Notification {
|
||||
.icon(IconName::Close)
|
||||
.ghost()
|
||||
.xsmall()
|
||||
.on_click(cx.listener(|this, _, window, cx| {
|
||||
.on_click(cx.listener(move |this, _ev, window, cx| {
|
||||
this.dismiss(window, cx);
|
||||
})),
|
||||
),
|
||||
@@ -345,21 +391,52 @@ impl Render for Notification {
|
||||
on_click(event, window, cx);
|
||||
}))
|
||||
})
|
||||
.on_aux_click(cx.listener(move |view, event: &ClickEvent, window, cx| {
|
||||
if event.is_middle_click() {
|
||||
view.dismiss(window, cx);
|
||||
}
|
||||
}))
|
||||
.with_animation(
|
||||
ElementId::NamedInteger("slide-down".into(), closing as u64),
|
||||
Animation::new(Duration::from_secs_f64(0.25))
|
||||
.with_easing(cubic_bezier(0.4, 0., 0.2, 1.)),
|
||||
move |this, delta| {
|
||||
if closing {
|
||||
let x_offset = px(0.) + delta * px(45.);
|
||||
let opacity = 1. - delta;
|
||||
this.left(px(0.) + x_offset)
|
||||
let that = this
|
||||
.shadow_none()
|
||||
.opacity(opacity)
|
||||
.when(opacity < 0.85, |this| this.shadow_none())
|
||||
.when(opacity < 0.85, |this| this.shadow_none());
|
||||
match placement {
|
||||
Anchor::TopRight | Anchor::BottomRight => {
|
||||
let x_offset = px(0.) + delta * px(45.);
|
||||
that.left(px(0.) + x_offset)
|
||||
}
|
||||
Anchor::TopLeft | Anchor::BottomLeft => {
|
||||
let x_offset = px(0.) - delta * px(45.);
|
||||
that.left(px(0.) + x_offset)
|
||||
}
|
||||
Anchor::TopCenter => {
|
||||
let y_offset = px(0.) - delta * px(45.);
|
||||
that.top(px(0.) + y_offset)
|
||||
}
|
||||
Anchor::BottomCenter => {
|
||||
let y_offset = px(0.) + delta * px(45.);
|
||||
that.top(px(0.) + y_offset)
|
||||
}
|
||||
_ => that,
|
||||
}
|
||||
} else {
|
||||
let y_offset = px(-45.) + delta * px(45.);
|
||||
let opacity = delta;
|
||||
let y_offset = match placement {
|
||||
Anchor::TopLeft | Anchor::TopRight | Anchor::TopCenter => {
|
||||
px(-45.) + delta * px(45.)
|
||||
}
|
||||
Anchor::BottomLeft | Anchor::BottomRight | Anchor::BottomCenter => {
|
||||
px(45.) - delta * px(45.)
|
||||
}
|
||||
_ => px(0.),
|
||||
};
|
||||
this.top(px(0.) + y_offset)
|
||||
.opacity(opacity)
|
||||
.when(opacity < 0.85, |this| this.shadow_none())
|
||||
@@ -373,7 +450,11 @@ impl Render for Notification {
|
||||
pub struct NotificationList {
|
||||
/// Notifications that will be auto hidden.
|
||||
pub(crate) notifications: VecDeque<Entity<Notification>>,
|
||||
|
||||
/// Whether the notification list is expanded.
|
||||
expanded: bool,
|
||||
|
||||
/// Subscriptions
|
||||
_subscriptions: HashMap<NotificationId, Subscription>,
|
||||
}
|
||||
|
||||
@@ -386,10 +467,12 @@ impl NotificationList {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn push<T>(&mut self, notification: T, window: &mut Window, cx: &mut Context<Self>)
|
||||
where
|
||||
T: Into<Notification>,
|
||||
{
|
||||
pub fn push(
|
||||
&mut self,
|
||||
notification: impl Into<Notification>,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let notification = notification.into();
|
||||
let id = notification.id.clone();
|
||||
let autohide = notification.autohide;
|
||||
@@ -411,36 +494,35 @@ impl NotificationList {
|
||||
|
||||
if autohide {
|
||||
// Sleep for 5 seconds to autohide the notification
|
||||
cx.spawn_in(window, async move |_, cx| {
|
||||
Timer::after(Duration::from_secs(5)).await;
|
||||
cx.spawn_in(window, async move |_this, cx| {
|
||||
cx.background_executor().timer(Duration::from_secs(5)).await;
|
||||
|
||||
if let Err(error) =
|
||||
if let Err(err) =
|
||||
notification.update_in(cx, |note, window, cx| note.dismiss(window, cx))
|
||||
{
|
||||
log::error!("Failed to auto hide notification: {error}");
|
||||
log::error!("failed to auto hide notification: {:?}", err);
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
pub fn close<T>(&mut self, key: T, window: &mut Window, cx: &mut Context<Self>)
|
||||
where
|
||||
T: Into<ElementId>,
|
||||
{
|
||||
let id = (TypeId::of::<DefaultIdType>(), key.into()).into();
|
||||
|
||||
pub(crate) fn close(
|
||||
&mut self,
|
||||
id: impl Into<NotificationId>,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let id: NotificationId = id.into();
|
||||
if let Some(n) = self.notifications.iter().find(|n| n.read(cx).id == id) {
|
||||
n.update(cx, |note, cx| {
|
||||
note.dismiss(window, cx);
|
||||
});
|
||||
n.update(cx, |note, cx| note.dismiss(window, cx))
|
||||
}
|
||||
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
pub fn clear(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
|
||||
pub fn clear(&mut self, _: &mut Window, cx: &mut Context<Self>) {
|
||||
self.notifications.clear();
|
||||
cx.notify();
|
||||
}
|
||||
@@ -451,25 +533,46 @@ impl NotificationList {
|
||||
}
|
||||
|
||||
impl Render for NotificationList {
|
||||
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
fn render(
|
||||
&mut self,
|
||||
window: &mut gpui::Window,
|
||||
cx: &mut gpui::Context<Self>,
|
||||
) -> impl IntoElement {
|
||||
let size = window.viewport_size();
|
||||
let items = self.notifications.iter().rev().take(10).rev().cloned();
|
||||
|
||||
div()
|
||||
.id("notification-wrapper")
|
||||
.absolute()
|
||||
.top_4()
|
||||
.right_4()
|
||||
.child(
|
||||
v_flex()
|
||||
.id("notification-list")
|
||||
.h(size.height - px(8.))
|
||||
.gap_3()
|
||||
.children(items)
|
||||
.on_hover(cx.listener(|view, hovered, _, cx| {
|
||||
view.expanded = *hovered;
|
||||
cx.notify()
|
||||
})),
|
||||
let placement = cx.theme().notification.placement;
|
||||
let margins = &cx.theme().notification.margins;
|
||||
|
||||
v_flex()
|
||||
.id("notification-list")
|
||||
.max_h(size.height)
|
||||
.pt(margins.top)
|
||||
.pb(margins.bottom)
|
||||
.gap_3()
|
||||
.when(
|
||||
matches!(placement, Anchor::TopRight),
|
||||
|this| this.pr(margins.right), // ignore left
|
||||
)
|
||||
.when(
|
||||
matches!(placement, Anchor::TopLeft),
|
||||
|this| this.pl(margins.left), // ignore right
|
||||
)
|
||||
.when(
|
||||
matches!(placement, Anchor::BottomLeft),
|
||||
|this| this.flex_col_reverse().pl(margins.left), // ignore right
|
||||
)
|
||||
.when(
|
||||
matches!(placement, Anchor::BottomRight),
|
||||
|this| this.flex_col_reverse().pr(margins.right), // ignore left
|
||||
)
|
||||
.when(matches!(placement, Anchor::BottomCenter), |this| {
|
||||
this.flex_col_reverse()
|
||||
})
|
||||
.on_hover(cx.listener(|view, hovered, _, cx| {
|
||||
view.expanded = *hovered;
|
||||
cx.notify()
|
||||
}))
|
||||
.children(items)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,14 +2,14 @@ use std::rc::Rc;
|
||||
|
||||
use gpui::prelude::FluentBuilder as _;
|
||||
use gpui::{
|
||||
deferred, div, px, AnyElement, App, Bounds, Context, Deferred, DismissEvent, Div, ElementId,
|
||||
EventEmitter, FocusHandle, Focusable, Half, InteractiveElement as _, IntoElement, KeyBinding,
|
||||
MouseButton, ParentElement, Pixels, Point, Render, RenderOnce, Stateful, StyleRefinement,
|
||||
Styled, Subscription, Window,
|
||||
Anchor, AnyElement, App, Bounds, Context, Deferred, DismissEvent, Div, ElementId, EventEmitter,
|
||||
FocusHandle, Focusable, InteractiveElement as _, IntoElement, KeyBinding, MouseButton,
|
||||
ParentElement, Pixels, Point, Render, RenderOnce, Stateful, StyleRefinement, Styled,
|
||||
Subscription, Window, anchored, deferred, div, px,
|
||||
};
|
||||
|
||||
use crate::actions::Cancel;
|
||||
use crate::{anchored, v_flex, Anchor, ElementExt, Selectable, StyledExt as _};
|
||||
use crate::{ElementExt, Selectable, StyledExt as _, v_flex};
|
||||
|
||||
const CONTEXT: &str = "Popover";
|
||||
|
||||
@@ -174,19 +174,26 @@ impl Popover {
|
||||
self
|
||||
}
|
||||
|
||||
fn resolved_corner(anchor: Anchor, trigger_bounds: Bounds<Pixels>) -> Point<Pixels> {
|
||||
let offset = if anchor.is_center() {
|
||||
gpui::point(trigger_bounds.size.width.half(), px(0.))
|
||||
} else {
|
||||
Point::default()
|
||||
};
|
||||
|
||||
trigger_bounds.corner(anchor.swap_vertical().into())
|
||||
+ offset
|
||||
+ Point {
|
||||
x: px(0.),
|
||||
y: -trigger_bounds.size.height,
|
||||
}
|
||||
pub(crate) fn resolved_corner(anchor: Anchor, trigger_bounds: Bounds<Pixels>) -> Point<Pixels> {
|
||||
match anchor {
|
||||
Anchor::TopLeft => trigger_bounds.origin,
|
||||
Anchor::TopCenter => trigger_bounds.top_center(),
|
||||
Anchor::TopRight => trigger_bounds.top_right(),
|
||||
Anchor::BottomLeft => Point {
|
||||
x: trigger_bounds.origin.x,
|
||||
y: trigger_bounds.origin.y - trigger_bounds.size.height,
|
||||
},
|
||||
Anchor::BottomCenter => Point {
|
||||
x: trigger_bounds.top_center().x,
|
||||
y: trigger_bounds.origin.y - trigger_bounds.size.height,
|
||||
},
|
||||
Anchor::BottomRight => Point {
|
||||
x: trigger_bounds.top_right().x,
|
||||
y: trigger_bounds.origin.y - trigger_bounds.size.height,
|
||||
},
|
||||
// Fallback for LeftCenter/RightCenter – adjust as needed.
|
||||
_ => trigger_bounds.origin,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -329,6 +336,7 @@ impl Popover {
|
||||
.map(|this| match anchor {
|
||||
Anchor::TopLeft | Anchor::TopCenter | Anchor::TopRight => this.top_1(),
|
||||
Anchor::BottomLeft | Anchor::BottomCenter | Anchor::BottomRight => this.bottom_1(),
|
||||
Anchor::LeftCenter | Anchor::RightCenter => this.top_1(), // Fallback for centered
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use std::ops::Range;
|
||||
|
||||
use gpui::{
|
||||
px, Along, App, Axis, Bounds, Context, ElementId, EventEmitter, IsZero, Pixels, Window,
|
||||
Along, App, Axis, Bounds, Context, ElementId, EventEmitter, IsZero, Pixels, Window, px,
|
||||
};
|
||||
|
||||
mod panel;
|
||||
@@ -142,10 +142,10 @@ impl ResizableState {
|
||||
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);
|
||||
}
|
||||
if let Some(resizing_panel_ix) = self.resizing_panel_ix
|
||||
&& resizing_panel_ix > panel_ix
|
||||
{
|
||||
self.resizing_panel_ix = Some(resizing_panel_ix - 1);
|
||||
}
|
||||
self.adjust_to_container_size(cx);
|
||||
}
|
||||
|
||||
@@ -3,14 +3,15 @@ use std::rc::Rc;
|
||||
|
||||
use gpui::prelude::FluentBuilder;
|
||||
use gpui::{
|
||||
div, Along, AnyElement, App, AppContext, Axis, Bounds, Context, Element, ElementId, Empty,
|
||||
Entity, EventEmitter, InteractiveElement as _, IntoElement, IsZero as _, MouseMoveEvent,
|
||||
MouseUpEvent, ParentElement, Pixels, Render, RenderOnce, Style, Styled, Window,
|
||||
Along, AnyElement, App, AppContext, Axis, Bounds, Context, Element, ElementId, Empty, Entity,
|
||||
EventEmitter, InteractiveElement as _, IntoElement, IsZero as _, MouseMoveEvent, MouseUpEvent,
|
||||
ParentElement, Pixels, Render, RenderOnce, Style, Styled, Window, div,
|
||||
};
|
||||
use theme::AxisExt;
|
||||
|
||||
use super::{resizable_panel, resize_handle, ResizableState};
|
||||
use super::{ResizableState, resizable_panel, resize_handle};
|
||||
use crate::resizable::PANEL_MIN_SIZE;
|
||||
use crate::{h_flex, v_flex, AxisExt, ElementExt};
|
||||
use crate::{ElementExt, h_flex, v_flex};
|
||||
|
||||
pub enum ResizablePanelEvent {
|
||||
Resized,
|
||||
|
||||
@@ -3,14 +3,13 @@ use std::rc::Rc;
|
||||
|
||||
use gpui::prelude::FluentBuilder as _;
|
||||
use gpui::{
|
||||
div, px, AnyElement, App, Axis, Element, ElementId, Entity, GlobalElementId,
|
||||
InteractiveElement, IntoElement, MouseDownEvent, MouseUpEvent, ParentElement as _, Pixels,
|
||||
Point, Render, StatefulInteractiveElement, Styled as _, Window,
|
||||
AnyElement, App, Axis, Element, ElementId, Entity, GlobalElementId, InteractiveElement,
|
||||
IntoElement, MouseDownEvent, MouseUpEvent, ParentElement as _, Pixels, Point, Render,
|
||||
StatefulInteractiveElement, Styled as _, Window, div, px,
|
||||
};
|
||||
use theme::ActiveTheme;
|
||||
use theme::{ActiveTheme, AxisExt};
|
||||
|
||||
use crate::dock_area::dock::DockPlacement;
|
||||
use crate::AxisExt;
|
||||
use crate::dock::DockPlacement;
|
||||
|
||||
pub(crate) const HANDLE_PADDING: Pixels = px(4.);
|
||||
pub(crate) const HANDLE_SIZE: Pixels = px(1.);
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
use std::any::TypeId;
|
||||
use std::rc::Rc;
|
||||
|
||||
use gpui::prelude::FluentBuilder;
|
||||
use gpui::{
|
||||
canvas, div, point, px, size, AnyView, App, AppContext, Bounds, Context, CursorStyle,
|
||||
Decorations, Edges, Entity, FocusHandle, HitboxBehavior, Hsla, InteractiveElement, IntoElement,
|
||||
MouseButton, ParentElement as _, Pixels, Point, Render, ResizeEdge, SharedString, Size, Styled,
|
||||
Tiling, WeakFocusHandle, Window,
|
||||
AnyView, App, AppContext, Bounds, Context, CursorStyle, Decorations, Edges, ElementId, Entity,
|
||||
FocusHandle, HitboxBehavior, Hsla, InteractiveElement, IntoElement, MouseButton,
|
||||
ParentElement as _, Pixels, Point, Render, ResizeEdge, Size, Styled, Tiling, WeakFocusHandle,
|
||||
Window, canvas, div, point, px, size,
|
||||
};
|
||||
use theme::{
|
||||
ActiveTheme, CLIENT_SIDE_DECORATION_BORDER, CLIENT_SIDE_DECORATION_ROUNDING,
|
||||
@@ -91,7 +92,7 @@ impl Root {
|
||||
pub fn render_notification_layer(
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) -> Option<impl IntoElement> {
|
||||
) -> Option<impl IntoElement + use<>> {
|
||||
let root = window.root::<Root>()??;
|
||||
|
||||
Some(
|
||||
@@ -104,7 +105,10 @@ impl Root {
|
||||
}
|
||||
|
||||
/// Render the modal layer.
|
||||
pub fn render_modal_layer(window: &mut Window, cx: &mut App) -> Option<impl IntoElement> {
|
||||
pub fn render_modal_layer(
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) -> Option<impl IntoElement + use<>> {
|
||||
let root = window.root::<Root>()??;
|
||||
let active_modals = root.read(cx).active_modals.clone();
|
||||
|
||||
@@ -138,10 +142,10 @@ impl Root {
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
if let Some(ix) = show_overlay_ix {
|
||||
if let Some(modal) = modals.get_mut(ix) {
|
||||
modal.overlay_visible = true;
|
||||
}
|
||||
if let Some(ix) = show_overlay_ix
|
||||
&& let Some(modal) = modals.get_mut(ix)
|
||||
{
|
||||
modal.overlay_visible = true;
|
||||
}
|
||||
|
||||
Some(div().children(modals))
|
||||
@@ -213,13 +217,30 @@ impl Root {
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// Clear a notification by its ID.
|
||||
pub fn clear_notification<T>(&mut self, id: T, window: &mut Window, cx: &mut Context<Self>)
|
||||
where
|
||||
T: Into<SharedString>,
|
||||
{
|
||||
self.notification
|
||||
.update(cx, |view, cx| view.close(id.into(), window, cx));
|
||||
/// Clear a notification by its type.
|
||||
pub fn clear_notification<T: Sized + 'static>(
|
||||
&mut self,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<'_, Root>,
|
||||
) {
|
||||
self.notification.update(cx, |view, cx| {
|
||||
let id = TypeId::of::<T>();
|
||||
view.close(id, window, cx);
|
||||
});
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// Clear a notification by its type.
|
||||
pub fn clear_notification_by_id<T: Sized + 'static>(
|
||||
&mut self,
|
||||
key: impl Into<ElementId>,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<'_, Root>,
|
||||
) {
|
||||
self.notification.update(cx, |view, cx| {
|
||||
let id = (TypeId::of::<T>(), key.into());
|
||||
view.close(id, window, cx);
|
||||
});
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
@@ -249,7 +270,6 @@ impl Render for Root {
|
||||
div()
|
||||
.id("window")
|
||||
.size_full()
|
||||
.bg(gpui::transparent_black())
|
||||
.map(|div| match decorations {
|
||||
Decorations::Server => div,
|
||||
Decorations::Client { tiling } => div
|
||||
|
||||
@@ -3,13 +3,13 @@ use std::rc::Rc;
|
||||
|
||||
use gpui::prelude::FluentBuilder;
|
||||
use gpui::{
|
||||
div, App, Div, Element, ElementId, InteractiveElement, IntoElement, ParentElement, RenderOnce,
|
||||
ScrollHandle, Stateful, StatefulInteractiveElement, StyleRefinement, Styled, Window,
|
||||
App, Div, Element, ElementId, InteractiveElement, IntoElement, ParentElement, RenderOnce,
|
||||
ScrollHandle, Stateful, StatefulInteractiveElement, StyleRefinement, Styled, Window, div,
|
||||
};
|
||||
|
||||
use super::{Scrollbar, ScrollbarAxis};
|
||||
use crate::scroll::ScrollbarHandle;
|
||||
use crate::StyledExt;
|
||||
use crate::scroll::ScrollbarHandle;
|
||||
|
||||
/// A trait for elements that can be made scrollable with scrollbars.
|
||||
pub trait ScrollableElement: InteractiveElement + Styled + ParentElement + Element {
|
||||
@@ -160,6 +160,7 @@ where
|
||||
}
|
||||
|
||||
impl ScrollableElement for Div {}
|
||||
|
||||
impl<E> ScrollableElement for Stateful<E>
|
||||
where
|
||||
E: ParentElement + Styled + Element,
|
||||
@@ -195,6 +196,7 @@ fn render_scrollbar<H: ScrollbarHandle + Clone>(
|
||||
// Do not render scrollbar when inspector is picking elements,
|
||||
// to allow us to pick the background elements.
|
||||
let is_inspector_picking = window.is_inspector_picking(cx);
|
||||
|
||||
if is_inspector_picking {
|
||||
return div();
|
||||
}
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
use gpui::{
|
||||
px, relative, App, Axis, BorderStyle, Bounds, ContentMask, Corners, Edges, Element, ElementId,
|
||||
EntityId, GlobalElementId, Hitbox, Hsla, IntoElement, IsZero as _, LayoutId, PaintQuad, Pixels,
|
||||
Point, Position, ScrollHandle, ScrollWheelEvent, Size, Style, Window,
|
||||
App, Axis, BorderStyle, Bounds, ContentMask, Corners, Edges, Element, ElementId, EntityId,
|
||||
GlobalElementId, Hitbox, Hsla, IntoElement, IsZero as _, LayoutId, PaintQuad, Pixels, Point,
|
||||
Position, ScrollHandle, ScrollWheelEvent, Size, Style, Window, px, relative,
|
||||
};
|
||||
|
||||
use crate::AxisExt;
|
||||
use theme::AxisExt;
|
||||
|
||||
/// Make a scrollable mask element to cover the parent view with the mouse wheel event listening.
|
||||
///
|
||||
|
||||
@@ -5,15 +5,13 @@ use std::rc::Rc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use gpui::{
|
||||
fill, point, px, relative, size, App, Axis, BorderStyle, Bounds, ContentMask, Corner,
|
||||
CursorStyle, Edges, Element, ElementId, GlobalElementId, Hitbox, HitboxBehavior, Hsla,
|
||||
InspectorElementId, IntoElement, IsZero, LayoutId, ListState, MouseDownEvent, MouseMoveEvent,
|
||||
MouseUpEvent, PaintQuad, Pixels, Point, Position, ScrollHandle, ScrollWheelEvent, Size, Style,
|
||||
UniformListScrollHandle, Window,
|
||||
Anchor, App, Axis, BorderStyle, Bounds, ContentMask, CursorStyle, Edges, Element, ElementId,
|
||||
GlobalElementId, Hitbox, HitboxBehavior, Hsla, InspectorElementId, IntoElement, IsZero,
|
||||
LayoutId, ListState, MouseDownEvent, MouseMoveEvent, MouseUpEvent, PaintQuad, Pixels, Point,
|
||||
Position, ScrollHandle, ScrollWheelEvent, Size, Style, UniformListScrollHandle, Window, fill,
|
||||
point, px, relative, size,
|
||||
};
|
||||
use theme::{ActiveTheme, ScrollbarMode};
|
||||
|
||||
use crate::AxisExt;
|
||||
use theme::{ActiveTheme, AxisExt, ScrollbarMode};
|
||||
|
||||
/// The width of the scrollbar (THUMB_ACTIVE_INSET * 2 + THUMB_ACTIVE_WIDTH)
|
||||
const WIDTH: Pixels = px(1. * 2. + 8.);
|
||||
@@ -54,7 +52,7 @@ impl ScrollbarHandle for ScrollHandle {
|
||||
}
|
||||
|
||||
fn content_size(&self) -> Size<Pixels> {
|
||||
self.max_offset() + self.bounds().size
|
||||
Size::from(self.max_offset()) + self.bounds().size
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,7 +67,7 @@ impl ScrollbarHandle for UniformListScrollHandle {
|
||||
|
||||
fn content_size(&self) -> Size<Pixels> {
|
||||
let base_handle = &self.0.borrow().base_handle;
|
||||
base_handle.max_offset() + base_handle.bounds().size
|
||||
Size::from(base_handle.max_offset()) + base_handle.bounds().size
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,7 +81,7 @@ impl ScrollbarHandle for ListState {
|
||||
}
|
||||
|
||||
fn content_size(&self) -> Size<Pixels> {
|
||||
self.viewport_bounds().size + self.max_offset_for_scrollbar()
|
||||
Size::from(self.max_offset_for_scrollbar()) + self.viewport_bounds().size
|
||||
}
|
||||
|
||||
fn start_drag(&self) {
|
||||
@@ -407,7 +405,6 @@ impl Scrollbar {
|
||||
ScrollbarMode::Scrolling => (THUMB_WIDTH, THUMB_INSET, THUMB_RADIUS),
|
||||
_ => (THUMB_ACTIVE_WIDTH, THUMB_ACTIVE_INSET, THUMB_ACTIVE_RADIUS),
|
||||
};
|
||||
|
||||
(
|
||||
cx.theme().scrollbar_thumb_background,
|
||||
cx.theme().scrollbar_track_background,
|
||||
@@ -522,6 +519,7 @@ impl Element for Scrollbar {
|
||||
|
||||
let mut states = vec![];
|
||||
let mut has_both = self.axis.is_both();
|
||||
|
||||
let scroll_size = self
|
||||
.scroll_size
|
||||
.unwrap_or(self.scroll_handle.content_size());
|
||||
@@ -650,14 +648,14 @@ impl Element for Scrollbar {
|
||||
// The clickable area of the thumb
|
||||
let thumb_length = thumb_end - thumb_start - inset * 2;
|
||||
let thumb_bounds = if is_vertical {
|
||||
Bounds::from_corner_and_size(
|
||||
Corner::TopRight,
|
||||
Bounds::from_anchor_and_size(
|
||||
Anchor::TopRight,
|
||||
bounds.top_right() + point(-inset, inset + thumb_start),
|
||||
size(WIDTH, thumb_length),
|
||||
)
|
||||
} else {
|
||||
Bounds::from_corner_and_size(
|
||||
Corner::BottomLeft,
|
||||
Bounds::from_anchor_and_size(
|
||||
Anchor::BottomLeft,
|
||||
bounds.bottom_left() + point(inset + thumb_start, -inset),
|
||||
size(thumb_length, WIDTH),
|
||||
)
|
||||
@@ -665,14 +663,14 @@ impl Element for Scrollbar {
|
||||
|
||||
// The actual render area of the thumb
|
||||
let thumb_fill_bounds = if is_vertical {
|
||||
Bounds::from_corner_and_size(
|
||||
Corner::TopRight,
|
||||
Bounds::from_anchor_and_size(
|
||||
Anchor::TopRight,
|
||||
bounds.top_right() + point(-inset, inset + thumb_start),
|
||||
size(thumb_width, thumb_length),
|
||||
)
|
||||
} else {
|
||||
Bounds::from_corner_and_size(
|
||||
Corner::BottomLeft,
|
||||
Bounds::from_anchor_and_size(
|
||||
Anchor::BottomLeft,
|
||||
bounds.bottom_left() + point(inset + thumb_start, -inset),
|
||||
size(thumb_length, thumb_width),
|
||||
)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use gpui::{div, px, App, Div, Pixels, Refineable, StyleRefinement, Styled};
|
||||
use gpui::{App, DefiniteLength, Div, Edges, Pixels, Refineable, StyleRefinement, Styled, div, px};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use theme::ActiveTheme;
|
||||
|
||||
@@ -46,6 +46,30 @@ pub trait StyledExt: Styled + Sized {
|
||||
self.flex().flex_col()
|
||||
}
|
||||
|
||||
/// Apply paddings to the element.
|
||||
fn paddings<L>(self, paddings: impl Into<Edges<L>>) -> Self
|
||||
where
|
||||
L: Into<DefiniteLength> + Clone + Default + std::fmt::Debug + PartialEq,
|
||||
{
|
||||
let paddings = paddings.into();
|
||||
self.pt(paddings.top.into())
|
||||
.pb(paddings.bottom.into())
|
||||
.pl(paddings.left.into())
|
||||
.pr(paddings.right.into())
|
||||
}
|
||||
|
||||
/// Apply margins to the element.
|
||||
fn margins<L>(self, margins: impl Into<Edges<L>>) -> Self
|
||||
where
|
||||
L: Into<DefiniteLength> + Clone + Default + std::fmt::Debug + PartialEq,
|
||||
{
|
||||
let margins = margins.into();
|
||||
self.mt(margins.top.into())
|
||||
.mb(margins.bottom.into())
|
||||
.ml(margins.left.into())
|
||||
.mr(margins.right.into())
|
||||
}
|
||||
|
||||
font_weight!(font_thin, THIN);
|
||||
font_weight!(font_extralight, EXTRA_LIGHT);
|
||||
font_weight!(font_light, LIGHT);
|
||||
|
||||
@@ -4,13 +4,13 @@ use std::time::Duration;
|
||||
|
||||
use gpui::prelude::FluentBuilder as _;
|
||||
use gpui::{
|
||||
div, px, white, Animation, AnimationExt as _, AnyElement, App, Element, ElementId,
|
||||
GlobalElementId, InteractiveElement, IntoElement, LayoutId, ParentElement as _, SharedString,
|
||||
Styled as _, Window,
|
||||
Animation, AnimationExt as _, AnyElement, App, Element, ElementId, GlobalElementId,
|
||||
InteractiveElement, IntoElement, LayoutId, ParentElement as _, SharedString, Styled as _,
|
||||
Window, div, px, white,
|
||||
};
|
||||
use theme::ActiveTheme;
|
||||
use theme::{ActiveTheme, Side};
|
||||
|
||||
use crate::{Disableable, Side, Sizable, Size};
|
||||
use crate::{Disableable, Sizable, Size};
|
||||
|
||||
type OnClick = Option<Rc<dyn Fn(&bool, &mut Window, &mut App)>>;
|
||||
|
||||
|
||||
@@ -1,74 +1,557 @@
|
||||
use gpui::prelude::FluentBuilder;
|
||||
use gpui::{
|
||||
div, px, AnyElement, App, Div, InteractiveElement, IntoElement, MouseButton, ParentElement,
|
||||
RenderOnce, StatefulInteractiveElement, Styled, Window,
|
||||
};
|
||||
use theme::{ActiveTheme, TABBAR_HEIGHT};
|
||||
use std::rc::Rc;
|
||||
|
||||
use crate::{Selectable, Sizable, Size};
|
||||
use gpui::prelude::FluentBuilder as _;
|
||||
use gpui::{
|
||||
AnyElement, App, ClickEvent, Div, Edges, Hsla, InteractiveElement, IntoElement, MouseButton,
|
||||
ParentElement, Pixels, RenderOnce, SharedString, StatefulInteractiveElement, Styled, Window,
|
||||
div, px, relative,
|
||||
};
|
||||
use theme::ActiveTheme;
|
||||
|
||||
use crate::{Icon, IconName, Selectable, Sizable, Size, StyledExt, h_flex};
|
||||
|
||||
pub mod tab_bar;
|
||||
|
||||
/// Tab variants.
|
||||
#[derive(Debug, Clone, Default, Copy, PartialEq, Eq, Hash)]
|
||||
pub enum TabVariant {
|
||||
#[default]
|
||||
Tab,
|
||||
Outline,
|
||||
Pill,
|
||||
Segmented,
|
||||
Underline,
|
||||
}
|
||||
|
||||
impl TabVariant {
|
||||
fn height(&self, size: Size) -> Pixels {
|
||||
match size {
|
||||
Size::XSmall => match self {
|
||||
TabVariant::Underline => px(26.),
|
||||
_ => px(20.),
|
||||
},
|
||||
Size::Small => match self {
|
||||
TabVariant::Underline => px(30.),
|
||||
_ => px(24.),
|
||||
},
|
||||
Size::Large => match self {
|
||||
TabVariant::Underline => px(44.),
|
||||
_ => px(36.),
|
||||
},
|
||||
_ => match self {
|
||||
TabVariant::Underline => px(36.),
|
||||
_ => px(32.),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn inner_height(&self, size: Size) -> Pixels {
|
||||
match size {
|
||||
Size::XSmall => match self {
|
||||
TabVariant::Tab | TabVariant::Outline | TabVariant::Pill => px(18.),
|
||||
TabVariant::Segmented => px(16.),
|
||||
TabVariant::Underline => px(20.),
|
||||
},
|
||||
Size::Small => match self {
|
||||
TabVariant::Tab | TabVariant::Outline | TabVariant::Pill => px(22.),
|
||||
TabVariant::Segmented => px(18.),
|
||||
TabVariant::Underline => px(22.),
|
||||
},
|
||||
Size::Large => match self {
|
||||
TabVariant::Tab | TabVariant::Outline | TabVariant::Pill => px(36.),
|
||||
TabVariant::Segmented => px(28.),
|
||||
TabVariant::Underline => px(32.),
|
||||
},
|
||||
_ => match self {
|
||||
TabVariant::Tab => px(30.),
|
||||
TabVariant::Outline | TabVariant::Pill => px(26.),
|
||||
TabVariant::Segmented => px(24.),
|
||||
TabVariant::Underline => px(26.),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Default px(12) to match panel px_3, See [`crate::dock::TabPanel`]
|
||||
fn inner_paddings(&self, size: Size) -> Edges<Pixels> {
|
||||
let mut padding_x = match size {
|
||||
Size::XSmall => px(8.),
|
||||
Size::Small => px(10.),
|
||||
Size::Large => px(16.),
|
||||
_ => px(12.),
|
||||
};
|
||||
|
||||
if matches!(self, TabVariant::Underline) {
|
||||
padding_x = px(0.);
|
||||
}
|
||||
|
||||
Edges {
|
||||
left: padding_x,
|
||||
right: padding_x,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn inner_margins(&self, size: Size) -> Edges<Pixels> {
|
||||
match size {
|
||||
Size::XSmall => match self {
|
||||
TabVariant::Underline => Edges {
|
||||
top: px(1.),
|
||||
bottom: px(2.),
|
||||
..Default::default()
|
||||
},
|
||||
_ => Edges::all(px(0.)),
|
||||
},
|
||||
Size::Small => match self {
|
||||
TabVariant::Underline => Edges {
|
||||
top: px(2.),
|
||||
bottom: px(3.),
|
||||
..Default::default()
|
||||
},
|
||||
_ => Edges::all(px(0.)),
|
||||
},
|
||||
Size::Large => match self {
|
||||
TabVariant::Underline => Edges {
|
||||
top: px(5.),
|
||||
bottom: px(6.),
|
||||
..Default::default()
|
||||
},
|
||||
_ => Edges::all(px(0.)),
|
||||
},
|
||||
_ => match self {
|
||||
TabVariant::Underline => Edges {
|
||||
top: px(3.),
|
||||
bottom: px(4.),
|
||||
..Default::default()
|
||||
},
|
||||
_ => Edges::all(px(0.)),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn normal(&self, cx: &App) -> TabStyle {
|
||||
match self {
|
||||
TabVariant::Tab => TabStyle {
|
||||
fg: cx.theme().tab_foreground,
|
||||
bg: gpui::transparent_black(),
|
||||
borders: Edges {
|
||||
left: px(1.),
|
||||
right: px(1.),
|
||||
..Default::default()
|
||||
},
|
||||
border_color: gpui::transparent_black(),
|
||||
..Default::default()
|
||||
},
|
||||
TabVariant::Outline => TabStyle {
|
||||
fg: cx.theme().tab_foreground,
|
||||
bg: gpui::transparent_black(),
|
||||
borders: Edges::all(px(1.)),
|
||||
border_color: cx.theme().border,
|
||||
..Default::default()
|
||||
},
|
||||
TabVariant::Pill => TabStyle {
|
||||
fg: cx.theme().text,
|
||||
bg: gpui::transparent_black(),
|
||||
..Default::default()
|
||||
},
|
||||
TabVariant::Segmented => TabStyle {
|
||||
fg: cx.theme().tab_foreground,
|
||||
bg: gpui::transparent_black(),
|
||||
..Default::default()
|
||||
},
|
||||
TabVariant::Underline => TabStyle {
|
||||
fg: cx.theme().tab_foreground,
|
||||
bg: gpui::transparent_black(),
|
||||
inner_bg: gpui::transparent_black(),
|
||||
borders: Edges {
|
||||
bottom: px(2.),
|
||||
..Default::default()
|
||||
},
|
||||
border_color: gpui::transparent_black(),
|
||||
..Default::default()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn hovered(&self, selected: bool, cx: &App) -> TabStyle {
|
||||
match self {
|
||||
TabVariant::Tab => TabStyle {
|
||||
fg: cx.theme().tab_foreground,
|
||||
bg: gpui::transparent_black(),
|
||||
borders: Edges {
|
||||
left: px(1.),
|
||||
right: px(1.),
|
||||
..Default::default()
|
||||
},
|
||||
border_color: gpui::transparent_black(),
|
||||
..Default::default()
|
||||
},
|
||||
TabVariant::Outline => TabStyle {
|
||||
fg: cx.theme().secondary_foreground,
|
||||
bg: cx.theme().secondary_hover,
|
||||
borders: Edges::all(px(1.)),
|
||||
border_color: cx.theme().border,
|
||||
..Default::default()
|
||||
},
|
||||
TabVariant::Pill => TabStyle {
|
||||
fg: cx.theme().secondary_foreground,
|
||||
bg: cx.theme().secondary_background,
|
||||
..Default::default()
|
||||
},
|
||||
TabVariant::Segmented => TabStyle {
|
||||
fg: cx.theme().tab_foreground,
|
||||
bg: gpui::transparent_black(),
|
||||
inner_bg: if selected {
|
||||
cx.theme().background
|
||||
} else {
|
||||
gpui::transparent_black()
|
||||
},
|
||||
..Default::default()
|
||||
},
|
||||
TabVariant::Underline => TabStyle {
|
||||
fg: cx.theme().tab_foreground,
|
||||
bg: gpui::transparent_black(),
|
||||
inner_bg: gpui::transparent_black(),
|
||||
borders: Edges {
|
||||
bottom: px(2.),
|
||||
..Default::default()
|
||||
},
|
||||
border_color: gpui::transparent_black(),
|
||||
..Default::default()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn selected(&self, cx: &App) -> TabStyle {
|
||||
match self {
|
||||
TabVariant::Tab => TabStyle {
|
||||
fg: cx.theme().tab_active_foreground,
|
||||
bg: cx.theme().tab_active_background,
|
||||
borders: Edges {
|
||||
left: px(1.),
|
||||
right: px(1.),
|
||||
..Default::default()
|
||||
},
|
||||
border_color: cx.theme().border,
|
||||
..Default::default()
|
||||
},
|
||||
TabVariant::Outline => TabStyle {
|
||||
fg: cx.theme().text_accent,
|
||||
bg: gpui::transparent_black(),
|
||||
borders: Edges::all(px(1.)),
|
||||
border_color: cx.theme().element_active,
|
||||
..Default::default()
|
||||
},
|
||||
TabVariant::Pill => TabStyle {
|
||||
fg: cx.theme().element_foreground,
|
||||
bg: cx.theme().element_background,
|
||||
..Default::default()
|
||||
},
|
||||
TabVariant::Segmented => TabStyle {
|
||||
fg: cx.theme().tab_active_foreground,
|
||||
bg: gpui::transparent_black(),
|
||||
inner_bg: cx.theme().background,
|
||||
shadow: true,
|
||||
..Default::default()
|
||||
},
|
||||
TabVariant::Underline => TabStyle {
|
||||
fg: cx.theme().tab_active_foreground,
|
||||
bg: gpui::transparent_black(),
|
||||
borders: Edges {
|
||||
bottom: px(2.),
|
||||
..Default::default()
|
||||
},
|
||||
border_color: cx.theme().element_active,
|
||||
..Default::default()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn disabled(&self, selected: bool, cx: &App) -> TabStyle {
|
||||
match self {
|
||||
TabVariant::Tab => TabStyle {
|
||||
fg: cx.theme().text_muted,
|
||||
bg: gpui::transparent_black(),
|
||||
border_color: if selected {
|
||||
cx.theme().border
|
||||
} else {
|
||||
gpui::transparent_black()
|
||||
},
|
||||
borders: Edges {
|
||||
left: px(1.),
|
||||
right: px(1.),
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
},
|
||||
TabVariant::Outline => TabStyle {
|
||||
fg: cx.theme().text_muted,
|
||||
bg: gpui::transparent_black(),
|
||||
borders: Edges::all(px(1.)),
|
||||
border_color: if selected {
|
||||
cx.theme().element_active
|
||||
} else {
|
||||
cx.theme().border
|
||||
},
|
||||
..Default::default()
|
||||
},
|
||||
TabVariant::Pill => TabStyle {
|
||||
fg: if selected {
|
||||
cx.theme().element_foreground.opacity(0.5)
|
||||
} else {
|
||||
cx.theme().text_muted
|
||||
},
|
||||
bg: if selected {
|
||||
cx.theme().element_background.opacity(0.5)
|
||||
} else {
|
||||
gpui::transparent_black()
|
||||
},
|
||||
..Default::default()
|
||||
},
|
||||
TabVariant::Segmented => TabStyle {
|
||||
fg: cx.theme().text_muted,
|
||||
bg: cx.theme().tab_background,
|
||||
inner_bg: if selected {
|
||||
cx.theme().background
|
||||
} else {
|
||||
gpui::transparent_black()
|
||||
},
|
||||
..Default::default()
|
||||
},
|
||||
TabVariant::Underline => TabStyle {
|
||||
fg: cx.theme().text_muted,
|
||||
bg: gpui::transparent_black(),
|
||||
border_color: if selected {
|
||||
cx.theme().border
|
||||
} else {
|
||||
gpui::transparent_black()
|
||||
},
|
||||
borders: Edges {
|
||||
bottom: px(2.),
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn tab_bar_radius(&self, size: Size, cx: &App) -> Pixels {
|
||||
if *self != TabVariant::Segmented {
|
||||
return px(0.);
|
||||
}
|
||||
|
||||
match size {
|
||||
Size::XSmall | Size::Small => cx.theme().radius,
|
||||
Size::Large => cx.theme().radius_lg,
|
||||
_ => cx.theme().radius_lg,
|
||||
}
|
||||
}
|
||||
|
||||
fn radius(&self, size: Size, cx: &App) -> Pixels {
|
||||
match self {
|
||||
TabVariant::Outline | TabVariant::Pill => px(99.),
|
||||
TabVariant::Segmented => match size {
|
||||
Size::XSmall | Size::Small => cx.theme().radius,
|
||||
Size::Large => cx.theme().radius_lg,
|
||||
_ => cx.theme().radius_lg,
|
||||
},
|
||||
_ => px(0.),
|
||||
}
|
||||
}
|
||||
|
||||
fn inner_radius(&self, size: Size, cx: &App) -> Pixels {
|
||||
match self {
|
||||
TabVariant::Segmented => match size {
|
||||
Size::Large => self.tab_bar_radius(size, cx) - px(3.),
|
||||
_ => self.tab_bar_radius(size, cx) - px(2.),
|
||||
},
|
||||
_ => px(0.),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
struct TabStyle {
|
||||
borders: Edges<Pixels>,
|
||||
border_color: Hsla,
|
||||
bg: Hsla,
|
||||
fg: Hsla,
|
||||
shadow: bool,
|
||||
inner_bg: Hsla,
|
||||
}
|
||||
|
||||
impl Default for TabStyle {
|
||||
fn default() -> Self {
|
||||
TabStyle {
|
||||
borders: Edges::all(px(0.)),
|
||||
border_color: gpui::transparent_white(),
|
||||
bg: gpui::transparent_white(),
|
||||
fg: gpui::transparent_white(),
|
||||
shadow: false,
|
||||
inner_bg: gpui::transparent_white(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::type_complexity)]
|
||||
/// A Tab element for the [`super::TabBar`].
|
||||
#[derive(IntoElement)]
|
||||
pub struct Tab {
|
||||
ix: usize,
|
||||
base: Div,
|
||||
label: Option<AnyElement>,
|
||||
pub(super) label: Option<SharedString>,
|
||||
icon: Option<Icon>,
|
||||
prefix: Option<AnyElement>,
|
||||
pub(super) tab_bar_prefix: Option<bool>,
|
||||
suffix: Option<AnyElement>,
|
||||
disabled: bool,
|
||||
selected: bool,
|
||||
children: Vec<AnyElement>,
|
||||
variant: TabVariant,
|
||||
size: Size,
|
||||
pub(super) disabled: bool,
|
||||
pub(super) selected: bool,
|
||||
on_click: Option<Rc<dyn Fn(&ClickEvent, &mut Window, &mut App) + 'static>>,
|
||||
}
|
||||
|
||||
impl Tab {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
ix: 0,
|
||||
base: div(),
|
||||
label: None,
|
||||
disabled: false,
|
||||
selected: false,
|
||||
prefix: None,
|
||||
suffix: None,
|
||||
size: Size::default(),
|
||||
}
|
||||
impl From<&'static str> for Tab {
|
||||
fn from(label: &'static str) -> Self {
|
||||
Self::new().label(label)
|
||||
}
|
||||
}
|
||||
|
||||
/// Set label for the tab.
|
||||
pub fn label(mut self, label: impl Into<AnyElement>) -> Self {
|
||||
self.label = Some(label.into());
|
||||
self
|
||||
impl From<String> for Tab {
|
||||
fn from(label: String) -> Self {
|
||||
Self::new().label(label)
|
||||
}
|
||||
}
|
||||
|
||||
/// Set the left side of the tab
|
||||
pub fn prefix(mut self, prefix: impl Into<AnyElement>) -> Self {
|
||||
self.prefix = Some(prefix.into());
|
||||
self
|
||||
impl From<SharedString> for Tab {
|
||||
fn from(label: SharedString) -> Self {
|
||||
Self::new().label(label)
|
||||
}
|
||||
}
|
||||
|
||||
/// Set the right side of the tab
|
||||
pub fn suffix(mut self, suffix: impl Into<AnyElement>) -> Self {
|
||||
self.suffix = Some(suffix.into());
|
||||
self
|
||||
impl From<Icon> for Tab {
|
||||
fn from(icon: Icon) -> Self {
|
||||
Self::default().icon(icon)
|
||||
}
|
||||
}
|
||||
|
||||
/// Set disabled state to the tab
|
||||
pub fn disabled(mut self, disabled: bool) -> Self {
|
||||
self.disabled = disabled;
|
||||
self
|
||||
}
|
||||
|
||||
/// Set index to the tab.
|
||||
pub fn ix(mut self, ix: usize) -> Self {
|
||||
self.ix = ix;
|
||||
self
|
||||
impl From<IconName> for Tab {
|
||||
fn from(icon_name: IconName) -> Self {
|
||||
Self::default().icon(Icon::new(icon_name))
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for Tab {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
Self {
|
||||
ix: 0,
|
||||
base: div(),
|
||||
label: None,
|
||||
icon: None,
|
||||
tab_bar_prefix: None,
|
||||
children: Vec::new(),
|
||||
disabled: false,
|
||||
selected: false,
|
||||
prefix: None,
|
||||
suffix: None,
|
||||
variant: TabVariant::default(),
|
||||
size: Size::default(),
|
||||
on_click: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Tab {
|
||||
/// Create a new tab with a label.
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Set label for the tab.
|
||||
pub fn label(mut self, label: impl Into<SharedString>) -> Self {
|
||||
self.label = Some(label.into());
|
||||
self
|
||||
}
|
||||
|
||||
/// Set icon for the tab.
|
||||
pub fn icon(mut self, icon: impl Into<Icon>) -> Self {
|
||||
self.icon = Some(icon.into());
|
||||
self
|
||||
}
|
||||
|
||||
/// Set Tab Variant.
|
||||
pub fn with_variant(mut self, variant: TabVariant) -> Self {
|
||||
self.variant = variant;
|
||||
self
|
||||
}
|
||||
|
||||
/// Use Pill variant.
|
||||
pub fn pill(mut self) -> Self {
|
||||
self.variant = TabVariant::Pill;
|
||||
self
|
||||
}
|
||||
|
||||
/// Use outline variant.
|
||||
pub fn outline(mut self) -> Self {
|
||||
self.variant = TabVariant::Outline;
|
||||
self
|
||||
}
|
||||
|
||||
/// Use Segmented variant.
|
||||
pub fn segmented(mut self) -> Self {
|
||||
self.variant = TabVariant::Segmented;
|
||||
self
|
||||
}
|
||||
|
||||
/// Use Underline variant.
|
||||
pub fn underline(mut self) -> Self {
|
||||
self.variant = TabVariant::Underline;
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the left side of the tab
|
||||
pub fn prefix(mut self, prefix: impl IntoElement) -> Self {
|
||||
self.prefix = Some(prefix.into_any_element());
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the right side of the tab
|
||||
pub fn suffix(mut self, suffix: impl IntoElement) -> Self {
|
||||
self.suffix = Some(suffix.into_any_element());
|
||||
self
|
||||
}
|
||||
|
||||
/// Set disabled state to the tab, default false.
|
||||
pub fn disabled(mut self, disabled: bool) -> Self {
|
||||
self.disabled = disabled;
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the click handler for the tab.
|
||||
pub fn on_click(
|
||||
mut self,
|
||||
on_click: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
|
||||
) -> Self {
|
||||
self.on_click = Some(Rc::new(on_click));
|
||||
self
|
||||
}
|
||||
|
||||
/// Set index to the tab.
|
||||
pub(crate) fn ix(mut self, ix: usize) -> Self {
|
||||
self.ix = ix;
|
||||
self
|
||||
}
|
||||
|
||||
/// Set if the tab bar has a prefix.
|
||||
pub(crate) fn tab_bar_prefix(mut self, tab_bar_prefix: bool) -> Self {
|
||||
self.tab_bar_prefix = Some(tab_bar_prefix);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl ParentElement for Tab {
|
||||
fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
|
||||
self.children.extend(elements);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -105,62 +588,115 @@ impl Sizable for Tab {
|
||||
}
|
||||
|
||||
impl RenderOnce for Tab {
|
||||
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let (text_color, hover_text_color, bg_color, border_color) =
|
||||
match (self.selected, self.disabled) {
|
||||
(true, false) => (
|
||||
cx.theme().tab_active_foreground,
|
||||
cx.theme().tab_hover_foreground,
|
||||
cx.theme().tab_active_background,
|
||||
cx.theme().border,
|
||||
),
|
||||
(false, false) => (
|
||||
cx.theme().tab_inactive_foreground,
|
||||
cx.theme().tab_hover_foreground,
|
||||
cx.theme().ghost_element_background,
|
||||
cx.theme().border_transparent,
|
||||
),
|
||||
(true, true) => (
|
||||
cx.theme().tab_inactive_foreground,
|
||||
cx.theme().tab_hover_foreground,
|
||||
cx.theme().ghost_element_background,
|
||||
cx.theme().border_disabled,
|
||||
),
|
||||
(false, true) => (
|
||||
cx.theme().tab_inactive_foreground,
|
||||
cx.theme().tab_hover_foreground,
|
||||
cx.theme().ghost_element_background,
|
||||
cx.theme().border_disabled,
|
||||
),
|
||||
};
|
||||
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let mut tab_style = if self.selected {
|
||||
self.variant.selected(cx)
|
||||
} else {
|
||||
self.variant.normal(cx)
|
||||
};
|
||||
|
||||
let mut hover_style = self.variant.hovered(self.selected, cx);
|
||||
|
||||
if self.disabled {
|
||||
tab_style = self.variant.disabled(self.selected, cx);
|
||||
hover_style = self.variant.disabled(self.selected, cx);
|
||||
}
|
||||
|
||||
let tab_bar_prefix = self.tab_bar_prefix.unwrap_or_default();
|
||||
|
||||
if !tab_bar_prefix && self.ix == 0 && self.variant == TabVariant::Tab {
|
||||
tab_style.borders.left = px(0.);
|
||||
hover_style.borders.left = px(0.);
|
||||
}
|
||||
|
||||
let radius = self.variant.radius(self.size, cx);
|
||||
let inner_radius = self.variant.inner_radius(self.size, cx);
|
||||
let inner_paddings = self.variant.inner_paddings(self.size);
|
||||
let inner_margins = self.variant.inner_margins(self.size);
|
||||
let inner_height = self.variant.inner_height(self.size);
|
||||
let height = self.variant.height(self.size);
|
||||
|
||||
self.base
|
||||
.id(self.ix)
|
||||
.h(TABBAR_HEIGHT)
|
||||
.px_4()
|
||||
.relative()
|
||||
.flex()
|
||||
.flex_wrap()
|
||||
.gap_1()
|
||||
.items_center()
|
||||
.flex_shrink_0()
|
||||
.cursor_pointer()
|
||||
.h(height)
|
||||
.overflow_hidden()
|
||||
.text_xs()
|
||||
.text_ellipsis()
|
||||
.text_color(text_color)
|
||||
.bg(bg_color)
|
||||
.border_l(px(1.))
|
||||
.border_r(px(1.))
|
||||
.border_color(border_color)
|
||||
.text_color(tab_style.fg)
|
||||
.map(|this| match self.size {
|
||||
Size::XSmall => this.text_xs(),
|
||||
Size::Large => this.text_base(),
|
||||
_ => this.text_sm(),
|
||||
})
|
||||
.bg(tab_style.bg)
|
||||
.border_l(tab_style.borders.left)
|
||||
.border_r(tab_style.borders.right)
|
||||
.border_t(tab_style.borders.top)
|
||||
.border_b(tab_style.borders.bottom)
|
||||
.border_color(tab_style.border_color)
|
||||
.rounded(radius)
|
||||
.when(!self.selected && !self.disabled, |this| {
|
||||
this.hover(|this| this.text_color(hover_text_color))
|
||||
this.hover(|this| {
|
||||
this.text_color(hover_style.fg)
|
||||
.bg(hover_style.bg)
|
||||
.border_l(hover_style.borders.left)
|
||||
.border_r(hover_style.borders.right)
|
||||
.border_t(hover_style.borders.top)
|
||||
.border_b(hover_style.borders.bottom)
|
||||
.border_color(hover_style.border_color)
|
||||
.rounded(radius)
|
||||
})
|
||||
})
|
||||
.when_some(self.prefix, |this, prefix| {
|
||||
this.child(prefix).text_color(text_color)
|
||||
.when_some(self.prefix, |this, prefix| this.child(prefix))
|
||||
.child(
|
||||
h_flex()
|
||||
.flex_1()
|
||||
.h(inner_height)
|
||||
.line_height(relative(1.))
|
||||
.whitespace_nowrap()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.overflow_hidden()
|
||||
.margins(inner_margins)
|
||||
.flex_shrink_0()
|
||||
.map(|this| match self.icon {
|
||||
Some(icon) => {
|
||||
this.w(inner_height * 1.25)
|
||||
.child(icon.map(|this| match self.size {
|
||||
Size::XSmall => this.size_2p5(),
|
||||
Size::Small => this.size_3p5(),
|
||||
Size::Large => this.size_4(),
|
||||
_ => this.size_4(),
|
||||
}))
|
||||
}
|
||||
None => this
|
||||
.paddings(inner_paddings)
|
||||
.map(|this| match self.label {
|
||||
Some(label) => this.child(label),
|
||||
None => this,
|
||||
})
|
||||
.children(self.children),
|
||||
})
|
||||
.bg(tab_style.inner_bg)
|
||||
.rounded(inner_radius)
|
||||
.when(tab_style.shadow, |this| this.shadow_xs())
|
||||
.hover(|this| this.bg(hover_style.inner_bg).rounded(inner_radius)),
|
||||
)
|
||||
.when_some(self.suffix, |this, suffix| {
|
||||
this.child(div().pr_2().child(suffix))
|
||||
})
|
||||
.when_some(self.label, |this, label| this.child(label))
|
||||
.when_some(self.suffix, |this, suffix| this.child(suffix))
|
||||
.on_mouse_down(MouseButton::Left, |_ev, _window, cx| {
|
||||
.on_mouse_down(MouseButton::Left, |_, _, cx| {
|
||||
// Stop propagation behavior, for works on TitleBar.
|
||||
// https://github.com/longbridge/gpui-component/issues/1836
|
||||
cx.stop_propagation();
|
||||
})
|
||||
.when(!self.disabled, |this| {
|
||||
this.when_some(self.on_click.clone(), |this, on_click| {
|
||||
this.on_click(move |event, window, cx| on_click(event, window, cx))
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,41 +1,92 @@
|
||||
use std::rc::Rc;
|
||||
|
||||
use gpui::prelude::FluentBuilder as _;
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
use gpui::Pixels;
|
||||
use gpui::{
|
||||
div, px, AnyElement, App, Div, InteractiveElement, IntoElement, ParentElement, RenderOnce,
|
||||
ScrollHandle, StatefulInteractiveElement as _, StyleRefinement, Styled, Window,
|
||||
Anchor, AnyElement, App, Div, Edges, ElementId, InteractiveElement, IntoElement, ParentElement,
|
||||
RenderOnce, ScrollHandle, Stateful, StatefulInteractiveElement as _, StyleRefinement, Styled,
|
||||
Window, div, px,
|
||||
};
|
||||
use smallvec::SmallVec;
|
||||
use theme::ActiveTheme;
|
||||
|
||||
use crate::{h_flex, Sizable, Size, StyledExt};
|
||||
use super::{Tab, TabVariant};
|
||||
use crate::button::{Button, ButtonVariants as _};
|
||||
use crate::menu::{DropdownMenu as _, PopupMenuItem};
|
||||
use crate::{IconName, Selectable, Sizable, Size, StyledExt, h_flex};
|
||||
|
||||
#[allow(clippy::type_complexity)]
|
||||
/// A TabBar element that contains multiple [`Tab`] items.
|
||||
#[derive(IntoElement)]
|
||||
pub struct TabBar {
|
||||
base: Div,
|
||||
base: Stateful<Div>,
|
||||
style: StyleRefinement,
|
||||
scroll_handle: Option<ScrollHandle>,
|
||||
prefix: Option<AnyElement>,
|
||||
suffix: Option<AnyElement>,
|
||||
children: SmallVec<[Tab; 2]>,
|
||||
last_empty_space: AnyElement,
|
||||
children: SmallVec<[AnyElement; 2]>,
|
||||
selected_index: Option<usize>,
|
||||
variant: TabVariant,
|
||||
size: Size,
|
||||
menu: bool,
|
||||
on_click: Option<Rc<dyn Fn(&usize, &mut Window, &mut App) + 'static>>,
|
||||
}
|
||||
|
||||
impl TabBar {
|
||||
pub fn new() -> Self {
|
||||
/// Create a new TabBar.
|
||||
pub fn new(id: impl Into<ElementId>) -> Self {
|
||||
Self {
|
||||
base: h_flex().px(px(-1.)),
|
||||
base: div().id(id).px(px(-1.)),
|
||||
style: StyleRefinement::default(),
|
||||
scroll_handle: None,
|
||||
children: SmallVec::new(),
|
||||
scroll_handle: None,
|
||||
prefix: None,
|
||||
suffix: None,
|
||||
variant: TabVariant::default(),
|
||||
size: Size::default(),
|
||||
last_empty_space: div().w_3().into_any_element(),
|
||||
selected_index: None,
|
||||
on_click: None,
|
||||
menu: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Set the Tab variant, all children will inherit the variant.
|
||||
pub fn with_variant(mut self, variant: TabVariant) -> Self {
|
||||
self.variant = variant;
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the Tab variant to Pill, all children will inherit the variant.
|
||||
pub fn pill(mut self) -> Self {
|
||||
self.variant = TabVariant::Pill;
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the Tab variant to Outline, all children will inherit the variant.
|
||||
pub fn outline(mut self) -> Self {
|
||||
self.variant = TabVariant::Outline;
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the Tab variant to Segmented, all children will inherit the variant.
|
||||
pub fn segmented(mut self) -> Self {
|
||||
self.variant = TabVariant::Segmented;
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the Tab variant to Underline, all children will inherit the variant.
|
||||
pub fn underline(mut self) -> Self {
|
||||
self.variant = TabVariant::Underline;
|
||||
self
|
||||
}
|
||||
|
||||
/// Set whether to show the menu button when tabs overflow, default is false.
|
||||
pub fn menu(mut self, menu: bool) -> Self {
|
||||
self.menu = menu;
|
||||
self
|
||||
}
|
||||
|
||||
/// Track the scroll of the TabBar.
|
||||
pub fn track_scroll(mut self, scroll_handle: &ScrollHandle) -> Self {
|
||||
self.scroll_handle = Some(scroll_handle.clone());
|
||||
@@ -54,27 +105,39 @@ impl TabBar {
|
||||
self
|
||||
}
|
||||
|
||||
/// Add children of the TabBar, all children will inherit the variant.
|
||||
pub fn children(mut self, children: impl IntoIterator<Item = impl Into<Tab>>) -> Self {
|
||||
self.children.extend(children.into_iter().map(Into::into));
|
||||
self
|
||||
}
|
||||
|
||||
/// Add child of the TabBar, tab will inherit the variant.
|
||||
pub fn child(mut self, child: impl Into<Tab>) -> Self {
|
||||
self.children.push(child.into());
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the selected index of the TabBar.
|
||||
pub fn selected_index(mut self, index: usize) -> Self {
|
||||
self.selected_index = Some(index);
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the last empty space element of the TabBar.
|
||||
pub fn last_empty_space(mut self, last_empty_space: impl IntoElement) -> Self {
|
||||
self.last_empty_space = last_empty_space.into_any_element();
|
||||
self
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
pub fn height(window: &mut Window) -> Pixels {
|
||||
(1.75 * window.rem_size()).max(px(36.))
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for TabBar {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl ParentElement for TabBar {
|
||||
fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
|
||||
self.children.extend(elements)
|
||||
/// Set the on_click callback of the TabBar, the first parameter is the index of the clicked tab.
|
||||
///
|
||||
/// When this is set, the children's on_click will be ignored.
|
||||
pub fn on_click<F>(mut self, on_click: F) -> Self
|
||||
where
|
||||
F: Fn(&usize, &mut Window, &mut App) + 'static,
|
||||
{
|
||||
self.on_click = Some(Rc::new(on_click));
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
@@ -92,37 +155,136 @@ impl Sizable for TabBar {
|
||||
}
|
||||
|
||||
impl RenderOnce for TabBar {
|
||||
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let default_gap = match self.size {
|
||||
Size::Small | Size::XSmall => px(8.),
|
||||
Size::Large => px(16.),
|
||||
_ => px(12.),
|
||||
};
|
||||
let (bg, paddings, gap) = match self.variant {
|
||||
TabVariant::Tab => {
|
||||
let padding = Edges::all(px(0.));
|
||||
(cx.theme().tab_background, padding, px(0.))
|
||||
}
|
||||
TabVariant::Outline => {
|
||||
let padding = Edges::all(px(0.));
|
||||
(gpui::transparent_black(), padding, default_gap)
|
||||
}
|
||||
TabVariant::Pill => {
|
||||
let padding = Edges::all(px(0.));
|
||||
(gpui::transparent_black(), padding, px(4.))
|
||||
}
|
||||
TabVariant::Segmented => {
|
||||
let padding_x = match self.size {
|
||||
Size::XSmall => px(2.),
|
||||
Size::Small => px(3.),
|
||||
_ => px(4.),
|
||||
};
|
||||
let padding = Edges {
|
||||
left: padding_x,
|
||||
right: padding_x,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
(cx.theme().tab_background, padding, px(2.))
|
||||
}
|
||||
TabVariant::Underline => {
|
||||
// This gap is same as the tab inner_paddings
|
||||
let gap = match self.size {
|
||||
Size::XSmall => px(10.),
|
||||
Size::Small => px(12.),
|
||||
Size::Large => px(20.),
|
||||
_ => px(16.),
|
||||
};
|
||||
|
||||
(gpui::transparent_black(), Edges::all(px(0.)), gap)
|
||||
}
|
||||
};
|
||||
|
||||
let mut item_labels = Vec::new();
|
||||
let selected_index = self.selected_index;
|
||||
let on_click = self.on_click.clone();
|
||||
|
||||
self.base
|
||||
.group("tab-bar")
|
||||
.relative()
|
||||
.refine_style(&self.style)
|
||||
.bg(cx.theme().surface_background)
|
||||
.child(
|
||||
div()
|
||||
.id("border-bottom")
|
||||
.absolute()
|
||||
.left_0()
|
||||
.bottom_0()
|
||||
.size_full()
|
||||
.border_b_1()
|
||||
.border_color(cx.theme().border),
|
||||
.flex()
|
||||
.items_center()
|
||||
.bg(bg)
|
||||
.text_color(cx.theme().tab_foreground)
|
||||
.when(
|
||||
self.variant == TabVariant::Underline || self.variant == TabVariant::Tab,
|
||||
|this| {
|
||||
this.child(
|
||||
div()
|
||||
.id("border-b")
|
||||
.absolute()
|
||||
.left_0()
|
||||
.bottom_0()
|
||||
.size_full()
|
||||
.border_b_1()
|
||||
.border_color(cx.theme().border),
|
||||
)
|
||||
},
|
||||
)
|
||||
.text_color(cx.theme().text)
|
||||
.rounded(self.variant.tab_bar_radius(self.size, cx))
|
||||
.paddings(paddings)
|
||||
.refine_style(&self.style)
|
||||
.when_some(self.prefix, |this, prefix| this.child(prefix))
|
||||
.child(
|
||||
h_flex()
|
||||
.id("tabs")
|
||||
.flex_grow()
|
||||
.flex_1()
|
||||
.overflow_x_scroll()
|
||||
.when_some(self.scroll_handle, |this, scroll_handle| {
|
||||
this.track_scroll(&scroll_handle)
|
||||
})
|
||||
.children(self.children)
|
||||
.when(self.suffix.is_some(), |this| {
|
||||
.gap(gap)
|
||||
.children(self.children.into_iter().enumerate().map(|(ix, child)| {
|
||||
item_labels.push((child.label.clone(), child.disabled));
|
||||
let tab_bar_prefix = child.tab_bar_prefix.unwrap_or(true);
|
||||
child
|
||||
.ix(ix)
|
||||
.tab_bar_prefix(tab_bar_prefix)
|
||||
.with_variant(self.variant)
|
||||
.with_size(self.size)
|
||||
.when_some(self.selected_index, |this, selected_ix| {
|
||||
this.selected(selected_ix == ix)
|
||||
})
|
||||
.when_some(self.on_click.clone(), move |this, on_click| {
|
||||
this.on_click(move |_, window, cx| on_click(&ix, window, cx))
|
||||
})
|
||||
}))
|
||||
.when(self.suffix.is_some() || self.menu, |this| {
|
||||
this.child(self.last_empty_space)
|
||||
}),
|
||||
)
|
||||
.when(self.menu, |this| {
|
||||
this.child(
|
||||
Button::new("more")
|
||||
.xsmall()
|
||||
.ghost()
|
||||
.icon(IconName::ChevronDown)
|
||||
.dropdown_menu(move |mut this, _, _| {
|
||||
this = this.scrollable(true);
|
||||
for (ix, (label, disabled)) in item_labels.iter().enumerate() {
|
||||
this = this.item(
|
||||
PopupMenuItem::new(label.clone().unwrap_or_default())
|
||||
.checked(selected_index == Some(ix))
|
||||
.disabled(*disabled)
|
||||
.when_some(on_click.clone(), |this, on_click| {
|
||||
this.on_click(move |_, window, cx| {
|
||||
on_click(&ix, window, cx)
|
||||
})
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
this
|
||||
})
|
||||
.anchor(Anchor::TopRight),
|
||||
)
|
||||
})
|
||||
.when_some(self.suffix, |this, suffix| this.child(suffix))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
use std::rc::Rc;
|
||||
|
||||
use gpui::{App, Entity, SharedString, Window};
|
||||
use gpui::{App, ElementId, Entity, Window};
|
||||
|
||||
use crate::Root;
|
||||
use crate::input::InputState;
|
||||
use crate::modal::Modal;
|
||||
use crate::notification::Notification;
|
||||
use crate::Root;
|
||||
|
||||
/// Extension trait for [`Window`] to add modal, notification .. functionality.
|
||||
pub trait WindowExtension: Sized {
|
||||
@@ -31,10 +31,15 @@ pub trait WindowExtension: Sized {
|
||||
where
|
||||
T: Into<Notification>;
|
||||
|
||||
/// Clears a notification by its ID.
|
||||
fn clear_notification<T>(&mut self, id: T, cx: &mut App)
|
||||
where
|
||||
T: Into<SharedString>;
|
||||
/// Clear the unique notification.
|
||||
fn clear_notification<T: Sized + 'static>(&mut self, cx: &mut App);
|
||||
|
||||
/// Clear the unique notification with the given id.
|
||||
fn clear_notification_by_id<T: Sized + 'static>(
|
||||
&mut self,
|
||||
key: impl Into<ElementId>,
|
||||
cx: &mut App,
|
||||
);
|
||||
|
||||
/// Clear all notifications
|
||||
fn clear_notifications(&mut self, cx: &mut App);
|
||||
@@ -88,13 +93,21 @@ impl WindowExtension for Window {
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn clear_notification<T>(&mut self, id: T, cx: &mut App)
|
||||
where
|
||||
T: Into<SharedString>,
|
||||
{
|
||||
let id = id.into();
|
||||
Root::update(self, cx, move |root, window, cx| {
|
||||
root.clear_notification(id, window, cx);
|
||||
fn clear_notification<T: Sized + 'static>(&mut self, cx: &mut App) {
|
||||
Root::update(self, cx, |root, window, cx| {
|
||||
root.clear_notification::<T>(window, cx);
|
||||
})
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn clear_notification_by_id<T: Sized + 'static>(
|
||||
&mut self,
|
||||
key: impl Into<ElementId>,
|
||||
cx: &mut App,
|
||||
) {
|
||||
let key: ElementId = key.into();
|
||||
Root::update(self, cx, |root, window, cx| {
|
||||
root.clear_notification_by_id::<T>(key, window, cx);
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -14,8 +14,8 @@ product-name = "Coop"
|
||||
description = "Chat Freely, Stay Private on Nostr"
|
||||
identifier = "su.reya.coop"
|
||||
category = "SocialNetworking"
|
||||
version = "0.3.0"
|
||||
out-dir = "../../dist"
|
||||
version = "1.0.0-beta4"
|
||||
out-dir = "../dist"
|
||||
before-packaging-command = "cargo build --release"
|
||||
resources = ["Cargo.toml", "src"]
|
||||
icons = [
|
||||
@@ -27,19 +27,19 @@ icons = [
|
||||
]
|
||||
|
||||
[dependencies]
|
||||
assets = { path = "../assets" }
|
||||
ui = { path = "../ui" }
|
||||
title_bar = { path = "../title_bar" }
|
||||
theme = { path = "../theme" }
|
||||
common = { path = "../common" }
|
||||
state = { path = "../state" }
|
||||
device = { path = "../device" }
|
||||
chat = { path = "../chat" }
|
||||
chat_ui = { path = "../chat_ui" }
|
||||
settings = { path = "../settings" }
|
||||
auto_update = { path = "../auto_update" }
|
||||
person = { path = "../person" }
|
||||
relay_auth = { path = "../relay_auth" }
|
||||
assets = { path = "../crates/assets" }
|
||||
ui = { path = "../crates/ui" }
|
||||
title_bar = { path = "../crates/title_bar" }
|
||||
theme = { path = "../crates/theme" }
|
||||
common = { path = "../crates/common" }
|
||||
state = { path = "../crates/state" }
|
||||
device = { path = "../crates/device" }
|
||||
chat = { path = "../crates/chat" }
|
||||
chat_ui = { path = "../crates/chat_ui" }
|
||||
settings = { path = "../crates/settings" }
|
||||
auto_update = { path = "../crates/auto_update" }
|
||||
person = { path = "../crates/person" }
|
||||
relay_auth = { path = "../crates/relay_auth" }
|
||||
|
||||
gpui.workspace = true
|
||||
gpui_platform.workspace = true
|
||||
@@ -62,6 +62,10 @@ smol.workspace = true
|
||||
futures.workspace = true
|
||||
oneshot.workspace = true
|
||||
webbrowser.workspace = true
|
||||
tracing-subscriber.workspace = true
|
||||
|
||||
indexset = "0.12.3"
|
||||
tracing-subscriber = { version = "0.3.18", features = ["fmt"] }
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
# Temporary workaround https://github.com/zed-industries/zed/issues/47168
|
||||
core-text = "=21.0.0"
|
||||
|
Before Width: | Height: | Size: 8.4 KiB After Width: | Height: | Size: 8.4 KiB |
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 22 KiB |