54 Commits

Author SHA1 Message Date
a8bdd49072 . 2026-07-27 17:43:31 +07:00
fd32b8c0ce . 2026-07-27 17:22:50 +07:00
8d4d8184e6 . 2026-07-27 16:47:32 +07:00
e85974497b fix 2026-07-27 16:29:25 +07:00
da7167013f . 2026-07-27 16:07:25 +07:00
2ff83079b8 . 2026-07-27 15:02:50 +07:00
b349656c56 . 2026-07-27 10:46:10 +07:00
8bbb472103 optimize 2026-07-27 10:14:19 +07:00
dfda7ff157 clean up 2026-07-27 09:25:38 +07:00
f46f15e10c update 2026-07-27 09:23:26 +07:00
2f15615d7b clean up input component 2026-07-27 08:38:25 +07:00
9addbd49f4 fix web build 2026-07-27 08:09:55 +07:00
b518c729f6 feat: add initial support web via wasm (#35)
Reviewed-on: #35
2026-07-25 03:43:51 +00:00
4b57a1d2a6 chore: update deps 2026-07-16 10:43:16 +07:00
5ea6cdb34f chore: prepare for rc version (#34)
**TODOs:**

- [x] Fix all clippy issues
- [x] Make NIP-4e optional (disabled by default)
- [x] Remove support for bunker (Nostr Connect)
- [x] Group messages in the same timeframe
- [ ] ...

Reviewed-on: #34
2026-06-19 09:16:37 +00:00
1f04a824d7 chore: release version 1.0.0-beta5 2026-06-03 20:20:30 +07:00
c78e0a5163 fix: chat input crashing when moving the cursor (#33)
Reviewed-on: #33
2026-06-03 13:18:03 +00:00
5d4c8634ef chore: update gpui 2026-06-01 15:28:49 +07:00
4efeec08c4 chore: connect to search relays only when needed 2026-06-01 15:07:11 +07:00
2e451aae12 chore: fix ci and clean up 2026-05-30 07:51:04 +07:00
cdfcfdd782 chore: release version 1.0.0-beta4 2026-05-28 09:02:17 +07:00
9817dd29a6 nip4e: remove app name in the client tag 2026-05-22 07:52:49 +07:00
f066cb8223 chore: force gossip ignore neg sync 2026-05-20 18:03:46 +07:00
6d60726f27 chore: bump gpui 2026-04-25 07:01:14 +07:00
80186a79e5 chore: update deps and remove mobile crate 2026-04-17 07:41:52 +07:00
reya
cffcb4711d Merge pull request 'feat: add k tag to gift wraps and process only chat message rumors' (#30) from only-nip17 into master
Reviewed-on: #30
2026-04-10 02:41:20 +00:00
Ren Amamiya
44484d9992 . 2026-04-10 09:41:05 +07:00
Ren Amamiya
15ac8d6775 add k tag to gift wrap event 2026-04-10 09:34:24 +07:00
Ren Amamiya
6f0cefed33 only process nip17 rumor 2026-04-10 09:32:04 +07:00
Ren Amamiya
c239e351b8 feat: add support for rendering images in chat messages (#29)
Reviewed-on: #29
Co-authored-by: Ren Amamiya <reya@lume.nu>
Co-committed-by: Ren Amamiya <reya@lume.nu>
2026-04-10 02:00:18 +00:00
Ren Amamiya
9ff18aae35 chore: restruture 2026-04-07 11:46:08 +07:00
Ren Amamiya
6fef2ae1c6 chore: release version 1.0.0-beta3 2026-04-06 06:53:05 +07:00
Ren Amamiya
c2a723faa8 chore: prepare mobile(android/ios) target 2026-04-04 09:32:41 +07:00
reya
6b872527ad chore: simplify codebase and prepare for multi-platforms (#28)
Reviewed-on: #28
2026-04-04 02:22:08 +00:00
Ren Amamiya
d9b16aea9a feat: add tracking for which signer encrypted the message (#27)
Reviewed-on: #27
Co-authored-by: Ren Amamiya <reya@lume.nu>
Co-committed-by: Ren Amamiya <reya@lume.nu>
2026-04-01 02:53:49 +00:00
Ren Amamiya
8345def015 feat: (re)add tracking for where messages have been seen (#26)
Reviewed-on: #26
Co-authored-by: Ren Amamiya <reya@lume.nu>
Co-committed-by: Ren Amamiya <reya@lume.nu>
2026-03-31 05:26:04 +00:00
Ren Amamiya
b0ba2549d7 chore: prepare for web target 2026-03-31 08:45:35 +07:00
Ren Amamiya
c8034642c5 chore: fix issue where setting theme mode doesn't work 2026-03-31 08:13:10 +07:00
Ren Amamiya
8205c69e19 feat: add error log panel (#25)
Reviewed-on: #25
Co-authored-by: Ren Amamiya <reya@lume.nu>
Co-committed-by: Ren Amamiya <reya@lume.nu>
2026-03-30 07:56:28 +00:00
Ren Amamiya
d36364d60d chore: update nostr sdk 2026-03-25 13:52:21 +07:00
Ren Amamiya
99363475e0 chore: fix prepare flathub script 2026-03-20 13:22:58 +07:00
Ren Amamiya
a52e1877fe chore: add prepare flathub script (#24)
Reviewed-on: #24
Co-authored-by: Ren Amamiya <reya@lume.nu>
Co-committed-by: Ren Amamiya <reya@lume.nu>
2026-03-20 06:20:06 +00:00
Ren Amamiya
b41de00c95 chore: release version 1.0.0-beta2 2026-03-18 15:22:04 +07:00
Ren Amamiya
94cbb4aa0e chore: fix some gossip and nip4e bugs (#23)
Reviewed-on: #23
Co-authored-by: Ren Amamiya <reya@lume.nu>
Co-committed-by: Ren Amamiya <reya@lume.nu>
2026-03-18 08:21:39 +00:00
Ren Amamiya
40e7ca368b feat: add backup/restore for NIP-4e encryption key (#22)
Reviewed-on: #22
Co-authored-by: Ren Amamiya <reya@lume.nu>
Co-committed-by: Ren Amamiya <reya@lume.nu>
2026-03-17 07:42:25 +00:00
Ren Amamiya
b91697defc feat: add relay tracking for gift wrap events (#21)
Reviewed-on: #21
Co-authored-by: Ren Amamiya <reya@lume.nu>
Co-committed-by: Ren Amamiya <reya@lume.nu>
2026-03-14 08:18:19 +00:00
Ren Amamiya
1d57a2deab chore: bump edition from 2021 to 2024 2026-03-13 09:13:04 +07:00
Ren Amamiya
aa26c9ccba chore: release version 1.0.0-beta1 2026-03-13 08:44:55 +07:00
Ren Amamiya
ff61c28a76 chore: update deps 2026-03-13 08:38:48 +07:00
Ren Amamiya
069eae8145 feat: refactor send report (#20)
Reviewed-on: #20
Co-authored-by: Ren Amamiya <reya@lume.nu>
Co-committed-by: Ren Amamiya <reya@lume.nu>
2026-03-12 10:06:13 +00:00
Ren Amamiya
ccbcc644db chore: make the ui consistent (#19)
Reviewed-on: #19
Co-authored-by: Ren Amamiya <reya@lume.nu>
Co-committed-by: Ren Amamiya <reya@lume.nu>
2026-03-12 02:19:59 +00:00
Ren Amamiya
15c5ce7677 chore: remove ai stuffs 2026-03-10 17:28:51 +07:00
Ren Amamiya
40d726c986 feat: refactor to use gpui event instead of local state (#18)
Reviewed-on: #18
Co-authored-by: Ren Amamiya <reya@lume.nu>
Co-committed-by: Ren Amamiya <reya@lume.nu>
2026-03-10 08:19:02 +00:00
Ren Amamiya
fe4eb7df74 chore: re-add missing actions (#17)
Added:

- [x] Chage subject
- [x] Copy public key
- [x] View user's messaging relays
- [x] View user profile on njump

Reviewed-on: #17
Co-authored-by: Ren Amamiya <reya@lume.nu>
Co-committed-by: Ren Amamiya <reya@lume.nu>
2026-03-06 08:25:31 +00:00
166 changed files with 13324 additions and 10593 deletions

View File

@@ -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
@@ -154,13 +154,13 @@ 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/**/*

3
.gitignore vendored
View File

@@ -21,3 +21,6 @@ dist/
.DS_Store
# Added by goreleaser init:
.intentionally-empty-file.o
.cargo/
vendor/

3880
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,17 +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 = ["font-kit", "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" }
@@ -20,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"
@@ -33,14 +35,19 @@ 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 }
[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

View File

@@ -1,12 +1,12 @@
![Coop](/docs/coop.png)
<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/input.svg Normal file
View 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

144
assets/themes/aurora.json Normal file
View 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"
}
}

View File

@@ -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"
}
}

View File

@@ -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"
}
}

View File

@@ -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"
}
}

View File

@@ -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"
}
}

View File

@@ -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
View 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
View 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"
}
}

View File

@@ -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"
}
}

View File

@@ -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"
}
}

View File

@@ -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"
}
}

View File

