Compare commits
73 Commits
dcf28e2b60
...
fix-web
| Author | SHA1 | Date | |
|---|---|---|---|
| 0faa8694f8 | |||
| 5ddae637f6 | |||
| 0ae38d38d4 | |||
| 4253a6df3e | |||
| 3a95456917 | |||
| a8bdd49072 | |||
| fd32b8c0ce | |||
| 8d4d8184e6 | |||
| e85974497b | |||
| da7167013f | |||
| 2ff83079b8 | |||
| b349656c56 | |||
| 8bbb472103 | |||
| dfda7ff157 | |||
| f46f15e10c | |||
| 2f15615d7b | |||
| 9addbd49f4 | |||
| b518c729f6 | |||
| 4b57a1d2a6 | |||
| 5ea6cdb34f | |||
| 1f04a824d7 | |||
| c78e0a5163 | |||
| 5d4c8634ef | |||
| 4efeec08c4 | |||
| 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 |
16
.github/workflows/release.yml
vendored
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: |
|
||||
|
||||
7
.gitignore
vendored
7
.gitignore
vendored
@@ -19,5 +19,8 @@ dist/
|
||||
|
||||
# Useless stuffs
|
||||
.DS_Store
|
||||
# Added by goreleaser init:
|
||||
.intentionally-empty-file.o
|
||||
|
||||
.cargo/
|
||||
vendor/
|
||||
wasm/
|
||||
node_modules/
|
||||
|
||||
3970
Cargo.lock
generated
3970
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
27
Cargo.toml
27
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 = "1.0.0-beta"
|
||||
edition = "2021"
|
||||
version = "1.0.0-beta5"
|
||||
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 = ["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" }
|
||||
@@ -21,10 +20,12 @@ reqwest_client = { git = "https://github.com/zed-industries/zed" }
|
||||
|
||||
# Nostr
|
||||
nostr-lmdb = { git = "https://github.com/rust-nostr/nostr" }
|
||||
nostr-connect = { git = "https://github.com/rust-nostr/nostr" }
|
||||
nostr-memory = { 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-connect = { git = "https://github.com/rust-nostr/nostr" }
|
||||
nostr-sdk = { git = "https://github.com/rust-nostr/nostr" }
|
||||
nostr = { git = "https://github.com/rust-nostr/nostr", features = [ "nip96", "nip59", "nip49", "nip44" ] }
|
||||
nostr = { git = "https://github.com/rust-nostr/nostr", features = [ "nip59", "nip49", "nip44" ] }
|
||||
|
||||
# Others
|
||||
anyhow = "1.0.44"
|
||||
@@ -34,14 +35,20 @@ itertools = "0.13.0"
|
||||
log = "0.4"
|
||||
oneshot = "0.1.10"
|
||||
flume = { version = "0.11.1", default-features = false, features = ["async", "select"] }
|
||||
rust-embed = "8.5.0"
|
||||
rust-embed = { version = "8.5", features = ["include-exclude"] }
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
schemars = "1"
|
||||
smallvec = "1.14.0"
|
||||
smol = "2"
|
||||
tracing = "0.1.40"
|
||||
webbrowser = "1.0.4"
|
||||
tracing-subscriber = { version = "0.3.18", features = ["fmt", "env-filter"] }
|
||||
errno = { version = "0.3.14", default-features = false }
|
||||
instant = "0.1"
|
||||
|
||||
[patch.crates-io]
|
||||
# Use stacker's psm version which may have better WASM support
|
||||
psm = { git = "https://github.com/rust-lang/stacker", branch = "master" }
|
||||
|
||||
[profile.release]
|
||||
strip = true
|
||||
|
||||
16
README.md
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/device.svg
Normal file
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
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
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
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 |
144
assets/themes/aurora.json
Normal file
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
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
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"
|
||||
}
|
||||
}
|
||||
@@ -8,9 +8,8 @@ publish.workspace = true
|
||||
common = { path = "../common" }
|
||||
|
||||
gpui.workspace = true
|
||||
gpui_tokio.workspace = true
|
||||
instant.workspace = true
|
||||
anyhow.workspace = true
|
||||
smol.workspace = true
|
||||
log.workspace = true
|
||||
smallvec.workspace = true
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
@@ -18,4 +17,6 @@ serde_json.workspace = true
|
||||
|
||||
semver = "1.0.27"
|
||||
tempfile = "3.23.0"
|
||||
futures.workspace = true
|
||||
|
||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||
smol.workspace = true
|
||||
|
||||
@@ -1,17 +1,19 @@
|
||||
#![cfg(not(target_arch = "wasm32"))]
|
||||
|
||||
use std::ffi::OsString;
|
||||
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,
|
||||
Window,
|
||||
};
|
||||
use instant::Duration;
|
||||
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 +22,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 {
|
||||
|
||||
@@ -13,15 +13,16 @@ settings = { path = "../settings" }
|
||||
|
||||
gpui.workspace = true
|
||||
nostr-sdk.workspace = true
|
||||
instant.workspace = true
|
||||
|
||||
anyhow.workspace = true
|
||||
itertools.workspace = true
|
||||
smallvec.workspace = true
|
||||
smol.workspace = true
|
||||
log.workspace = true
|
||||
futures.workspace = true
|
||||
flume.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
|
||||
futures.workspace = true
|
||||
fuzzy-matcher = "0.3.7"
|
||||
|
||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||
smol.workspace = true
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,8 +1,110 @@
|
||||
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::*;
|
||||
|
||||
/// Rendered message.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Message {
|
||||
pub id: EventId,
|
||||
/// Author's public key
|
||||
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<Mention>,
|
||||
/// List of event of the message this message is a reply to
|
||||
pub replies_to: Vec<EventId>,
|
||||
}
|
||||
|
||||
impl From<&Event> for Message {
|
||||
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: string,
|
||||
media,
|
||||
created_at: val.created_at,
|
||||
mentions,
|
||||
replies_to,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&UnsignedEvent> for Message {
|
||||
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: string,
|
||||
media,
|
||||
created_at: val.created_at,
|
||||
mentions,
|
||||
replies_to,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&NewMessage> for Message {
|
||||
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: string,
|
||||
media,
|
||||
created_at: val.rumor.created_at,
|
||||
mentions,
|
||||
replies_to,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Eq for Message {}
|
||||
|
||||
impl PartialEq for Message {
|
||||
fn eq(&self, other: &Self) -> bool {
|
||||
self.id == other.id
|
||||
}
|
||||
}
|
||||
|
||||
impl Ord for Message {
|
||||
fn cmp(&self, other: &Self) -> std::cmp::Ordering {
|
||||
self.created_at.cmp(&other.created_at)
|
||||
}
|
||||
}
|
||||
|
||||
impl PartialOrd for Message {
|
||||
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
|
||||
Some(self.cmp(other))
|
||||
}
|
||||
}
|
||||
|
||||
impl Hash for Message {
|
||||
fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
|
||||
self.id.hash(state);
|
||||
}
|
||||
}
|
||||
|
||||
/// New message.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub struct NewMessage {
|
||||
@@ -23,194 +125,62 @@ impl NewMessage {
|
||||
}
|
||||
}
|
||||
|
||||
/// Message.
|
||||
#[derive(Debug, Clone, Hash, PartialEq, Eq)]
|
||||
pub enum Message {
|
||||
User(RenderedMessage),
|
||||
Warning(String, Timestamp),
|
||||
System(Timestamp),
|
||||
/// Trash message.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub struct FailedMessage {
|
||||
pub raw_event: SharedString,
|
||||
pub reason: SharedString,
|
||||
}
|
||||
|
||||
impl Message {
|
||||
pub fn user<I>(user: I) -> Self
|
||||
impl FailedMessage {
|
||||
pub fn new<T>(event: &Event, reason: T) -> Self
|
||||
where
|
||||
I: Into<RenderedMessage>,
|
||||
T: Into<SharedString>,
|
||||
{
|
||||
Self::User(user.into())
|
||||
}
|
||||
|
||||
pub fn warning<I>(content: I) -> Self
|
||||
where
|
||||
I: Into<String>,
|
||||
{
|
||||
Self::Warning(content.into(), Timestamp::now())
|
||||
}
|
||||
|
||||
pub fn system() -> Self {
|
||||
Self::System(Timestamp::default())
|
||||
}
|
||||
|
||||
fn timestamp(&self) -> &Timestamp {
|
||||
match self {
|
||||
Message::User(msg) => &msg.created_at,
|
||||
Message::Warning(_, ts) => ts,
|
||||
Message::System(ts) => ts,
|
||||
Self {
|
||||
raw_event: SharedString::from(event.as_json()),
|
||||
reason: reason.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&NewMessage> for Message {
|
||||
fn from(val: &NewMessage) -> Self {
|
||||
Self::User(val.into())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&UnsignedEvent> for Message {
|
||||
fn from(val: &UnsignedEvent) -> Self {
|
||||
Self::User(val.into())
|
||||
}
|
||||
}
|
||||
|
||||
impl Ord for Message {
|
||||
fn cmp(&self, other: &Self) -> std::cmp::Ordering {
|
||||
match (self, other) {
|
||||
// System always comes first
|
||||
(Message::System(_), Message::System(_)) => self.timestamp().cmp(other.timestamp()),
|
||||
(Message::System(_), _) => std::cmp::Ordering::Less,
|
||||
(_, Message::System(_)) => std::cmp::Ordering::Greater,
|
||||
|
||||
// For non-system messages, compare by timestamp
|
||||
_ => self.timestamp().cmp(other.timestamp()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl PartialOrd for Message {
|
||||
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
|
||||
Some(self.cmp(other))
|
||||
}
|
||||
}
|
||||
|
||||
/// Rendered message.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RenderedMessage {
|
||||
pub id: EventId,
|
||||
/// Author's public key
|
||||
pub author: PublicKey,
|
||||
/// The content/text of the message
|
||||
pub content: String,
|
||||
/// Message created time as unix timestamp
|
||||
pub created_at: Timestamp,
|
||||
/// List of mentioned public keys in the message
|
||||
pub mentions: Vec<PublicKey>,
|
||||
/// List of event of the message this message is a reply to
|
||||
pub replies_to: Vec<EventId>,
|
||||
pub struct Mention {
|
||||
pub public_key: PublicKey,
|
||||
pub range: Range<usize>,
|
||||
}
|
||||
|
||||
impl From<&Event> for RenderedMessage {
|
||||
fn from(val: &Event) -> Self {
|
||||
let mentions = extract_mentions(&val.content);
|
||||
let replies_to = extract_reply_ids(&val.tags);
|
||||
|
||||
Self {
|
||||
id: val.id,
|
||||
author: val.pubkey,
|
||||
content: val.content.clone(),
|
||||
created_at: val.created_at,
|
||||
mentions,
|
||||
replies_to,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&UnsignedEvent> for RenderedMessage {
|
||||
fn from(val: &UnsignedEvent) -> Self {
|
||||
let mentions = extract_mentions(&val.content);
|
||||
let replies_to = extract_reply_ids(&val.tags);
|
||||
|
||||
Self {
|
||||
// Event ID must be known
|
||||
id: val.id.unwrap(),
|
||||
author: val.pubkey,
|
||||
content: val.content.clone(),
|
||||
created_at: val.created_at,
|
||||
mentions,
|
||||
replies_to,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
Self {
|
||||
// Event ID must be known
|
||||
id: val.rumor.id.unwrap(),
|
||||
author: val.rumor.pubkey,
|
||||
content: val.rumor.content.clone(),
|
||||
created_at: val.rumor.created_at,
|
||||
mentions,
|
||||
replies_to,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Eq for RenderedMessage {}
|
||||
|
||||
impl PartialEq for RenderedMessage {
|
||||
fn eq(&self, other: &Self) -> bool {
|
||||
self.id == other.id
|
||||
}
|
||||
}
|
||||
|
||||
impl Ord for RenderedMessage {
|
||||
fn cmp(&self, other: &Self) -> std::cmp::Ordering {
|
||||
self.created_at.cmp(&other.created_at)
|
||||
}
|
||||
}
|
||||
|
||||
impl PartialOrd for RenderedMessage {
|
||||
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
|
||||
Some(self.cmp(other))
|
||||
}
|
||||
}
|
||||
|
||||
impl Hash for RenderedMessage {
|
||||
fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
|
||||
self.id.hash(state);
|
||||
impl Mention {
|
||||
pub fn new(public_key: PublicKey, range: Range<usize>) -> Self {
|
||||
Self { public_key, range }
|
||||
}
|
||||
}
|
||||
|
||||
/// 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.
|
||||
fn extract_reply_ids(inner: &Tags) -> Vec<EventId> {
|
||||
let mut replies_to = vec![];
|
||||
|
||||
for tag in inner.filter(TagKind::e()) {
|
||||
for tag in inner.iter().filter(|tag| tag.kind() == "e") {
|
||||
if let Some(id) = tag.content().and_then(|id| EventId::parse(id).ok()) {
|
||||
replies_to.push(id);
|
||||
}
|
||||
}
|
||||
|
||||
for tag in inner.filter(TagKind::q()) {
|
||||
for tag in inner.iter().filter(|tag| tag.kind() == "q") {
|
||||
if let Some(id) = tag.content().and_then(|id| EventId::parse(id).ok()) {
|
||||
replies_to.push(id);
|
||||
}
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
use std::cmp::Ordering;
|
||||
use std::collections::HashMap;
|
||||
use std::hash::{Hash, Hasher};
|
||||
use std::time::Duration;
|
||||
use instant::Duration;
|
||||
|
||||
use anyhow::Error;
|
||||
use common::EventUtils;
|
||||
use anyhow::{Error, anyhow};
|
||||
use common::EventExt;
|
||||
use device::DeviceRegistry;
|
||||
use gpui::{App, AppContext, Context, EventEmitter, SharedString, Task};
|
||||
use itertools::Itertools;
|
||||
use nostr_sdk::prelude::*;
|
||||
use person::{Person, PersonRegistry};
|
||||
use settings::{RoomConfig, SignerKind};
|
||||
use state::{NostrRegistry, BOOTSTRAP_RELAYS, TIMEOUT};
|
||||
use state::{NostrRegistry, TIMEOUT, UniversalSigner};
|
||||
|
||||
use crate::NewMessage;
|
||||
|
||||
@@ -22,7 +22,7 @@ pub struct SendReport {
|
||||
pub receiver: PublicKey,
|
||||
pub gift_wrap_id: Option<EventId>,
|
||||
pub error: Option<SharedString>,
|
||||
pub output: Option<Output<EventId>>,
|
||||
pub output: Option<Output<EventId, EventSendStatus>>,
|
||||
}
|
||||
|
||||
impl SendReport {
|
||||
@@ -42,7 +42,7 @@ impl SendReport {
|
||||
}
|
||||
|
||||
/// Set the output.
|
||||
pub fn output(mut self, output: Output<EventId>) -> Self {
|
||||
pub fn output(mut self, output: Output<EventId, EventSendStatus>) -> Self {
|
||||
self.output = Some(output);
|
||||
self
|
||||
}
|
||||
@@ -58,16 +58,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 }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -144,7 +172,8 @@ impl From<&UnsignedEvent> for Room {
|
||||
let members = val.extract_public_keys();
|
||||
let subject = val
|
||||
.tags
|
||||
.find(TagKind::Subject)
|
||||
.iter()
|
||||
.find(|tag| tag.kind() == "subject")
|
||||
.and_then(|tag| tag.content().map(|s| s.to_owned().into()));
|
||||
|
||||
Room {
|
||||
@@ -178,7 +207,7 @@ impl Room {
|
||||
// WARNING: never sign this event
|
||||
let mut event = EventBuilder::new(Kind::PrivateDirectMessage, "")
|
||||
.tags(tags)
|
||||
.build(author);
|
||||
.finalize_unsigned(author);
|
||||
|
||||
// Ensure that the ID is set
|
||||
event.ensure_id();
|
||||
@@ -205,10 +234,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
|
||||
@@ -329,46 +356,39 @@ impl Room {
|
||||
pub fn connect(&self, cx: &App) -> Task<Result<(), Error>> {
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let client = nostr.read(cx).client();
|
||||
|
||||
let signer = nostr.read(cx).signer();
|
||||
let sender = signer.public_key().unwrap();
|
||||
|
||||
// Get room's id
|
||||
let id = self.id;
|
||||
|
||||
// Get all members, excluding the sender
|
||||
let members: Vec<PublicKey> = self
|
||||
.members
|
||||
.iter()
|
||||
.filter(|public_key| public_key != &&sender)
|
||||
.copied()
|
||||
.collect();
|
||||
let members = self.members();
|
||||
|
||||
cx.background_spawn(async move {
|
||||
let id = SubscriptionId::new(format!("room-{id}"));
|
||||
let opts = SubscribeAutoCloseOptions::default()
|
||||
.exit_policy(ReqExitPolicy::ExitOnEOSE)
|
||||
.timeout(Some(Duration::from_secs(TIMEOUT)));
|
||||
|
||||
// Construct filters for each member
|
||||
let filters: Vec<Filter> = members
|
||||
let tasks: Vec<_> = members
|
||||
.into_iter()
|
||||
.map(|public_key| {
|
||||
Filter::new()
|
||||
.author(public_key)
|
||||
.kind(Kind::RelayList)
|
||||
.limit(1)
|
||||
let client = client.clone();
|
||||
async move {
|
||||
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);
|
||||
|
||||
client
|
||||
.subscribe(vec![inbox, announcement])
|
||||
.close_on(opts)
|
||||
.await
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Construct target for subscription
|
||||
let target: HashMap<&str, Vec<Filter>> = BOOTSTRAP_RELAYS
|
||||
.into_iter()
|
||||
.map(|relay| (relay, filters.clone()))
|
||||
.collect();
|
||||
|
||||
// Subscribe to the target
|
||||
client.subscribe(target).close_on(opts).with_id(id).await?;
|
||||
for result in futures::future::join_all(tasks).await {
|
||||
result?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
})
|
||||
@@ -378,12 +398,12 @@ impl Room {
|
||||
pub fn get_messages(&self, cx: &App) -> Task<Result<Vec<UnsignedEvent>, Error>> {
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let client = nostr.read(cx).client();
|
||||
let conversation_id = self.id.to_string();
|
||||
let room_id = self.id.to_string();
|
||||
|
||||
cx.background_spawn(async move {
|
||||
let filter = Filter::new()
|
||||
.kind(Kind::ApplicationSpecificData)
|
||||
.custom_tag(SingleLetterTag::lowercase(Alphabet::C), conversation_id);
|
||||
.custom_tag(SingleLetterTag::lowercase(Alphabet::R), room_id);
|
||||
|
||||
let messages = client
|
||||
.database()
|
||||
@@ -412,24 +432,14 @@ impl Room {
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
|
||||
// Get current user's public key
|
||||
let sender = nostr.read(cx).signer().public_key()?;
|
||||
|
||||
// Get all members, excluding the sender
|
||||
let members: Vec<Person> = self
|
||||
.members
|
||||
.iter()
|
||||
.filter(|public_key| public_key != &&sender)
|
||||
.map(|member| persons.read(cx).get(member, cx))
|
||||
.collect();
|
||||
let sender = nostr.read(cx).current_user()?;
|
||||
|
||||
// Construct event's tags
|
||||
let mut tags = vec![];
|
||||
|
||||
// Add subject tag if present
|
||||
if let Some(value) = self.subject.as_ref() {
|
||||
tags.push(Tag::from_standardized_without_cell(TagStandard::Subject(
|
||||
value.to_string(),
|
||||
)));
|
||||
tags.push(Tag::custom("subject", vec![value.to_string()]));
|
||||
}
|
||||
|
||||
// Add all reply tags
|
||||
@@ -437,21 +447,23 @@ impl Room {
|
||||
tags.push(Tag::event(id))
|
||||
}
|
||||
|
||||
// Add all receiver tags
|
||||
for member in members.into_iter() {
|
||||
tags.push(Tag::from_standardized_without_cell(
|
||||
TagStandard::PublicKey {
|
||||
// Add all receiver tags (no intermediate allocation)
|
||||
for public_key in self.members.iter().filter(|pk| *pk != &sender) {
|
||||
let member = persons.read(cx).get(public_key, cx);
|
||||
tags.push(
|
||||
Nip01Tag::PublicKey {
|
||||
public_key: member.public_key(),
|
||||
relay_url: member.messaging_relay_hint(),
|
||||
alias: None,
|
||||
uppercase: false,
|
||||
},
|
||||
));
|
||||
relay_hint: member.messaging_relay_hint(),
|
||||
}
|
||||
.to_tag(),
|
||||
);
|
||||
}
|
||||
|
||||
// Construct a direct message rumor event
|
||||
// WARNING: never sign and send this event to relays
|
||||
let mut event = EventBuilder::new(kind, content).tags(tags).build(sender);
|
||||
let mut event = EventBuilder::new(kind, content)
|
||||
.tags(tags)
|
||||
.finalize_unsigned(sender);
|
||||
|
||||
// Ensure that the ID is set
|
||||
event.ensure_id();
|
||||
@@ -459,17 +471,45 @@ impl Room {
|
||||
Some(event)
|
||||
}
|
||||
|
||||
/// Select the appropriate signer based on signer kind and available keys.
|
||||
fn select_signer(
|
||||
signer_kind: &SignerKind,
|
||||
has_announcement: bool,
|
||||
encryption_signer: &Option<UniversalSigner>,
|
||||
user_signer: &UniversalSigner,
|
||||
) -> UniversalSigner {
|
||||
match signer_kind {
|
||||
SignerKind::Auto => {
|
||||
if has_announcement {
|
||||
encryption_signer
|
||||
.clone()
|
||||
.unwrap_or_else(|| user_signer.clone())
|
||||
} else {
|
||||
user_signer.clone()
|
||||
}
|
||||
}
|
||||
SignerKind::Encryption => encryption_signer
|
||||
.clone()
|
||||
.expect("encryption signer must be set"),
|
||||
SignerKind::User => user_signer.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Send rumor event to all members's messaging relays
|
||||
pub fn send(&self, rumor: UnsignedEvent, cx: &App) -> Option<Task<Vec<SendReport>>> {
|
||||
let config = self.config.clone();
|
||||
let persons = PersonRegistry::global(cx);
|
||||
|
||||
let device = DeviceRegistry::global(cx);
|
||||
let encryption_signer = device.read(cx).signer(cx);
|
||||
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let client = nostr.read(cx).client();
|
||||
let signer = nostr.read(cx).signer();
|
||||
let user_signer = nostr.read(cx).signer();
|
||||
let current_user = nostr.read(cx).current_user()?;
|
||||
|
||||
// Get current user's public key
|
||||
let public_key = nostr.read(cx).signer().public_key()?;
|
||||
let sender = persons.read(cx).get(&public_key, cx);
|
||||
// Get sender's profile
|
||||
let persons = PersonRegistry::global(cx);
|
||||
let sender = persons.read(cx).get(¤t_user, cx);
|
||||
|
||||
// Get all members (excluding sender)
|
||||
let members: Vec<Person> = self
|
||||
@@ -483,65 +523,45 @@ impl Room {
|
||||
let signer_kind = config.signer_kind();
|
||||
let backup = config.backup();
|
||||
|
||||
let user_signer = signer.get().await;
|
||||
let encryption_signer = signer.get_encryption_signer().await;
|
||||
|
||||
let mut sents = 0;
|
||||
let mut reports = Vec::new();
|
||||
|
||||
// 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 {
|
||||
SignerKind::Auto => {
|
||||
if let Some(announcement) = announcement {
|
||||
if let Some(enc_signer) = encryption_signer.as_ref() {
|
||||
(announcement.public_key(), enc_signer.clone())
|
||||
} else {
|
||||
(member.public_key(), user_signer.clone())
|
||||
}
|
||||
} else {
|
||||
(member.public_key(), user_signer.clone())
|
||||
}
|
||||
}
|
||||
SignerKind::Encryption => {
|
||||
// Safe to unwrap due to earlier checks
|
||||
(
|
||||
announcement.unwrap().public_key(),
|
||||
encryption_signer.as_ref().unwrap().clone(),
|
||||
)
|
||||
}
|
||||
SignerKind::User => (member.public_key(), user_signer.clone()),
|
||||
};
|
||||
// Determine the signer to use
|
||||
let signer = Self::select_signer(
|
||||
signer_kind,
|
||||
announcement.is_some(),
|
||||
&encryption_signer,
|
||||
&user_signer,
|
||||
);
|
||||
|
||||
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) => {
|
||||
Err(error) => {
|
||||
let report = SendReport::new(public_key).error(error.to_string());
|
||||
reports.push(report);
|
||||
}
|
||||
}
|
||||
@@ -549,14 +569,22 @@ impl Room {
|
||||
|
||||
// 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 = Self::select_signer(
|
||||
signer_kind,
|
||||
sender.announcement().is_some(),
|
||||
&encryption_signer,
|
||||
&user_signer,
|
||||
);
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -566,39 +594,54 @@ impl Room {
|
||||
}
|
||||
|
||||
// Helper function to send a gift-wrapped event
|
||||
async fn send_gift_wrap<T>(
|
||||
async fn send_gift_wrap(
|
||||
client: &Client,
|
||||
signer: &T,
|
||||
receiver: &PublicKey,
|
||||
signer: &UniversalSigner,
|
||||
receiver: &Person,
|
||||
rumor: &UnsignedEvent,
|
||||
relays: &[RelayUrl],
|
||||
public_key: PublicKey,
|
||||
) -> Result<(SendReport, bool), SendReport>
|
||||
where
|
||||
T: NostrSigner + 'static,
|
||||
{
|
||||
// Ensure relay connections
|
||||
for url in relays {
|
||||
client.add_relay(url).and_connect().await.ok();
|
||||
}
|
||||
config: &SignerKind,
|
||||
) -> Result<SendReport, Error> {
|
||||
let k_tag = Tag::custom("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 = nip59::GiftWrapBuilder::new(receiver, rumor.clone())
|
||||
.extra_tags(extra_tags)
|
||||
.finalize_async(signer)
|
||||
.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)
|
||||
}
|
||||
|
||||
@@ -14,17 +14,15 @@ chat = { path = "../chat" }
|
||||
settings = { path = "../settings" }
|
||||
|
||||
gpui.workspace = true
|
||||
gpui_tokio.workspace = true
|
||||
|
||||
nostr-sdk.workspace = true
|
||||
anyhow.workspace = true
|
||||
itertools.workspace = true
|
||||
smallvec.workspace = true
|
||||
smol.workspace = true
|
||||
flume.workspace = true
|
||||
futures.workspace = true
|
||||
log.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
|
||||
once_cell = "1.19.0"
|
||||
regex = "1"
|
||||
linkify = "0.10.0"
|
||||
pulldown-cmark = "0.13.1"
|
||||
|
||||
|
||||
@@ -7,21 +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);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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(" ");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,15 +7,14 @@ publish.workspace = true
|
||||
[dependencies]
|
||||
gpui.workspace = true
|
||||
nostr.workspace = true
|
||||
instant.workspace = true
|
||||
nostr-sdk.workspace = true
|
||||
|
||||
anyhow.workspace = true
|
||||
itertools.workspace = true
|
||||
chrono.workspace = true
|
||||
smallvec.workspace = true
|
||||
smol.workspace = true
|
||||
futures.workspace = true
|
||||
log.workspace = true
|
||||
|
||||
dirs = "5.0"
|
||||
qrcode = "0.14.1"
|
||||
regex = "1.10"
|
||||
|
||||
135
crates/common/src/caching.rs
Normal file
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,5 +1,5 @@
|
||||
use std::marker::PhantomData;
|
||||
use std::time::Duration;
|
||||
use instant::Duration;
|
||||
|
||||
use futures::channel::oneshot;
|
||||
use futures::FutureExt;
|
||||
|
||||
@@ -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();
|
||||
@@ -18,14 +18,14 @@ impl EventUtils for Event {
|
||||
}
|
||||
|
||||
fn extract_public_keys(&self) -> Vec<PublicKey> {
|
||||
let mut public_keys: Vec<PublicKey> = self.tags.public_keys().copied().collect();
|
||||
let mut public_keys: Vec<PublicKey> = self.tags.public_keys().collect();
|
||||
public_keys.push(self.pubkey);
|
||||
|
||||
public_keys.into_iter().unique().collect()
|
||||
}
|
||||
}
|
||||
|
||||
impl EventUtils for UnsignedEvent {
|
||||
impl EventExt for UnsignedEvent {
|
||||
fn uniq_id(&self) -> u64 {
|
||||
let mut hasher = DefaultHasher::new();
|
||||
let mut pubkeys: Vec<PublicKey> = vec![];
|
||||
@@ -46,7 +46,7 @@ impl EventUtils for UnsignedEvent {
|
||||
}
|
||||
|
||||
fn extract_public_keys(&self) -> Vec<PublicKey> {
|
||||
let mut public_keys: Vec<PublicKey> = self.tags.public_keys().copied().collect();
|
||||
let mut public_keys: Vec<PublicKey> = self.tags.public_keys().collect();
|
||||
public_keys.push(self.pubkey);
|
||||
public_keys.into_iter().unique().sorted().collect()
|
||||
}
|
||||
|
||||
@@ -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
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
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
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,67 +0,0 @@
|
||||
[package]
|
||||
name = "coop"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
publish.workspace = true
|
||||
|
||||
[[bin]]
|
||||
name = "coop"
|
||||
path = "src/main.rs"
|
||||
|
||||
[package.metadata.packager]
|
||||
name = "Coop"
|
||||
product-name = "Coop"
|
||||
description = "Chat Freely, Stay Private on Nostr"
|
||||
identifier = "su.reya.coop"
|
||||
category = "SocialNetworking"
|
||||
version = "1.0.0-beta"
|
||||
out-dir = "../../dist"
|
||||
before-packaging-command = "cargo build --release"
|
||||
resources = ["Cargo.toml", "src"]
|
||||
icons = [
|
||||
"resources/32x32.png",
|
||||
"resources/128x128.png",
|
||||
"resources/128x128@2x.png",
|
||||
"resources/icon.icns",
|
||||
"resources/icon.ico",
|
||||
]
|
||||
|
||||
[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" }
|
||||
|
||||
gpui.workspace = true
|
||||
gpui_platform.workspace = true
|
||||
gpui_linux.workspace = true
|
||||
gpui_windows.workspace = true
|
||||
gpui_macos.workspace = true
|
||||
gpui_tokio.workspace = true
|
||||
reqwest_client.workspace = true
|
||||
|
||||
nostr-connect.workspace = true
|
||||
nostr-sdk.workspace = true
|
||||
|
||||
anyhow.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
itertools.workspace = true
|
||||
log.workspace = true
|
||||
smallvec.workspace = true
|
||||
smol.workspace = true
|
||||
futures.workspace = true
|
||||
oneshot.workspace = true
|
||||
webbrowser.workspace = true
|
||||
|
||||
indexset = "0.12.3"
|
||||
tracing-subscriber = { version = "0.3.18", features = ["fmt"] }
|
||||
@@ -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,292 +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",
|
||||
)),
|
||||
)
|
||||
})
|
||||
.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,648 +0,0 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use ::settings::AppSettings;
|
||||
use chat::{ChatEvent, ChatRegistry, InboxState};
|
||||
use gpui::prelude::FluentBuilder;
|
||||
use gpui::{
|
||||
div, px, 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, PopupMenuItem};
|
||||
use ui::{h_flex, v_flex, IconName, Root, Sizable, WindowExtension};
|
||||
|
||||
use crate::dialogs::settings;
|
||||
use crate::panels::{
|
||||
backup, contact_list, 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,
|
||||
ShowContactList,
|
||||
}
|
||||
|
||||
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::ShowContactList => {
|
||||
self.dock.update(cx, |this, cx| {
|
||||
this.add_panel(
|
||||
Arc::new(contact_list::init(window, cx)),
|
||||
DockPlacement::Right,
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
});
|
||||
}
|
||||
Command::ShowBackup => {
|
||||
self.dock.update(cx, |this, cx| {
|
||||
this.add_panel(
|
||||
Arc::new(backup::init(window, cx)),
|
||||
DockPlacement::Right,
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
});
|
||||
}
|
||||
Command::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);
|
||||
let avatar = profile.avatar();
|
||||
let name = profile.name();
|
||||
|
||||
this.child(
|
||||
Button::new("current-user")
|
||||
.child(Avatar::new(avatar.clone()).xsmall())
|
||||
.small()
|
||||
.caret()
|
||||
.compact()
|
||||
.transparent()
|
||||
.dropdown_menu(move |this, _window, _cx| {
|
||||
let avatar = avatar.clone();
|
||||
let name = name.clone();
|
||||
|
||||
this.min_w(px(256.))
|
||||
.item(PopupMenuItem::element(move |_window, cx| {
|
||||
h_flex()
|
||||
.gap_1p5()
|
||||
.text_xs()
|
||||
.text_color(cx.theme().text_muted)
|
||||
.child(Avatar::new(avatar.clone()).xsmall())
|
||||
.child(name.clone())
|
||||
}))
|
||||
.separator()
|
||||
.menu_with_icon(
|
||||
"Profile",
|
||||
IconName::Profile,
|
||||
Box::new(Command::ShowProfile),
|
||||
)
|
||||
.menu_with_icon(
|
||||
"Contact List",
|
||||
IconName::Book,
|
||||
Box::new(Command::ShowContactList),
|
||||
)
|
||||
.menu_with_icon(
|
||||
"Backup",
|
||||
IconName::UserKey,
|
||||
Box::new(Command::ShowBackup),
|
||||
)
|
||||
.menu_with_icon(
|
||||
"Themes",
|
||||
IconName::Sun,
|
||||
Box::new(Command::ToggleTheme),
|
||||
)
|
||||
.separator()
|
||||
.menu_with_icon(
|
||||
"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| {
|
||||
let persons = PersonRegistry::global(cx);
|
||||
let profile = persons.read(cx).get(&pkey, cx);
|
||||
let urls: Vec<SharedString> = profile
|
||||
.messaging_relays()
|
||||
.iter()
|
||||
.map(|url| SharedString::from(url.to_string()))
|
||||
.collect();
|
||||
|
||||
// Header
|
||||
let menu = this.min_w(px(260.)).label("Messaging Relays");
|
||||
|
||||
// Content
|
||||
let menu = urls.into_iter().fold(menu, |this, url| {
|
||||
this.item(PopupMenuItem::element(move |_window, _cx| {
|
||||
h_flex()
|
||||
.px_1()
|
||||
.w_full()
|
||||
.gap_2()
|
||||
.text_sm()
|
||||
.child(
|
||||
div().size_1p5().rounded_full().bg(gpui::green()),
|
||||
)
|
||||
.child(url.clone())
|
||||
}))
|
||||
});
|
||||
|
||||
// Footer
|
||||
menu.separator()
|
||||
.menu_with_icon(
|
||||
"Reload",
|
||||
IconName::Refresh,
|
||||
Box::new(Command::RefreshMessagingRelays),
|
||||
)
|
||||
.menu_with_icon(
|
||||
"Update relays",
|
||||
IconName::Settings,
|
||||
Box::new(Command::ShowMessaging),
|
||||
)
|
||||
}),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_2()
|
||||
.child(
|
||||
div()
|
||||
.text_xs()
|
||||
.text_color(cx.theme().text_muted)
|
||||
.map(|this| match 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| {
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let urls = nostr.read(cx).read_only_relays(&pkey, cx);
|
||||
|
||||
// Header
|
||||
let menu = this.min_w(px(260.)).label("Relays");
|
||||
|
||||
// Content
|
||||
let menu = urls.into_iter().fold(menu, |this, url| {
|
||||
this.item(PopupMenuItem::element(move |_window, _cx| {
|
||||
h_flex()
|
||||
.px_1()
|
||||
.w_full()
|
||||
.gap_2()
|
||||
.text_sm()
|
||||
.child(
|
||||
div().size_1p5().rounded_full().bg(gpui::green()),
|
||||
)
|
||||
.child(url.clone())
|
||||
}))
|
||||
});
|
||||
|
||||
// Footer
|
||||
menu.separator()
|
||||
.menu_with_icon(
|
||||
"Reload",
|
||||
IconName::Refresh,
|
||||
Box::new(Command::RefreshRelayList),
|
||||
)
|
||||
.menu_with_icon(
|
||||
"Update relay list",
|
||||
IconName::Settings,
|
||||
Box::new(Command::ShowRelayList),
|
||||
)
|
||||
}),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
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,15 +8,17 @@ publish.workspace = true
|
||||
common = { path = "../common" }
|
||||
state = { path = "../state" }
|
||||
person = { path = "../person" }
|
||||
ui = { path = "../ui" }
|
||||
theme = { path = "../theme" }
|
||||
settings = { path = "../settings" }
|
||||
|
||||
gpui.workspace = true
|
||||
nostr-sdk.workspace = true
|
||||
|
||||
instant.workspace = true
|
||||
anyhow.workspace = true
|
||||
itertools.workspace = true
|
||||
smallvec.workspace = true
|
||||
smol.workspace = true
|
||||
log.workspace = true
|
||||
flume.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
|
||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||
smol.workspace = true
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -10,8 +10,9 @@ state = { path = "../state" }
|
||||
|
||||
gpui.workspace = true
|
||||
nostr-sdk.workspace = true
|
||||
instant.workspace = true
|
||||
anyhow.workspace = true
|
||||
smallvec.workspace = true
|
||||
smol.workspace = true
|
||||
flume.workspace = true
|
||||
log.workspace = true
|
||||
urlencoding = "2.1.3"
|
||||
|
||||
@@ -1,21 +1,20 @@
|
||||
use std::cell::RefCell;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::rc::Rc;
|
||||
use std::time::Duration;
|
||||
use std::sync::RwLock;
|
||||
use instant::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>);
|
||||
@@ -24,9 +23,9 @@ impl Global for GlobalPersonRegistry {}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
enum Dispatch {
|
||||
Person(Box<Person>),
|
||||
Announcement(Box<Event>),
|
||||
Relays(Box<Event>),
|
||||
Person(Person),
|
||||
Announcement(Event),
|
||||
Relays(Event),
|
||||
}
|
||||
|
||||
/// Person Registry
|
||||
@@ -36,13 +35,13 @@ pub struct PersonRegistry {
|
||||
persons: HashMap<PublicKey, Entity<Person>>,
|
||||
|
||||
/// Set of public keys that have been seen
|
||||
seen: Rc<RefCell<HashSet<PublicKey>>>,
|
||||
seen: RwLock<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,87 +56,55 @@ 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 (metadata_tx, metadata_rx) = flume::unbounded::<PublicKey>();
|
||||
|
||||
let mut tasks = smallvec![];
|
||||
|
||||
tasks.push(
|
||||
// Handle nostr notifications
|
||||
cx.background_spawn({
|
||||
let client = client.clone();
|
||||
let client2 = client.clone();
|
||||
tasks.push(cx.background_spawn(async move {
|
||||
Self::handle_notifications(&client2, &tx).await;
|
||||
}));
|
||||
|
||||
async move {
|
||||
Self::handle_notifications(&client, &tx).await;
|
||||
}
|
||||
}),
|
||||
);
|
||||
let client3 = client.clone();
|
||||
tasks.push(cx.background_spawn(async move {
|
||||
Self::handle_requests(&client3, &metadata_rx).await;
|
||||
}));
|
||||
|
||||
tasks.push(
|
||||
// Handle metadata requests
|
||||
cx.background_spawn({
|
||||
let client = client.clone();
|
||||
tasks.push(cx.spawn(async move |this, cx| {
|
||||
while let Ok(event) = rx.recv_async().await {
|
||||
this.update(cx, |this, cx| {
|
||||
match event {
|
||||
Dispatch::Person(person) => {
|
||||
this.insert(person, cx);
|
||||
}
|
||||
Dispatch::Announcement(event) => {
|
||||
this.set_announcement(&event, cx);
|
||||
}
|
||||
Dispatch::Relays(event) => {
|
||||
this.set_messaging_relays(&event, cx);
|
||||
}
|
||||
};
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
}));
|
||||
|
||||
async move {
|
||||
Self::handle_requests(&client, &mta_rx).await;
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
tasks.push(
|
||||
// Update GPUI state
|
||||
cx.spawn(async move |this, cx| {
|
||||
while let Ok(event) = rx.recv_async().await {
|
||||
this.update(cx, |this, cx| {
|
||||
match event {
|
||||
Dispatch::Person(person) => {
|
||||
this.insert(*person, cx);
|
||||
}
|
||||
Dispatch::Announcement(event) => {
|
||||
this.set_announcement(&event, cx);
|
||||
}
|
||||
Dispatch::Relays(event) => {
|
||||
this.set_messaging_relays(&event, cx);
|
||||
}
|
||||
};
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
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())),
|
||||
sender: mta_tx,
|
||||
_tasks: tasks,
|
||||
seen: RwLock::new(HashSet::new()),
|
||||
sender: metadata_tx,
|
||||
tasks,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -152,7 +119,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;
|
||||
@@ -162,28 +129,25 @@ impl PersonRegistry {
|
||||
Kind::Metadata => {
|
||||
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();
|
||||
if tx.send_async(Dispatch::Person(person)).await.is_err() {
|
||||
log::warn!("PersonRegistry channel closed, dropping metadata event");
|
||||
}
|
||||
}
|
||||
Kind::ContactList => {
|
||||
let public_keys = event.extract_public_keys();
|
||||
|
||||
// Get metadata for all public keys
|
||||
get_metadata(client, public_keys).await.ok();
|
||||
if let Err(e) = get_metadata(client, public_keys).await {
|
||||
log::warn!("Failed to get metadata for contact list: {e}");
|
||||
}
|
||||
}
|
||||
Kind::InboxRelays => {
|
||||
let val = Box::new(event.into_owned());
|
||||
|
||||
// Send
|
||||
tx.send_async(Dispatch::Relays(val)).await.ok();
|
||||
tx.send_async(Dispatch::Relays(event.into_owned()))
|
||||
.await
|
||||
.ok();
|
||||
}
|
||||
Kind::Custom(10044) => {
|
||||
let val = Box::new(event.into_owned());
|
||||
|
||||
// Send
|
||||
tx.send_async(Dispatch::Announcement(val)).await.ok();
|
||||
tx.send_async(Dispatch::Announcement(event.into_owned()))
|
||||
.await
|
||||
.ok();
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
@@ -198,50 +162,95 @@ 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);
|
||||
// Process the batch if it's full
|
||||
if batch.len() >= 20 {
|
||||
get_metadata(client, std::mem::take(&mut batch)).await.ok();
|
||||
if batch.len() >= 20
|
||||
&& let Err(e) = get_metadata(client, std::mem::take(&mut batch)).await
|
||||
{
|
||||
log::warn!("Failed to get metadata batch: {e}");
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
get_metadata(client, std::mem::take(&mut batch)).await.ok();
|
||||
if !batch.is_empty()
|
||||
&& let Err(e) = get_metadata(client, std::mem::take(&mut batch)).await
|
||||
{
|
||||
log::warn!("Failed to get metadata batch: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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_insert(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).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>) {
|
||||
fn bulk_insert(&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();
|
||||
}
|
||||
@@ -270,15 +279,14 @@ impl PersonRegistry {
|
||||
}
|
||||
|
||||
let public_key = *public_key;
|
||||
let mut seen = self.seen.borrow_mut();
|
||||
|
||||
if seen.insert(public_key) {
|
||||
if self.seen.write().unwrap().insert(public_key) {
|
||||
let sender = self.sender.clone();
|
||||
|
||||
// Spawn background task to request metadata
|
||||
cx.background_spawn(async move {
|
||||
if let Err(e) = sender.send_async(public_key).await {
|
||||
log::warn!("Failed to send public key for metadata request: {}", e);
|
||||
log::warn!("Failed to send public key for metadata request: {e}");
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
@@ -322,19 +330,3 @@ where
|
||||
|
||||
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
|
||||
@@ -75,21 +90,11 @@ impl Person {
|
||||
self.metadata.clone()
|
||||
}
|
||||
|
||||
/// Set profile metadata
|
||||
pub fn set_metadata(&mut self, metadata: Metadata) {
|
||||
self.metadata = metadata;
|
||||
}
|
||||
|
||||
/// Get profile encryption keys announcement
|
||||
pub fn announcement(&self) -> Option<Announcement> {
|
||||
self.announcement.clone()
|
||||
}
|
||||
|
||||
/// Set profile encryption keys announcement
|
||||
pub fn set_announcement(&mut self, announcement: Announcement) {
|
||||
self.announcement = Some(announcement);
|
||||
}
|
||||
|
||||
/// Get profile messaging relays
|
||||
pub fn messaging_relays(&self) -> &Vec<RelayUrl> {
|
||||
&self.messaging_relays
|
||||
@@ -100,14 +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();
|
||||
}
|
||||
|
||||
/// Get profile avatar
|
||||
pub fn avatar(&self) -> SharedString {
|
||||
self.metadata()
|
||||
@@ -115,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()
|
||||
})
|
||||
@@ -125,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.trim());
|
||||
}
|
||||
|
||||
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.trim());
|
||||
}
|
||||
|
||||
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
|
||||
@@ -148,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..]
|
||||
)
|
||||
|
||||
@@ -1,378 +0,0 @@
|
||||
use std::borrow::Cow;
|
||||
use std::cell::Cell;
|
||||
use std::collections::HashSet;
|
||||
use std::hash::Hash;
|
||||
use std::rc::Rc;
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::{anyhow, Context as AnyhowContext, Error};
|
||||
use gpui::{
|
||||
App, AppContext, Context, Entity, Global, IntoElement, ParentElement, SharedString, Styled,
|
||||
Task, Window,
|
||||
};
|
||||
use nostr_sdk::prelude::*;
|
||||
use settings::{AppSettings, AuthMode};
|
||||
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};
|
||||
|
||||
const AUTH_MESSAGE: &str =
|
||||
"Approve the authentication request to allow Coop to continue sending or receiving events.";
|
||||
|
||||
pub fn init(window: &mut Window, cx: &mut App) {
|
||||
RelayAuth::set_global(cx.new(|cx| RelayAuth::new(window, cx)), cx);
|
||||
}
|
||||
|
||||
/// Authentication request
|
||||
#[derive(Debug, Clone, Hash, PartialEq, Eq, PartialOrd, Ord)]
|
||||
struct AuthRequest {
|
||||
url: RelayUrl,
|
||||
challenge: String,
|
||||
}
|
||||
|
||||
impl AuthRequest {
|
||||
pub fn new(challenge: impl Into<String>, url: RelayUrl) -> Self {
|
||||
Self {
|
||||
challenge: challenge.into(),
|
||||
url,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn url(&self) -> &RelayUrl {
|
||||
&self.url
|
||||
}
|
||||
|
||||
pub fn challenge(&self) -> &str {
|
||||
&self.challenge
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
|
||||
enum Signal {
|
||||
Auth(Arc<AuthRequest>),
|
||||
Pending((EventId, RelayUrl)),
|
||||
}
|
||||
|
||||
struct GlobalRelayAuth(Entity<RelayAuth>);
|
||||
|
||||
impl Global for GlobalRelayAuth {}
|
||||
|
||||
// Relay authentication
|
||||
#[derive(Debug)]
|
||||
pub struct RelayAuth {
|
||||
/// Pending events waiting for resend after authentication
|
||||
pending_events: HashSet<(EventId, RelayUrl)>,
|
||||
|
||||
/// Tasks for asynchronous operations
|
||||
_tasks: SmallVec<[Task<()>; 2]>,
|
||||
}
|
||||
|
||||
impl RelayAuth {
|
||||
/// Retrieve the global relay auth state
|
||||
pub fn global(cx: &App) -> Entity<Self> {
|
||||
cx.global::<GlobalRelayAuth>().0.clone()
|
||||
}
|
||||
|
||||
/// Set the global relay auth instance
|
||||
fn set_global(state: Entity<Self>, cx: &mut App) {
|
||||
cx.set_global(GlobalRelayAuth(state));
|
||||
}
|
||||
|
||||
/// Create a new relay auth instance
|
||||
fn new(window: &mut Window, cx: &mut Context<Self>) -> 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);
|
||||
|
||||
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 {
|
||||
RelayMessage::Auth { challenge } => {
|
||||
if challenges.insert(challenge.clone()) {
|
||||
let request = Arc::new(AuthRequest::new(challenge, relay_url));
|
||||
let signal = Signal::Auth(request);
|
||||
|
||||
tx.send_async(signal).await.ok();
|
||||
}
|
||||
}
|
||||
RelayMessage::Closed {
|
||||
subscription_id,
|
||||
message,
|
||||
} => {
|
||||
let msg = MachineReadablePrefix::parse(&message);
|
||||
|
||||
if let Some(MachineReadablePrefix::AuthRequired) = msg {
|
||||
if let Ok(Some(relay)) = client.relay(&relay_url).await {
|
||||
// Send close message to relay
|
||||
relay
|
||||
.send_msg(ClientMessage::Close(subscription_id))
|
||||
.await
|
||||
.ok();
|
||||
}
|
||||
}
|
||||
}
|
||||
RelayMessage::Ok {
|
||||
event_id, message, ..
|
||||
} => {
|
||||
let msg = MachineReadablePrefix::parse(&message);
|
||||
|
||||
// Handle authentication messages
|
||||
if let Some(MachineReadablePrefix::AuthRequired) = msg {
|
||||
let signal = Signal::Pending((event_id, relay_url));
|
||||
tx.send_async(signal).await.ok();
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
||||
tasks.push(cx.spawn_in(window, async move |this, cx| {
|
||||
while let Ok(signal) = rx.recv_async().await {
|
||||
match signal {
|
||||
Signal::Auth(req) => {
|
||||
this.update_in(cx, |this, window, cx| {
|
||||
this.handle_auth(&req, window, cx);
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
Signal::Pending((event_id, relay_url)) => {
|
||||
this.update_in(cx, |this, _window, cx| {
|
||||
this.insert_pending_event(event_id, relay_url, cx);
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
||||
Self {
|
||||
pending_events: HashSet::default(),
|
||||
_tasks: tasks,
|
||||
}
|
||||
}
|
||||
|
||||
/// Insert a pending event waiting for resend after authentication
|
||||
fn insert_pending_event(&mut self, id: EventId, relay: RelayUrl, cx: &mut Context<Self>) {
|
||||
self.pending_events.insert((id, relay));
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// Get all pending events for a specific relay,
|
||||
fn get_pending_events(&self, relay: &RelayUrl, _cx: &App) -> Vec<EventId> {
|
||||
self.pending_events
|
||||
.iter()
|
||||
.filter(|(_, pending_relay)| pending_relay == relay)
|
||||
.map(|(id, _relay)| id)
|
||||
.cloned()
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Clear all pending events for a specific relay,
|
||||
fn clear_pending_events(&mut self, relay: &RelayUrl, cx: &mut Context<Self>) {
|
||||
self.pending_events
|
||||
.retain(|(_, pending_relay)| pending_relay != relay);
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// Handle authentication request
|
||||
fn handle_auth(&mut self, req: &Arc<AuthRequest>, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let settings = AppSettings::global(cx);
|
||||
let trusted_relay = settings.read(cx).trusted_relay(req.url(), cx);
|
||||
let mode = AppSettings::get_auth_mode(cx);
|
||||
|
||||
if trusted_relay && mode == AuthMode::Auto {
|
||||
// Automatically authenticate if the relay is authenticated before
|
||||
self.response(req, window, cx);
|
||||
} else {
|
||||
// Otherwise open the auth request popup
|
||||
self.ask_for_approval(req, window, cx);
|
||||
}
|
||||
}
|
||||
|
||||
/// Send auth response and wait for confirmation
|
||||
fn auth(&self, req: &Arc<AuthRequest>, cx: &App) -> Task<Result<(), Error>> {
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let client = nostr.read(cx).client();
|
||||
let req = req.clone();
|
||||
|
||||
// Get all pending events for the relay
|
||||
let pending_events = self.get_pending_events(req.url(), cx);
|
||||
|
||||
cx.background_spawn(async move {
|
||||
// Construct event
|
||||
let builder = EventBuilder::auth(req.challenge(), req.url().clone());
|
||||
let event = client.sign_event_builder(builder).await?;
|
||||
|
||||
// Get the event ID
|
||||
let id = event.id;
|
||||
|
||||
// Get the relay
|
||||
let relay = client.relay(req.url()).await?.context("Relay not found")?;
|
||||
|
||||
// Subscribe to notifications
|
||||
let mut notifications = relay.notifications();
|
||||
|
||||
// Send the AUTH message
|
||||
relay
|
||||
.send_msg(ClientMessage::Auth(Cow::Borrowed(&event)))
|
||||
.await?;
|
||||
|
||||
log::info!("Sending AUTH event");
|
||||
|
||||
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?;
|
||||
}
|
||||
}
|
||||
|
||||
// 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,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
Err(anyhow!("Authentication failed"))
|
||||
})
|
||||
}
|
||||
|
||||
/// Respond to an authentication request.
|
||||
fn response(&self, req: &Arc<AuthRequest>, window: &Window, cx: &Context<Self>) {
|
||||
let settings = AppSettings::global(cx);
|
||||
let req = req.clone();
|
||||
let challenge = req.challenge().to_string();
|
||||
|
||||
// Create a task for authentication
|
||||
let task = self.auth(&req, cx);
|
||||
|
||||
cx.spawn_in(window, async move |this, cx| {
|
||||
let result = task.await;
|
||||
let url = req.url();
|
||||
|
||||
this.update_in(cx, |this, window, cx| {
|
||||
window.clear_notification(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);
|
||||
}
|
||||
Err(e) => {
|
||||
window.push_notification(Notification::error(e.to_string()), cx);
|
||||
}
|
||||
}
|
||||
})
|
||||
.ok();
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
/// Push a popup to approve the authentication request.
|
||||
fn ask_for_approval(&self, req: &Arc<AuthRequest>, window: &Window, cx: &Context<Self>) {
|
||||
let notification = self.notification(req, cx);
|
||||
|
||||
cx.spawn_in(window, async move |_this, cx| {
|
||||
cx.update(|window, cx| {
|
||||
window.push_notification(notification, cx);
|
||||
})
|
||||
.ok();
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
/// Build a notification for the authentication request.
|
||||
fn notification(&self, req: &Arc<AuthRequest>, cx: &Context<Self>) -> Notification {
|
||||
let req = req.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))
|
||||
.autohide(false)
|
||||
.icon(IconName::Info)
|
||||
.title(SharedString::from("Authentication Required"))
|
||||
.content(move |_window, cx| {
|
||||
v_flex()
|
||||
.gap_2()
|
||||
.text_sm()
|
||||
.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)
|
||||
.child(url.clone()),
|
||||
)
|
||||
.into_any_element()
|
||||
})
|
||||
.action(move |_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);
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,6 @@ common = { path = "../common" }
|
||||
|
||||
nostr-sdk.workspace = true
|
||||
gpui.workspace = true
|
||||
smol.workspace = true
|
||||
anyhow.workspace = true
|
||||
log.workspace = true
|
||||
smallvec.workspace = true
|
||||
@@ -18,3 +17,6 @@ serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
|
||||
paste = "1.0.15"
|
||||
|
||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||
smol.workspace = true
|
||||
|
||||
@@ -1,13 +1,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) {
|
||||
@@ -20,13 +19,15 @@ macro_rules! setting_accessors {
|
||||
$(
|
||||
paste::paste! {
|
||||
pub fn [<get_ $field>](cx: &App) -> $type {
|
||||
Self::global(cx).read(cx).values.$field.clone()
|
||||
Self::global(cx).read(cx).inner.read(cx).$field.clone()
|
||||
}
|
||||
|
||||
pub fn [<update_ $field>](value: $type, cx: &mut App) {
|
||||
Self::global(cx).update(cx, |this, cx| {
|
||||
this.values.$field = value;
|
||||
cx.notify();
|
||||
this.inner.update(cx, |inner, cx| {
|
||||
inner.$field = value;
|
||||
cx.notify();
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -40,9 +41,9 @@ setting_accessors! {
|
||||
pub theme_mode: ThemeMode,
|
||||
pub hide_avatar: bool,
|
||||
pub screening: bool,
|
||||
pub nip4e: bool,
|
||||
pub auth_mode: AuthMode,
|
||||
pub trusted_relays: HashSet<RelayUrl>,
|
||||
pub room_configs: HashMap<u64, RoomConfig>,
|
||||
pub trusted_relays: Vec<String>,
|
||||
pub file_server: Url,
|
||||
}
|
||||
|
||||
@@ -66,10 +67,10 @@ impl Display for AuthMode {
|
||||
/// Signer kind
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub enum SignerKind {
|
||||
#[default]
|
||||
Auto,
|
||||
User,
|
||||
Encryption,
|
||||
#[default]
|
||||
User,
|
||||
}
|
||||
|
||||
impl SignerKind {
|
||||
@@ -97,7 +98,7 @@ impl RoomConfig {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
backup: true,
|
||||
signer_kind: SignerKind::Auto,
|
||||
signer_kind: SignerKind::default(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -137,14 +138,14 @@ pub struct Settings {
|
||||
/// Enable screening for unknown chat requests
|
||||
pub screening: bool,
|
||||
|
||||
/// Enable decoupling encryption key
|
||||
pub nip4e: bool,
|
||||
|
||||
/// Authentication mode
|
||||
pub auth_mode: AuthMode,
|
||||
|
||||
/// Trusted relays; Coop will automatically authenticate with these relays
|
||||
pub trusted_relays: HashSet<RelayUrl>,
|
||||
|
||||
/// Configuration for each chat room
|
||||
pub room_configs: HashMap<u64, RoomConfig>,
|
||||
pub trusted_relays: Vec<String>,
|
||||
|
||||
/// Server for blossom media attachments
|
||||
pub file_server: Url,
|
||||
@@ -157,9 +158,9 @@ impl Default for Settings {
|
||||
theme_mode: ThemeMode::default(),
|
||||
hide_avatar: false,
|
||||
screening: true,
|
||||
nip4e: false,
|
||||
auth_mode: AuthMode::default(),
|
||||
trusted_relays: HashSet::default(),
|
||||
room_configs: HashMap::default(),
|
||||
trusted_relays: vec![],
|
||||
file_server: Url::parse("https://blossom.band/").unwrap(),
|
||||
}
|
||||
}
|
||||
@@ -178,7 +179,7 @@ impl Global for GlobalAppSettings {}
|
||||
/// Application settings
|
||||
pub struct AppSettings {
|
||||
/// Settings
|
||||
values: Settings,
|
||||
inner: Entity<Settings>,
|
||||
|
||||
/// Event subscriptions
|
||||
_subscriptions: SmallVec<[Subscription; 2]>,
|
||||
@@ -196,11 +197,12 @@ impl AppSettings {
|
||||
}
|
||||
|
||||
fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||
let inner = cx.new(|_| Settings::default());
|
||||
let mut subscriptions = smallvec![];
|
||||
|
||||
subscriptions.push(
|
||||
// Observe and automatically save settings on changes
|
||||
cx.observe_self(|this, cx| {
|
||||
cx.observe(&inner, |this, _inner, cx| {
|
||||
this.save(cx);
|
||||
}),
|
||||
);
|
||||
@@ -211,27 +213,30 @@ impl AppSettings {
|
||||
});
|
||||
|
||||
Self {
|
||||
values: Settings::default(),
|
||||
inner,
|
||||
_subscriptions: subscriptions,
|
||||
}
|
||||
}
|
||||
|
||||
/// Update settings
|
||||
fn set_settings(&mut self, settings: Settings, cx: &mut Context<Self>) {
|
||||
self.values = settings;
|
||||
cx.notify();
|
||||
self.inner.update(cx, |this, cx| {
|
||||
*this = settings;
|
||||
cx.notify();
|
||||
});
|
||||
}
|
||||
|
||||
/// Load settings
|
||||
fn load(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let task: Task<Result<Settings, Error>> = cx.background_spawn(async move {
|
||||
let path = config_dir().join(".settings");
|
||||
|
||||
if let Ok(content) = smol::fs::read_to_string(&path).await {
|
||||
Ok(serde_json::from_str(&content)?)
|
||||
} else {
|
||||
Err(anyhow!("Not found"))
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
let path = config_dir().join(".settings");
|
||||
if let Ok(content) = smol::fs::read_to_string(&path).await {
|
||||
return Ok(serde_json::from_str(&content)?);
|
||||
}
|
||||
}
|
||||
Err(anyhow!("Not found"))
|
||||
});
|
||||
|
||||
cx.spawn_in(window, async move |this, cx| {
|
||||
@@ -249,19 +254,15 @@ impl AppSettings {
|
||||
|
||||
/// Save settings
|
||||
pub fn save(&mut self, cx: &mut Context<Self>) {
|
||||
let settings = self.values.clone();
|
||||
|
||||
let task: Task<Result<(), Error>> = cx.background_spawn(async move {
|
||||
let path = config_dir().join(".settings");
|
||||
let content = serde_json::to_string(&settings)?;
|
||||
|
||||
// Write settings to file
|
||||
smol::fs::write(&path, content).await?;
|
||||
|
||||
Ok(())
|
||||
});
|
||||
|
||||
task.detach();
|
||||
let settings = self.inner.read(cx);
|
||||
if let Ok(content) = serde_json::to_string(&settings) {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
cx.background_spawn(async move {
|
||||
let path = config_dir().join(".settings");
|
||||
smol::fs::write(&path, content).await.ok();
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
}
|
||||
|
||||
/// Set theme
|
||||
@@ -270,50 +271,66 @@ impl AppSettings {
|
||||
T: Into<String>,
|
||||
{
|
||||
// Update settings
|
||||
self.values.theme = Some(theme.into());
|
||||
cx.notify();
|
||||
self.inner.update(cx, |this, cx| {
|
||||
this.theme = Some(theme.into());
|
||||
cx.notify();
|
||||
});
|
||||
|
||||
// Apply the new theme
|
||||
self.apply_theme(window, cx);
|
||||
}
|
||||
|
||||
/// Reset theme
|
||||
pub fn reset_theme(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
self.inner.update(cx, |this, cx| {
|
||||
this.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() {
|
||||
if let Some(name) = self.inner.read(cx).theme.as_ref() {
|
||||
let mode = self.inner.read(cx).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 decoupling encryption key is enabled
|
||||
pub fn is_nip4e_enabled(&self, cx: &App) -> bool {
|
||||
self.inner.read(cx).nip4e
|
||||
}
|
||||
|
||||
/// 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| {
|
||||
relay.as_str_without_trailing_slash() == url.as_str_without_trailing_slash()
|
||||
})
|
||||
pub fn trusted_relay(&self, url: &RelayUrl, cx: &App) -> bool {
|
||||
self.inner
|
||||
.read(cx)
|
||||
.trusted_relays
|
||||
.iter()
|
||||
.any(|relay| relay == url.as_str_without_trailing_slash())
|
||||
}
|
||||
|
||||
/// Add a relay to the trusted list
|
||||
pub fn add_trusted_relay(&mut self, url: &RelayUrl, cx: &mut Context<Self>) {
|
||||
self.values.trusted_relays.insert(url.clone());
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// Add a room configuration
|
||||
pub fn add_room_config(&mut self, id: u64, config: RoomConfig, cx: &mut Context<Self>) {
|
||||
self.values
|
||||
.room_configs
|
||||
.entry(id)
|
||||
.and_modify(|this| *this = config)
|
||||
.or_default();
|
||||
cx.notify();
|
||||
self.inner.update(cx, |this, cx| {
|
||||
if !this
|
||||
.trusted_relays
|
||||
.iter()
|
||||
.any(|relay| relay == url.as_str_without_trailing_slash())
|
||||
{
|
||||
this.trusted_relays
|
||||
.push(url.as_str_without_trailing_slash().to_string());
|
||||
cx.notify();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,21 +9,26 @@ common = { path = "../common" }
|
||||
|
||||
nostr.workspace = true
|
||||
nostr-sdk.workspace = true
|
||||
nostr-lmdb.workspace = true
|
||||
nostr-connect.workspace = true
|
||||
nostr-gossip-memory.workspace = true
|
||||
nostr-blossom.workspace = true
|
||||
nostr-connect.workspace = true
|
||||
|
||||
gpui.workspace = true
|
||||
gpui_tokio.workspace = true
|
||||
smol.workspace = true
|
||||
instant.workspace = true
|
||||
flume.workspace = true
|
||||
futures.workspace = true
|
||||
log.workspace = true
|
||||
anyhow.workspace = true
|
||||
webbrowser.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
|
||||
rustls = "0.23"
|
||||
petname = "2.0.2"
|
||||
whoami = "1.6.1"
|
||||
mime_guess = "2.0.4"
|
||||
|
||||
[target.'cfg(target_arch = "wasm32")'.dependencies]
|
||||
nostr-memory.workspace = true
|
||||
|
||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||
nostr-lmdb.workspace = true
|
||||
smol.workspace = true
|
||||
gpui_tokio.workspace = true
|
||||
rustls = "0.23"
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
use std::path::PathBuf;
|
||||
|
||||
use anyhow::{anyhow, Error};
|
||||
use anyhow::{Error, anyhow};
|
||||
use gpui::AsyncApp;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
use gpui_tokio::Tokio;
|
||||
use mime_guess::from_path;
|
||||
use nostr_blossom::prelude::*;
|
||||
use nostr_sdk::prelude::*;
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub async fn upload(server: Url, path: PathBuf, cx: &AsyncApp) -> Result<Url, Error> {
|
||||
let content_type = from_path(&path).first_or_octet_stream().to_string();
|
||||
let data = smol::fs::read(path).await?;
|
||||
@@ -25,3 +27,8 @@ pub async fn upload(server: Url, path: PathBuf, cx: &AsyncApp) -> Result<Url, Er
|
||||
.await
|
||||
.map_err(|e| anyhow!("Upload error: {e}"))?
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub async fn upload(_server: Url, _path: PathBuf, _cx: &AsyncApp) -> Result<Url, Error> {
|
||||
Err(anyhow!("File upload not supported on web"))
|
||||
}
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
use std::sync::OnceLock;
|
||||
|
||||
/// Client name (Application name)
|
||||
pub const CLIENT_NAME: &str = "Coop";
|
||||
|
||||
@@ -10,51 +8,45 @@ pub const COOP_PUBKEY: &str = "npub1j3rz3ndl902lya6ywxvy5c983lxs8mpukqnx4pa4lt5w
|
||||
pub const APP_ID: &str = "su.reya.coop";
|
||||
|
||||
/// Keyring name
|
||||
pub const KEYRING: &str = "Coop Safe Storage";
|
||||
pub const MASTER_KEYRING: &str = "Coop Master Key";
|
||||
pub const USER_KEYRING: &str = "Coop User Credential";
|
||||
|
||||
/// Default timeout for subscription
|
||||
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;
|
||||
|
||||
/// 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 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; 4] = [
|
||||
"wss://nos.lol",
|
||||
"wss://relay.damus.io",
|
||||
pub const BOOTSTRAP_RELAYS: [&str; 3] = [
|
||||
"wss://relay.ditto.pub",
|
||||
"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,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),
|
||||
);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,9 +1,9 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::Error;
|
||||
use futures::io::AsyncReadExt;
|
||||
use gpui::http_client::{AsyncBody, HttpClient};
|
||||
use nostr_sdk::prelude::*;
|
||||
use smol::io::AsyncReadExt;
|
||||
|
||||
#[allow(async_fn_in_trait)]
|
||||
pub trait NostrAddress {
|
||||
|
||||
@@ -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 {
|
||||
@@ -38,14 +16,15 @@ impl From<&Event> for Announcement {
|
||||
let public_key = val
|
||||
.tags
|
||||
.iter()
|
||||
.find(|tag| tag.kind().as_str() == "n")
|
||||
.find(|tag| tag.kind() == "n")
|
||||
.and_then(|tag| tag.content())
|
||||
.and_then(|c| PublicKey::parse(c).ok())
|
||||
.unwrap_or(val.pubkey);
|
||||
|
||||
let client_name = val
|
||||
.tags
|
||||
.find(TagKind::Client)
|
||||
.iter()
|
||||
.find(|tag| tag.kind() == "client")
|
||||
.and_then(|tag| tag.content())
|
||||
.map(|c| c.to_string());
|
||||
|
||||
@@ -1,146 +1,206 @@
|
||||
use std::borrow::Cow;
|
||||
use std::result::Result;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::error::Error;
|
||||
use std::fmt;
|
||||
use std::future::Future;
|
||||
use std::pin::Pin;
|
||||
use std::sync::{Arc, RwLock};
|
||||
|
||||
use nostr_connect::client::AuthUrlHandler;
|
||||
use nostr_sdk::prelude::*;
|
||||
use smol::lock::RwLock;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct CoopSigner {
|
||||
/// User's signer
|
||||
signer: RwLock<Arc<dyn NostrSigner>>,
|
||||
pub struct UniversalSignerError(Box<dyn Error + Send + Sync + 'static>);
|
||||
|
||||
/// User's signer public key
|
||||
signer_pkey: RwLock<Option<PublicKey>>,
|
||||
|
||||
/// 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 fmt::Display for UniversalSignerError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "{}", self.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl CoopSigner {
|
||||
impl Error for UniversalSignerError {
|
||||
fn source(&self) -> Option<&(dyn Error + 'static)> {
|
||||
Some(&*self.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl UniversalSignerError {
|
||||
pub fn new<E>(err: E) -> Self
|
||||
where
|
||||
E: Error + Send + Sync + 'static,
|
||||
{
|
||||
UniversalSignerError(Box::new(err))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct UniversalSigner {
|
||||
inner: Arc<RwLock<Arc<dyn InnerSigner>>>,
|
||||
}
|
||||
|
||||
impl UniversalSigner {
|
||||
pub fn new<T>(signer: T) -> Self
|
||||
where
|
||||
T: IntoNostrSigner,
|
||||
T: AsyncGetPublicKey + AsyncSignEvent + AsyncNip44 + 'static,
|
||||
<T as AsyncGetPublicKey>::Error: Error + Send + Sync + 'static,
|
||||
<T as AsyncSignEvent>::Error: Error + Send + Sync + 'static,
|
||||
<T as AsyncNip44>::Error: Error + Send + Sync + 'static,
|
||||
{
|
||||
Self {
|
||||
signer: RwLock::new(signer.into_nostr_signer()),
|
||||
signer_pkey: RwLock::new(None),
|
||||
encryption_signer: RwLock::new(None),
|
||||
owned: AtomicBool::new(false),
|
||||
inner: Arc::new(RwLock::new(Arc::new(InnerSignerImpl(signer)))),
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the current signer.
|
||||
pub async fn get(&self) -> Arc<dyn NostrSigner> {
|
||||
self.signer.read().await.clone()
|
||||
}
|
||||
|
||||
/// Get the encryption signer.
|
||||
pub async fn get_encryption_signer(&self) -> Option<Arc<dyn NostrSigner>> {
|
||||
self.encryption_signer.read().await.clone()
|
||||
}
|
||||
|
||||
/// Get public key
|
||||
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)
|
||||
}
|
||||
|
||||
/// Switch the current signer to a new signer.
|
||||
pub async fn switch<T>(&self, new: T, owned: bool)
|
||||
/// Swap the inner signer in-place. All clones see the new signer.
|
||||
pub fn swap_inner<T>(&self, new_signer: T)
|
||||
where
|
||||
T: IntoNostrSigner,
|
||||
T: AsyncGetPublicKey + AsyncSignEvent + AsyncNip44 + 'static,
|
||||
<T as AsyncGetPublicKey>::Error: Error + Send + Sync + 'static,
|
||||
<T as AsyncSignEvent>::Error: Error + Send + Sync + 'static,
|
||||
<T as AsyncNip44>::Error: Error + Send + Sync + 'static,
|
||||
{
|
||||
let new_signer = new.into_nostr_signer();
|
||||
let public_key = new_signer.get_public_key().await.ok();
|
||||
let mut signer = self.signer.write().await;
|
||||
let mut signer_pkey = self.signer_pkey.write().await;
|
||||
let mut encryption_signer = self.encryption_signer.write().await;
|
||||
|
||||
// Switch to the new signer
|
||||
*signer = new_signer;
|
||||
|
||||
// Update the public key
|
||||
*signer_pkey = public_key;
|
||||
|
||||
// Reset the encryption signer
|
||||
*encryption_signer = None;
|
||||
|
||||
// Update the owned flag
|
||||
self.owned.store(owned, Ordering::SeqCst);
|
||||
}
|
||||
|
||||
/// Set the encryption signer.
|
||||
pub async fn set_encryption_signer<T>(&self, new: T)
|
||||
where
|
||||
T: IntoNostrSigner,
|
||||
{
|
||||
let mut encryption_signer = self.encryption_signer.write().await;
|
||||
*encryption_signer = Some(new.into_nostr_signer());
|
||||
*self.inner.write().expect("RwLock poisoned") = Arc::new(InnerSignerImpl(new_signer));
|
||||
}
|
||||
}
|
||||
|
||||
impl NostrSigner for CoopSigner {
|
||||
#[allow(mismatched_lifetime_syntaxes)]
|
||||
fn backend(&self) -> SignerBackend {
|
||||
SignerBackend::Custom(Cow::Borrowed("custom"))
|
||||
}
|
||||
|
||||
fn get_public_key<'a>(&'a self) -> BoxedFuture<'a, Result<PublicKey, SignerError>> {
|
||||
Box::pin(async move { self.get().await.get_public_key().await })
|
||||
}
|
||||
|
||||
fn sign_event<'a>(
|
||||
&'a self,
|
||||
trait InnerSigner: fmt::Debug + Send + Sync + 'static {
|
||||
fn get_public_key_async(
|
||||
&self,
|
||||
) -> Pin<Box<dyn Future<Output = Result<PublicKey, UniversalSignerError>> + Send + '_>>;
|
||||
fn sign_event_async(
|
||||
&self,
|
||||
unsigned: UnsignedEvent,
|
||||
) -> BoxedFuture<'a, Result<Event, SignerError>> {
|
||||
Box::pin(async move { self.get().await.sign_event(unsigned).await })
|
||||
}
|
||||
|
||||
fn nip04_encrypt<'a>(
|
||||
) -> Pin<Box<dyn Future<Output = Result<Event, UniversalSignerError>> + Send + '_>>;
|
||||
fn nip44_encrypt_async<'a>(
|
||||
&'a self,
|
||||
public_key: &'a PublicKey,
|
||||
content: &'a str,
|
||||
) -> BoxedFuture<'a, Result<String, SignerError>> {
|
||||
Box::pin(async move { self.get().await.nip04_encrypt(public_key, content).await })
|
||||
}
|
||||
|
||||
fn nip04_decrypt<'a>(
|
||||
&'a self,
|
||||
public_key: &'a PublicKey,
|
||||
encrypted_content: &'a str,
|
||||
) -> BoxedFuture<'a, Result<String, SignerError>> {
|
||||
Box::pin(async move {
|
||||
self.get()
|
||||
.await
|
||||
.nip04_decrypt(public_key, encrypted_content)
|
||||
.await
|
||||
})
|
||||
}
|
||||
|
||||
fn nip44_encrypt<'a>(
|
||||
&'a self,
|
||||
public_key: &'a PublicKey,
|
||||
content: &'a str,
|
||||
) -> BoxedFuture<'a, Result<String, SignerError>> {
|
||||
Box::pin(async move { self.get().await.nip44_encrypt(public_key, content).await })
|
||||
}
|
||||
|
||||
fn nip44_decrypt<'a>(
|
||||
) -> Pin<Box<dyn Future<Output = Result<String, UniversalSignerError>> + Send + 'a>>;
|
||||
fn nip44_decrypt_async<'a>(
|
||||
&'a self,
|
||||
public_key: &'a PublicKey,
|
||||
payload: &'a str,
|
||||
) -> BoxedFuture<'a, Result<String, SignerError>> {
|
||||
Box::pin(async move { self.get().await.nip44_decrypt(public_key, payload).await })
|
||||
) -> Pin<Box<dyn Future<Output = Result<String, UniversalSignerError>> + Send + 'a>>;
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct InnerSignerImpl<T>(T);
|
||||
|
||||
impl<T> InnerSigner for InnerSignerImpl<T>
|
||||
where
|
||||
T: AsyncGetPublicKey + AsyncSignEvent + AsyncNip44 + Send + Sync + 'static,
|
||||
<T as AsyncGetPublicKey>::Error: Error + Send + Sync + 'static,
|
||||
<T as AsyncSignEvent>::Error: Error + Send + Sync + 'static,
|
||||
<T as AsyncNip44>::Error: Error + Send + Sync + 'static,
|
||||
{
|
||||
fn get_public_key_async(
|
||||
&self,
|
||||
) -> Pin<Box<dyn Future<Output = Result<PublicKey, UniversalSignerError>> + Send + '_>> {
|
||||
Box::pin(async move {
|
||||
AsyncGetPublicKey::get_public_key_async(&self.0)
|
||||
.await
|
||||
.map_err(UniversalSignerError::new)
|
||||
})
|
||||
}
|
||||
|
||||
fn sign_event_async(
|
||||
&self,
|
||||
unsigned: UnsignedEvent,
|
||||
) -> Pin<Box<dyn Future<Output = Result<Event, UniversalSignerError>> + Send + '_>> {
|
||||
Box::pin(async move {
|
||||
AsyncSignEvent::sign_event_async(&self.0, unsigned)
|
||||
.await
|
||||
.map_err(UniversalSignerError::new)
|
||||
})
|
||||
}
|
||||
|
||||
fn nip44_encrypt_async<'a>(
|
||||
&'a self,
|
||||
public_key: &'a PublicKey,
|
||||
content: &'a str,
|
||||
) -> Pin<Box<dyn Future<Output = Result<String, UniversalSignerError>> + Send + 'a>> {
|
||||
Box::pin(async move {
|
||||
AsyncNip44::nip44_encrypt_async(&self.0, public_key, content)
|
||||
.await
|
||||
.map_err(UniversalSignerError::new)
|
||||
})
|
||||
}
|
||||
|
||||
fn nip44_decrypt_async<'a>(
|
||||
&'a self,
|
||||
public_key: &'a PublicKey,
|
||||
payload: &'a str,
|
||||
) -> Pin<Box<dyn Future<Output = Result<String, UniversalSignerError>> + Send + 'a>> {
|
||||
Box::pin(async move {
|
||||
AsyncNip44::nip44_decrypt_async(&self.0, public_key, payload)
|
||||
.await
|
||||
.map_err(UniversalSignerError::new)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl UniversalSigner {
|
||||
#[allow(dead_code)]
|
||||
fn with_inner<R>(&self, f: impl FnOnce(&dyn InnerSigner) -> R) -> R {
|
||||
let guard = self.inner.read().expect("RwLock poisoned");
|
||||
f(&**guard)
|
||||
}
|
||||
}
|
||||
|
||||
impl AsyncGetPublicKey for UniversalSigner {
|
||||
type Error = UniversalSignerError;
|
||||
|
||||
fn get_public_key_async(
|
||||
&self,
|
||||
) -> Pin<Box<dyn Future<Output = Result<PublicKey, Self::Error>> + Send + '_>> {
|
||||
let inner = self.inner.read().expect("RwLock poisoned").clone();
|
||||
Box::pin(async move { inner.get_public_key_async().await })
|
||||
}
|
||||
}
|
||||
|
||||
impl AsyncSignEvent for UniversalSigner {
|
||||
type Error = UniversalSignerError;
|
||||
|
||||
fn sign_event_async(
|
||||
&self,
|
||||
unsigned: UnsignedEvent,
|
||||
) -> Pin<Box<dyn Future<Output = Result<Event, Self::Error>> + Send + '_>> {
|
||||
let inner = self.inner.read().expect("RwLock poisoned").clone();
|
||||
Box::pin(async move { inner.sign_event_async(unsigned).await })
|
||||
}
|
||||
}
|
||||
|
||||
impl AsyncNip44 for UniversalSigner {
|
||||
type Error = UniversalSignerError;
|
||||
|
||||
fn nip44_encrypt_async<'a>(
|
||||
&'a self,
|
||||
public_key: &'a PublicKey,
|
||||
content: &'a str,
|
||||
) -> Pin<Box<dyn Future<Output = Result<String, Self::Error>> + Send + 'a>> {
|
||||
let inner = self.inner.read().expect("RwLock poisoned").clone();
|
||||
Box::pin(async move { inner.nip44_encrypt_async(public_key, content).await })
|
||||
}
|
||||
|
||||
fn nip44_decrypt_async<'a>(
|
||||
&'a self,
|
||||
public_key: &'a PublicKey,
|
||||
payload: &'a str,
|
||||
) -> Pin<Box<dyn Future<Output = Result<String, Self::Error>> + Send + 'a>> {
|
||||
let inner = self.inner.read().expect("RwLock poisoned").clone();
|
||||
Box::pin(async move { inner.nip44_decrypt_async(public_key, payload).await })
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CoopAuthUrlHandler;
|
||||
|
||||
impl AuthUrlHandler for CoopAuthUrlHandler {
|
||||
fn on_auth_url(
|
||||
&self,
|
||||
auth_url: Url,
|
||||
) -> Pin<Box<dyn Future<Output = Result<(), nostr_connect::error::Error>> + Send + '_>> {
|
||||
Box::pin(async move {
|
||||
webbrowser::open(auth_url.as_str()).unwrap();
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
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,
|
||||
}
|
||||
@@ -185,7 +192,6 @@ impl From<ThemeFamily> for Theme {
|
||||
let mode = ThemeMode::default();
|
||||
|
||||
// Define the font family based on the platform.
|
||||
// TODO: Use native fonts on Linux too.
|
||||
let font_family = match platform {
|
||||
PlatformKind::Linux => "Inter",
|
||||
_ => ".SystemUIFont",
|
||||
@@ -200,10 +206,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
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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ pub enum PlatformKind {
|
||||
Mac,
|
||||
Linux,
|
||||
Windows,
|
||||
Web,
|
||||
}
|
||||
|
||||
impl PlatformKind {
|
||||
@@ -11,22 +12,21 @@ impl PlatformKind {
|
||||
Self::Linux
|
||||
} else if cfg!(target_os = "windows") {
|
||||
Self::Windows
|
||||
} else {
|
||||
} else if cfg!(target_os = "macos") {
|
||||
Self::Mac
|
||||
} else {
|
||||
Self::Web
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn is_linux(&self) -> bool {
|
||||
matches!(self, Self::Linux)
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn is_windows(&self) -> bool {
|
||||
matches!(self, Self::Windows)
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn is_mac(&self) -> bool {
|
||||
matches!(self, Self::Mac)
|
||||
}
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
[package]
|
||||
name = "title_bar"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
publish.workspace = true
|
||||
|
||||
[dependencies]
|
||||
common = { path = "../common" }
|
||||
theme = { path = "../theme" }
|
||||
ui = { path = "../ui" }
|
||||
|
||||
nostr-sdk.workspace = true
|
||||
gpui.workspace = true
|
||||
smol.workspace = true
|
||||
smallvec.workspace = true
|
||||
anyhow.workspace = true
|
||||
log.workspace = true
|
||||
|
||||
[target.'cfg(target_os = "windows")'.dependencies]
|
||||
windows = { version = "0.61", features = ["Wdk_System_SystemServices"] }
|
||||
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
linicon = "2.3.0"
|
||||
@@ -1,174 +0,0 @@
|
||||
use gpui::prelude::FluentBuilder;
|
||||
#[cfg(target_os = "linux")]
|
||||
use gpui::MouseButton;
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
use gpui::Pixels;
|
||||
use gpui::{
|
||||
px, AnyElement, Context, Decorations, Hsla, InteractiveElement as _, IntoElement,
|
||||
ParentElement, Render, StatefulInteractiveElement as _, Styled, Window, WindowControlArea,
|
||||
};
|
||||
use smallvec::{smallvec, SmallVec};
|
||||
use theme::{ActiveTheme, PlatformKind, CLIENT_SIDE_DECORATION_ROUNDING};
|
||||
use ui::h_flex;
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
use crate::platforms::linux::LinuxWindowControls;
|
||||
use crate::platforms::mac::TRAFFIC_LIGHT_PADDING;
|
||||
use crate::platforms::windows::WindowsWindowControls;
|
||||
|
||||
mod platforms;
|
||||
|
||||
/// Titlebar
|
||||
pub struct TitleBar {
|
||||
/// Children elements of the title bar.
|
||||
children: SmallVec<[AnyElement; 2]>,
|
||||
|
||||
/// Whether the title bar is currently being moved.
|
||||
should_move: bool,
|
||||
}
|
||||
|
||||
impl TitleBar {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
children: smallvec![],
|
||||
should_move: false,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
pub fn height(&self, window: &mut Window) -> Pixels {
|
||||
(1.75 * window.rem_size()).max(px(34.))
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
pub fn height(&self, _window: &mut Window) -> Pixels {
|
||||
px(32.)
|
||||
}
|
||||
|
||||
pub fn titlebar_color(&self, window: &mut Window, cx: &mut Context<Self>) -> Hsla {
|
||||
if cfg!(any(target_os = "linux", target_os = "freebsd")) {
|
||||
if window.is_window_active() && !self.should_move {
|
||||
cx.theme().title_bar
|
||||
} else {
|
||||
cx.theme().title_bar_inactive
|
||||
}
|
||||
} else {
|
||||
cx.theme().title_bar
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_children<T>(&mut self, children: T)
|
||||
where
|
||||
T: IntoIterator<Item = AnyElement>,
|
||||
{
|
||||
self.children = children.into_iter().collect();
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for TitleBar {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for TitleBar {
|
||||
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let height = self.height(window);
|
||||
let color = self.titlebar_color(window, cx);
|
||||
let children = std::mem::take(&mut self.children);
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
let supported_controls = window.window_controls();
|
||||
let decorations = window.window_decorations();
|
||||
|
||||
h_flex()
|
||||
.window_control_area(WindowControlArea::Drag)
|
||||
.h(height)
|
||||
.w_full()
|
||||
.map(|this| {
|
||||
if window.is_fullscreen() {
|
||||
this.px_2()
|
||||
} else if cx.theme().platform.is_mac() {
|
||||
this.pr_2().pl(px(TRAFFIC_LIGHT_PADDING))
|
||||
} else {
|
||||
this.px_2()
|
||||
}
|
||||
})
|
||||
.map(|this| match decorations {
|
||||
Decorations::Server => this,
|
||||
Decorations::Client { tiling } => this
|
||||
.when(!(tiling.top || tiling.right), |div| {
|
||||
div.rounded_tr(CLIENT_SIDE_DECORATION_ROUNDING)
|
||||
})
|
||||
.when(!(tiling.top || tiling.left), |div| {
|
||||
div.rounded_tl(CLIENT_SIDE_DECORATION_ROUNDING)
|
||||
}),
|
||||
})
|
||||
.bg(color)
|
||||
.border_b_1()
|
||||
.border_color(cx.theme().border)
|
||||
.content_stretch()
|
||||
.child(
|
||||
h_flex()
|
||||
.id("title-bar")
|
||||
.justify_between()
|
||||
.w_full()
|
||||
.when(cx.theme().platform.is_mac(), |this| {
|
||||
this.on_click(|event, window, _| {
|
||||
if event.click_count() == 2 {
|
||||
window.titlebar_double_click();
|
||||
}
|
||||
})
|
||||
})
|
||||
.when(cx.theme().platform.is_linux(), |this| {
|
||||
this.on_click(|event, window, _| {
|
||||
if event.click_count() == 2 {
|
||||
window.zoom_window();
|
||||
}
|
||||
})
|
||||
})
|
||||
.when(!cx.theme().platform.is_mac(), |this| this.pr_2())
|
||||
.children(children),
|
||||
)
|
||||
.when(!window.is_fullscreen(), |this| match cx.theme().platform {
|
||||
PlatformKind::Linux => {
|
||||
#[cfg(target_os = "linux")]
|
||||
if matches!(decorations, Decorations::Client { .. }) {
|
||||
this.child(LinuxWindowControls::new(None))
|
||||
.when(supported_controls.window_menu, |this| {
|
||||
this.on_mouse_down(MouseButton::Right, move |ev, window, _| {
|
||||
window.show_window_menu(ev.position)
|
||||
})
|
||||
})
|
||||
.on_mouse_move(cx.listener(move |this, _ev, window, _| {
|
||||
if this.should_move {
|
||||
this.should_move = false;
|
||||
window.start_window_move();
|
||||
}
|
||||
}))
|
||||
.on_mouse_down_out(cx.listener(move |this, _ev, _window, _cx| {
|
||||
this.should_move = false;
|
||||
}))
|
||||
.on_mouse_up(
|
||||
MouseButton::Left,
|
||||
cx.listener(move |this, _ev, _window, _cx| {
|
||||
this.should_move = false;
|
||||
}),
|
||||
)
|
||||
.on_mouse_down(
|
||||
MouseButton::Left,
|
||||
cx.listener(move |this, _ev, _window, _cx| {
|
||||
this.should_move = true;
|
||||
}),
|
||||
)
|
||||
} else {
|
||||
this
|
||||
}
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
this
|
||||
}
|
||||
PlatformKind::Windows => this.child(WindowsWindowControls::new(height)),
|
||||
PlatformKind::Mac => this,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,227 +0,0 @@
|
||||
use std::collections::HashMap;
|
||||
use std::sync::OnceLock;
|
||||
|
||||
use gpui::prelude::FluentBuilder;
|
||||
use gpui::{
|
||||
svg, Action, App, InteractiveElement, IntoElement, MouseButton, ParentElement, RenderOnce,
|
||||
SharedString, StatefulInteractiveElement, Styled, Window,
|
||||
};
|
||||
use linicon::{lookup_icon, IconType};
|
||||
use theme::ActiveTheme;
|
||||
use ui::{h_flex, Icon, IconName, Sizable};
|
||||
|
||||
#[derive(IntoElement)]
|
||||
pub struct LinuxWindowControls {
|
||||
close_window_action: Option<Box<dyn Action>>,
|
||||
}
|
||||
|
||||
impl LinuxWindowControls {
|
||||
pub fn new(close_window_action: Option<Box<dyn Action>>) -> Self {
|
||||
Self {
|
||||
close_window_action,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for LinuxWindowControls {
|
||||
fn render(self, window: &mut Window, _cx: &mut App) -> impl IntoElement {
|
||||
let supported_controls = window.window_controls();
|
||||
|
||||
h_flex()
|
||||
.id("linux-window-controls")
|
||||
.gap_2()
|
||||
.on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation())
|
||||
.when(supported_controls.minimize, |this| {
|
||||
this.child(WindowControl::new(
|
||||
LinuxControl::Minimize,
|
||||
IconName::WindowMinimize,
|
||||
))
|
||||
})
|
||||
.when(supported_controls.maximize, |this| {
|
||||
this.child({
|
||||
if window.is_maximized() {
|
||||
WindowControl::new(LinuxControl::Restore, IconName::WindowRestore)
|
||||
} else {
|
||||
WindowControl::new(LinuxControl::Maximize, IconName::WindowMaximize)
|
||||
}
|
||||
})
|
||||
})
|
||||
.child(
|
||||
WindowControl::new(LinuxControl::Close, IconName::WindowClose)
|
||||
.when_some(self.close_window_action, |this, close_action| {
|
||||
this.close_action(close_action)
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(IntoElement)]
|
||||
pub struct WindowControl {
|
||||
kind: LinuxControl,
|
||||
fallback: IconName,
|
||||
close_action: Option<Box<dyn Action>>,
|
||||
}
|
||||
|
||||
impl WindowControl {
|
||||
pub fn new(kind: LinuxControl, fallback: IconName) -> Self {
|
||||
Self {
|
||||
kind,
|
||||
fallback,
|
||||
close_action: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn close_action(mut self, action: Box<dyn Action>) -> Self {
|
||||
self.close_action = Some(action);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn is_gnome(&self) -> bool {
|
||||
matches!(detect_desktop_environment(), DesktopEnvironment::Gnome)
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for WindowControl {
|
||||
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let is_gnome = self.is_gnome();
|
||||
|
||||
h_flex()
|
||||
.id(self.kind.as_icon_name())
|
||||
.group("")
|
||||
.justify_center()
|
||||
.items_center()
|
||||
.rounded_full()
|
||||
.size_6()
|
||||
.when(is_gnome, |this| {
|
||||
this.bg(cx.theme().ghost_element_background_alt)
|
||||
.hover(|this| this.bg(cx.theme().ghost_element_hover))
|
||||
.active(|this| this.bg(cx.theme().ghost_element_active))
|
||||
})
|
||||
.map(|this| {
|
||||
if let Some(Some(path)) = linux_controls().get(&self.kind).cloned() {
|
||||
this.child(
|
||||
svg()
|
||||
.external_path(SharedString::from(path))
|
||||
.size_4()
|
||||
.text_color(cx.theme().text),
|
||||
)
|
||||
} else {
|
||||
this.child(Icon::new(self.fallback).small().text_color(cx.theme().text))
|
||||
}
|
||||
})
|
||||
.on_mouse_move(|_, _window, cx| cx.stop_propagation())
|
||||
.on_click(move |_, window, cx| {
|
||||
cx.stop_propagation();
|
||||
match self.kind {
|
||||
LinuxControl::Minimize => window.minimize_window(),
|
||||
LinuxControl::Restore => window.zoom_window(),
|
||||
LinuxControl::Maximize => window.zoom_window(),
|
||||
LinuxControl::Close => cx.quit(),
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
static DE: OnceLock<DesktopEnvironment> = OnceLock::new();
|
||||
static LINUX_CONTROLS: OnceLock<HashMap<LinuxControl, Option<String>>> = OnceLock::new();
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub enum DesktopEnvironment {
|
||||
Gnome,
|
||||
Kde,
|
||||
Unknown,
|
||||
}
|
||||
|
||||
/// Detect the current desktop environment
|
||||
pub fn detect_desktop_environment() -> &'static DesktopEnvironment {
|
||||
DE.get_or_init(|| {
|
||||
// Try to use environment variables first
|
||||
if let Ok(output) = std::env::var("XDG_CURRENT_DESKTOP") {
|
||||
let desktop = output.to_lowercase();
|
||||
if desktop.contains("gnome") {
|
||||
return DesktopEnvironment::Gnome;
|
||||
} else if desktop.contains("kde") {
|
||||
return DesktopEnvironment::Kde;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback detection methods
|
||||
if let Ok(output) = std::env::var("DESKTOP_SESSION") {
|
||||
let session = output.to_lowercase();
|
||||
if session.contains("gnome") {
|
||||
return DesktopEnvironment::Gnome;
|
||||
} else if session.contains("kde") || session.contains("plasma") {
|
||||
return DesktopEnvironment::Kde;
|
||||
}
|
||||
}
|
||||
|
||||
DesktopEnvironment::Unknown
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Clone, Copy)]
|
||||
pub enum LinuxControl {
|
||||
Minimize,
|
||||
Restore,
|
||||
Maximize,
|
||||
Close,
|
||||
}
|
||||
|
||||
impl LinuxControl {
|
||||
pub fn as_icon_name(&self) -> &'static str {
|
||||
match self {
|
||||
LinuxControl::Close => "window-close",
|
||||
LinuxControl::Minimize => "window-minimize",
|
||||
LinuxControl::Maximize => "window-maximize",
|
||||
LinuxControl::Restore => "window-restore",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn linux_controls() -> &'static HashMap<LinuxControl, Option<String>> {
|
||||
LINUX_CONTROLS.get_or_init(|| {
|
||||
let mut icons = HashMap::new();
|
||||
icons.insert(LinuxControl::Close, None);
|
||||
icons.insert(LinuxControl::Minimize, None);
|
||||
icons.insert(LinuxControl::Maximize, None);
|
||||
icons.insert(LinuxControl::Restore, None);
|
||||
|
||||
let icon_names = [
|
||||
(LinuxControl::Close, vec!["window-close", "dialog-close"]),
|
||||
(
|
||||
LinuxControl::Minimize,
|
||||
vec!["window-minimize", "window-lower"],
|
||||
),
|
||||
(
|
||||
LinuxControl::Maximize,
|
||||
vec!["window-maximize", "window-expand"],
|
||||
),
|
||||
(
|
||||
LinuxControl::Restore,
|
||||
vec!["window-restore", "window-return"],
|
||||
),
|
||||
];
|
||||
|
||||
for (control, icon_names) in icon_names {
|
||||
for icon_name in icon_names {
|
||||
// Try GNOME-style naming first
|
||||
let mut control_icon = lookup_icon(format!("{icon_name}-symbolic"))
|
||||
.find(|icon| matches!(icon, Ok(icon) if icon.icon_type == IconType::SVG));
|
||||
|
||||
// If not found, try KDE-style naming
|
||||
if control_icon.is_none() {
|
||||
control_icon = lookup_icon(icon_name)
|
||||
.find(|icon| matches!(icon, Ok(icon) if icon.icon_type == IconType::SVG));
|
||||
}
|
||||
|
||||
if let Some(Ok(icon)) = control_icon {
|
||||
icons
|
||||
.entry(control)
|
||||
.and_modify(|v| *v = Some(icon.path.to_string_lossy().to_string()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
icons
|
||||
})
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
/// Use pixels here instead of a rem-based size because the macOS traffic
|
||||
/// lights are a static size, and don't scale with the rest of the UI.
|
||||
///
|
||||
/// Magic number: There is one extra pixel of padding on the left side due to
|
||||
/// the 1px border around the window on macOS apps.
|
||||
pub const TRAFFIC_LIGHT_PADDING: f32 = 80.;
|
||||
@@ -1,4 +0,0 @@
|
||||
#[cfg(target_os = "linux")]
|
||||
pub mod linux;
|
||||
pub mod mac;
|
||||
pub mod windows;
|
||||
@@ -1,147 +0,0 @@
|
||||
use gpui::prelude::FluentBuilder;
|
||||
use gpui::{
|
||||
div, px, App, ElementId, Hsla, InteractiveElement, IntoElement, ParentElement, Pixels,
|
||||
RenderOnce, Rgba, StatefulInteractiveElement, Styled, Window, WindowControlArea,
|
||||
};
|
||||
use theme::ActiveTheme;
|
||||
use ui::h_flex;
|
||||
|
||||
#[derive(IntoElement)]
|
||||
pub struct WindowsWindowControls {
|
||||
button_height: Pixels,
|
||||
}
|
||||
|
||||
impl WindowsWindowControls {
|
||||
pub fn new(button_height: Pixels) -> Self {
|
||||
Self { button_height }
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
fn get_font() -> &'static str {
|
||||
"Segoe Fluent Icons"
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn get_font() -> &'static str {
|
||||
use windows::Wdk::System::SystemServices::RtlGetVersion;
|
||||
|
||||
let mut version = unsafe { std::mem::zeroed() };
|
||||
let status = unsafe { RtlGetVersion(&mut version) };
|
||||
|
||||
if status.is_ok() && version.dwBuildNumber >= 22000 {
|
||||
"Segoe Fluent Icons"
|
||||
} else {
|
||||
"Segoe MDL2 Assets"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for WindowsWindowControls {
|
||||
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let close_button_hover_color = Rgba {
|
||||
r: 232.0 / 255.0,
|
||||
g: 17.0 / 255.0,
|
||||
b: 32.0 / 255.0,
|
||||
a: 1.0,
|
||||
};
|
||||
|
||||
let button_hover_color = cx.theme().ghost_element_hover;
|
||||
let button_active_color = cx.theme().ghost_element_active;
|
||||
|
||||
div()
|
||||
.id("windows-window-controls")
|
||||
.font_family(Self::get_font())
|
||||
.flex()
|
||||
.flex_row()
|
||||
.justify_center()
|
||||
.content_stretch()
|
||||
.max_h(self.button_height)
|
||||
.min_h(self.button_height)
|
||||
.child(WindowsCaptionButton::new(
|
||||
"minimize",
|
||||
WindowsCaptionButtonIcon::Minimize,
|
||||
button_hover_color,
|
||||
button_active_color,
|
||||
))
|
||||
.child(WindowsCaptionButton::new(
|
||||
"maximize-or-restore",
|
||||
if window.is_maximized() {
|
||||
WindowsCaptionButtonIcon::Restore
|
||||
} else {
|
||||
WindowsCaptionButtonIcon::Maximize
|
||||
},
|
||||
button_hover_color,
|
||||
button_active_color,
|
||||
))
|
||||
.child(WindowsCaptionButton::new(
|
||||
"close",
|
||||
WindowsCaptionButtonIcon::Close,
|
||||
close_button_hover_color,
|
||||
button_active_color,
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Clone, Copy)]
|
||||
enum WindowsCaptionButtonIcon {
|
||||
Minimize,
|
||||
Restore,
|
||||
Maximize,
|
||||
Close,
|
||||
}
|
||||
|
||||
#[derive(IntoElement)]
|
||||
struct WindowsCaptionButton {
|
||||
id: ElementId,
|
||||
icon: WindowsCaptionButtonIcon,
|
||||
hover_background_color: Hsla,
|
||||
active_background_color: Hsla,
|
||||
}
|
||||
|
||||
impl WindowsCaptionButton {
|
||||
pub fn new(
|
||||
id: impl Into<ElementId>,
|
||||
icon: WindowsCaptionButtonIcon,
|
||||
hover_background_color: impl Into<Hsla>,
|
||||
active_background_color: impl Into<Hsla>,
|
||||
) -> Self {
|
||||
Self {
|
||||
id: id.into(),
|
||||
icon,
|
||||
hover_background_color: hover_background_color.into(),
|
||||
active_background_color: active_background_color.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for WindowsCaptionButton {
|
||||
fn render(self, _window: &mut Window, _cx: &mut App) -> impl IntoElement {
|
||||
h_flex()
|
||||
.id(self.id)
|
||||
.justify_center()
|
||||
.content_center()
|
||||
.occlude()
|
||||
.w(px(36.))
|
||||
.h_full()
|
||||
.text_size(px(10.0))
|
||||
.hover(|style| style.bg(self.hover_background_color))
|
||||
.active(|style| style.bg(self.active_background_color))
|
||||
.map(|this| match self.icon {
|
||||
WindowsCaptionButtonIcon::Close => {
|
||||
this.window_control_area(WindowControlArea::Close)
|
||||
}
|
||||
WindowsCaptionButtonIcon::Maximize | WindowsCaptionButtonIcon::Restore => {
|
||||
this.window_control_area(WindowControlArea::Max)
|
||||
}
|
||||
WindowsCaptionButtonIcon::Minimize => {
|
||||
this.window_control_area(WindowControlArea::Min)
|
||||
}
|
||||
})
|
||||
.child(match self.icon {
|
||||
WindowsCaptionButtonIcon::Minimize => "\u{e921}",
|
||||
WindowsCaptionButtonIcon::Restore => "\u{e923}",
|
||||
WindowsCaptionButtonIcon::Maximize => "\u{e922}",
|
||||
WindowsCaptionButtonIcon::Close => "\u{e8bb}",
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -9,9 +9,8 @@ common = { path = "../common" }
|
||||
theme = { path = "../theme" }
|
||||
|
||||
gpui.workspace = true
|
||||
smol.workspace = true
|
||||
instant.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
smallvec.workspace = true
|
||||
anyhow.workspace = true
|
||||
itertools.workspace = true
|
||||
@@ -20,7 +19,10 @@ log.workspace = true
|
||||
unicode-segmentation = "1.12.0"
|
||||
uuid = "1.10"
|
||||
regex = "1"
|
||||
image = "0.25.1"
|
||||
lsp-types = "0.97.0"
|
||||
rope = { git = "https://github.com/zed-industries/zed" }
|
||||
ropey = { version = "=2.0.0-beta.1", features = ["metric_lines_lf", "metric_utf16"] }
|
||||
sum_tree = { git = "https://github.com/zed-industries/zed" }
|
||||
|
||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||
smol.workspace = true
|
||||
tree-sitter = "0.26"
|
||||
|
||||
@@ -1,191 +0,0 @@
|
||||
Copyright 2024 Longbridge <https://longbridge.com>
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
|
||||
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
@@ -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,12 +1,12 @@
|
||||
use gpui::prelude::FluentBuilder;
|
||||
use gpui::{
|
||||
div, img, px, AbsoluteLength, App, Div, Hsla, ImageSource, Img, InteractiveElement,
|
||||
Interactivity, IntoElement, ParentElement, RenderOnce, StyleRefinement, 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::{Sizable, Size};
|
||||
use crate::{Selectable, Sizable, Size};
|
||||
|
||||
/// Returns the size of the avatar based on the given [`Size`].
|
||||
pub(super) fn avatar_size(size: Size) -> AbsoluteLength {
|
||||
@@ -37,6 +37,7 @@ pub struct Avatar {
|
||||
style: StyleRefinement,
|
||||
size: Size,
|
||||
border_color: Option<Hsla>,
|
||||
selected: bool,
|
||||
}
|
||||
|
||||
impl Avatar {
|
||||
@@ -48,6 +49,7 @@ impl Avatar {
|
||||
style: StyleRefinement::default(),
|
||||
size: Size::Medium,
|
||||
border_color: None,
|
||||
selected: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,6 +91,17 @@ impl Styled for Avatar {
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
|
||||
@@ -2,15 +2,15 @@ use std::rc::Rc;
|
||||
|
||||
use gpui::prelude::FluentBuilder as _;
|
||||
use gpui::{
|
||||
div, relative, AnyElement, App, ClickEvent, Div, ElementId, Hsla, InteractiveElement,
|
||||
IntoElement, ParentElement, RenderOnce, SharedString, Stateful,
|
||||
StatefulInteractiveElement as _, StyleRefinement, Styled, Window,
|
||||
AnyElement, App, ClickEvent, Div, ElementId, Hsla, InteractiveElement, IntoElement,
|
||||
ParentElement, RenderOnce, SharedString, Stateful, StatefulInteractiveElement as _,
|
||||
StyleRefinement, Styled, Window, div, relative,
|
||||
};
|
||||
use theme::ActiveTheme;
|
||||
|
||||
use crate::indicator::Indicator;
|
||||
use crate::tooltip::Tooltip;
|
||||
use crate::{h_flex, Disableable, Icon, IconName, Selectable, Sizable, Size, StyledExt};
|
||||
use crate::{Disableable, Icon, IconName, Selectable, Sizable, Size, StyledExt, h_flex};
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
pub struct ButtonCustomVariant {
|
||||
@@ -617,7 +617,7 @@ impl ButtonVariant {
|
||||
};
|
||||
|
||||
let fg = match self {
|
||||
ButtonVariant::Primary => cx.theme().text_muted, // TODO: use a different color?
|
||||
ButtonVariant::Primary => cx.theme().text_muted,
|
||||
_ => cx.theme().text_muted,
|
||||
};
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use std::rc::Rc;
|
||||
use std::time::Duration;
|
||||
use instant::Duration;
|
||||
|
||||
use gpui::prelude::FluentBuilder as _;
|
||||
use gpui::{
|
||||
|
||||
@@ -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,
|
||||
@@ -208,6 +214,8 @@ impl Dock {
|
||||
pub fn set_open(&mut self, open: bool, window: &mut Window, cx: &mut Context<Self>) {
|
||||
self.open = open;
|
||||
let item = self.panel.clone();
|
||||
// Use defer_in (not window.defer) so the callback is cancelled
|
||||
// if this Dock entity is dropped before the deferred frame runs.
|
||||
cx.defer_in(window, move |_, window, cx| {
|
||||
item.set_collapsed(!open, window, cx);
|
||||
});
|
||||
@@ -265,22 +273,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 +329,8 @@ impl Render for Dock {
|
||||
return div();
|
||||
}
|
||||
|
||||
let cache_style = StyleRefinement::default().absolute().size_full();
|
||||
|
||||
div()
|
||||
.relative()
|
||||
.overflow_hidden()
|
||||
@@ -336,7 +346,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);
|
||||
}
|
||||
}),
|
||||
@@ -730,7 +750,7 @@ impl TabPanel {
|
||||
div()
|
||||
.id("tab-bar-empty-space")
|
||||
.h_full()
|
||||
.flex_grow()
|
||||
.flex_grow_1()
|
||||
.min_w_16()
|
||||
.when(state.droppable, |this| {
|
||||
let view = cx.entity();
|
||||
@@ -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,6 +1,8 @@
|
||||
use std::fmt::Debug;
|
||||
use std::time::{Duration, Instant};
|
||||
use instant::{Duration, Instant};
|
||||
|
||||
/// A HistoryItem represents a single change in the history.
|
||||
/// It must implement Clone and PartialEq to be used in the History.
|
||||
pub trait HistoryItem: Clone + PartialEq {
|
||||
fn version(&self) -> usize;
|
||||
fn set_version(&mut self, version: usize);
|
||||
@@ -22,10 +24,11 @@ pub struct History<I: HistoryItem> {
|
||||
redos: Vec<I>,
|
||||
last_changed_at: Instant,
|
||||
version: usize,
|
||||
max_undo: usize,
|
||||
pub(crate) ignore: bool,
|
||||
max_undos: usize,
|
||||
group_interval: Option<Duration>,
|
||||
grouping: bool,
|
||||
unique: bool,
|
||||
pub ignore: bool,
|
||||
}
|
||||
|
||||
impl<I> History<I>
|
||||
@@ -39,15 +42,16 @@ where
|
||||
ignore: false,
|
||||
last_changed_at: Instant::now(),
|
||||
version: 0,
|
||||
max_undo: 1000,
|
||||
max_undos: 1000,
|
||||
group_interval: None,
|
||||
grouping: false,
|
||||
unique: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Set the maximum number of undo steps to keep, defaults to 1000.
|
||||
pub fn max_undo(mut self, max_undo: usize) -> Self {
|
||||
self.max_undo = max_undo;
|
||||
pub fn max_undos(mut self, max_undos: usize) -> Self {
|
||||
self.max_undos = max_undos;
|
||||
self
|
||||
}
|
||||
|
||||
@@ -64,10 +68,20 @@ where
|
||||
self
|
||||
}
|
||||
|
||||
/// Start grouping changes, this will prevent the version from being incremented until `end_grouping` is called.
|
||||
pub fn start_grouping(&mut self) {
|
||||
self.grouping = true;
|
||||
}
|
||||
|
||||
/// End grouping changes, this will allow the version to be incremented again.
|
||||
pub fn end_grouping(&mut self) {
|
||||
self.grouping = false;
|
||||
}
|
||||
|
||||
/// Increment the version number if the last change was made more than `GROUP_INTERVAL` milliseconds ago.
|
||||
fn inc_version(&mut self) -> usize {
|
||||
let t = Instant::now();
|
||||
if Some(self.last_changed_at.elapsed()) > self.group_interval {
|
||||
if !self.grouping && Some(self.last_changed_at.elapsed()) > self.group_interval {
|
||||
self.version += 1;
|
||||
}
|
||||
|
||||
@@ -80,10 +94,11 @@ where
|
||||
self.version
|
||||
}
|
||||
|
||||
/// Push a new change to the history.
|
||||
pub fn push(&mut self, item: I) {
|
||||
let version = self.inc_version();
|
||||
|
||||
if self.undos.len() >= self.max_undo {
|
||||
if self.undos.len() >= self.max_undos {
|
||||
self.undos.remove(0);
|
||||
}
|
||||
|
||||
@@ -113,6 +128,7 @@ where
|
||||
self.redos.clear();
|
||||
}
|
||||
|
||||
/// Undo the last change and return the changes that were undone.
|
||||
pub fn undo(&mut self) -> Option<Vec<I>> {
|
||||
if let Some(first_change) = self.undos.pop() {
|
||||
let mut changes = vec![first_change.clone()];
|
||||
@@ -135,6 +151,7 @@ where
|
||||
}
|
||||
}
|
||||
|
||||
/// Redo the last undone change and return the changes that were redone.
|
||||
pub fn redo(&mut self) -> Option<Vec<I>> {
|
||||
if let Some(first_change) = self.redos.pop() {
|
||||
let mut changes = vec![first_change.clone()];
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -34,10 +34,12 @@ pub enum IconName {
|
||||
CloseCircle,
|
||||
CloseCircleFill,
|
||||
Copy,
|
||||
Device,
|
||||
Door,
|
||||
Ellipsis,
|
||||
Emoji,
|
||||
Eye,
|
||||
Input,
|
||||
Info,
|
||||
Invite,
|
||||
Inbox,
|
||||
@@ -52,12 +54,14 @@ pub enum IconName {
|
||||
Relay,
|
||||
Reply,
|
||||
Refresh,
|
||||
Scan,
|
||||
Search,
|
||||
Settings,
|
||||
Settings2,
|
||||
Sun,
|
||||
Ship,
|
||||
Shield,
|
||||
Group,
|
||||
UserKey,
|
||||
Upload,
|
||||
Usb,
|
||||
@@ -102,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",
|
||||
@@ -120,6 +126,7 @@ 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",
|
||||
@@ -129,6 +136,7 @@ impl IconNamed for IconName {
|
||||
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",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use std::time::Duration;
|
||||
use instant::Duration;
|
||||
|
||||
use gpui::prelude::FluentBuilder as _;
|
||||
use gpui::{
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
use std::time::Duration;
|
||||
use instant::Duration;
|
||||
|
||||
use gpui::{px, Context, Pixels};
|
||||
use gpui::{Context, Pixels, Task, px};
|
||||
|
||||
static INTERVAL: Duration = Duration::from_millis(500);
|
||||
static PAUSE_DELAY: Duration = Duration::from_millis(300);
|
||||
|
||||
// On Windows, Linux, we should use integer to avoid blurry cursor.
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
pub(super) const CURSOR_WIDTH: Pixels = px(2.);
|
||||
#[cfg(target_os = "macos")]
|
||||
pub(super) const CURSOR_WIDTH: Pixels = px(1.5);
|
||||
|
||||
/// To manage the Input cursor blinking.
|
||||
@@ -12,10 +17,12 @@ pub(super) const CURSOR_WIDTH: Pixels = px(1.5);
|
||||
/// Every loop will notify the view to update the `visible`, and Input will observe this update to touch repaint.
|
||||
///
|
||||
/// The input painter will check if this in visible state, then it will draw the cursor.
|
||||
pub struct BlinkCursor {
|
||||
pub(crate) struct BlinkCursor {
|
||||
visible: bool,
|
||||
paused: bool,
|
||||
epoch: usize,
|
||||
|
||||
_task: Task<()>,
|
||||
}
|
||||
|
||||
impl BlinkCursor {
|
||||
@@ -24,6 +31,7 @@ impl BlinkCursor {
|
||||
visible: false,
|
||||
paused: false,
|
||||
epoch: 0,
|
||||
_task: Task::ready(()),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,14 +61,12 @@ impl BlinkCursor {
|
||||
|
||||
// Schedule the next blink
|
||||
let epoch = self.next_epoch();
|
||||
cx.spawn(async move |this, cx| {
|
||||
self._task = cx.spawn(async move |this, cx| {
|
||||
cx.background_executor().timer(INTERVAL).await;
|
||||
|
||||
if let Some(this) = this.upgrade() {
|
||||
this.update(cx, |this, cx| this.blink(epoch, cx));
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
});
|
||||
}
|
||||
|
||||
pub fn visible(&self) -> bool {
|
||||
@@ -76,7 +82,7 @@ impl BlinkCursor {
|
||||
|
||||
// delay 500ms to start the blinking
|
||||
let epoch = self.next_epoch();
|
||||
cx.spawn(async move |this, cx| {
|
||||
self._task = cx.spawn(async move |this, cx| {
|
||||
cx.background_executor().timer(PAUSE_DELAY).await;
|
||||
|
||||
if let Some(this) = this.upgrade() {
|
||||
@@ -85,13 +91,6 @@ impl BlinkCursor {
|
||||
this.blink(epoch, cx);
|
||||
});
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for BlinkCursor {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
use std::fmt::Debug;
|
||||
|
||||
use crate::history::HistoryItem;
|
||||
use crate::input::cursor::Selection;
|
||||
use crate::{history::HistoryItem, input::Selection};
|
||||
|
||||
#[derive(Debug, PartialEq, Clone)]
|
||||
pub struct Change {
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
use gpui::{App, Styled};
|
||||
use theme::ActiveTheme;
|
||||
|
||||
use crate::button::{Button, ButtonVariants};
|
||||
use crate::{Icon, IconName, Sizable};
|
||||
use crate::button::{Button, ButtonVariants as _};
|
||||
use crate::{Icon, IconName, Sizable as _};
|
||||
|
||||
#[inline]
|
||||
pub(crate) fn clear_button(cx: &App) -> Button {
|
||||
Button::new("clean")
|
||||
.icon(Icon::new(IconName::CloseCircle))
|
||||
.tooltip("Clear")
|
||||
.small()
|
||||
.transparent()
|
||||
.text_color(cx.theme().text_muted)
|
||||
.ghost()
|
||||
.xsmall()
|
||||
.tab_stop(false)
|
||||
.text_color(cx.theme().icon_muted)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use std::ops::Range;
|
||||
use std::ops::{Range, RangeBounds};
|
||||
|
||||
/// A selection in the text, represented by start and end byte indices.
|
||||
#[derive(Debug, Copy, Clone, PartialEq, Eq, Default)]
|
||||
@@ -42,5 +42,12 @@ impl From<Selection> for Range<usize> {
|
||||
value.start..value.end
|
||||
}
|
||||
}
|
||||
impl RangeBounds<usize> for Selection {
|
||||
fn start_bound(&self) -> std::ops::Bound<&usize> {
|
||||
std::ops::Bound::Included(&self.start)
|
||||
}
|
||||
|
||||
pub type Position = lsp_types::Position;
|
||||
fn end_bound(&self) -> std::ops::Bound<&usize> {
|
||||
std::ops::Bound::Excluded(&self.end)
|
||||
}
|
||||
}
|
||||
|
||||
172
crates/ui/src/input/display_map/display_map.rs
Normal file
172
crates/ui/src/input/display_map/display_map.rs
Normal file
@@ -0,0 +1,172 @@
|
||||
use std::ops::Range;
|
||||
|
||||
use gpui::{App, Font, Pixels};
|
||||
use ropey::Rope;
|
||||
|
||||
use super::text_wrapper::{LineItem, WrapDisplayPoint};
|
||||
use super::wrap_map::WrapMap;
|
||||
use crate::input::Point as TreeSitterPoint;
|
||||
|
||||
/// DisplayMap is the main interface for Input coordinate mapping.
|
||||
pub struct DisplayMap {
|
||||
wrap_map: WrapMap,
|
||||
}
|
||||
|
||||
impl DisplayMap {
|
||||
pub fn new(font: Font, font_size: Pixels, wrap_width: Option<Pixels>) -> Self {
|
||||
Self {
|
||||
wrap_map: WrapMap::new(font, font_size, wrap_width),
|
||||
}
|
||||
}
|
||||
|
||||
/// Get total number of display rows (same as wrap rows without folding)
|
||||
#[inline]
|
||||
pub fn display_row_count(&self) -> usize {
|
||||
self.wrap_map.wrap_row_count()
|
||||
}
|
||||
|
||||
/// Get the buffer line for a given display row
|
||||
pub fn display_row_to_buffer_line(&self, display_row: usize) -> usize {
|
||||
self.wrap_map.wrap_row_to_buffer_line(display_row)
|
||||
}
|
||||
|
||||
/// Get the display row range for a buffer line: [start, end)
|
||||
pub fn buffer_line_to_display_row_range(&self, line: usize) -> Option<Range<usize>> {
|
||||
let range = self.wrap_map.buffer_line_to_wrap_row_range(line);
|
||||
if range.is_empty() { None } else { Some(range) }
|
||||
}
|
||||
|
||||
/// Check if a buffer line is completely hidden (never true without folding)
|
||||
#[inline]
|
||||
pub fn is_buffer_line_hidden(&self, _line: usize) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
/// All wrap rows are visible since there's no folding.
|
||||
#[inline]
|
||||
pub fn folded_ranges(&self) -> &[()] {
|
||||
&[]
|
||||
}
|
||||
|
||||
/// Adjust folds for edit (no-op without folding)
|
||||
pub fn adjust_folds_for_edit(
|
||||
&mut self,
|
||||
_old_text: &Rope,
|
||||
_range: &Range<usize>,
|
||||
_new_text: &str,
|
||||
) {
|
||||
// No-op: no folding
|
||||
}
|
||||
|
||||
/// Update text (incremental or full)
|
||||
pub fn on_text_changed(
|
||||
&mut self,
|
||||
changed_text: &Rope,
|
||||
range: &Range<usize>,
|
||||
new_text: &Rope,
|
||||
cx: &mut App,
|
||||
) {
|
||||
self.wrap_map
|
||||
.on_text_changed(changed_text, range, new_text, cx);
|
||||
}
|
||||
|
||||
/// Update layout parameters (wrap width or font)
|
||||
pub fn on_layout_changed(&mut self, wrap_width: Option<Pixels>, cx: &mut App) {
|
||||
self.wrap_map.on_layout_changed(wrap_width, cx);
|
||||
}
|
||||
|
||||
/// Set font parameters
|
||||
pub fn set_font(&mut self, font: Font, font_size: Pixels, cx: &mut App) {
|
||||
self.wrap_map.set_font(font, font_size, cx);
|
||||
}
|
||||
|
||||
/// Ensure text is prepared (initializes wrapper if needed)
|
||||
pub fn ensure_text_prepared(&mut self, text: &Rope, cx: &mut App) {
|
||||
self.wrap_map.ensure_text_prepared(text, cx);
|
||||
}
|
||||
|
||||
/// Initialize with text
|
||||
pub fn set_text(&mut self, text: &Rope, cx: &mut App) {
|
||||
self.wrap_map.set_text(text, cx);
|
||||
}
|
||||
|
||||
/// Convert byte offset to wrap display point (with soft wrap info).
|
||||
#[inline]
|
||||
pub(crate) fn offset_to_wrap_display_point(&self, offset: usize) -> WrapDisplayPoint {
|
||||
self.wrap_map.wrapper().offset_to_display_point(offset)
|
||||
}
|
||||
|
||||
/// Convert wrap display point to byte offset.
|
||||
#[inline]
|
||||
pub(crate) fn wrap_display_point_to_offset(&self, point: WrapDisplayPoint) -> usize {
|
||||
self.wrap_map.wrapper().display_point_to_offset(point)
|
||||
}
|
||||
|
||||
/// Convert wrap display point to TreeSitterPoint (buffer line/col).
|
||||
#[inline]
|
||||
pub(crate) fn wrap_display_point_to_point(&self, point: WrapDisplayPoint) -> TreeSitterPoint {
|
||||
self.wrap_map.wrapper().display_point_to_point(point)
|
||||
}
|
||||
|
||||
/// Since there's no folding, wrap row == display row.
|
||||
#[inline]
|
||||
pub fn wrap_row_to_display_row(&self, wrap_row: usize) -> Option<usize> {
|
||||
if wrap_row < self.wrap_row_count() {
|
||||
Some(wrap_row)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Since there's no folding, nearest visible row is the row itself.
|
||||
#[inline]
|
||||
pub fn nearest_visible_display_row(&self, wrap_row: usize) -> usize {
|
||||
wrap_row.min(self.wrap_row_count().saturating_sub(1))
|
||||
}
|
||||
|
||||
/// Since there's no folding, display row == wrap row.
|
||||
#[inline]
|
||||
pub fn display_row_to_wrap_row(&self, display_row: usize) -> Option<usize> {
|
||||
if display_row < self.wrap_row_count() {
|
||||
Some(display_row)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the longest row index (by byte length).
|
||||
#[inline]
|
||||
pub(crate) fn longest_row(&self) -> usize {
|
||||
self.wrap_map.wrapper().longest_row.row
|
||||
}
|
||||
|
||||
/// Get access to line items (for rendering)
|
||||
#[inline]
|
||||
pub(crate) fn lines(&self) -> &[LineItem] {
|
||||
self.wrap_map.lines()
|
||||
}
|
||||
|
||||
/// Get the rope text
|
||||
#[inline]
|
||||
pub fn text(&self) -> &Rope {
|
||||
self.wrap_map.text()
|
||||
}
|
||||
|
||||
/// Calculate how many wrap rows of a buffer line are visible
|
||||
#[inline]
|
||||
pub fn visible_wrap_row_count_for_buffer_line(&self, line: usize) -> usize {
|
||||
self.wrap_map.visible_wrap_row_count_for_buffer_line(line)
|
||||
}
|
||||
|
||||
/// Get the wrap row count
|
||||
#[inline]
|
||||
pub fn wrap_row_count(&self) -> usize {
|
||||
self.wrap_map.wrap_row_count()
|
||||
}
|
||||
|
||||
/// Get the buffer line count (logical lines)
|
||||
#[inline]
|
||||
pub fn buffer_line_count(&self) -> usize {
|
||||
self.wrap_map.buffer_line_count()
|
||||
}
|
||||
}
|
||||
7
crates/ui/src/input/display_map/mod.rs
Normal file
7
crates/ui/src/input/display_map/mod.rs
Normal file
@@ -0,0 +1,7 @@
|
||||
#[allow(clippy::module_inception)]
|
||||
mod display_map;
|
||||
mod text_wrapper;
|
||||
mod wrap_map;
|
||||
|
||||
pub use self::display_map::DisplayMap;
|
||||
pub(crate) use self::text_wrapper::LineLayout;
|
||||
582
crates/ui/src/input/display_map/text_wrapper.rs
Normal file
582
crates/ui/src/input/display_map/text_wrapper.rs
Normal file
@@ -0,0 +1,582 @@
|
||||
use std::ops::Range;
|
||||
|
||||
use gpui::{
|
||||
App, Font, Half, LineFragment, Pixels, Point, ShapedLine, Size, TextAlign, Window, point, px,
|
||||
size,
|
||||
};
|
||||
use ropey::Rope;
|
||||
use smallvec::SmallVec;
|
||||
|
||||
use crate::input::{LastLayout, Point as TreeSitterPoint, RopeExt, WhitespaceIndicators};
|
||||
|
||||
/// A line with soft wrapped lines info.
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct LineItem {
|
||||
/// The original line text, without end `\n`.
|
||||
line: Rope,
|
||||
/// The soft wrapped lines relative byte range (0..line.len) of this line (Include first line).
|
||||
///
|
||||
/// Not contains the line end `\n`.
|
||||
pub(crate) wrapped_lines: Vec<Range<usize>>,
|
||||
}
|
||||
|
||||
impl LineItem {
|
||||
/// Get the bytes length of this line.
|
||||
#[inline]
|
||||
pub(crate) fn len(&self) -> usize {
|
||||
self.line.len()
|
||||
}
|
||||
|
||||
/// Get number of soft wrapped lines of this line (include the first line).
|
||||
#[inline]
|
||||
pub(crate) fn lines_len(&self) -> usize {
|
||||
self.wrapped_lines.len()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub(crate) struct LongestRow {
|
||||
/// The 0-based row index.
|
||||
pub row: usize,
|
||||
/// The bytes length of the longest line.
|
||||
pub len: usize,
|
||||
}
|
||||
|
||||
/// Used to prepare the text with soft wrap to be get lines to displayed in the Editor.
|
||||
///
|
||||
/// After use lines to calculate the scroll size of the Editor.
|
||||
pub(crate) struct TextWrapper {
|
||||
text: Rope,
|
||||
/// Total wrapped lines (Inlucde the first line), value is start and end index of the line.
|
||||
soft_lines: usize,
|
||||
font: Font,
|
||||
font_size: Pixels,
|
||||
/// If is none, it means the text is not wrapped
|
||||
wrap_width: Option<Pixels>,
|
||||
/// The longest (row, bytes len) in characters, used to calculate the horizontal scroll width.
|
||||
pub(crate) longest_row: LongestRow,
|
||||
/// The lines by split \n
|
||||
pub(crate) lines: Vec<LineItem>,
|
||||
|
||||
_initialized: bool,
|
||||
}
|
||||
|
||||
#[allow(unused)]
|
||||
impl TextWrapper {
|
||||
pub(crate) fn new(font: Font, font_size: Pixels, wrap_width: Option<Pixels>) -> Self {
|
||||
Self {
|
||||
text: Rope::new(),
|
||||
font,
|
||||
font_size,
|
||||
wrap_width,
|
||||
soft_lines: 0,
|
||||
longest_row: LongestRow::default(),
|
||||
lines: Vec::new(),
|
||||
_initialized: false,
|
||||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub(crate) fn set_default_text(&mut self, text: &Rope) {
|
||||
self.text = text.clone();
|
||||
}
|
||||
|
||||
/// Get reference to the rope text.
|
||||
#[inline]
|
||||
pub(crate) fn text(&self) -> &Rope {
|
||||
&self.text
|
||||
}
|
||||
|
||||
/// Get the total number of lines including wrapped lines.
|
||||
#[inline]
|
||||
pub(crate) fn len(&self) -> usize {
|
||||
self.soft_lines
|
||||
}
|
||||
|
||||
/// Get the line item by row index.
|
||||
#[inline]
|
||||
pub(crate) fn line(&self, row: usize) -> Option<&LineItem> {
|
||||
self.lines.get(row)
|
||||
}
|
||||
|
||||
pub(crate) fn set_wrap_width(&mut self, wrap_width: Option<Pixels>, cx: &mut App) {
|
||||
if wrap_width == self.wrap_width {
|
||||
return;
|
||||
}
|
||||
|
||||
self.wrap_width = wrap_width;
|
||||
self.update_all(&self.text.clone(), cx);
|
||||
}
|
||||
|
||||
pub(crate) fn set_font(&mut self, font: Font, font_size: Pixels, cx: &mut App) {
|
||||
if self.font.eq(&font) && self.font_size == font_size {
|
||||
return;
|
||||
}
|
||||
|
||||
self.font = font;
|
||||
self.font_size = font_size;
|
||||
self.update_all(&self.text.clone(), cx);
|
||||
}
|
||||
|
||||
pub(crate) fn prepare_if_need(&mut self, text: &Rope, cx: &mut App) -> bool {
|
||||
if self._initialized {
|
||||
return false;
|
||||
}
|
||||
self._initialized = true;
|
||||
self.update_all(text, cx);
|
||||
true
|
||||
}
|
||||
|
||||
/// Update the text wrapper and recalculate the wrapped lines.
|
||||
///
|
||||
/// If the `text` is the same as the current text, do nothing.
|
||||
///
|
||||
/// - `changed_text`: The text [`Rope`] that has changed.
|
||||
/// - `range`: The `selected_range` before change.
|
||||
/// - `new_text`: The inserted text.
|
||||
/// - `force`: Whether to force the update, if false, the update will be skipped if the text is the same.
|
||||
/// - `cx`: The application context.
|
||||
pub(crate) fn update(
|
||||
&mut self,
|
||||
changed_text: &Rope,
|
||||
range: &Range<usize>,
|
||||
new_text: &Rope,
|
||||
cx: &mut App,
|
||||
) {
|
||||
let mut line_wrapper = cx
|
||||
.text_system()
|
||||
.line_wrapper(self.font.clone(), self.font_size);
|
||||
self._update(
|
||||
changed_text,
|
||||
range,
|
||||
new_text,
|
||||
&mut |line_str, wrap_width| {
|
||||
line_wrapper
|
||||
.wrap_line(&[LineFragment::text(line_str)], wrap_width)
|
||||
.collect()
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
fn _update<F>(
|
||||
&mut self,
|
||||
changed_text: &Rope,
|
||||
range: &Range<usize>,
|
||||
new_text: &Rope,
|
||||
wrap_line: &mut F,
|
||||
) where
|
||||
F: FnMut(&str, Pixels) -> Vec<gpui::Boundary>,
|
||||
{
|
||||
// Remove the old changed lines.
|
||||
let start_row = self.text.offset_to_point(range.start).row;
|
||||
let start_row = start_row.min(self.lines.len().saturating_sub(1));
|
||||
let end_row = self.text.offset_to_point(range.end).row;
|
||||
let end_row = end_row.min(self.lines.len().saturating_sub(1));
|
||||
let rows_range = start_row..=end_row;
|
||||
|
||||
if rows_range.contains(&self.longest_row.row) {
|
||||
self.longest_row = LongestRow::default();
|
||||
}
|
||||
|
||||
let mut longest_row_ix = self.longest_row.row;
|
||||
let mut longest_row_len = self.longest_row.len;
|
||||
|
||||
// To add the new lines.
|
||||
let new_start_row = changed_text.offset_to_point(range.start).row;
|
||||
let new_start_offset = changed_text.line_start_offset(new_start_row);
|
||||
let new_end_row = changed_text
|
||||
.offset_to_point(range.start + new_text.len())
|
||||
.row;
|
||||
let new_end_offset = changed_text.line_end_offset(new_end_row);
|
||||
let new_range = new_start_offset..new_end_offset;
|
||||
|
||||
let mut new_lines = vec![];
|
||||
let wrap_width = self.wrap_width;
|
||||
|
||||
// line not contains `\n`.
|
||||
for (ix, line) in Rope::from(changed_text.slice(new_range))
|
||||
.iter_lines()
|
||||
.enumerate()
|
||||
{
|
||||
let line_str = line.to_string();
|
||||
let mut wrapped_lines = vec![];
|
||||
let mut prev_boundary_ix = 0;
|
||||
|
||||
if line_str.len() > longest_row_len {
|
||||
longest_row_ix = new_start_row + ix;
|
||||
longest_row_len = line_str.len();
|
||||
}
|
||||
|
||||
// If wrap_width is Pixels::MAX, skip wrapping to disable word wrap
|
||||
if let Some(wrap_width) = wrap_width {
|
||||
// Here only have wrapped line, if there is no wrap meet, the `line_wraps` result will empty.
|
||||
for boundary in wrap_line(&line_str, wrap_width) {
|
||||
wrapped_lines.push(prev_boundary_ix..boundary.ix);
|
||||
prev_boundary_ix = boundary.ix;
|
||||
}
|
||||
}
|
||||
|
||||
// Reset of the line
|
||||
if !line_str[prev_boundary_ix..].is_empty() || prev_boundary_ix == 0 {
|
||||
wrapped_lines.push(prev_boundary_ix..line.len());
|
||||
}
|
||||
|
||||
new_lines.push(LineItem {
|
||||
line: Rope::from(line),
|
||||
wrapped_lines,
|
||||
});
|
||||
}
|
||||
|
||||
if self.lines.is_empty() {
|
||||
self.lines = new_lines;
|
||||
} else {
|
||||
self.lines.splice(rows_range, new_lines);
|
||||
}
|
||||
|
||||
self.text = changed_text.clone();
|
||||
self.soft_lines = self.lines.iter().map(|l| l.lines_len()).sum();
|
||||
self.longest_row = LongestRow {
|
||||
row: longest_row_ix,
|
||||
len: longest_row_len,
|
||||
}
|
||||
}
|
||||
|
||||
/// Update the text wrapper and recalculate the wrapped lines.
|
||||
///
|
||||
/// If the `text` is the same as the current text, do nothing.
|
||||
fn update_all(&mut self, text: &Rope, cx: &mut App) {
|
||||
self.update(text, &(0..text.len()), text, cx);
|
||||
}
|
||||
|
||||
/// Return display point (with soft wrap) from the given byte offset in the text.
|
||||
///
|
||||
/// Panics if the `offset` is out of bounds.
|
||||
pub(crate) fn offset_to_display_point(&self, offset: usize) -> WrapDisplayPoint {
|
||||
let row = self.text.offset_to_point(offset).row;
|
||||
let start = self.text.line_start_offset(row);
|
||||
let line = &self.lines[row];
|
||||
|
||||
let mut wrapped_row = self
|
||||
.lines
|
||||
.iter()
|
||||
.take(row)
|
||||
.map(|l| l.lines_len())
|
||||
.sum::<usize>();
|
||||
|
||||
let local_offset = offset.saturating_sub(start);
|
||||
for (ix, range) in line.wrapped_lines.iter().enumerate() {
|
||||
if range.contains(&local_offset) {
|
||||
return WrapDisplayPoint::new(
|
||||
wrapped_row + ix,
|
||||
ix,
|
||||
local_offset.saturating_sub(range.start),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Otherwise return the eof of the line.
|
||||
let last_range = line.wrapped_lines.last().unwrap_or(&(0..0));
|
||||
let ix = line.lines_len().saturating_sub(1);
|
||||
|
||||
WrapDisplayPoint::new(wrapped_row + ix, ix, last_range.len())
|
||||
}
|
||||
|
||||
/// Return byte offset in the text from the given display point (with soft wrap).
|
||||
///
|
||||
/// Panics if the `point.row` is out of bounds.
|
||||
pub(crate) fn display_point_to_offset(&self, point: WrapDisplayPoint) -> usize {
|
||||
let mut wrapped_row = 0;
|
||||
for (row, line) in self.lines.iter().enumerate() {
|
||||
if wrapped_row + line.lines_len() > point.row {
|
||||
let line_start = self.text.line_start_offset(row);
|
||||
let local_row = point.row.saturating_sub(wrapped_row);
|
||||
if let Some(range) = line.wrapped_lines.get(local_row) {
|
||||
return line_start + (range.start + point.column).min(range.end);
|
||||
} else {
|
||||
// If not found, return the end of the line.
|
||||
return line_start + line.len();
|
||||
}
|
||||
}
|
||||
|
||||
wrapped_row += line.lines_len();
|
||||
}
|
||||
|
||||
self.text.len()
|
||||
}
|
||||
|
||||
pub(crate) fn display_point_to_point(&self, point: WrapDisplayPoint) -> TreeSitterPoint {
|
||||
let offset = self.display_point_to_offset(point);
|
||||
self.text.offset_to_point(offset)
|
||||
}
|
||||
|
||||
pub(crate) fn point_to_display_point(&self, point: TreeSitterPoint) -> WrapDisplayPoint {
|
||||
let offset = self.text.point_to_offset(point);
|
||||
self.offset_to_display_point(offset)
|
||||
}
|
||||
}
|
||||
|
||||
/// A display point within the soft-wrapped text.
|
||||
///
|
||||
/// This represents a position in the text after soft-wrapping,
|
||||
/// with an additional `local_row` field tracking the wrap line
|
||||
/// within the original buffer line.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) struct WrapDisplayPoint {
|
||||
/// The 0-based soft wrapped row index in the text.
|
||||
pub row: usize,
|
||||
/// The 0-based row index in local line (include first line).
|
||||
///
|
||||
/// This value only valid when return from [`TextWrapper::offset_to_display_point`], otherwise it will be ignored.
|
||||
pub local_row: usize,
|
||||
/// The 0-based column byte index in the display line (with soft wrap).
|
||||
pub column: usize,
|
||||
}
|
||||
|
||||
impl WrapDisplayPoint {
|
||||
pub fn new(row: usize, local_row: usize, column: usize) -> Self {
|
||||
Self {
|
||||
row,
|
||||
local_row,
|
||||
column,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The layout info of a line with soft wrapped lines.
|
||||
pub(crate) struct LineLayout {
|
||||
/// Total bytes length of this line.
|
||||
len: usize,
|
||||
/// The soft wrapped lines of this line (Include the first line).
|
||||
pub(crate) wrapped_lines: SmallVec<[ShapedLine; 1]>,
|
||||
pub(crate) longest_width: Pixels,
|
||||
pub(crate) whitespace_indicators: Option<WhitespaceIndicators>,
|
||||
/// Whitespace indicators: (line_index, x_position, is_tab)
|
||||
pub(crate) whitespace_chars: Vec<(usize, Pixels, bool)>,
|
||||
}
|
||||
|
||||
impl LineLayout {
|
||||
pub(crate) fn new() -> Self {
|
||||
Self {
|
||||
len: 0,
|
||||
longest_width: px(0.),
|
||||
wrapped_lines: SmallVec::new(),
|
||||
whitespace_chars: Vec::new(),
|
||||
whitespace_indicators: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn lines(mut self, wrapped_lines: SmallVec<[ShapedLine; 1]>) -> Self {
|
||||
self.set_wrapped_lines(wrapped_lines);
|
||||
self
|
||||
}
|
||||
|
||||
pub(crate) fn set_wrapped_lines(&mut self, wrapped_lines: SmallVec<[ShapedLine; 1]>) {
|
||||
self.len = wrapped_lines.iter().map(|l| l.len).sum();
|
||||
let width = wrapped_lines
|
||||
.iter()
|
||||
.map(|l| l.width)
|
||||
.max()
|
||||
.unwrap_or_default();
|
||||
self.longest_width = width;
|
||||
self.wrapped_lines = wrapped_lines;
|
||||
}
|
||||
|
||||
pub(crate) fn with_whitespaces(mut self, indicators: Option<WhitespaceIndicators>) -> Self {
|
||||
self.whitespace_indicators = indicators;
|
||||
let Some(indicators) = self.whitespace_indicators.as_ref() else {
|
||||
return self;
|
||||
};
|
||||
|
||||
let space_indicator_offset = indicators.space.width.half();
|
||||
|
||||
for (line_index, wrapped_line) in self.wrapped_lines.iter().enumerate() {
|
||||
for (relative_offset, c) in wrapped_line.text.char_indices() {
|
||||
if matches!(c, ' ' | '\t') {
|
||||
let is_tab = c == '\t';
|
||||
let start_x = wrapped_line.x_for_index(relative_offset);
|
||||
let end_x = wrapped_line.x_for_index(relative_offset + c.len_utf8());
|
||||
// Center the indicator in the actual character's space
|
||||
let x_position = if c == ' ' {
|
||||
(start_x + end_x).half() - space_indicator_offset
|
||||
} else {
|
||||
start_x
|
||||
};
|
||||
|
||||
self.whitespace_chars.push((line_index, x_position, is_tab));
|
||||
}
|
||||
}
|
||||
}
|
||||
self
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub(crate) fn len(&self) -> usize {
|
||||
self.len
|
||||
}
|
||||
|
||||
/// Get the position (x, y) for the given index in this line layout.
|
||||
///
|
||||
/// - The `offset` is a local byte index in this line layout.
|
||||
/// - When `line_end_affinity` is true, an offset at a soft wrap boundary is placed at
|
||||
/// the end of the current visual line rather than the start of the next one.
|
||||
/// - The return value is relative to the top-left corner of this line layout, start from (0, 0)
|
||||
pub(crate) fn position_for_index(
|
||||
&self,
|
||||
offset: usize,
|
||||
last_layout: &LastLayout,
|
||||
line_end_affinity: bool,
|
||||
) -> Option<Point<Pixels>> {
|
||||
let mut acc_len = 0;
|
||||
let mut offset_y = px(0.);
|
||||
|
||||
let x_offset = last_layout.alignment_offset(self.longest_width);
|
||||
|
||||
for (i, line) in self.wrapped_lines.iter().enumerate() {
|
||||
let is_last = i + 1 == self.wrapped_lines.len();
|
||||
|
||||
let matches = if line.len == 0 {
|
||||
// Empty visual lines still own their boundary offset.
|
||||
offset == acc_len
|
||||
} else if is_last || line_end_affinity {
|
||||
// Inclusive: cursor can sit at end of this visual line.
|
||||
offset >= acc_len && offset <= acc_len + line.len
|
||||
} else {
|
||||
// Exclusive: boundary offset belongs to the next visual line.
|
||||
offset >= acc_len && offset < acc_len + line.len
|
||||
};
|
||||
|
||||
if matches {
|
||||
let x = line.x_for_index(offset.saturating_sub(acc_len)) + x_offset;
|
||||
return Some(point(x, offset_y));
|
||||
}
|
||||
|
||||
// Always advance by actual line length. The last line gets +1 so the
|
||||
// cursor can be placed after the final character.
|
||||
acc_len += if is_last { line.len + 1 } else { line.len };
|
||||
offset_y += last_layout.line_height;
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
/// Get the closest index for the given x in this line layout.
|
||||
pub(crate) fn closest_index_for_x(&self, x: Pixels, last_layout: &LastLayout) -> usize {
|
||||
let mut acc_len = 0;
|
||||
let x_offset = last_layout.alignment_offset(self.longest_width);
|
||||
let x = x - x_offset;
|
||||
|
||||
for (i, line) in self.wrapped_lines.iter().enumerate() {
|
||||
let is_last = i + 1 == self.wrapped_lines.len();
|
||||
if x <= line.width {
|
||||
let mut ix = line.closest_index_for_x(x);
|
||||
if !is_last && ix == line.text.len() {
|
||||
// For soft wrap line, we can't put the cursor at the end of the line.
|
||||
let c_len = line.text.chars().last().map(|c| c.len_utf8()).unwrap_or(0);
|
||||
ix = ix.saturating_sub(c_len);
|
||||
}
|
||||
|
||||
return acc_len + ix;
|
||||
}
|
||||
acc_len += line.text.len();
|
||||
}
|
||||
|
||||
acc_len
|
||||
}
|
||||
|
||||
/// Get the index for the given position (x, y) in this line layout.
|
||||
///
|
||||
/// The `pos` is relative to the top-left corner of this line layout, start from (0, 0)
|
||||
/// The return value is a local byte index in this line layout, start from 0.
|
||||
pub(crate) fn closest_index_for_position(
|
||||
&self,
|
||||
pos: Point<Pixels>,
|
||||
last_layout: &LastLayout,
|
||||
) -> Option<usize> {
|
||||
let mut offset = 0;
|
||||
let mut line_top = px(0.);
|
||||
let x_offset = last_layout.alignment_offset(self.longest_width);
|
||||
for (i, line) in self.wrapped_lines.iter().enumerate() {
|
||||
let is_last = i + 1 == self.wrapped_lines.len();
|
||||
let line_bottom = line_top + last_layout.line_height;
|
||||
if pos.y >= line_top && pos.y < line_bottom {
|
||||
let mut ix = line.closest_index_for_x(pos.x - x_offset);
|
||||
if !is_last && ix == line.text.len() {
|
||||
// For soft wrap line, we can't put the cursor at the end of the line.
|
||||
let c_len = line.text.chars().last().map(|c| c.len_utf8()).unwrap_or(0);
|
||||
ix = ix.saturating_sub(c_len);
|
||||
}
|
||||
return Some(offset + ix);
|
||||
}
|
||||
|
||||
offset += line.text.len();
|
||||
line_top = line_bottom;
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
pub(crate) fn index_for_position(
|
||||
&self,
|
||||
pos: Point<Pixels>,
|
||||
last_layout: &LastLayout,
|
||||
) -> Option<usize> {
|
||||
let mut offset = 0;
|
||||
let mut line_top = px(0.);
|
||||
let x_offset = last_layout.alignment_offset(self.longest_width);
|
||||
for line in self.wrapped_lines.iter() {
|
||||
let line_bottom = line_top + last_layout.line_height;
|
||||
if pos.y >= line_top && pos.y < line_bottom {
|
||||
let ix = line.index_for_x(pos.x - x_offset)?;
|
||||
return Some(offset + ix);
|
||||
}
|
||||
|
||||
offset += line.text.len();
|
||||
line_top = line_bottom;
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
pub(crate) fn size(&self, line_height: Pixels) -> Size<Pixels> {
|
||||
size(self.longest_width, self.wrapped_lines.len() * line_height)
|
||||
}
|
||||
|
||||
pub(crate) fn paint(
|
||||
&self,
|
||||
pos: Point<Pixels>,
|
||||
line_height: Pixels,
|
||||
text_align: TextAlign,
|
||||
align_width: Option<Pixels>,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) {
|
||||
for (ix, line) in self.wrapped_lines.iter().enumerate() {
|
||||
_ = line.paint(
|
||||
pos + point(px(0.), ix * line_height),
|
||||
line_height,
|
||||
text_align,
|
||||
align_width,
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
}
|
||||
|
||||
// Paint whitespace indicators
|
||||
if let Some(indicators) = self.whitespace_indicators.as_ref() {
|
||||
for (line_index, x_position, is_tab) in &self.whitespace_chars {
|
||||
let invisible = if *is_tab {
|
||||
indicators.tab.clone()
|
||||
} else {
|
||||
indicators.space.clone()
|
||||
};
|
||||
|
||||
let origin = point(
|
||||
pos.x + *x_position,
|
||||
pos.y + *line_index as f32 * line_height,
|
||||
);
|
||||
|
||||
_ = invisible.paint(origin, line_height, text_align, align_width, window, cx);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
172
crates/ui/src/input/display_map/wrap_map.rs
Normal file
172
crates/ui/src/input/display_map/wrap_map.rs
Normal file
@@ -0,0 +1,172 @@
|
||||
/// WrapMap: Soft-wrapping layer (Buffer → Wrap rows).
|
||||
///
|
||||
/// This module wraps the existing TextWrapper and provides:
|
||||
/// - BufferPoint ↔ WrapPoint mapping
|
||||
/// - Efficient buffer_line → wrap_row queries via prefix sum cache
|
||||
/// - Incremental updates when text or layout changes
|
||||
use std::ops::Range;
|
||||
|
||||
use gpui::{App, Font, Pixels};
|
||||
use ropey::Rope;
|
||||
|
||||
use super::text_wrapper::{LineItem, TextWrapper};
|
||||
|
||||
/// WrapMap manages soft-wrapping and provides buffer ↔ wrap coordinate mapping.
|
||||
pub struct WrapMap {
|
||||
/// The underlying text wrapper (reuses existing implementation)
|
||||
wrapper: TextWrapper,
|
||||
|
||||
/// Prefix sum cache: buffer_line_starts[line] = first wrap_row for buffer line `line`
|
||||
/// This allows O(1) lookup of buffer_line → wrap_row
|
||||
buffer_line_starts: Vec<usize>,
|
||||
|
||||
/// Cached line count from last rebuild
|
||||
cached_line_count: usize,
|
||||
|
||||
/// Cached total wrap row count from last rebuild.
|
||||
/// Used together with `cached_line_count` to detect if the cache is stale.
|
||||
/// When soft wrap changes a line's wrap count without changing buffer line count,
|
||||
/// this catches the staleness.
|
||||
cached_wrap_row_count: usize,
|
||||
}
|
||||
|
||||
impl WrapMap {
|
||||
pub fn new(font: Font, font_size: Pixels, wrap_width: Option<Pixels>) -> Self {
|
||||
Self {
|
||||
wrapper: TextWrapper::new(font, font_size, wrap_width),
|
||||
buffer_line_starts: Vec::new(),
|
||||
cached_line_count: 0,
|
||||
cached_wrap_row_count: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Get total number of wrap rows (visual rows after soft-wrapping)
|
||||
#[inline]
|
||||
pub fn wrap_row_count(&self) -> usize {
|
||||
self.wrapper.len()
|
||||
}
|
||||
|
||||
/// Get total number of buffer lines (logical lines)
|
||||
#[inline]
|
||||
pub fn buffer_line_count(&self) -> usize {
|
||||
self.wrapper.lines.len()
|
||||
}
|
||||
|
||||
/// Get the buffer line for a given wrap row
|
||||
pub fn wrap_row_to_buffer_line(&self, wrap_row: usize) -> usize {
|
||||
if wrap_row >= self.wrap_row_count() {
|
||||
return self.buffer_line_count().saturating_sub(1);
|
||||
}
|
||||
|
||||
// Binary search in prefix sum cache
|
||||
match self.buffer_line_starts.binary_search(&wrap_row) {
|
||||
Ok(line) => line,
|
||||
Err(insert_pos) => insert_pos.saturating_sub(1),
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the first wrap row for a given buffer line
|
||||
pub fn buffer_line_to_first_wrap_row(&self, line: usize) -> usize {
|
||||
if line >= self.buffer_line_starts.len() {
|
||||
return self.wrap_row_count();
|
||||
}
|
||||
self.buffer_line_starts[line]
|
||||
}
|
||||
|
||||
/// Get the wrap row range for a buffer line: [start, end)
|
||||
pub fn buffer_line_to_wrap_row_range(&self, line: usize) -> Range<usize> {
|
||||
let start = self.buffer_line_to_first_wrap_row(line);
|
||||
let end = if line + 1 < self.buffer_line_starts.len() {
|
||||
self.buffer_line_starts[line + 1]
|
||||
} else {
|
||||
self.wrap_row_count()
|
||||
};
|
||||
start..end
|
||||
}
|
||||
|
||||
/// Update text (incremental or full)
|
||||
pub fn on_text_changed(
|
||||
&mut self,
|
||||
changed_text: &Rope,
|
||||
range: &Range<usize>,
|
||||
new_text: &Rope,
|
||||
cx: &mut App,
|
||||
) {
|
||||
self.wrapper.update(changed_text, range, new_text, cx);
|
||||
self.rebuild_cache();
|
||||
}
|
||||
|
||||
/// Update layout parameters (wrap width or font)
|
||||
pub fn on_layout_changed(&mut self, wrap_width: Option<Pixels>, cx: &mut App) {
|
||||
self.wrapper.set_wrap_width(wrap_width, cx);
|
||||
self.rebuild_cache();
|
||||
}
|
||||
|
||||
/// Set font parameters
|
||||
pub fn set_font(&mut self, font: Font, font_size: Pixels, cx: &mut App) {
|
||||
self.wrapper.set_font(font, font_size, cx);
|
||||
self.rebuild_cache();
|
||||
}
|
||||
|
||||
/// Ensure text is prepared (initializes wrapper if needed)
|
||||
pub fn ensure_text_prepared(&mut self, text: &Rope, cx: &mut App) -> bool {
|
||||
let did_initialize = self.wrapper.prepare_if_need(text, cx);
|
||||
if did_initialize {
|
||||
self.rebuild_cache();
|
||||
}
|
||||
did_initialize
|
||||
}
|
||||
|
||||
/// Initialize with text
|
||||
pub fn set_text(&mut self, text: &Rope, cx: &mut App) {
|
||||
self.wrapper.set_default_text(text);
|
||||
self.wrapper.prepare_if_need(text, cx);
|
||||
self.rebuild_cache();
|
||||
}
|
||||
|
||||
/// Rebuild the prefix sum cache: buffer_line_starts
|
||||
fn rebuild_cache(&mut self) {
|
||||
let line_count = self.wrapper.lines.len();
|
||||
let wrap_row_count = self.wrapper.len();
|
||||
|
||||
// Skip if nothing changed: both buffer line count and total wrap row count must match.
|
||||
if line_count == self.cached_line_count
|
||||
&& wrap_row_count == self.cached_wrap_row_count
|
||||
&& !self.buffer_line_starts.is_empty()
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
self.buffer_line_starts.clear();
|
||||
|
||||
let mut wrap_row = 0;
|
||||
for line_item in &self.wrapper.lines {
|
||||
self.buffer_line_starts.push(wrap_row);
|
||||
wrap_row += line_item.lines_len();
|
||||
}
|
||||
|
||||
self.cached_line_count = line_count;
|
||||
self.cached_wrap_row_count = wrap_row_count;
|
||||
}
|
||||
|
||||
/// Get access to the underlying wrapper (for rendering/hit-testing)
|
||||
pub(crate) fn wrapper(&self) -> &TextWrapper {
|
||||
&self.wrapper
|
||||
}
|
||||
|
||||
/// Get access to line items (for rendering)
|
||||
pub(crate) fn lines(&self) -> &[LineItem] {
|
||||
&self.wrapper.lines
|
||||
}
|
||||
|
||||
/// Get the rope text
|
||||
pub fn text(&self) -> &Rope {
|
||||
self.wrapper.text()
|
||||
}
|
||||
|
||||
/// Calculate how many wrap rows of a buffer line are visible.
|
||||
/// Without folding, all wrap rows are visible.
|
||||
pub fn visible_wrap_row_count_for_buffer_line(&self, line: usize) -> usize {
|
||||
self.buffer_line_to_wrap_row_range(line).len()
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
269
crates/ui/src/input/indent.rs
Normal file
269
crates/ui/src/input/indent.rs
Normal file
@@ -0,0 +1,269 @@
|
||||
use gpui::{Context, EntityInputHandler, SharedString, Window};
|
||||
use ropey::RopeSlice;
|
||||
|
||||
use crate::input::mode::InputMode;
|
||||
use crate::input::{Indent, IndentInline, InputState, Outdent, OutdentInline};
|
||||
|
||||
#[derive(Debug, Copy, Clone)]
|
||||
pub struct TabSize {
|
||||
/// Default is 2
|
||||
pub tab_size: usize,
|
||||
/// Set true to use `\t` as tab indent, default is false
|
||||
pub hard_tabs: bool,
|
||||
}
|
||||
|
||||
impl Default for TabSize {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
tab_size: 2,
|
||||
hard_tabs: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl TabSize {
|
||||
pub(super) fn to_string(self) -> SharedString {
|
||||
if self.hard_tabs {
|
||||
"\t".into()
|
||||
} else {
|
||||
" ".repeat(self.tab_size).into()
|
||||
}
|
||||
}
|
||||
|
||||
/// Count the indent size of the line in spaces.
|
||||
pub fn indent_count(&self, line: &RopeSlice) -> usize {
|
||||
let mut count = 0;
|
||||
for ch in line.chars() {
|
||||
match ch {
|
||||
'\t' => count += self.tab_size,
|
||||
' ' => count += 1,
|
||||
_ => break,
|
||||
}
|
||||
}
|
||||
|
||||
count
|
||||
}
|
||||
}
|
||||
|
||||
impl InputState {
|
||||
/// Set the tab size for the input.
|
||||
///
|
||||
/// Only for [`InputMode::PlainText`] mode with multi_line.
|
||||
pub fn tab_size(mut self, tab: TabSize) -> Self {
|
||||
debug_assert!(self.mode.is_multi_line());
|
||||
if let InputMode::PlainText { tab: t, .. } = &mut self.mode {
|
||||
*t = tab;
|
||||
}
|
||||
self
|
||||
}
|
||||
|
||||
pub(super) fn indent_inline(
|
||||
&mut self,
|
||||
_: &IndentInline,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
self.indent(false, window, cx);
|
||||
}
|
||||
|
||||
pub(super) fn indent_block(&mut self, _: &Indent, window: &mut Window, cx: &mut Context<Self>) {
|
||||
self.indent(true, window, cx);
|
||||
}
|
||||
|
||||
pub(super) fn outdent_inline(
|
||||
&mut self,
|
||||
_: &OutdentInline,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
self.outdent(false, window, cx);
|
||||
}
|
||||
|
||||
pub(super) fn outdent_block(
|
||||
&mut self,
|
||||
_: &Outdent,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
self.outdent(true, window, cx);
|
||||
}
|
||||
|
||||
pub(super) fn indent(&mut self, block: bool, window: &mut Window, cx: &mut Context<Self>) {
|
||||
if !self.mode.is_indentable() {
|
||||
cx.propagate();
|
||||
return;
|
||||
};
|
||||
|
||||
let tab_indent = self.mode.tab_size().to_string();
|
||||
let selected_range = self.selected_range;
|
||||
let mut added_len = 0;
|
||||
let is_selected = !self.selected_range.is_empty();
|
||||
|
||||
if is_selected || block {
|
||||
let start_offset = self.start_of_line_of_selection(window, cx);
|
||||
let mut offset = start_offset;
|
||||
|
||||
let selected_text = self
|
||||
.text_for_range(
|
||||
self.range_to_utf16(&(offset..selected_range.end)),
|
||||
&mut None,
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
.unwrap_or("".into());
|
||||
|
||||
for line in selected_text.split('\n') {
|
||||
self.replace_text_in_range_silent(
|
||||
Some(self.range_to_utf16(&(offset..offset))),
|
||||
&tab_indent,
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
added_len += tab_indent.len();
|
||||
// +1 for "\n", the `\r` is included in the `line`.
|
||||
offset += line.len() + tab_indent.len() + 1;
|
||||
}
|
||||
|
||||
if is_selected {
|
||||
self.selected_range = (start_offset..selected_range.end + added_len).into();
|
||||
} else {
|
||||
self.selected_range =
|
||||
(selected_range.start + added_len..selected_range.end + added_len).into();
|
||||
}
|
||||
} else {
|
||||
// Selected none
|
||||
let offset = self.selected_range.start;
|
||||
self.replace_text_in_range_silent(
|
||||
Some(self.range_to_utf16(&(offset..offset))),
|
||||
&tab_indent,
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
added_len = tab_indent.len();
|
||||
|
||||
self.selected_range =
|
||||
(selected_range.start + added_len..selected_range.end + added_len).into();
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn outdent(&mut self, block: bool, window: &mut Window, cx: &mut Context<Self>) {
|
||||
if !self.mode.is_indentable() {
|
||||
cx.propagate();
|
||||
return;
|
||||
};
|
||||
|
||||
let tab_indent = self.mode.tab_size().to_string();
|
||||
let selected_range = self.selected_range;
|
||||
let mut removed_len = 0;
|
||||
let is_selected = !self.selected_range.is_empty();
|
||||
|
||||
if is_selected || block {
|
||||
let start_offset = self.start_of_line_of_selection(window, cx);
|
||||
let mut offset = start_offset;
|
||||
|
||||
let selected_text = self
|
||||
.text_for_range(
|
||||
self.range_to_utf16(&(offset..selected_range.end)),
|
||||
&mut None,
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
.unwrap_or("".into());
|
||||
|
||||
for line in selected_text.split('\n') {
|
||||
if line.starts_with(tab_indent.as_ref()) {
|
||||
self.replace_text_in_range_silent(
|
||||
Some(self.range_to_utf16(&(offset..offset + tab_indent.len()))),
|
||||
"",
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
removed_len += tab_indent.len();
|
||||
|
||||
// +1 for "\n"
|
||||
offset += line.len().saturating_sub(tab_indent.len()) + 1;
|
||||
} else {
|
||||
offset += line.len() + 1;
|
||||
}
|
||||
}
|
||||
|
||||
if is_selected {
|
||||
self.selected_range =
|
||||
(start_offset..selected_range.end.saturating_sub(removed_len)).into();
|
||||
} else {
|
||||
self.selected_range = (selected_range.start.saturating_sub(removed_len)
|
||||
..selected_range.end.saturating_sub(removed_len))
|
||||
.into();
|
||||
}
|
||||
} else {
|
||||
// Selected none
|
||||
let start_offset = self.selected_range.start;
|
||||
let offset = self.start_of_line_of_selection(window, cx);
|
||||
let offset = self.offset_from_utf16(self.offset_to_utf16(offset));
|
||||
// FIXME: To improve performance
|
||||
if self
|
||||
.text
|
||||
.slice(offset..self.text.len())
|
||||
.to_string()
|
||||
.starts_with(tab_indent.as_ref())
|
||||
{
|
||||
self.replace_text_in_range_silent(
|
||||
Some(self.range_to_utf16(&(offset..offset + tab_indent.len()))),
|
||||
"",
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
removed_len = tab_indent.len();
|
||||
let new_offset = start_offset.saturating_sub(removed_len);
|
||||
self.selected_range = (new_offset..new_offset).into();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use ropey::RopeSlice;
|
||||
|
||||
use super::TabSize;
|
||||
|
||||
#[test]
|
||||
fn test_tab_size() {
|
||||
let tab = TabSize {
|
||||
tab_size: 2,
|
||||
hard_tabs: false,
|
||||
};
|
||||
assert_eq!(tab.to_string(), " ");
|
||||
let tab = TabSize {
|
||||
tab_size: 4,
|
||||
hard_tabs: false,
|
||||
};
|
||||
assert_eq!(tab.to_string(), " ");
|
||||
|
||||
let tab = TabSize {
|
||||
tab_size: 2,
|
||||
hard_tabs: true,
|
||||
};
|
||||
assert_eq!(tab.to_string(), "\t");
|
||||
let tab = TabSize {
|
||||
tab_size: 4,
|
||||
hard_tabs: true,
|
||||
};
|
||||
assert_eq!(tab.to_string(), "\t");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_tab_size_indent_count() {
|
||||
let tab = TabSize {
|
||||
tab_size: 4,
|
||||
hard_tabs: false,
|
||||
};
|
||||
assert_eq!(tab.indent_count(&RopeSlice::from("abc")), 0);
|
||||
assert_eq!(tab.indent_count(&RopeSlice::from(" abc")), 2);
|
||||
assert_eq!(tab.indent_count(&RopeSlice::from(" abc")), 4);
|
||||
assert_eq!(tab.indent_count(&RopeSlice::from("\tabc")), 4);
|
||||
assert_eq!(tab.indent_count(&RopeSlice::from(" \tabc")), 6);
|
||||
assert_eq!(tab.indent_count(&RopeSlice::from(" \t abc ")), 6);
|
||||
assert_eq!(tab.indent_count(&RopeSlice::from("abc")), 0);
|
||||
}
|
||||
}
|
||||
@@ -1,19 +1,30 @@
|
||||
use gpui::prelude::FluentBuilder as _;
|
||||
use gpui::{
|
||||
div, px, relative, AnyElement, App, DefiniteLength, Entity, InteractiveElement as _,
|
||||
AnyElement, App, DefiniteLength, Edges, EdgesRefinement, Entity, Hsla, InteractiveElement as _,
|
||||
IntoElement, MouseButton, ParentElement as _, Rems, RenderOnce, StyleRefinement, Styled,
|
||||
Window,
|
||||
TextAlign, Window, div, px, relative,
|
||||
};
|
||||
use theme::ActiveTheme;
|
||||
|
||||
use super::clear_button::clear_button;
|
||||
use super::state::{InputState, CONTEXT};
|
||||
use crate::button::{Button, ButtonVariants};
|
||||
use super::InputState;
|
||||
use super::element::EditorScrollbar;
|
||||
use crate::button::{Button, ButtonVariants as _};
|
||||
use crate::indicator::Indicator;
|
||||
use crate::{h_flex, IconName, Sizable, Size, StyleSized, StyledExt};
|
||||
use crate::input::clear_button;
|
||||
use crate::{IconName, Selectable, Sizable, Size, StyleSized, StyledExt, h_flex, v_flex};
|
||||
|
||||
/// Returns `(background, foreground)` colors for input-like components.
|
||||
pub(crate) fn input_style(disabled: bool, cx: &App) -> (Hsla, Hsla) {
|
||||
if disabled {
|
||||
(cx.theme().surface_background, cx.theme().text_muted)
|
||||
} else {
|
||||
(cx.theme().elevated_surface_background, cx.theme().text)
|
||||
}
|
||||
}
|
||||
|
||||
/// A text input element bind to an [`InputState`].
|
||||
#[derive(IntoElement)]
|
||||
pub struct TextInput {
|
||||
pub struct Input {
|
||||
state: Entity<InputState>,
|
||||
style: StyleRefinement,
|
||||
size: Size,
|
||||
@@ -26,17 +37,30 @@ pub struct TextInput {
|
||||
disabled: bool,
|
||||
bordered: bool,
|
||||
focus_bordered: bool,
|
||||
tab_index: isize,
|
||||
selected: bool,
|
||||
}
|
||||
|
||||
impl Sizable for TextInput {
|
||||
impl Sizable for Input {
|
||||
fn with_size(mut self, size: impl Into<Size>) -> Self {
|
||||
self.size = size.into();
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl TextInput {
|
||||
/// Create a new [`TextInput`] element bind to the [`InputState`].
|
||||
impl Selectable for Input {
|
||||
fn selected(mut self, selected: bool) -> Self {
|
||||
self.selected = selected;
|
||||
self
|
||||
}
|
||||
|
||||
fn is_selected(&self) -> bool {
|
||||
self.selected
|
||||
}
|
||||
}
|
||||
|
||||
impl Input {
|
||||
/// Create a new [`Input`] element bind to the [`InputState`].
|
||||
pub fn new(state: &Entity<InputState>) -> Self {
|
||||
Self {
|
||||
state: state.clone(),
|
||||
@@ -51,6 +75,8 @@ impl TextInput {
|
||||
disabled: false,
|
||||
bordered: true,
|
||||
focus_bordered: true,
|
||||
tab_index: 0,
|
||||
selected: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -94,9 +120,9 @@ impl TextInput {
|
||||
self
|
||||
}
|
||||
|
||||
/// Set true to show the clear button when the input field is not empty.
|
||||
pub fn cleanable(mut self) -> Self {
|
||||
self.cleanable = true;
|
||||
/// Set whether to show the clear button when the input field is not empty, default is false.
|
||||
pub fn cleanable(mut self, cleanable: bool) -> Self {
|
||||
self.cleanable = cleanable;
|
||||
self
|
||||
}
|
||||
|
||||
@@ -112,79 +138,117 @@ impl TextInput {
|
||||
self
|
||||
}
|
||||
|
||||
fn render_toggle_mask_button(state: Entity<InputState>) -> impl IntoElement {
|
||||
/// Set the tab index for the input, default is 0.
|
||||
pub fn tab_index(mut self, index: isize) -> Self {
|
||||
self.tab_index = index;
|
||||
self
|
||||
}
|
||||
|
||||
fn render_toggle_mask_button(state: &Entity<InputState>, cx: &App) -> impl IntoElement {
|
||||
let _masked = state.read(cx).masked;
|
||||
Button::new("toggle-mask")
|
||||
.icon(IconName::Eye)
|
||||
.xsmall()
|
||||
.ghost()
|
||||
.on_mouse_down(MouseButton::Left, {
|
||||
.tab_stop(false)
|
||||
.on_click({
|
||||
let state = state.clone();
|
||||
move |_, window, cx| {
|
||||
state.update(cx, |state, cx| {
|
||||
state.set_masked(false, window, cx);
|
||||
})
|
||||
}
|
||||
})
|
||||
.on_mouse_up(MouseButton::Left, {
|
||||
let state = state.clone();
|
||||
move |_, window, cx| {
|
||||
state.update(cx, |state, cx| {
|
||||
state.set_masked(true, window, cx);
|
||||
state.set_masked(!state.masked, window, cx);
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// This method must after the refine_style.
|
||||
fn render_editor(
|
||||
paddings: EdgesRefinement<DefiniteLength>,
|
||||
input_state: &Entity<InputState>,
|
||||
state: &InputState,
|
||||
window: &Window,
|
||||
) -> impl IntoElement {
|
||||
let base_size = window.text_style().font_size;
|
||||
let rem_size = window.rem_size();
|
||||
|
||||
let paddings = Edges {
|
||||
left: paddings
|
||||
.left
|
||||
.map(|v| v.to_pixels(base_size, rem_size))
|
||||
.unwrap_or(px(0.)),
|
||||
right: paddings
|
||||
.right
|
||||
.map(|v| v.to_pixels(base_size, rem_size))
|
||||
.unwrap_or(px(0.)),
|
||||
top: paddings
|
||||
.top
|
||||
.map(|v| v.to_pixels(base_size, rem_size))
|
||||
.unwrap_or(px(0.)),
|
||||
bottom: paddings
|
||||
.bottom
|
||||
.map(|v| v.to_pixels(base_size, rem_size))
|
||||
.unwrap_or(px(0.)),
|
||||
};
|
||||
|
||||
state.editor_scrollbar_paddings.set(paddings);
|
||||
state.editor_scrollbar_snapshot.set(None);
|
||||
|
||||
v_flex().size_full().child(
|
||||
div()
|
||||
.relative()
|
||||
.flex_1()
|
||||
.child(input_state.clone())
|
||||
.child(EditorScrollbar::new(input_state.clone())),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl Styled for TextInput {
|
||||
impl Styled for Input {
|
||||
fn style(&mut self) -> &mut StyleRefinement {
|
||||
&mut self.style
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for TextInput {
|
||||
impl RenderOnce for Input {
|
||||
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
const LINE_HEIGHT: Rems = Rems(1.25);
|
||||
let text_align = self.style.text.text_align.unwrap_or(TextAlign::Left);
|
||||
|
||||
let font = window.text_style().font();
|
||||
let font_size = window.text_style().font_size.to_pixels(window.rem_size());
|
||||
|
||||
self.state.update(cx, |state, cx| {
|
||||
state.text_wrapper.set_font(font, font_size, cx);
|
||||
state.text_wrapper.prepare_if_need(&state.text, cx);
|
||||
self.state.update(cx, |state, _| {
|
||||
state.disabled = self.disabled;
|
||||
state.size = self.size;
|
||||
// Only for single line mode
|
||||
if state.mode.is_single_line() {
|
||||
state.text_align = text_align;
|
||||
}
|
||||
});
|
||||
|
||||
let state = self.state.read(cx);
|
||||
let focused = state.focus_handle.is_focused(window) && !state.disabled;
|
||||
let _focused = state.focus_handle.is_focused(window) && !state.disabled;
|
||||
|
||||
let gap_x = match self.size {
|
||||
Size::Small => px(4.),
|
||||
Size::Large => px(8.),
|
||||
_ => px(4.),
|
||||
_ => px(6.),
|
||||
};
|
||||
|
||||
let bg = if state.disabled {
|
||||
cx.theme().surface_background
|
||||
} else {
|
||||
cx.theme().elevated_surface_background
|
||||
};
|
||||
let (bg, _) = input_style(state.disabled, cx);
|
||||
|
||||
let prefix = self.prefix;
|
||||
let suffix = self.suffix;
|
||||
|
||||
let show_clear_button = self.cleanable
|
||||
&& !state.disabled
|
||||
&& !state.loading
|
||||
&& !state.text.is_empty()
|
||||
&& state.text.len() > 0
|
||||
&& state.mode.is_single_line();
|
||||
|
||||
let has_suffix = suffix.is_some() || state.loading || self.mask_toggle || show_clear_button;
|
||||
|
||||
div()
|
||||
.id(("input", self.state.entity_id()))
|
||||
.flex()
|
||||
.key_context(CONTEXT)
|
||||
.track_focus(&state.focus_handle)
|
||||
.key_context(crate::input::CONTEXT)
|
||||
.track_focus(&state.focus_handle.clone())
|
||||
.tab_index(self.tab_index)
|
||||
.when(!state.disabled, |this| {
|
||||
this.on_action(window.listener_for(&self.state, InputState::backspace))
|
||||
.on_action(window.listener_for(&self.state, InputState::delete))
|
||||
@@ -205,9 +269,6 @@ impl RenderOnce for TextInput {
|
||||
.on_action(window.listener_for(&self.state, InputState::outdent_inline))
|
||||
.on_action(window.listener_for(&self.state, InputState::indent_block))
|
||||
.on_action(window.listener_for(&self.state, InputState::outdent_block))
|
||||
.on_action(
|
||||
window.listener_for(&self.state, InputState::shift_to_new_line),
|
||||
)
|
||||
})
|
||||
})
|
||||
.on_action(window.listener_for(&self.state, InputState::left))
|
||||
@@ -260,8 +321,8 @@ impl RenderOnce for TextInput {
|
||||
.input_px(self.size)
|
||||
.input_py(self.size)
|
||||
.input_h(self.size)
|
||||
.cursor_text()
|
||||
.text_size(font_size)
|
||||
.input_font_size(self.size)
|
||||
.when(!self.disabled, |this| this.cursor_text())
|
||||
.items_center()
|
||||
.when(state.mode.is_multi_line(), |this| {
|
||||
this.h_auto()
|
||||
@@ -269,33 +330,34 @@ impl RenderOnce for TextInput {
|
||||
})
|
||||
.when(self.appearance, |this| {
|
||||
this.bg(bg)
|
||||
.when(self.disabled, |this| this.opacity(0.5))
|
||||
.rounded(cx.theme().radius)
|
||||
.when(self.bordered, |this| {
|
||||
this.border_color(cx.theme().border)
|
||||
.border_1()
|
||||
.when(cx.theme().shadow, |this| this.shadow_xs())
|
||||
.when(focused && self.focus_bordered, |this| {
|
||||
this.border_color(cx.theme().border_focused)
|
||||
})
|
||||
})
|
||||
})
|
||||
.items_center()
|
||||
.gap(gap_x)
|
||||
.refine_style(&self.style)
|
||||
.children(prefix)
|
||||
.child(self.state.clone())
|
||||
.when(state.mode.is_multi_line(), |mut this| {
|
||||
let paddings = this.style().padding.clone();
|
||||
this.child(Self::render_editor(paddings, &self.state, state, window))
|
||||
})
|
||||
.when(!state.mode.is_multi_line(), |this| {
|
||||
this.child(self.state.clone())
|
||||
})
|
||||
.when(has_suffix, |this| {
|
||||
this.pr_2().child(
|
||||
h_flex()
|
||||
.id("suffix")
|
||||
.gap(gap_x)
|
||||
.when(self.appearance, |this| this.bg(bg))
|
||||
.items_center()
|
||||
.when(state.loading, |this| {
|
||||
this.child(Indicator::new().color(cx.theme().text_muted))
|
||||
})
|
||||
.when(state.loading, |this| this.child(Indicator::new()))
|
||||
.when(self.mask_toggle, |this| {
|
||||
this.child(Self::render_toggle_mask_button(self.state.clone()))
|
||||
this.child(Self::render_toggle_mask_button(&self.state, cx))
|
||||
})
|
||||
.when(show_clear_button, |this| {
|
||||
this.child(clear_button(cx).on_click({
|
||||
@@ -303,6 +365,7 @@ impl RenderOnce for TextInput {
|
||||
move |_, window, cx| {
|
||||
state.update(cx, |state, cx| {
|
||||
state.clean(window, cx);
|
||||
state.focus(window, cx);
|
||||
})
|
||||
}
|
||||
}))
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user