@@ -8,9 +8,7 @@ publish.workspace = true
common = { path = "../common" }
gpui.workspace = true
gpui_tokio.workspace = true
anyhow.workspace = true
smol.workspace = true
log.workspace = true
smallvec.workspace = true
serde = { workspace = true, features = ["derive"] }
@@ -18,4 +16,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

View File

@@ -1,3 +1,5 @@
#![cfg(not(target_arch = "wasm32"))]
use std::ffi::OsString;
use std::path::{Path, PathBuf};
use std::sync::Arc;

View File

@@ -17,11 +17,11 @@ nostr-sdk.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

View File

@@ -1,9 +1,110 @@
use std::hash::Hash;
use std::ops::Range;
use common::{EventUtils, NostrParser};
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 {
@@ -24,74 +125,25 @@ 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))
}
}
#[derive(Debug, Clone)]
pub struct Mention {
pub public_key: PublicKey,
@@ -104,98 +156,6 @@ impl Mention {
}
}
/// 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<Mention>,
/// List of event of the message this message is a reply to
pub replies_to: Vec<EventId>,
}
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);
}
}
/// Extracts all mentions (public keys) from a content string.
fn extract_mentions(content: &str) -> Vec<Mention> {
let parser = NostrParser::new();
@@ -214,13 +174,13 @@ fn extract_mentions(content: &str) -> Vec<Mention> {
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);
}

View File

@@ -1,16 +1,16 @@
use std::cmp::Ordering;
use std::collections::HashMap;
use std::hash::{Hash, Hasher};
use std::time::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();
// Get room's id
let id = self.id;
// Get all members, excluding the sender
let members: Vec<PublicKey> = self
.members
.iter()
.filter(|public_key| Some(**public_key) != sender)
.copied()
.collect();
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(&current_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)
}

View File

@@ -14,19 +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"

View File

@@ -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

View File

@@ -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.",
)),
)
}
}

View File

@@ -69,6 +69,7 @@ impl RenderedText {
pub fn element(&self, id: ElementId, window: &Window, cx: &App) -> AnyElement {
let code_background = cx.theme().elevated_surface_background;
let color = cx.theme().text_accent;
InteractiveText::new(
id,
@@ -100,6 +101,7 @@ impl RenderedText {
}
}
Highlight::Mention => HighlightStyle {
color: Some(color),
underline: Some(UnderlineStyle {
thickness: 1.0.into(),
..Default::default()

View File

@@ -9,14 +9,11 @@ gpui.workspace = true
nostr.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"
bech32 = "0.11.1"
regex = "1.10"

View 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
}
}

View File

@@ -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();

View File

@@ -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()
}

View File

@@ -1,13 +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;

View 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)
}

View File

@@ -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"))
}

View File

@@ -1,71 +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", "env-filter"] }
[target.'cfg(target_os = "macos")'.dependencies]
# Temporary workaround https://github.com/zed-industries/zed/issues/47168
core-text = "=21.0.0"

View File

@@ -1,256 +0,0 @@
use anyhow::Error;
use gpui::prelude::FluentBuilder;
use gpui::{
div, px, App, AppContext, Context, Entity, InteractiveElement, IntoElement, ParentElement,
Render, SharedString, StatefulInteractiveElement, Styled, Subscription, Task, Window,
};
use nostr_sdk::prelude::*;
use person::PersonRegistry;
use state::{NostrRegistry, SignerEvent};
use theme::ActiveTheme;
use ui::avatar::Avatar;
use ui::button::{Button, ButtonVariants};
use ui::indicator::Indicator;
use ui::{h_flex, v_flex, Disableable, Icon, IconName, Sizable, WindowExtension};
use crate::dialogs::connect::ConnectSigner;
use crate::dialogs::import::ImportKey;
pub fn init(window: &mut Window, cx: &mut App) -> Entity<AccountSelector> {
cx.new(|cx| AccountSelector::new(window, cx))
}
/// Account selector
pub struct AccountSelector {
/// Public key currently being chosen for login
logging_in: Entity<Option<PublicKey>>,
/// The error message displayed when an error occurs.
error: Entity<Option<SharedString>>,
/// Async tasks
tasks: Vec<Task<Result<(), Error>>>,
/// Subscription to the signer events
_subscription: Option<Subscription>,
}
impl AccountSelector {
pub fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
let logging_in = cx.new(|_| None);
let error = cx.new(|_| None);
// Subscribe to the signer events
let nostr = NostrRegistry::global(cx);
let subscription = cx.subscribe_in(&nostr, window, |this, _state, event, window, cx| {
match event {
SignerEvent::Set => {
window.close_all_modals(cx);
window.refresh();
}
SignerEvent::Error(e) => {
this.set_error(e.to_string(), cx);
}
};
});
Self {
logging_in,
error,
tasks: vec![],
_subscription: Some(subscription),
}
}
fn logging_in(&self, public_key: &PublicKey, cx: &App) -> bool {
self.logging_in.read(cx) == &Some(*public_key)
}
fn set_logging_in(&mut self, public_key: PublicKey, cx: &mut Context<Self>) {
self.logging_in.update(cx, |this, cx| {
*this = Some(public_key);
cx.notify();
});
}
fn set_error<T>(&mut self, error: T, cx: &mut Context<Self>)
where
T: Into<SharedString>,
{
self.error.update(cx, |this, cx| {
*this = Some(error.into());
cx.notify();
});
self.logging_in.update(cx, |this, cx| {
*this = None;
cx.notify();
})
}
fn login(&mut self, public_key: PublicKey, window: &mut Window, cx: &mut Context<Self>) {
let nostr = NostrRegistry::global(cx);
let task = nostr.read(cx).get_signer(&public_key, cx);
// Mark the public key as being logged in
self.set_logging_in(public_key, cx);
self.tasks.push(cx.spawn_in(window, async move |this, cx| {
match task.await {
Ok(signer) => {
nostr.update(cx, |this, cx| {
this.set_signer(signer, cx);
});
}
Err(e) => {
this.update(cx, |this, cx| {
this.set_error(e.to_string(), cx);
})?;
}
};
Ok(())
}));
}
fn remove(&mut self, public_key: PublicKey, cx: &mut Context<Self>) {
let nostr = NostrRegistry::global(cx);
nostr.update(cx, |this, cx| {
this.remove_signer(&public_key, cx);
});
}
fn open_import(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let import = cx.new(|cx| ImportKey::new(window, cx));
window.open_modal(cx, move |this, _window, _cx| {
this.width(px(460.))
.title("Import a Secret Key or Bunker Connection")
.show_close(true)
.pb_2()
.child(import.clone())
});
}
fn open_connect(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let connect = cx.new(|cx| ConnectSigner::new(window, cx));
window.open_modal(cx, move |this, _window, _cx| {
this.width(px(460.))
.title("Scan QR Code to Connect")
.show_close(true)
.pb_2()
.child(connect.clone())
});
}
}
impl Render for AccountSelector {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let persons = PersonRegistry::global(cx);
let nostr = NostrRegistry::global(cx);
let npubs = nostr.read(cx).npubs();
let loading = self.logging_in.read(cx).is_some();
v_flex()
.size_full()
.gap_2()
.when_some(self.error.read(cx).as_ref(), |this, error| {
this.child(
div()
.italic()
.text_xs()
.text_center()
.text_color(cx.theme().danger_active)
.child(error.clone()),
)
})
.children({
let mut items = vec![];
for (ix, public_key) in npubs.read(cx).iter().enumerate() {
let profile = persons.read(cx).get(public_key, cx);
let logging_in = self.logging_in(public_key, cx);
items.push(
h_flex()
.id(ix)
.group("")
.px_2()
.h_10()
.justify_between()
.w_full()
.rounded(cx.theme().radius)
.bg(cx.theme().ghost_element_background)
.hover(|this| this.bg(cx.theme().ghost_element_hover))
.child(
h_flex()
.gap_2()
.child(Avatar::new(profile.avatar()).small())
.child(div().text_sm().child(profile.name())),
)
.when(logging_in, |this| this.child(Indicator::new().small()))
.when(!logging_in, |this| {
this.child(
h_flex()
.gap_1()
.invisible()
.group_hover("", |this| this.visible())
.child(
Button::new(format!("del-{ix}"))
.icon(IconName::Close)
.ghost()
.small()
.disabled(logging_in)
.on_click(cx.listener({
let public_key = *public_key;
move |this, _ev, _window, cx| {
cx.stop_propagation();
this.remove(public_key, cx);
}
})),
),
)
})
.when(!logging_in, |this| {
let public_key = *public_key;
this.on_click(cx.listener(move |this, _ev, window, cx| {
this.login(public_key, window, cx);
}))
}),
);
}
items
})
.child(div().w_full().h_px().bg(cx.theme().border_variant))
.child(
h_flex()
.gap_1()
.justify_end()
.w_full()
.child(
Button::new("input")
.icon(Icon::new(IconName::Usb))
.label("Import")
.ghost()
.small()
.disabled(loading)
.on_click(cx.listener(move |this, _ev, window, cx| {
this.open_import(window, cx);
})),
)
.child(
Button::new("qr")
.icon(Icon::new(IconName::Scan))
.label("Scan QR to connect")
.ghost()
.small()
.disabled(loading)
.on_click(cx.listener(move |this, _ev, window, cx| {
this.open_connect(window, cx);
})),
),
)
}
}

View File

@@ -1,115 +0,0 @@
use std::sync::Arc;
use std::time::Duration;
use common::TextUtils;
use gpui::prelude::FluentBuilder;
use gpui::{
div, img, px, AppContext, Context, Entity, Image, IntoElement, ParentElement, Render,
SharedString, Styled, Subscription, Window,
};
use nostr_connect::prelude::*;
use state::{
CoopAuthUrlHandler, NostrRegistry, SignerEvent, CLIENT_NAME, NOSTR_CONNECT_RELAY,
NOSTR_CONNECT_TIMEOUT,
};
use theme::ActiveTheme;
use ui::v_flex;
pub struct ConnectSigner {
/// QR Code
qr_code: Option<Arc<Image>>,
/// Error message
error: Entity<Option<SharedString>>,
/// Subscription to the signer event
_subscription: Option<Subscription>,
}
impl ConnectSigner {
pub fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
let error = cx.new(|_| None);
let nostr = NostrRegistry::global(cx);
let app_keys = nostr.read(cx).app_keys.clone();
let timeout = Duration::from_secs(NOSTR_CONNECT_TIMEOUT);
let relay = RelayUrl::parse(NOSTR_CONNECT_RELAY).unwrap();
// Generate the nostr connect uri
let uri = NostrConnectUri::client(app_keys.public_key(), vec![relay], CLIENT_NAME);
// Generate the nostr connect
let mut signer = NostrConnect::new(uri.clone(), app_keys.clone(), timeout, None).unwrap();
// Handle the auth request
signer.auth_url_handler(CoopAuthUrlHandler);
// Generate a QR code for quick connection
let qr_code = uri.to_string().to_qr();
// Set signer in the background
nostr.update(cx, |this, cx| {
this.add_nip46_signer(&signer, cx);
});
// Subscribe to the signer event
let subscription = cx.subscribe_in(&nostr, window, |this, _state, event, _window, cx| {
if let SignerEvent::Error(e) = event {
this.set_error(e, cx);
}
});
Self {
qr_code,
error,
_subscription: Some(subscription),
}
}
fn set_error<S>(&mut self, message: S, cx: &mut Context<Self>)
where
S: Into<SharedString>,
{
self.error.update(cx, |this, cx| {
*this = Some(message.into());
cx.notify();
});
}
}
impl Render for ConnectSigner {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
const MSG: &str = "Scan with any Nostr Connect-compatible app to connect";
v_flex()
.size_full()
.items_center()
.justify_center()
.p_4()
.when_some(self.qr_code.as_ref(), |this, qr| {
this.child(
img(qr.clone())
.size(px(256.))
.rounded(cx.theme().radius_lg)
.border_1()
.border_color(cx.theme().border),
)
})
.when_some(self.error.read(cx).as_ref(), |this, error| {
this.child(
div()
.text_xs()
.text_center()
.text_color(cx.theme().danger_active)
.child(error.clone()),
)
})
.child(
div()
.text_xs()
.text_color(cx.theme().text_muted)
.child(SharedString::from(MSG)),
)
}
}

View File

@@ -1,6 +0,0 @@
pub mod accounts;
pub mod screening;
pub mod settings;
mod connect;
mod import;

View File

@@ -10,15 +10,14 @@ state = { path = "../state" }
person = { path = "../person" }
ui = { path = "../ui" }
theme = { path = "../theme" }
settings = { path = "../settings" }
gpui.workspace = true
nostr-sdk.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

View File

@@ -12,6 +12,6 @@ gpui.workspace = true
nostr-sdk.workspace = true
anyhow.workspace = true
smallvec.workspace = true
smol.workspace = true
flume.workspace = true
log.workspace = true
urlencoding = "2.1.3"

View File

@@ -1,21 +1,20 @@
use std::cell::RefCell;
use std::collections::{HashMap, HashSet};
use std::rc::Rc;
use std::time::Duration;
use anyhow::{anyhow, Error};
use common::EventUtils;
use gpui::{App, AppContext, Context, Entity, Global, Task};
use anyhow::{Error, anyhow};
use common::EventExt;
use gpui::{App, AppContext, Context, Entity, Global, Task, Window};
use nostr_sdk::prelude::*;
use smallvec::{smallvec, SmallVec};
use state::{Announcement, NostrRegistry, BOOTSTRAP_RELAYS, TIMEOUT};
use smallvec::{SmallVec, smallvec};
use state::{Announcement, BOOTSTRAP_RELAYS, NostrRegistry, TIMEOUT};
mod person;
pub use person::*;
pub fn init(cx: &mut App) {
PersonRegistry::set_global(cx.new(PersonRegistry::new), cx);
pub fn init(window: &mut Window, cx: &mut App) {
PersonRegistry::set_global(cx.new(|cx| PersonRegistry::new(window, cx)), cx);
}
struct GlobalPersonRegistry(Entity<PersonRegistry>);
@@ -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: RefCell<HashSet<PublicKey>>,
/// Sender for requesting metadata
sender: flume::Sender<PublicKey>,
/// Tasks for asynchronous operations
_tasks: SmallVec<[Task<()>; 4]>,
tasks: SmallVec<[Task<()>; 4]>,
}
impl PersonRegistry {
@@ -57,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: RefCell::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.borrow_mut().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)
}

View File

@@ -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..]
)

View File

@@ -1,376 +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().elevated_surface_background)
.text_color(cx.theme().text_accent)
.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();
}
})
})
}
}

View File

@@ -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

View File

@@ -1,4 +1,3 @@
use std::collections::{HashMap, HashSet};
use std::fmt::Display;
use std::rc::Rc;
@@ -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,52 +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;
cx.notify();
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();
}
});
}
}

View File

@@ -9,21 +9,25 @@ 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
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"

View File

@@ -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"))
}

View File

@@ -1,5 +1,3 @@
use std::sync::OnceLock;
/// Client name (Application name)
pub const CLIENT_NAME: &str = "Coop";
@@ -10,11 +8,15 @@ 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;
@@ -36,24 +38,15 @@ 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; 3] = [
"wss://relay.ditto.pub",
"wss://relay.primal.net",
"wss://indexer.coracle.social",
"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})")
})
}

View File

@@ -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

View File

@@ -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 {

View File

@@ -1,40 +1,6 @@
use std::fmt::Display;
use gpui::SharedString;
use nostr_sdk::prelude::*;
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Default)]
pub enum DeviceState {
#[default]
Idle,
Requesting,
Set,
}
impl Display for DeviceState {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
DeviceState::Idle => write!(f, "Idle"),
DeviceState::Requesting => write!(f, "Wait for approval"),
DeviceState::Set => write!(f, "Encryption Key is ready"),
}
}
}
impl DeviceState {
pub fn idle(&self) -> bool {
matches!(self, DeviceState::Idle)
}
pub fn requesting(&self) -> bool {
matches!(self, DeviceState::Requesting)
}
pub fn set(&self) -> bool {
matches!(self, DeviceState::Set)
}
}
/// Announcement
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct Announcement {
@@ -50,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());

View File

@@ -1,134 +1,206 @@
use std::borrow::Cow;
use std::result::Result;
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>>>,
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),
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
///
/// Ensure to call this method after the signer has been initialized.
/// Otherwise, this method will panic.
pub fn public_key(&self) -> Option<PublicKey> {
*self.signer_pkey.read_blocking()
}
/// Switch the current signer to a new signer.
pub async fn switch<T>(&self, new: T)
/// 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;
}
/// 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(())
})
}
}

View File

@@ -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.),

View 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,
}
}
}

View File

@@ -4,6 +4,8 @@ use std::rc::Rc;
use gpui::{App, Global, Pixels, SharedString, Window, px};
mod colors;
mod geometry;
mod notification;
mod platform_kind;
mod registry;
mod scale;
@@ -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),

View 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,
}
}
}

View File

@@ -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)
}

View File

@@ -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"

View File

@@ -1,172 +0,0 @@
#[cfg(target_os = "linux")]
use gpui::MouseButton;
use gpui::prelude::FluentBuilder;
use gpui::{
AnyElement, Context, Decorations, Hsla, InteractiveElement as _, IntoElement, ParentElement,
Pixels, Render, StatefulInteractiveElement as _, Styled, Window, WindowControlArea, px,
};
use smallvec::{SmallVec, smallvec};
use theme::{ActiveTheme, CLIENT_SIDE_DECORATION_ROUNDING, PlatformKind};
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,
})
}
}

View File

@@ -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
})
}

View File

@@ -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.;

View File

@@ -1,4 +0,0 @@
#[cfg(target_os = "linux")]
pub mod linux;
pub mod mac;
pub mod windows;

View File

@@ -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}",
})
}
}

View File

@@ -9,9 +9,7 @@ common = { path = "../common" }
theme = { path = "../theme" }
gpui.workspace = true
smol.workspace = true
serde.workspace = true
serde_json.workspace = true
smallvec.workspace = true
anyhow.workspace = true
itertools.workspace = true
@@ -20,7 +18,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"

View File

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

View File

@@ -1,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 }
}
}

View File

@@ -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()

View File

@@ -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,
};

View File

@@ -3,20 +3,26 @@ use std::sync::Arc;
use gpui::prelude::FluentBuilder as _;
use gpui::{
div, px, App, AppContext, Axis, Context, Element, Entity, IntoElement, MouseMoveEvent,
MouseUpEvent, ParentElement as _, Pixels, Point, Render, Style, Styled as _, WeakEntity,
Window,
App, AppContext, Axis, Context, Element, Empty, Entity, IntoElement, MouseMoveEvent,
MouseUpEvent, ParentElement as _, Pixels, Point, Render, Style, StyleRefinement, Styled as _,
WeakEntity, Window, div, px,
};
use super::{DockArea, DockItem};
use crate::dock_area::panel::PanelView;
use crate::dock_area::tab_panel::TabPanel;
use crate::resizable::{resize_handle, PANEL_MIN_SIZE};
use crate::StyledExt;
use crate::dock::panel::PanelView;
use crate::dock::tab_panel::TabPanel;
use crate::resizable::{PANEL_MIN_SIZE, resize_handle};
#[derive(Clone, Render)]
#[derive(Clone)]
struct ResizePanel;
impl Render for ResizePanel {
fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
Empty
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DockPlacement {
Center,
@@ -265,22 +271,22 @@ impl Dock {
let mut right_dock_size = px(0.0);
// Get the size of the left dock if it's open and not the current dock
if let Some(left_dock) = &dock_area.left_dock {
if left_dock.entity_id() != cx.entity().entity_id() {
let left_dock_read = left_dock.read(cx);
if left_dock_read.is_open() {
left_dock_size = left_dock_read.size;
}
if let Some(left_dock) = &dock_area.left_dock
&& left_dock.entity_id() != cx.entity().entity_id()
{
let left_dock_read = left_dock.read(cx);
if left_dock_read.is_open() {
left_dock_size = left_dock_read.size;
}
}
// Get the size of the right dock if it's open and not the current dock
if let Some(right_dock) = &dock_area.right_dock {
if right_dock.entity_id() != cx.entity().entity_id() {
let right_dock_read = right_dock.read(cx);
if right_dock_read.is_open() {
right_dock_size = right_dock_read.size;
}
if let Some(right_dock) = &dock_area.right_dock
&& right_dock.entity_id() != cx.entity().entity_id()
{
let right_dock_read = right_dock.read(cx);
if right_dock_read.is_open() {
right_dock_size = right_dock_read.size;
}
}
@@ -321,6 +327,8 @@ impl Render for Dock {
return div();
}
let cache_style = StyleRefinement::default().absolute().size_full();
div()
.relative()
.overflow_hidden()
@@ -336,7 +344,7 @@ impl Render for Dock {
.map(|this| match &self.panel {
DockItem::Split { view, .. } => this.child(view.clone()),
DockItem::Tabs { view, .. } => this.child(view.clone()),
DockItem::Panel { view, .. } => this.child(view.clone().view()),
DockItem::Panel { view, .. } => this.child(view.clone().view().cached(cache_style)),
})
.child(self.render_resize_handle(window, cx))
.child(DockElement {

View File

@@ -2,22 +2,24 @@ use std::sync::Arc;
use gpui::prelude::FluentBuilder;
use gpui::{
actions, div, px, 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,
AnyElement, AnyView, App, AppContext, Axis, Bounds, Context, Decorations, Edges, Entity,
EntityId, EventEmitter, Focusable, InteractiveElement as _, IntoElement, ParentElement as _,
Pixels, Render, SharedString, Styled, Subscription, WeakEntity, Window, actions, div, px,
};
use theme::CLIENT_SIDE_DECORATION_ROUNDING;
use crate::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]);

View File

@@ -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.
///

View File

@@ -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() {

View File

@@ -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,10 +1101,10 @@ impl TabPanel {
window: &mut Window,
cx: &mut Context<Self>,
) {
if self.panels.len() > 1 {
if let Some(panel) = self.active_panel(cx) {
self.remove_panel(&panel, window, cx);
}
if self.panels.len() > 1
&& let Some(panel) = self.active_panel(cx)
{
self.remove_panel(&panel, window, cx);
}
}
}
@@ -1099,6 +1120,7 @@ impl Focusable for TabPanel {
}
impl EventEmitter<DismissEvent> for TabPanel {}
impl EventEmitter<PanelEvent> for TabPanel {}
impl Render for TabPanel {

View File

@@ -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,
}
}
}

View File

@@ -1,6 +1,8 @@
use std::fmt::Debug;
use std::time::{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()];

View File

@@ -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;
@@ -39,6 +39,7 @@ pub enum IconName {
Ellipsis,
Emoji,
Eye,
Input,
Info,
Invite,
Inbox,
@@ -110,6 +111,7 @@ impl IconNamed for IconName {
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",

View File

@@ -1,9 +1,14 @@
use std::time::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()
});
}
}

View File

@@ -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 {

View File

@@ -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)
}

View File

@@ -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)
}
}

View 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()
}
}

View 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;

View 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);
}
}
}
}

View 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

View 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);
}
}

View File

@@ -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);
})
}
}))

View File

@@ -225,13 +225,12 @@ impl MaskPattern {
}
// check if the fraction part is valid
if let Some(frac) = frac_part {
if !frac
if let Some(frac) = frac_part
&& !frac
.chars()
.all(|ch| ch.is_ascii_digit() || Some(ch) == *separator)
{
return false;
}
{
return false;
}
true
@@ -255,10 +254,10 @@ impl MaskPattern {
if token.is_sep() {
// If next token is match, it's valid
if let Some(next_token) = tokens.get(pos + 1) {
if next_token.is_match(ch) {
return true;
}
if let Some(next_token) = tokens.get(pos + 1)
&& next_token.is_match(ch)
{
return true;
}
}
}
@@ -320,14 +319,14 @@ impl MaskPattern {
if fraction == &Some(0) {
int_with_sep
} else {
format!("{int_with_sep}.{frac}")
format!("{}.{}", int_with_sep, frac)
}
} else {
int_with_sep
};
let final_str = if let Some(sign) = maybe_signed {
format!("{sign}{final_str}")
format!("{}{}", sign, final_str)
} else {
final_str
};

View File

@@ -1,15 +1,27 @@
pub(super) const MASK_CHAR: char = '*';
mod blink_cursor;
mod change;
mod clear_button;
mod cursor;
mod display_map;
mod element;
mod indent;
#[allow(clippy::module_inception)]
mod input;
mod mask_pattern;
mod mode;
mod movement;
mod rope_ext;
mod selection;
mod state;
mod text_input;
mod text_wrapper;
pub(crate) mod clear_button;
pub(crate) use clear_button::*;
pub use cursor::*;
pub use display_map::DisplayMap;
pub use indent::TabSize;
pub use input::*;
pub use mask_pattern::MaskPattern;
pub use rope_ext::{InputEdit, Point, RopeExt, RopeLines};
pub use ropey::Rope;
pub use state::*;
pub use text_input::*;

View File

@@ -1,42 +1,14 @@
use gpui::SharedString;
use super::display_map::DisplayMap;
use super::text_wrapper::TextWrapper;
#[derive(Debug, Copy, Clone)]
pub struct TabSize {
/// Default is 2
pub tab_size: usize,
/// Set true to use `\t` as tab indent, default is false
pub hard_tabs: bool,
}
impl Default for TabSize {
fn default() -> Self {
Self {
tab_size: 2,
hard_tabs: false,
}
}
}
impl TabSize {
pub(super) fn to_string(self) -> SharedString {
if self.hard_tabs {
"\t".into()
} else {
" ".repeat(self.tab_size).into()
}
}
}
#[derive(Default, Clone)]
pub enum InputMode {
#[default]
SingleLine,
MultiLine {
tab: TabSize,
#[derive(Clone)]
pub(crate) enum InputMode {
/// A plain text input mode.
PlainText {
multi_line: bool,
tab: crate::input::indent::TabSize,
rows: usize,
},
/// An auto grow input mode.
AutoGrow {
rows: usize,
min_rows: usize,
@@ -44,11 +16,43 @@ pub enum InputMode {
},
}
impl Default for InputMode {
fn default() -> Self {
InputMode::plain_text()
}
}
#[allow(unused)]
impl InputMode {
/// Create a plain input mode with default settings.
pub(super) fn plain_text() -> Self {
InputMode::PlainText {
multi_line: false,
tab: crate::input::indent::TabSize::default(),
rows: 1,
}
}
/// Create an auto grow input mode with given min and max rows.
pub(super) fn auto_grow(min_rows: usize, max_rows: usize) -> Self {
InputMode::AutoGrow {
rows: min_rows,
min_rows,
max_rows,
}
}
pub(super) fn multi_line(mut self, multi_line: bool) -> Self {
match &mut self {
InputMode::PlainText { multi_line: ml, .. } => *ml = multi_line,
InputMode::AutoGrow { .. } => {}
}
self
}
#[inline]
pub(super) fn is_single_line(&self) -> bool {
matches!(self, InputMode::SingleLine)
!self.is_multi_line()
}
#[inline]
@@ -58,15 +62,15 @@ impl InputMode {
#[inline]
pub(super) fn is_multi_line(&self) -> bool {
matches!(
self,
InputMode::MultiLine { .. } | InputMode::AutoGrow { .. }
)
match self {
InputMode::PlainText { multi_line, .. } => *multi_line,
InputMode::AutoGrow { max_rows, .. } => *max_rows > 1,
}
}
pub(super) fn set_rows(&mut self, new_rows: usize) {
match self {
InputMode::MultiLine { rows, .. } => {
InputMode::PlainText { rows, .. } => {
*rows = new_rows;
}
InputMode::AutoGrow {
@@ -76,25 +80,27 @@ impl InputMode {
} => {
*rows = new_rows.clamp(*min_rows, *max_rows);
}
_ => {}
}
}
pub(super) fn update_auto_grow(&mut self, text_wrapper: &TextWrapper) {
pub(super) fn update_auto_grow(&mut self, display_map: &DisplayMap) {
if self.is_single_line() {
return;
}
let wrapped_lines = text_wrapper.len();
let wrapped_lines = display_map.wrap_row_count();
self.set_rows(wrapped_lines);
}
/// At least 1 row be return.
pub(super) fn rows(&self) -> usize {
if !self.is_multi_line() {
return 1;
}
match self {
InputMode::MultiLine { rows, .. } => *rows,
InputMode::PlainText { rows, .. } => *rows,
InputMode::AutoGrow { rows, .. } => *rows,
_ => 1,
}
.max(1)
}
@@ -103,7 +109,6 @@ impl InputMode {
#[allow(unused)]
pub(super) fn min_rows(&self) -> usize {
match self {
InputMode::MultiLine { .. } => 1,
InputMode::AutoGrow { min_rows, .. } => *min_rows,
_ => 1,
}
@@ -112,18 +117,29 @@ impl InputMode {
#[allow(unused)]
pub(super) fn max_rows(&self) -> usize {
if !self.is_multi_line() {
return 1;
}
match self {
InputMode::MultiLine { .. } => usize::MAX,
InputMode::AutoGrow { max_rows, .. } => *max_rows,
_ => 1,
_ => usize::MAX,
}
}
#[inline]
pub(super) fn tab_size(&self) -> Option<&TabSize> {
pub(super) fn is_indentable(&self) -> bool {
match self {
InputMode::MultiLine { tab, .. } => Some(tab),
_ => None,
InputMode::PlainText { multi_line, .. } => *multi_line,
_ => false,
}
}
#[inline]
pub(super) fn tab_size(&self) -> crate::input::indent::TabSize {
match self {
InputMode::PlainText { tab, .. } => *tab,
_ => crate::input::indent::TabSize::default(),
}
}
}

View File

@@ -0,0 +1,264 @@
use gpui::{Context, Point, Window};
use crate::input::{
InputState, MoveDown, MoveEnd, MoveHome, MoveLeft, MovePageDown, MovePageUp, MoveRight,
MoveToEnd, MoveToNextWord, MoveToPreviousWord, MoveToStart, MoveUp, RopeExt as _,
};
#[derive(Clone, Copy, PartialEq, Eq)]
pub(crate) enum MoveDirection {
Up,
Down,
}
impl InputState {
/// Called after moving the cursor. Updates preferred_column if we know where the cursor now is.
pub(super) fn update_preferred_column(&mut self) {
let Some(last_layout) = &self.last_layout else {
self.preferred_column = None;
return;
};
let point = self.text.offset_to_point(self.cursor());
let Some(line) = last_layout.line(point.row) else {
self.preferred_column = None;
return;
};
let Some(pos) = line.position_for_index(point.column, last_layout, false) else {
self.preferred_column = None;
return;
};
self.preferred_column = Some((pos.x, point.column));
}
/// Move the cursor to the given offset.
///
/// The offset is the UTF-8 offset.
///
/// Ensure the offset use self.next_boundary or self.previous_boundary to get the correct offset.
pub(crate) fn move_to(
&mut self,
offset: usize,
direction: Option<MoveDirection>,
cx: &mut Context<Self>,
) {
let offset = offset.clamp(0, self.text.len());
self.cursor_line_end_affinity = false;
self.selected_range = (offset..offset).into();
self.scroll_to(offset, direction, cx);
self.pause_blink_cursor(cx);
self.update_preferred_column();
cx.notify()
}
/// Move the cursor vertically by one line (up or down) while preserving the column if possible.
///
/// move_lines: Number of lines to move vertically (positive for down, negative for up).
pub(super) fn move_vertical(
&mut self,
move_lines: isize,
_: &mut Window,
cx: &mut Context<Self>,
) {
if self.mode.is_single_line() {
return;
}
let Some(last_layout) = &self.last_layout else {
return;
};
let offset = self.cursor();
let was_preferred_column = self.preferred_column;
let mut display_point = self.display_map.offset_to_wrap_display_point(offset);
// Convert wrap row → display row (skips folded rows), move, then convert back
let current_display_row = self
.display_map
.wrap_row_to_display_row(display_point.row)
.unwrap_or_else(|| {
self.display_map
.nearest_visible_display_row(display_point.row)
});
let max_display_row = self.display_map.display_row_count().saturating_sub(1);
let target_display_row = current_display_row
.saturating_add_signed(move_lines)
.min(max_display_row);
let target_wrap_row = self
.display_map
.display_row_to_wrap_row(target_display_row)
.unwrap_or(display_point.row);
display_point.row = target_wrap_row;
display_point.column = 0;
let mut new_offset = self.display_map.wrap_display_point_to_offset(display_point);
if let Some((preferred_x, column)) = was_preferred_column {
// Get display point again to update local_row.
let mut next_display_point = self.display_map.offset_to_wrap_display_point(new_offset);
next_display_point.column = 0;
let next_point = self
.display_map
.wrap_display_point_to_point(next_display_point);
let line_start_offset = self.text.line_start_offset(next_point.row);
// If in visible range, prefer to use position to get column.
if let Some(line) = last_layout.line(next_point.row) {
if let Some(x) = line.closest_index_for_position(
Point {
x: preferred_x,
y: next_display_point.local_row * last_layout.line_height,
},
last_layout,
) {
new_offset = line_start_offset + x;
}
} else {
// Not in visible range, use column directly.
let max_line_len = self.text.slice_line(next_point.row).len();
new_offset = line_start_offset + column.min(max_line_len);
}
}
self.pause_blink_cursor(cx);
let direction = if move_lines < 0 {
MoveDirection::Up
} else {
MoveDirection::Down
};
self.move_to(new_offset, Some(direction), cx);
// Set back the preferred_column
self.preferred_column = was_preferred_column;
cx.notify();
}
pub(super) fn left(&mut self, _: &MoveLeft, _: &mut Window, cx: &mut Context<Self>) {
self.pause_blink_cursor(cx);
if self.selected_range.is_empty() {
self.move_to(self.previous_boundary(self.cursor()), None, cx);
} else {
self.move_to(self.selected_range.start, None, cx)
}
}
pub(super) fn right(&mut self, _: &MoveRight, _: &mut Window, cx: &mut Context<Self>) {
self.pause_blink_cursor(cx);
if self.selected_range.is_empty() {
self.move_to(self.next_boundary(self.selected_range.end), None, cx);
} else {
self.move_to(self.selected_range.end, None, cx)
}
}
pub(super) fn up(&mut self, _action: &MoveUp, window: &mut Window, cx: &mut Context<Self>) {
if self.mode.is_single_line() {
return;
}
if !self.selected_range.is_empty() {
self.move_to(
self.previous_boundary(self.selected_range.start.saturating_sub(1)),
Some(MoveDirection::Up),
cx,
);
}
self.pause_blink_cursor(cx);
self.move_vertical(-1, window, cx);
}
pub(super) fn down(&mut self, _action: &MoveDown, window: &mut Window, cx: &mut Context<Self>) {
if self.mode.is_single_line() {
return;
}
if !self.selected_range.is_empty() {
self.move_to(
self.next_boundary(self.selected_range.end.saturating_sub(1)),
Some(MoveDirection::Down),
cx,
);
}
self.pause_blink_cursor(cx);
self.move_vertical(1, window, cx);
}
pub(super) fn page_up(&mut self, _: &MovePageUp, window: &mut Window, cx: &mut Context<Self>) {
if self.mode.is_single_line() {
return;
}
let Some(last_layout) = &self.last_layout else {
return;
};
let display_lines = (self.input_bounds.size.height / last_layout.line_height) as isize;
self.move_vertical(-display_lines, window, cx);
}
pub(super) fn page_down(
&mut self,
_: &MovePageDown,
window: &mut Window,
cx: &mut Context<Self>,
) {
if self.mode.is_single_line() {
return;
}
let Some(last_layout) = &self.last_layout else {
return;
};
let display_lines = (self.input_bounds.size.height / last_layout.line_height) as isize;
self.move_vertical(display_lines, window, cx);
}
pub(super) fn home(&mut self, _: &MoveHome, _: &mut Window, cx: &mut Context<Self>) {
self.pause_blink_cursor(cx);
let offset = self.start_of_line();
self.move_to(offset, Some(MoveDirection::Up), cx);
}
pub(super) fn end(&mut self, _: &MoveEnd, _: &mut Window, cx: &mut Context<Self>) {
self.pause_blink_cursor(cx);
let offset = self.end_of_line();
self.move_to(offset, Some(MoveDirection::Down), cx);
self.cursor_line_end_affinity = true;
}
pub(super) fn move_to_start(
&mut self,
_: &MoveToStart,
_: &mut Window,
cx: &mut Context<Self>,
) {
self.move_to(0, None, cx);
}
pub(super) fn move_to_end(&mut self, _: &MoveToEnd, _: &mut Window, cx: &mut Context<Self>) {
self.move_to(self.text.len(), None, cx);
}
pub(super) fn move_to_previous_word(
&mut self,
_: &MoveToPreviousWord,
_: &mut Window,
cx: &mut Context<Self>,
) {
let offset = self.previous_start_of_word();
self.move_to(offset, None, cx);
}
pub(super) fn move_to_next_word(
&mut self,
_: &MoveToNextWord,
_: &mut Window,
cx: &mut Context<Self>,
) {
let offset = self.next_end_of_word();
self.move_to(offset, None, cx);
}
}

View File

@@ -1,70 +1,49 @@
use std::ops::Range;
use rope::{Point, Rope};
use ropey::{LineType, Rope, RopeSlice};
use sum_tree::Bias;
#[cfg(not(target_family = "wasm"))]
pub use tree_sitter::{InputEdit, Point};
use super::cursor::Position;
/// An extension trait for `Rope` to provide additional utility methods.
pub trait RopeExt {
/// Get the line at the given row (0-based) index, including the `\r` at the end, but not `\n`.
///
/// Return empty rope if the row (0-based) is out of bounds.
fn line(&self, row: usize) -> Rope;
/// Start offset of the line at the given row (0-based) index.
fn line_start_offset(&self, row: usize) -> usize;
/// Line the end offset (including `\n`) of the line at the given row (0-based) index.
///
/// Return the end of the rope if the row is out of bounds.
fn line_end_offset(&self, row: usize) -> usize;
/// Return the number of lines in the rope.
fn lines_len(&self) -> usize;
/// Return the lines iterator.
///
/// Each line is including the `\r` at the end, but not `\n`.
fn lines(&self) -> RopeLines;
/// Check is equal to another rope.
fn eq(&self, other: &Rope) -> bool;
/// Total number of characters in the rope.
fn chars_count(&self) -> usize;
/// Get char at the given offset (byte).
///
/// If the offset is in the middle of a multi-byte character will panic.
///
/// If the offset is out of bounds, return None.
fn char_at(&self, offset: usize) -> Option<char>;
/// Get the byte offset from the given line, column [`Position`] (0-based).
fn position_to_offset(&self, line_col: &Position) -> usize;
/// Get the line, column [`Position`] (0-based) from the given byte offset.
fn offset_to_position(&self, offset: usize) -> Position;
/// Get the word byte range at the given offset (byte).
#[allow(dead_code)]
fn word_range(&self, offset: usize) -> Option<Range<usize>>;
/// Get word at the given offset (byte).
#[allow(dead_code)]
fn word_at(&self, offset: usize) -> String;
#[cfg(target_family = "wasm")]
/// Stub type for tree-sitter Point on WASM (tree-sitter not available).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct Point {
pub row: usize,
pub column: usize,
}
#[cfg(target_family = "wasm")]
impl Point {
pub fn new(row: usize, column: usize) -> Self {
Self { row, column }
}
}
#[cfg(target_family = "wasm")]
/// Stub type for tree-sitter InputEdit on WASM (tree-sitter not available).
#[derive(Debug, Clone, Copy)]
pub struct InputEdit {
pub start_byte: usize,
pub old_end_byte: usize,
pub new_end_byte: usize,
pub start_position: Point,
pub old_end_position: Point,
pub new_end_position: Point,
}
pub type Position = lsp_types::Position;
/// An iterator over the lines of a `Rope`.
pub struct RopeLines {
pub struct RopeLines<'a> {
rope: &'a Rope,
row: usize,
end_row: usize,
rope: Rope,
}
impl RopeLines {
impl<'a> RopeLines<'a> {
/// Create a new `RopeLines` iterator.
pub fn new(rope: Rope) -> Self {
pub fn new(rope: &'a Rope) -> Self {
let end_row = rope.lines_len();
Self {
row: 0,
@@ -73,9 +52,8 @@ impl RopeLines {
}
}
}
impl Iterator for RopeLines {
type Item = Rope;
impl<'a> Iterator for RopeLines<'a> {
type Item = RopeSlice<'a>;
#[inline]
fn next(&mut self) -> Option<Self::Item> {
@@ -83,7 +61,7 @@ impl Iterator for RopeLines {
return None;
}
let line = self.rope.line(self.row);
let line = self.rope.slice_line(self.row);
self.row += 1;
Some(line)
}
@@ -101,23 +79,261 @@ impl Iterator for RopeLines {
}
}
impl std::iter::ExactSizeIterator for RopeLines {}
impl std::iter::FusedIterator for RopeLines {}
impl std::iter::ExactSizeIterator for RopeLines<'_> {}
impl std::iter::FusedIterator for RopeLines<'_> {}
/// An extension trait for [`Rope`] to provide additional utility methods.
pub trait RopeExt {
/// Start offset of the line at the given row (0-based) index.
///
/// # Example
///
/// ```
/// use gpui_component::{Rope, RopeExt};
///
/// let rope = Rope::from("Hello\nWorld\r\nThis is a test 中文\nRope");
/// assert_eq!(rope.line_start_offset(0), 0);
/// assert_eq!(rope.line_start_offset(1), 6);
/// ```
fn line_start_offset(&self, row: usize) -> usize;
/// Line the end offset (including `\n`) of the line at the given row (0-based) index.
///
/// Return the end of the rope if the row is out of bounds.
///
/// ```
/// use gpui_component::{Rope, RopeExt};
/// let rope = Rope::from("Hello\nWorld\r\nThis is a test 中文\nRope");
/// assert_eq!(rope.line_end_offset(0), 5); // "Hello\n"
/// assert_eq!(rope.line_end_offset(1), 12); // "World\r\n"
/// ```
fn line_end_offset(&self, row: usize) -> usize;
/// Return a line slice at the given row (0-based) index. including `\r` if present, but not `\n`.
///
/// ```
/// use gpui_component::{Rope, RopeExt};
/// let rope = Rope::from("Hello\nWorld\r\nThis is a test 中文\nRope");
/// assert_eq!(rope.slice_line(0).to_string(), "Hello");
/// assert_eq!(rope.slice_line(1).to_string(), "World\r");
/// assert_eq!(rope.slice_line(2).to_string(), "This is a test 中文");
/// assert_eq!(rope.slice_line(6).to_string(), ""); // out of bounds
/// ```
fn slice_line(&self, row: usize) -> RopeSlice<'_>;
/// Return a slice of rows in the given range (0-based, end exclusive).
///
/// If the range is out of bounds, it will be clamped to the valid range.
///
/// ```
/// use gpui_component::{Rope, RopeExt};
/// let rope = Rope::from("Hello\nWorld\r\nThis is a test 中文\nRope");
/// assert_eq!(rope.slice_lines(0..2).to_string(), "Hello\nWorld\r");
/// assert_eq!(rope.slice_lines(1..3).to_string(), "World\r\nThis is a test 中文");
/// assert_eq!(rope.slice_lines(2..5).to_string(), "This is a test 中文\nRope");
/// assert_eq!(rope.slice_lines(3..10).to_string(), "Rope");
/// assert_eq!(rope.slice_lines(5..10).to_string(), ""); // out of bounds
/// ```
fn slice_lines(&self, rows_range: Range<usize>) -> RopeSlice<'_>;
/// Return an iterator over all lines in the rope.
///
/// Each line slice includes `\r` if present, but not `\n`.
///
/// ```
/// use gpui_component::{Rope, RopeExt};
/// let rope = Rope::from("Hello\nWorld\r\nThis is a test 中文\nRope");
/// let lines: Vec<_> = rope.iter_lines().map(|r| r.to_string()).collect();
/// assert_eq!(lines, vec!["Hello", "World\r", "This is a test 中文", "Rope"]);
/// ```
fn iter_lines(&self) -> RopeLines<'_>;
/// Return the number of lines in the rope.
///
/// ```
/// use gpui_component::{Rope, RopeExt};
/// let rope = Rope::from("Hello\nWorld\r\nThis is a test 中文\nRope");
/// assert_eq!(rope.lines_len(), 4);
/// ```
fn lines_len(&self) -> usize;
/// Return the length of the row (0-based) in characters, including `\r` if present, but not `\n`.
///
/// If the row is out of bounds, return 0.
///
/// ```
/// use gpui_component::{Rope, RopeExt};
/// let rope = Rope::from("Hello\nWorld\r\nThis is a test 中文\nRope");
/// assert_eq!(rope.line_len(0), 5); // "Hello"
/// assert_eq!(rope.line_len(1), 6); // "World\r"
/// assert_eq!(rope.line_len(2), 21); // "This is a test 中文"
/// assert_eq!(rope.line_len(4), 0); // out of bounds
/// ```
fn line_len(&self, row: usize) -> usize;
/// Replace the text in the given byte range with new text.
///
/// # Panics
///
/// - If the range is not on char boundary.
/// - If the range is out of bounds.
///
/// ```
/// use gpui_component::{Rope, RopeExt};
/// let mut rope = Rope::from("Hello\nWorld\r\nThis is a test 中文\nRope");
/// rope.replace(6..11, "Universe");
/// assert_eq!(rope.to_string(), "Hello\nUniverse\r\nThis is a test 中文\nRope");
/// ```
fn replace(&mut self, range: Range<usize>, new_text: &str);
/// Get char at the given offset (byte).
///
/// - If the offset is in the middle of a multi-byte character will panic.
/// - If the offset is out of bounds, return None.
fn char_at(&self, offset: usize) -> Option<char>;
/// Get the byte offset from the given line, column [`Position`] (0-based).
///
/// The column is in characters.
fn position_to_offset(&self, line_col: &Position) -> usize;
/// Get the line, column [`Position`] (0-based) from the given byte offset.
///
/// The column is in characters.
fn offset_to_position(&self, offset: usize) -> Position;
/// Get point (row, column) from the given byte offset.
///
/// The column is in bytes.
fn offset_to_point(&self, offset: usize) -> Point;
/// Get byte offset from the given point (row, column).
///
/// The column is 0-based in bytes.
fn point_to_offset(&self, point: Point) -> usize;
/// Get the word byte range at the given byte offset (0-based).
fn word_range(&self, offset: usize) -> Option<Range<usize>>;
/// Get word at the given byte offset (0-based).
fn word_at(&self, offset: usize) -> String;
/// Convert offset in UTF-16 to byte offset (0-based).
///
/// Runs in O(log N) time.
fn offset_utf16_to_offset(&self, offset_utf16: usize) -> usize;
/// Convert byte offset (0-based) to offset in UTF-16.
///
/// Runs in O(log N) time.
fn offset_to_offset_utf16(&self, offset: usize) -> usize;
/// Get a clipped offset (avoid in a char boundary).
///
/// - If Bias::Left and inside the char boundary, return the ix - 1;
/// - If Bias::Right and in inside char boundary, return the ix + 1;
/// - Otherwise return the ix.
///
/// ```
/// use gpui_component::{Rope, RopeExt};
/// use sum_tree::Bias;
///
/// let rope = Rope::from("Hello 中文🎉 test\nRope");
/// assert_eq!(rope.clip_offset(5, Bias::Left), 5);
/// // Inside multi-byte character '中' (3 bytes)
/// assert_eq!(rope.clip_offset(7, Bias::Left), 6);
/// assert_eq!(rope.clip_offset(7, Bias::Right), 9);
/// ```
fn clip_offset(&self, offset: usize, bias: Bias) -> usize;
/// Convert offset in characters to byte offset (0-based).
///
/// Run in O(n) time.
///
/// # Example
///
/// ```
/// use gpui_component::{Rope, RopeExt};
/// let rope = Rope::from("a 中文🎉 test\nRope");
/// assert_eq!(rope.char_index_to_offset(0), 0);
/// assert_eq!(rope.char_index_to_offset(1), 1);
/// assert_eq!(rope.char_index_to_offset(3), "a 中".len());
/// assert_eq!(rope.char_index_to_offset(5), "a 中文🎉".len());
/// ```
fn char_index_to_offset(&self, char_index: usize) -> usize;
/// Convert byte offset (0-based) to offset in characters.
///
/// Run in O(n) time.
///
/// # Example
///
/// ```
/// use gpui_component::{Rope, RopeExt};
/// let rope = Rope::from("a 中文🎉 test\nRope");
/// assert_eq!(rope.offset_to_char_index(0), 0);
/// assert_eq!(rope.offset_to_char_index(1), 1);
/// assert_eq!(rope.offset_to_char_index(3), 3);
/// assert_eq!(rope.offset_to_char_index(4), 3);
/// ```
fn offset_to_char_index(&self, offset: usize) -> usize;
}
impl RopeExt for Rope {
fn line(&self, row: usize) -> Rope {
let start = self.line_start_offset(row);
let end = start + self.line_len(row as u32) as usize;
fn slice_line(&self, row: usize) -> RopeSlice<'_> {
let total_lines = self.lines_len();
if row >= total_lines {
return self.slice(0..0);
}
let line = self.line(row, LineType::LF);
if line.len() > 0 {
let line_end = line.len() - 1;
if line.is_char_boundary(line_end) && line.char(line_end) == '\n' {
return line.slice(..line_end);
}
}
line
}
fn slice_lines(&self, rows_range: Range<usize>) -> RopeSlice<'_> {
let start = self.line_start_offset(rows_range.start);
let end = self.line_end_offset(rows_range.end.saturating_sub(1));
self.slice(start..end)
}
fn iter_lines(&self) -> RopeLines<'_> {
RopeLines::new(self)
}
fn line_len(&self, row: usize) -> usize {
self.slice_line(row).len()
}
fn line_start_offset(&self, row: usize) -> usize {
let row = row as u32;
self.point_to_offset(Point::new(row, 0))
}
fn offset_to_point(&self, offset: usize) -> Point {
let offset = self.clip_offset(offset, Bias::Left);
let row = self.byte_to_line_idx(offset, LineType::LF);
let line_start = self.line_to_byte_idx(row, LineType::LF);
let column = offset.saturating_sub(line_start);
Point::new(row, column)
}
fn point_to_offset(&self, point: Point) -> usize {
if point.row >= self.lines_len() {
return self.len();
}
let line_start = self.line_to_byte_idx(point.row, LineType::LF);
line_start + point.column
}
fn position_to_offset(&self, pos: &Position) -> usize {
let line = self.line(pos.line as usize);
let line = self.slice_line(pos.line as usize);
self.line_start_offset(pos.line as usize)
+ line
.chars()
@@ -128,34 +344,22 @@ impl RopeExt for Rope {
fn offset_to_position(&self, offset: usize) -> Position {
let point = self.offset_to_point(offset);
let line = self.line(point.row as usize);
let column = line.clip_offset(point.column as usize, sum_tree::Bias::Left);
let character = line.slice(0..column).chars().count();
Position::new(point.row, character as u32)
let line = self.slice_line(point.row);
let offset = line.utf16_to_byte_idx(line.byte_to_utf16_idx(point.column));
let character = line.slice(..offset).chars().count();
Position::new(point.row as u32, character as u32)
}
fn line_end_offset(&self, row: usize) -> usize {
if row > self.max_point().row as usize {
if row > self.lines_len() {
return self.len();
}
self.line_start_offset(row) + self.line_len(row as u32) as usize
self.line_start_offset(row) + self.line_len(row)
}
fn lines_len(&self) -> usize {
self.max_point().row as usize + 1
}
fn lines(&self) -> RopeLines {
RopeLines::new(self.clone())
}
fn eq(&self, other: &Rope) -> bool {
self.summary() == other.summary()
}
fn chars_count(&self) -> usize {
self.chars().count()
self.len_lines(LineType::LF)
}
fn char_at(&self, offset: usize) -> Option<char> {
@@ -163,8 +367,7 @@ impl RopeExt for Rope {
return None;
}
let offset = self.clip_offset(offset, sum_tree::Bias::Left);
self.slice(offset..self.len()).chars().next()
self.get_char(offset).ok()
}
fn word_range(&self, offset: usize) -> Option<Range<usize>> {
@@ -172,10 +375,9 @@ impl RopeExt for Rope {
return None;
}
let offset = self.clip_offset(offset, sum_tree::Bias::Left);
let mut left = String::new();
for c in self.reversed_chars_at(offset) {
let offset = self.clip_offset(offset, Bias::Left);
for c in self.chars_at(offset).reversed() {
if c.is_alphanumeric() || c == '_' {
left.insert(0, c);
} else {
@@ -191,11 +393,7 @@ impl RopeExt for Rope {
let end = offset + right.len();
if start == end {
None
} else {
Some(start..end)
}
if start == end { None } else { Some(start..end) }
}
fn word_at(&self, offset: usize) -> String {
@@ -205,4 +403,54 @@ impl RopeExt for Rope {
String::new()
}
}
#[inline]
fn offset_utf16_to_offset(&self, offset_utf16: usize) -> usize {
if offset_utf16 > self.len_utf16() {
return self.len();
}
self.utf16_to_byte_idx(offset_utf16)
}
#[inline]
fn offset_to_offset_utf16(&self, offset: usize) -> usize {
if offset > self.len() {
return self.len_utf16();
}
self.byte_to_utf16_idx(offset)
}
fn replace(&mut self, range: Range<usize>, new_text: &str) {
let range =
self.clip_offset(range.start, Bias::Left)..self.clip_offset(range.end, Bias::Right);
self.remove(range.clone());
self.insert(range.start, new_text);
}
fn clip_offset(&self, offset: usize, bias: Bias) -> usize {
if offset > self.len() {
return self.len();
}
if self.is_char_boundary(offset) {
return offset;
}
if bias == Bias::Left {
self.floor_char_boundary(offset)
} else {
self.ceil_char_boundary(offset)
}
}
fn char_index_to_offset(&self, char_offset: usize) -> usize {
self.chars().take(char_offset).map(|c| c.len_utf8()).sum()
}
fn offset_to_char_index(&self, offset: usize) -> usize {
let offset = self.clip_offset(offset, Bias::Right);
self.slice(..offset).chars().count()
}
}

View File

@@ -0,0 +1,140 @@
use std::ops::Range;
use gpui::{Context, Window};
use ropey::Rope;
use sum_tree::Bias;
use crate::input::{InputState, RopeExt};
impl InputState {
/// Select the word at the given offset on double-click.
///
/// The offset is the UTF-8 offset.
pub(super) fn select_word(&mut self, offset: usize, _: &mut Window, cx: &mut Context<Self>) {
let Some(range) = TextSelector::word_range(&self.text, offset) else {
return;
};
self.selected_range = (range.start..range.end).into();
self.selected_word_range = Some(self.selected_range);
cx.notify()
}
/// Select the line at the given offset on triple-click.
///
/// The offset is the UTF-8 offset.
pub(super) fn select_line(&mut self, offset: usize, _: &mut Window, cx: &mut Context<Self>) {
let range = TextSelector::line_range(&self.text, offset);
self.selected_range = (range.start..range.end).into();
self.selected_word_range = None;
cx.notify()
}
}
struct TextSelector;
impl TextSelector {
/// Select a line in the given text at the specified offset.
///
/// The offset is the UTF-8 offset.
///
/// Returns the start and end offsets of the selected line.
pub fn line_range(text: &Rope, offset: usize) -> Range<usize> {
let offset = text.clip_offset(offset, Bias::Left);
let row = text.offset_to_point(offset).row;
let start = text.line_start_offset(row);
let end = text.line_end_offset(row);
start..end
}
/// Select a word in the given text at the specified offset.
///
/// The offset is the UTF-8 offset.
///
/// Returns the start and end offsets of the selected word.
pub fn word_range(text: &Rope, offset: usize) -> Option<Range<usize>> {
let offset = text.clip_offset(offset, Bias::Left);
let char = text.char_at(offset)?;
let end = offset + char.len_utf8();
let prev_chars = text.chars_at(offset).reversed().take(128);
let next_chars = text.chars_at(end).take(128);
Some(word_range_from_chars(offset, char, prev_chars, next_chars))
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum CharType {
/// a-z, A-Z, 0-9, _
Word,
/// '\t', ' ', '\u{00A0}' etc.
Whitespace,
/// \n, \r
Newline,
/// . , ; : ( ) [ ] { } ... or CJK characters: `汉`, `🎉` etc.
Other,
}
impl From<char> for CharType {
fn from(c: char) -> Self {
match c {
c if is_word_char(c) => CharType::Word,
c if c == '\n' || c == '\r' => CharType::Newline,
c if c.is_whitespace() => CharType::Whitespace,
_ => CharType::Other,
}
}
}
impl CharType {
fn is_connectable(self, c: char) -> bool {
matches!(
(self, CharType::from(c)),
(CharType::Word, CharType::Word) | (CharType::Whitespace, CharType::Whitespace)
)
}
}
fn is_word_char(c: char) -> bool {
matches!(c, '_')
// ASCII alphanumeric characters, for English, numbers: `Hello123`, etc.
|| c.is_ascii_alphanumeric()
// Latin script in Unicode for French, German, Spanish, etc.
|| matches!(c, '\u{00C0}'..='\u{00FF}')
|| matches!(c, '\u{0100}'..='\u{017F}')
|| matches!(c, '\u{0180}'..='\u{024F}')
// Cyrillic for Russian, Ukrainian, etc.
|| matches!(c, '\u{0400}'..='\u{04FF}')
// Vietnamese
|| matches!(c, '\u{1E00}'..='\u{1EFF}')
|| matches!(c, '\u{0300}'..='\u{036F}')
}
pub(crate) fn word_range_from_chars(
offset: usize,
c: char,
prev_chars: impl Iterator<Item = char>,
next_chars: impl Iterator<Item = char>,
) -> Range<usize> {
let char_type = CharType::from(c);
let mut start = offset;
let mut end = offset + c.len_utf8();
for prev in prev_chars.take(128) {
if char_type.is_connectable(prev) {
start -= prev.len_utf8();
} else {
break;
}
}
for next in next_chars.take(128) {
if char_type.is_connectable(next) {
end += next.len_utf8();
} else {
break;
}
}
start..end
}

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -1,13 +1,12 @@
pub use anchored::*;
pub use element_ext::ElementExt;
pub use event::InteractiveElementExt;
pub use focusable::FocusableCycle;
pub use geometry::*;
pub use icon::*;
pub use index_path::IndexPath;
pub use kbd::*;
pub use root::{window_paddings, Root};
pub use root::{Root, window_paddings};
pub use styled::*;
pub use title_bar::*;
pub use window_ext::*;
pub use crate::Disableable;
@@ -18,7 +17,7 @@ pub mod avatar;
pub mod button;
pub mod checkbox;
pub mod divider;
pub mod dock_area;
pub mod dock;
pub mod group_box;
pub mod history;
pub mod indicator;
@@ -35,16 +34,15 @@ pub mod switch;
pub mod tab;
pub mod tooltip;
mod anchored;
mod element_ext;
mod event;
mod focusable;
mod geometry;
mod icon;
mod index_path;
mod kbd;
mod root;
mod styled;
mod title_bar;
mod window_ext;
/// Initialize the UI module.

View File

@@ -3,21 +3,20 @@ use std::time::Duration;
use gpui::prelude::FluentBuilder;
use gpui::{
div, px, size, uniform_list, App, AppContext, AvailableSpace, ClickEvent, Context,
DefiniteLength, EdgesRefinement, Entity, EventEmitter, FocusHandle, Focusable,
InteractiveElement, IntoElement, KeyBinding, Length, ListSizingBehavior, MouseButton,
ParentElement, Render, RenderOnce, ScrollStrategy, SharedString, StatefulInteractiveElement,
StyleRefinement, Styled, Subscription, Task, UniformListScrollHandle, Window,
App, AppContext, AvailableSpace, ClickEvent, Context, DefiniteLength, EdgesRefinement, Entity,
EventEmitter, FocusHandle, Focusable, InteractiveElement, IntoElement, KeyBinding, Length,
ListSizingBehavior, MouseButton, ParentElement, Render, RenderOnce, ScrollStrategy,
SharedString, StatefulInteractiveElement, StyleRefinement, Styled, Subscription, Task,
UniformListScrollHandle, Window, div, px, size, uniform_list,
};
use smol::Timer;
use theme::ActiveTheme;
use crate::actions::{Cancel, Confirm, SelectDown, SelectUp};
use crate::input::{InputEvent, InputState, TextInput};
use crate::list::cache::{MeasuredEntrySize, RowEntry, RowsCache};
use crate::input::{Input, InputEvent, InputState};
use crate::list::ListDelegate;
use crate::list::cache::{MeasuredEntrySize, RowEntry, RowsCache};
use crate::scroll::{Scrollbar, ScrollbarHandle};
use crate::{v_flex, Icon, IconName, IndexPath, Selectable, Sizable, Size, StyledExt};
use crate::{Icon, IconName, IndexPath, Selectable, Sizable, Size, StyledExt, v_flex};
pub(crate) fn init(cx: &mut App) {
let context: Option<&str> = Some("List");
@@ -265,6 +264,7 @@ where
}
self.set_searching(true, window, cx);
let search = self.delegate.perform_search(&text, window, cx);
if self.rows_cache.len() > 0 {
@@ -273,6 +273,7 @@ where
self._set_selected_index(None, window, cx);
}
let executor = cx.background_executor().clone();
self._search_task = cx.spawn_in(window, async move |this, window| {
search.await;
@@ -282,13 +283,14 @@ where
});
// Always wait 100ms to avoid flicker
Timer::after(Duration::from_millis(100)).await;
executor.timer(Duration::from_millis(100)).await;
_ = this.update_in(window, |this, window, cx| {
this.set_searching(false, window, cx);
});
});
}
InputEvent::PressEnter { secondary } => self.on_action_confirm(
InputEvent::PressEnter { secondary, .. } => self.on_action_confirm(
&Confirm {
secondary: *secondary,
},
@@ -498,7 +500,7 @@ where
let scroll_handle = self.scroll_handle.clone();
v_flex()
.flex_grow()
.flex_grow_1()
.relative()
.size_full()
.when_some(self.options.max_height, |this, h| this.max_h(h))
@@ -578,10 +580,10 @@ where
self.prepare_items_if_needed(window, cx);
// Scroll to the selected item if it is set.
if let Some((ix, strategy)) = self.deferred_scroll_to_index.take() {
if let Some(item_ix) = self.rows_cache.position_of(&ix) {
self.scroll_handle.scroll_to_item(item_ix, strategy);
}
if let Some((ix, strategy)) = self.deferred_scroll_to_index.take()
&& let Some(item_ix) = self.rows_cache.position_of(&ix)
{
self.scroll_handle.scroll_to_item(item_ix, strategy);
}
let loading = self.delegate().loading(cx);
@@ -632,10 +634,10 @@ where
.border_b_1()
.border_color(cx.theme().border)
.child(
TextInput::new(&input)
Input::new(&input)
.with_size(self.options.size)
.appearance(false)
.cleanable()
.cleanable(true)
.p_0()
.prefix(
Icon::new(IconName::Search).text_color(cx.theme().text_muted),

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