Compare commits
44 Commits
v1.0.0-bet
...
feat/rc
| Author | SHA1 | Date | |
|---|---|---|---|
| e95cc5967f | |||
| 5edc8d311e | |||
| e812ae05a9 | |||
| 0230fcff23 | |||
| 04983be23f | |||
| 57a129fa93 | |||
| c791309659 | |||
| d53e9d538c | |||
| a0d76e2cf4 | |||
| 2d3d90774c | |||
| ef227032bb | |||
| ce8f431aaa | |||
| 1f04a824d7 | |||
| c78e0a5163 | |||
| 5d4c8634ef | |||
| 4efeec08c4 | |||
| 2e451aae12 | |||
| cdfcfdd782 | |||
| 9817dd29a6 | |||
| f066cb8223 | |||
| 6d60726f27 | |||
| 80186a79e5 | |||
|
|
cffcb4711d | ||
|
|
44484d9992 | ||
|
|
15ac8d6775 | ||
|
|
6f0cefed33 | ||
|
|
c239e351b8 | ||
|
|
9ff18aae35 | ||
|
|
6fef2ae1c6 | ||
|
|
c2a723faa8 | ||
|
|
6b872527ad | ||
|
|
d9b16aea9a | ||
|
|
8345def015 | ||
|
|
b0ba2549d7 | ||
|
|
c8034642c5 | ||
|
|
8205c69e19 | ||
|
|
d36364d60d | ||
|
|
99363475e0 | ||
|
|
a52e1877fe | ||
|
|
b41de00c95 | ||
|
|
94cbb4aa0e | ||
|
|
40e7ca368b | ||
|
|
b91697defc | ||
|
|
1d57a2deab |
10
.github/workflows/release.yml
vendored
@@ -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
@@ -21,3 +21,6 @@ dist/
|
||||
.DS_Store
|
||||
# Added by goreleaser init:
|
||||
.intentionally-empty-file.o
|
||||
|
||||
.cargo/
|
||||
vendor/
|
||||
|
||||
2266
Cargo.lock
generated
16
Cargo.toml
@@ -1,11 +1,11 @@
|
||||
[workspace]
|
||||
resolver = "2"
|
||||
members = ["crates/*"]
|
||||
default-members = ["crates/coop"]
|
||||
members = ["crates/*", "desktop", "web"]
|
||||
default-members = ["desktop"]
|
||||
|
||||
[workspace.package]
|
||||
version = "1.0.0-beta1"
|
||||
edition = "2021"
|
||||
version = "1.0.0-beta5"
|
||||
edition = "2024"
|
||||
publish = false
|
||||
|
||||
[workspace.dependencies]
|
||||
@@ -19,13 +19,12 @@ gpui_tokio = { git = "https://github.com/zed-industries/zed" }
|
||||
reqwest_client = { git = "https://github.com/zed-industries/zed" }
|
||||
|
||||
# Nostr
|
||||
nostr-lmdb = { git = "https://github.com/rust-nostr/nostr" }
|
||||
nostr-memory = { git = "https://github.com/rust-nostr/nostr" }
|
||||
nostr-lmdb = { git = "https://github.com/rust-nostr/nostr", }
|
||||
nostr-connect = { git = "https://github.com/rust-nostr/nostr" }
|
||||
nostr-blossom = { git = "https://github.com/rust-nostr/nostr" }
|
||||
nostr-gossip-sqlite = { git = "https://github.com/rust-nostr/nostr" }
|
||||
nostr-gossip-memory = { git = "https://github.com/rust-nostr/nostr" }
|
||||
nostr-sdk = { git = "https://github.com/rust-nostr/nostr" }
|
||||
nostr = { git = "https://github.com/rust-nostr/nostr", features = [ "nip96", "nip59", "nip49", "nip44" ] }
|
||||
nostr = { git = "https://github.com/rust-nostr/nostr", features = [ "nip59", "nip49", "nip44" ] }
|
||||
|
||||
# Others
|
||||
anyhow = "1.0.44"
|
||||
@@ -43,6 +42,7 @@ smallvec = "1.14.0"
|
||||
smol = "2"
|
||||
tracing = "0.1.40"
|
||||
webbrowser = "1.0.4"
|
||||
tracing-subscriber = { version = "0.3.18", features = ["fmt", "env-filter"] }
|
||||
|
||||
[profile.release]
|
||||
strip = true
|
||||
|
||||
16
README.md
@@ -1,12 +1,12 @@
|
||||

|
||||
|
||||
<p>
|
||||
<a href="https://github.com/lumehq/coop/actions/workflows/rust.yml">
|
||||
<img alt="Actions" src="https://github.com/lumehq/coop/actions/workflows/rust.yml/badge.svg">
|
||||
<a href="https://github.com/reyakov/coop/actions/workflows/rust.yml">
|
||||
<img alt="Actions" src="https://github.com/reyakov/coop/actions/workflows/rust.yml/badge.svg">
|
||||
</a>
|
||||
<img alt="GitHub repo size" src="https://img.shields.io/github/repo-size/lumehq/coop">
|
||||
<img alt="GitHub issues" src="https://img.shields.io/github/issues-raw/lumehq/coop">
|
||||
<img alt="GitHub pull requests" src="https://img.shields.io/github/issues-pr/lumehq/coop">
|
||||
<img alt="GitHub repo size" src="https://img.shields.io/github/repo-size/reyakov/coop">
|
||||
<img alt="GitHub issues" src="https://img.shields.io/github/issues-raw/reyakov/coop">
|
||||
<img alt="GitHub pull requests" src="https://img.shields.io/github/issues-pr/reyakov/coop">
|
||||
</p>
|
||||
|
||||
Coop is a simple, fast, and reliable nostr client for secure messaging across all platforms.
|
||||
@@ -36,7 +36,7 @@ To install Coop, follow these steps:
|
||||
|
||||
1. **Download the Latest Release**:
|
||||
|
||||
- Visit the [Coop Releases page on GitHub](https://github.com/lumehq/coop/releases).
|
||||
- Visit the [Coop Releases page on GitHub](https://github.com/reyakov/coop/releases).
|
||||
- Download the package that matches your operating system (Windows, macOS, or Linux).
|
||||
|
||||
2. **Install**:
|
||||
@@ -65,7 +65,7 @@ Coop is built using Rust and GPUI. All Nostr related stuffs handled by [Rust Nos
|
||||
1. Clone the repository:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/lumehq/coop.git
|
||||
git clone https://github.com/reyakov/coop.git
|
||||
cd coop
|
||||
```
|
||||
|
||||
@@ -118,7 +118,7 @@ For more information, see the [Contributing](#contributing) section.
|
||||
- [Rust Nostr](https://github.com/rust-nostr/nostr/)
|
||||
- [GPUI](https://www.gpui.rs/)
|
||||
- [GPUI Components](https://github.com/longbridge/gpui-component/)
|
||||
- [Coop Issue Tracker](https://github.com/lumehq/coop/issues/)
|
||||
- [Coop Issue Tracker](https://github.com/reyakov/coop/issues/)
|
||||
|
||||
### License
|
||||
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
use std::cmp::Reverse;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::collections::{BTreeSet, HashMap, HashSet};
|
||||
use std::hash::{DefaultHasher, Hash, Hasher};
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::{Context as AnyhowContext, Error, anyhow};
|
||||
use common::EventUtils;
|
||||
use common::EventExt;
|
||||
use fuzzy_matcher::FuzzyMatcher;
|
||||
use fuzzy_matcher::skim::SkimMatcherV2;
|
||||
use gpui::{
|
||||
@@ -15,7 +15,8 @@ use gpui::{
|
||||
};
|
||||
use nostr_sdk::prelude::*;
|
||||
use smallvec::{SmallVec, smallvec};
|
||||
use state::{DEVICE_GIFTWRAP, NostrRegistry, StateEvent, TIMEOUT, USER_GIFTWRAP};
|
||||
use smol::lock::RwLock;
|
||||
use state::{DEVICE_GIFTWRAP, NostrRegistry, USER_GIFTWRAP};
|
||||
|
||||
mod message;
|
||||
mod room;
|
||||
@@ -40,8 +41,8 @@ pub enum ChatEvent {
|
||||
CloseRoom(u64),
|
||||
/// An event to notify UI about a new chat request
|
||||
Ping,
|
||||
/// An event to notify UI that the chat registry has subscribed to messaging relays
|
||||
Subscribed,
|
||||
/// No Inbox Relays found, the app is not ready to subscribe messages
|
||||
InboxRelayNotFound,
|
||||
/// An error occurred
|
||||
Error(SharedString),
|
||||
}
|
||||
@@ -49,22 +50,63 @@ pub enum ChatEvent {
|
||||
/// Channel signal.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
|
||||
enum Signal {
|
||||
/// Inbox Relays found, the app is ready to subscribe messages
|
||||
InboxReady(Box<Event>),
|
||||
/// No Inbox Relays found, the app is not ready to subscribe messages
|
||||
InboxRelayNotFound,
|
||||
/// Message received from relay pool
|
||||
Message(NewMessage),
|
||||
/// Eose received from relay pool
|
||||
Eose,
|
||||
/// An error occurred
|
||||
Error(SharedString),
|
||||
Error(FailedMessage),
|
||||
}
|
||||
|
||||
impl Signal {
|
||||
pub fn message(gift_wrap: EventId, rumor: UnsignedEvent) -> Self {
|
||||
Self::Message(NewMessage::new(gift_wrap, rumor))
|
||||
}
|
||||
|
||||
pub fn inbox_ready(event: &Event) -> Self {
|
||||
Self::InboxReady(Box::new(event.to_owned()))
|
||||
}
|
||||
|
||||
pub fn inbox_relay_not_found() -> Self {
|
||||
Self::InboxRelayNotFound
|
||||
}
|
||||
|
||||
pub fn eose() -> Self {
|
||||
Self::Eose
|
||||
}
|
||||
|
||||
pub fn error<T>(event: &Event, reason: T) -> Self
|
||||
where
|
||||
T: Into<SharedString>,
|
||||
{
|
||||
Self::Error(FailedMessage::new(event, reason))
|
||||
}
|
||||
}
|
||||
|
||||
/// Chat Registry
|
||||
#[derive(Debug)]
|
||||
pub struct ChatRegistry {
|
||||
/// Collection of all chat rooms
|
||||
/// Chat rooms
|
||||
rooms: Vec<Entity<Room>>,
|
||||
|
||||
/// Events that failed to unwrap for any reason
|
||||
trashes: Entity<BTreeSet<FailedMessage>>,
|
||||
|
||||
/// Tracking events seen on which relays in the current session
|
||||
seens: Arc<RwLock<HashMap<EventId, HashSet<RelayUrl>>>>,
|
||||
|
||||
/// Mapping of unwrapped event ids to their gift wrap event ids
|
||||
event_map: Arc<RwLock<HashMap<EventId, EventId>>>,
|
||||
|
||||
/// Tracking the status of unwrapping gift wrap events.
|
||||
tracking_flag: Arc<AtomicBool>,
|
||||
tracking: Arc<AtomicBool>,
|
||||
|
||||
/// Whether the messaging relays have been found.
|
||||
msg_relays_existed: Arc<AtomicBool>,
|
||||
|
||||
/// Channel for sending signals to the UI.
|
||||
signal_tx: flume::Sender<Signal>,
|
||||
@@ -76,7 +118,7 @@ pub struct ChatRegistry {
|
||||
tasks: SmallVec<[Task<Result<(), Error>>; 2]>,
|
||||
|
||||
/// Subscriptions
|
||||
_subscriptions: SmallVec<[Subscription; 1]>,
|
||||
_subscriptions: SmallVec<[Subscription; 2]>,
|
||||
}
|
||||
|
||||
impl EventEmitter<ChatEvent> for ChatRegistry {}
|
||||
@@ -95,31 +137,36 @@ impl ChatRegistry {
|
||||
/// Create a new chat registry instance
|
||||
fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let user_signer = nostr.read(cx).signer.clone();
|
||||
|
||||
let (tx, rx) = flume::unbounded::<Signal>();
|
||||
let mut subscriptions = smallvec![];
|
||||
|
||||
subscriptions.push(
|
||||
// Subscribe to the signer event
|
||||
cx.subscribe(&nostr, |this, _state, event, cx| {
|
||||
if let StateEvent::SignerSet = event {
|
||||
cx.observe(&user_signer, |this, signer, cx| {
|
||||
if let Some(keys) = signer.read(cx).clone() {
|
||||
this.reset(cx);
|
||||
this.handle_notifications(keys, cx);
|
||||
this.get_metadata(cx);
|
||||
this.get_rooms(cx);
|
||||
this.get_contact_list(cx);
|
||||
this.get_messages(cx)
|
||||
}
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
// Run at the end of the current cycle
|
||||
cx.defer_in(window, |this, _window, cx| {
|
||||
this.get_rooms(cx);
|
||||
this.handle_notifications(cx);
|
||||
this.tracking(cx);
|
||||
this.get_rooms(cx);
|
||||
});
|
||||
|
||||
Self {
|
||||
rooms: vec![],
|
||||
tracking_flag: Arc::new(AtomicBool::new(false)),
|
||||
trashes: cx.new(|_| BTreeSet::default()),
|
||||
seens: Arc::new(RwLock::new(HashMap::default())),
|
||||
event_map: Arc::new(RwLock::new(HashMap::default())),
|
||||
tracking: Arc::new(AtomicBool::new(false)),
|
||||
msg_relays_existed: Arc::new(AtomicBool::new(false)),
|
||||
signal_rx: rx,
|
||||
signal_tx: tx,
|
||||
tasks: smallvec![],
|
||||
@@ -128,11 +175,16 @@ impl ChatRegistry {
|
||||
}
|
||||
|
||||
/// Handle nostr notifications
|
||||
fn handle_notifications(&mut self, cx: &mut Context<Self>) {
|
||||
fn handle_notifications(&mut self, signer: Keys, cx: &mut Context<Self>) {
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let client = nostr.read(cx).client();
|
||||
let signer = nostr.read(cx).signer();
|
||||
let status = self.tracking_flag.clone();
|
||||
|
||||
let tracking = self.tracking.clone();
|
||||
let msg_relays_existed = self.msg_relays_existed.clone();
|
||||
|
||||
let seens = self.seens.clone();
|
||||
let event_map = self.event_map.clone();
|
||||
let trashes = self.trashes.downgrade();
|
||||
|
||||
let initialized_at = Timestamp::now();
|
||||
let sub_id1 = SubscriptionId::new(DEVICE_GIFTWRAP);
|
||||
@@ -143,60 +195,80 @@ impl ChatRegistry {
|
||||
let rx = self.signal_rx.clone();
|
||||
|
||||
self.tasks.push(cx.background_spawn(async move {
|
||||
let device_signer = signer.get_encryption_signer().await;
|
||||
let mut notifications = client.notifications();
|
||||
let mut processed_events = HashSet::new();
|
||||
|
||||
while let Some(notification) = notifications.next().await {
|
||||
let ClientNotification::Message { message, .. } = notification else {
|
||||
let ClientNotification::Message { message, relay_url } = notification else {
|
||||
// Skip non-message notifications
|
||||
continue;
|
||||
};
|
||||
|
||||
match message {
|
||||
match *message {
|
||||
RelayMessage::Event { event, .. } => {
|
||||
// Keep track of which relays have seen this event
|
||||
{
|
||||
let mut seens = seens.write().await;
|
||||
seens.entry(event.id).or_default().insert(relay_url);
|
||||
}
|
||||
|
||||
// De-duplicate events by their ID
|
||||
if !processed_events.insert(event.id) {
|
||||
// Skip if the event has already been processed
|
||||
continue;
|
||||
}
|
||||
|
||||
// Handle msg relays event to determine when the app is ready to subscribe
|
||||
if event.kind == Kind::InboxRelays {
|
||||
let current_user = signer.get_public_key_async().await?;
|
||||
|
||||
if event.pubkey == current_user {
|
||||
// Mark that the msg relays have been found
|
||||
msg_relays_existed.store(true, Ordering::Release);
|
||||
// Emit the inbox ready signal
|
||||
tx.send_async(Signal::inbox_ready(&event)).await?;
|
||||
}
|
||||
}
|
||||
|
||||
// Skip non-gift wrap events
|
||||
if event.kind != Kind::GiftWrap {
|
||||
// Skip non-gift wrap events
|
||||
continue;
|
||||
}
|
||||
|
||||
// Extract the rumor from the gift wrap event
|
||||
match extract_rumor(&client, &device_signer, event.as_ref()).await {
|
||||
match extract_rumor(&client, &signer, event.as_ref()).await {
|
||||
Ok(rumor) => {
|
||||
if rumor.tags.is_empty() {
|
||||
let error: SharedString =
|
||||
"Message doesn't belong to any rooms".into();
|
||||
tx.send_async(Signal::Error(error)).await?;
|
||||
// Map the rumor id to the gift wrap event id for later lookup
|
||||
{
|
||||
let mut event_map = event_map.write().await;
|
||||
event_map.insert(rumor.id.unwrap(), event.id);
|
||||
}
|
||||
|
||||
match rumor.created_at >= initialized_at {
|
||||
true => {
|
||||
let new_message = NewMessage::new(event.id, rumor);
|
||||
let signal = Signal::Message(new_message);
|
||||
// Check if the rumor has a recipient
|
||||
if rumor.tags.is_empty() {
|
||||
let signal = Signal::error(&event, "Recipient is missing");
|
||||
tx.send_async(signal).await?;
|
||||
}
|
||||
|
||||
tx.send_async(signal).await?;
|
||||
}
|
||||
false => {
|
||||
status.store(true, Ordering::Release);
|
||||
}
|
||||
// Check if the rumor was created after the chat was initialized (for detecting new messages)
|
||||
if rumor.created_at >= initialized_at {
|
||||
let signal = Signal::message(event.id, rumor);
|
||||
tx.send_async(signal).await?;
|
||||
} else {
|
||||
// Mark the chat still processing new messages
|
||||
tracking.store(true, Ordering::Release);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
let error: SharedString =
|
||||
format!("Failed to unwrap the gift wrap event: {e}").into();
|
||||
tx.send_async(Signal::Error(error)).await?;
|
||||
let reason = format!("Failed to extract rumor: {e}");
|
||||
let signal = Signal::error(event.as_ref(), reason);
|
||||
tx.send_async(signal).await?;
|
||||
}
|
||||
}
|
||||
}
|
||||
RelayMessage::EndOfStoredEvents(id) => {
|
||||
if id.as_ref() == &sub_id1 || id.as_ref() == &sub_id2 {
|
||||
tx.send_async(Signal::Eose).await?;
|
||||
}
|
||||
RelayMessage::EndOfStoredEvents(id)
|
||||
if (id.as_ref() == &sub_id1 || id.as_ref() == &sub_id2) =>
|
||||
{
|
||||
tx.send_async(Signal::eose()).await?;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
@@ -213,14 +285,25 @@ impl ChatRegistry {
|
||||
this.new_message(message, cx);
|
||||
})?;
|
||||
}
|
||||
Signal::InboxReady(event) => {
|
||||
this.update(cx, |this, cx| {
|
||||
this.get_messages(&event, cx);
|
||||
})?;
|
||||
}
|
||||
Signal::InboxRelayNotFound => {
|
||||
this.update(cx, |_this, cx| {
|
||||
cx.emit(ChatEvent::InboxRelayNotFound);
|
||||
})?;
|
||||
}
|
||||
Signal::Eose => {
|
||||
this.update(cx, |this, cx| {
|
||||
this.get_rooms(cx);
|
||||
})?;
|
||||
}
|
||||
Signal::Error(error) => {
|
||||
this.update(cx, |_this, cx| {
|
||||
cx.emit(ChatEvent::Error(error));
|
||||
Signal::Error(trash) => {
|
||||
trashes.update(cx, |this, cx| {
|
||||
this.insert(trash);
|
||||
cx.notify();
|
||||
})?;
|
||||
}
|
||||
};
|
||||
@@ -232,7 +315,7 @@ impl ChatRegistry {
|
||||
|
||||
/// Tracking the status of unwrapping gift wrap events.
|
||||
fn tracking(&mut self, cx: &mut Context<Self>) {
|
||||
let status = self.tracking_flag.clone();
|
||||
let status = self.tracking.clone();
|
||||
let tx = self.signal_tx.clone();
|
||||
|
||||
self.tasks.push(cx.background_spawn(async move {
|
||||
@@ -250,102 +333,73 @@ impl ChatRegistry {
|
||||
}));
|
||||
}
|
||||
|
||||
/// Get contact list from relays
|
||||
pub fn get_contact_list(&mut self, cx: &mut Context<Self>) {
|
||||
/// Get all necessary metadata from relays for current user
|
||||
pub fn get_metadata(&mut self, cx: &mut Context<Self>) {
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let client = nostr.read(cx).client();
|
||||
let signer = nostr.read(cx).signer();
|
||||
|
||||
let Some(public_key) = signer.public_key() else {
|
||||
let Some(public_key) = nostr.read(cx).signer_pubkey(cx) else {
|
||||
return;
|
||||
};
|
||||
|
||||
let task: Task<Result<(), Error>> = cx.background_spawn(async move {
|
||||
let id = SubscriptionId::new("contact-list");
|
||||
let opts = SubscribeAutoCloseOptions::default()
|
||||
.exit_policy(ReqExitPolicy::ExitOnEOSE)
|
||||
.timeout(Some(Duration::from_secs(TIMEOUT)));
|
||||
self.tasks.push(cx.background_spawn(async move {
|
||||
let opts = SubscribeAutoCloseOptions::default().exit_policy(ReqExitPolicy::ExitOnEOSE);
|
||||
|
||||
// Construct filter for inbox relays
|
||||
let filter = Filter::new()
|
||||
// Construct filter for msg relays
|
||||
let msg_relays = Filter::new()
|
||||
.kind(Kind::InboxRelays)
|
||||
.author(public_key)
|
||||
.limit(1);
|
||||
|
||||
// Construct filter for contact list
|
||||
let contact_list = Filter::new()
|
||||
.kind(Kind::ContactList)
|
||||
.author(public_key)
|
||||
.limit(1);
|
||||
|
||||
// Subscribe
|
||||
client.subscribe(filter).close_on(opts).with_id(id).await?;
|
||||
client
|
||||
.subscribe(vec![msg_relays, contact_list])
|
||||
.close_on(opts)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
});
|
||||
}));
|
||||
|
||||
self.tasks.push(task);
|
||||
}
|
||||
let tx = self.signal_tx.clone();
|
||||
let msg_relays_existed = self.msg_relays_existed.clone();
|
||||
|
||||
/// Get all messages for current user
|
||||
pub fn get_messages(&mut self, cx: &mut Context<Self>) {
|
||||
let task = self.subscribe(cx);
|
||||
// Reset the status flag
|
||||
msg_relays_existed.store(false, Ordering::Release);
|
||||
|
||||
self.tasks.push(cx.spawn(async move |this, cx| {
|
||||
match task.await {
|
||||
Ok(_) => {
|
||||
this.update(cx, |_this, cx| {
|
||||
cx.emit(ChatEvent::Subscribed);
|
||||
})?;
|
||||
}
|
||||
Err(e) => {
|
||||
this.update(cx, |_this, cx| {
|
||||
cx.emit(ChatEvent::Error(SharedString::from(e.to_string())));
|
||||
})?;
|
||||
}
|
||||
// Wait for the msg relays to be found or timeout
|
||||
self.tasks.push(cx.background_spawn(async move {
|
||||
// Wait for 5 seconds
|
||||
smol::Timer::after(Duration::from_secs(5)).await;
|
||||
|
||||
// Then check if the msg relays have been found
|
||||
if !msg_relays_existed.load(Ordering::Acquire) {
|
||||
tx.send_async(Signal::inbox_relay_not_found()).await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}));
|
||||
}
|
||||
|
||||
// Get messaging relay list for current user
|
||||
fn get_messaging_relays(&self, cx: &App) -> Task<Result<Vec<RelayUrl>, Error>> {
|
||||
/// Get all messages for the provided signer
|
||||
fn get_messages(&mut self, msg_relays: &Event, cx: &mut Context<Self>) {
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let client = nostr.read(cx).client();
|
||||
let signer = nostr.read(cx).signer();
|
||||
let urls: Vec<RelayUrl> = nip17::extract_relay_list(msg_relays).collect();
|
||||
|
||||
cx.background_spawn(async move {
|
||||
let public_key = signer.get_public_key().await?;
|
||||
let Some(signer) = nostr.read(cx).signer(cx) else {
|
||||
return;
|
||||
};
|
||||
|
||||
// Construct filter for inbox relays
|
||||
let filter = Filter::new()
|
||||
.kind(Kind::InboxRelays)
|
||||
.author(public_key)
|
||||
.limit(1);
|
||||
|
||||
// Stream events from user's write relays
|
||||
let mut stream = client
|
||||
.stream_events(filter)
|
||||
.timeout(Duration::from_secs(TIMEOUT))
|
||||
.await?;
|
||||
|
||||
while let Some((_url, res)) = stream.next().await {
|
||||
if let Ok(event) = res {
|
||||
let urls: Vec<RelayUrl> = nip17::extract_owned_relay_list(event).collect();
|
||||
return Ok(urls);
|
||||
}
|
||||
}
|
||||
|
||||
Err(anyhow!("Messaging Relays not found"))
|
||||
})
|
||||
}
|
||||
|
||||
/// Continuously get gift wrap events for the current user in their messaging relays
|
||||
fn subscribe(&self, cx: &App) -> Task<Result<(), Error>> {
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let client = nostr.read(cx).client();
|
||||
let signer = nostr.read(cx).signer();
|
||||
let urls = self.get_messaging_relays(cx);
|
||||
|
||||
cx.background_spawn(async move {
|
||||
let urls = urls.await?;
|
||||
let public_key = signer.get_public_key().await?;
|
||||
let task: Task<Result<(), Error>> = cx.background_spawn(async move {
|
||||
let public_key = signer.get_public_key_async().await?;
|
||||
let filter = Filter::new().kind(Kind::GiftWrap).pubkey(public_key);
|
||||
let id = SubscriptionId::new(USER_GIFTWRAP);
|
||||
let id = SubscriptionId::new(format!("{}-msg", public_key.to_hex()));
|
||||
|
||||
// Ensure relay connections
|
||||
for url in urls.iter() {
|
||||
@@ -366,12 +420,28 @@ impl ChatRegistry {
|
||||
);
|
||||
|
||||
Ok(())
|
||||
})
|
||||
});
|
||||
|
||||
self.tasks.push(cx.spawn(async move |this, cx| {
|
||||
if let Err(e) = task.await {
|
||||
this.update(cx, |_this, cx| {
|
||||
cx.emit(ChatEvent::Error(SharedString::from(e.to_string())));
|
||||
})?;
|
||||
}
|
||||
Ok(())
|
||||
}));
|
||||
}
|
||||
|
||||
/// Refresh the chat registry, fetching messages and contact list from relays.
|
||||
pub fn refresh(&mut self, cx: &mut Context<Self>) {
|
||||
self.reset(cx);
|
||||
self.get_metadata(cx);
|
||||
self.get_rooms(cx);
|
||||
}
|
||||
|
||||
/// Get the loading status of the chat registry
|
||||
pub fn loading(&self) -> bool {
|
||||
self.tracking_flag.load(Ordering::Acquire)
|
||||
self.tracking.load(Ordering::Acquire)
|
||||
}
|
||||
|
||||
/// Get a weak reference to a room by its ID.
|
||||
@@ -399,17 +469,54 @@ impl ChatRegistry {
|
||||
.count()
|
||||
}
|
||||
|
||||
/// Count the number of messages seen by a given relay.
|
||||
pub fn count_messages(&self, relay_url: &RelayUrl) -> usize {
|
||||
self.seens
|
||||
.read_blocking()
|
||||
.values()
|
||||
.filter(|seen| seen.contains(relay_url))
|
||||
.count()
|
||||
}
|
||||
|
||||
/// Count the number of trash messages.
|
||||
pub fn count_trash_messages(&self, cx: &App) -> usize {
|
||||
self.trashes.read(cx).len()
|
||||
}
|
||||
|
||||
/// Get the trash messages entity.
|
||||
pub fn trashes(&self) -> Entity<BTreeSet<FailedMessage>> {
|
||||
self.trashes.clone()
|
||||
}
|
||||
|
||||
/// Get the relays that have seen a given rumor id.
|
||||
pub fn rumor_seen_on(&self, id: &EventId) -> Option<HashSet<RelayUrl>> {
|
||||
self.event_map
|
||||
.read_blocking()
|
||||
.get(id)
|
||||
.map(|id| self.seen_on(id))
|
||||
}
|
||||
|
||||
/// Get the relays that have seen a given gift wrap id.
|
||||
pub fn seen_on(&self, id: &EventId) -> HashSet<RelayUrl> {
|
||||
self.seens
|
||||
.read_blocking()
|
||||
.get(id)
|
||||
.cloned()
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Add a new room to the start of list.
|
||||
pub fn add_room<I>(&mut self, room: I, cx: &mut Context<Self>)
|
||||
where
|
||||
I: Into<Room> + 'static,
|
||||
{
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let client = nostr.read(cx).client();
|
||||
|
||||
let Some(public_key) = nostr.read(cx).signer_pubkey(cx) else {
|
||||
return;
|
||||
};
|
||||
|
||||
cx.spawn(async move |this, cx| {
|
||||
let signer = client.signer()?;
|
||||
let public_key = signer.get_public_key().await.ok()?;
|
||||
let room: Room = room.into().organize(&public_key);
|
||||
|
||||
this.update(cx, |this, cx| {
|
||||
@@ -477,6 +584,10 @@ impl ChatRegistry {
|
||||
/// Reset the registry.
|
||||
pub fn reset(&mut self, cx: &mut Context<Self>) {
|
||||
self.rooms.clear();
|
||||
self.trashes.update(cx, |this, cx| {
|
||||
this.clear();
|
||||
cx.notify();
|
||||
});
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
@@ -510,7 +621,13 @@ impl ChatRegistry {
|
||||
|
||||
/// Load all rooms from the database.
|
||||
pub fn get_rooms(&mut self, cx: &mut Context<Self>) {
|
||||
let task = self.get_rooms_from_database(cx);
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
|
||||
let Some(public_key) = nostr.read(cx).signer_pubkey(cx) else {
|
||||
return;
|
||||
};
|
||||
|
||||
let task = self.get_rooms_from_database(public_key, cx);
|
||||
|
||||
self.tasks.push(cx.spawn(async move |this, cx| {
|
||||
match task.await {
|
||||
@@ -530,14 +647,15 @@ impl ChatRegistry {
|
||||
}
|
||||
|
||||
/// Create a task to load rooms from the database
|
||||
fn get_rooms_from_database(&self, cx: &App) -> Task<Result<HashSet<Room>, Error>> {
|
||||
fn get_rooms_from_database(
|
||||
&self,
|
||||
public_key: PublicKey,
|
||||
cx: &App,
|
||||
) -> Task<Result<HashSet<Room>, Error>> {
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let client = nostr.read(cx).client();
|
||||
|
||||
cx.background_spawn(async move {
|
||||
let signer = client.signer().context("Signer not found")?;
|
||||
let public_key = signer.get_public_key().await?;
|
||||
|
||||
// Get contacts
|
||||
let contacts = client
|
||||
.database()
|
||||
@@ -570,10 +688,10 @@ impl ChatRegistry {
|
||||
|
||||
// Process each event and group by room hash
|
||||
for raw in events.into_iter() {
|
||||
if let Ok(rumor) = UnsignedEvent::from_json(&raw.content) {
|
||||
if rumor.tags.public_keys().peekable().peek().is_some() {
|
||||
grouped.entry(rumor.uniq_id()).or_default().push(rumor);
|
||||
}
|
||||
if let Ok(rumor) = UnsignedEvent::from_json(&raw.content)
|
||||
&& rumor.tags.public_keys().peekable().peek().is_some()
|
||||
{
|
||||
grouped.entry(rumor.uniq_id()).or_default().push(rumor);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -614,17 +732,16 @@ impl ChatRegistry {
|
||||
/// Updates room ordering based on the most recent messages.
|
||||
pub fn new_message(&mut self, message: NewMessage, cx: &mut Context<Self>) {
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let signer = nostr.read(cx).signer();
|
||||
|
||||
let Some(public_key) = nostr.read(cx).signer_pubkey(cx) else {
|
||||
return;
|
||||
};
|
||||
|
||||
match self.rooms.iter().find(|e| e.read(cx).id == message.room) {
|
||||
Some(room) => {
|
||||
room.update(cx, |this, cx| {
|
||||
if this.kind == RoomKind::Request {
|
||||
if let Some(public_key) = signer.public_key() {
|
||||
if message.rumor.pubkey == public_key {
|
||||
this.set_ongoing(cx);
|
||||
}
|
||||
}
|
||||
if this.kind == RoomKind::Request && message.rumor.pubkey == public_key {
|
||||
this.set_ongoing(cx);
|
||||
}
|
||||
this.push_message(message, cx);
|
||||
});
|
||||
@@ -652,16 +769,16 @@ impl ChatRegistry {
|
||||
/// Unwraps a gift-wrapped event and processes its contents.
|
||||
async fn extract_rumor(
|
||||
client: &Client,
|
||||
device_signer: &Option<Arc<dyn NostrSigner>>,
|
||||
signer: &Keys,
|
||||
gift_wrap: &Event,
|
||||
) -> Result<UnsignedEvent, Error> {
|
||||
// Try to get cached rumor first
|
||||
if let Ok(event) = get_rumor(client, gift_wrap.id).await {
|
||||
return Ok(event);
|
||||
if let Ok(rumor) = get_rumor(client, gift_wrap.id).await {
|
||||
return Ok(rumor);
|
||||
}
|
||||
|
||||
// Try to unwrap with the available signer
|
||||
let unwrapped = try_unwrap(client, device_signer, gift_wrap).await?;
|
||||
let unwrapped = try_unwrap(signer, gift_wrap).await?;
|
||||
let mut rumor = unwrapped.rumor;
|
||||
|
||||
// Generate event id for the rumor if it doesn't have one
|
||||
@@ -676,33 +793,29 @@ async fn extract_rumor(
|
||||
}
|
||||
|
||||
/// Helper method to try unwrapping with different signers
|
||||
async fn try_unwrap(
|
||||
client: &Client,
|
||||
device_signer: &Option<Arc<dyn NostrSigner>>,
|
||||
gift_wrap: &Event,
|
||||
) -> Result<UnwrappedGift, Error> {
|
||||
// Try with the device signer first
|
||||
if let Some(signer) = device_signer {
|
||||
if let Ok(unwrapped) = try_unwrap_with(gift_wrap, signer).await {
|
||||
async fn try_unwrap(signer: &Keys, gift_wrap: &Event) -> Result<UnwrappedGift, Error> {
|
||||
/*
|
||||
* // Try with the device signer first
|
||||
if let Some(signer) = signer.get_encryption_signer().await {
|
||||
log::info!("trying with encryption key");
|
||||
if let Ok(unwrapped) = try_unwrap_with(gift_wrap, &signer).await {
|
||||
return Ok(unwrapped);
|
||||
};
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Try with the user's signer
|
||||
let user_signer = client.signer().context("Signer not found")?;
|
||||
let unwrapped = try_unwrap_with(gift_wrap, user_signer).await?;
|
||||
// Fallback to the user's signer
|
||||
let user_signer = signer.get().await;
|
||||
*/
|
||||
let unwrapped = try_unwrap_with(gift_wrap, signer).await?;
|
||||
|
||||
Ok(unwrapped)
|
||||
}
|
||||
|
||||
/// Attempts to unwrap a gift wrap event with a given signer.
|
||||
async fn try_unwrap_with(
|
||||
gift_wrap: &Event,
|
||||
signer: &Arc<dyn NostrSigner>,
|
||||
) -> Result<UnwrappedGift, Error> {
|
||||
async fn try_unwrap_with(gift_wrap: &Event, signer: &Keys) -> Result<UnwrappedGift, Error> {
|
||||
// Get the sealed event
|
||||
let seal = signer
|
||||
.nip44_decrypt(&gift_wrap.pubkey, &gift_wrap.content)
|
||||
.nip44_decrypt_async(&gift_wrap.pubkey, &gift_wrap.content)
|
||||
.await?;
|
||||
|
||||
// Verify the sealed event
|
||||
@@ -710,7 +823,10 @@ async fn try_unwrap_with(
|
||||
seal.verify_with_ctx(&SECP256K1)?;
|
||||
|
||||
// Get the rumor event
|
||||
let rumor = signer.nip44_decrypt(&seal.pubkey, &seal.content).await?;
|
||||
let rumor = signer
|
||||
.nip44_decrypt_async(&seal.pubkey, &seal.content)
|
||||
.await?;
|
||||
|
||||
let rumor = UnsignedEvent::from_json(rumor)?;
|
||||
|
||||
Ok(UnwrappedGift {
|
||||
@@ -731,26 +847,17 @@ async fn set_rumor(client: &Client, id: EventId, rumor: &UnsignedEvent) -> Resul
|
||||
tags.push(Tag::identifier(id));
|
||||
|
||||
// Add a reference to the rumor's author
|
||||
tags.push(Tag::custom(
|
||||
TagKind::SingleLetter(SingleLetterTag::lowercase(Alphabet::A)),
|
||||
[author],
|
||||
));
|
||||
tags.push(Tag::custom("a", [author]));
|
||||
|
||||
// Add a conversation id
|
||||
tags.push(Tag::custom(
|
||||
TagKind::SingleLetter(SingleLetterTag::lowercase(Alphabet::C)),
|
||||
[conversation.to_string()],
|
||||
));
|
||||
tags.push(Tag::custom("c", [conversation.to_string()]));
|
||||
|
||||
// Add a reference to the rumor's id
|
||||
tags.push(Tag::event(rumor_id));
|
||||
|
||||
// Add references to the rumor's participants
|
||||
for receiver in rumor.tags.public_keys().copied() {
|
||||
tags.push(Tag::custom(
|
||||
TagKind::SingleLetter(SingleLetterTag::lowercase(Alphabet::P)),
|
||||
[receiver],
|
||||
));
|
||||
for receiver in rumor.tags.public_keys() {
|
||||
tags.push(Tag::custom("P", [receiver]));
|
||||
}
|
||||
|
||||
// Convert rumor to json
|
||||
@@ -759,7 +866,7 @@ async fn set_rumor(client: &Client, id: EventId, rumor: &UnsignedEvent) -> Resul
|
||||
// Construct the event
|
||||
let event = EventBuilder::new(Kind::ApplicationSpecificData, content)
|
||||
.tags(tags)
|
||||
.sign(&Keys::generate())
|
||||
.finalize_async(&Keys::generate())
|
||||
.await?;
|
||||
|
||||
// Save the event to the database
|
||||
@@ -785,7 +892,7 @@ async fn get_rumor(client: &Client, gift_wrap: EventId) -> Result<UnsignedEvent,
|
||||
/// Get the conversation ID for a given rumor (message).
|
||||
fn conversation_id(rumor: &UnsignedEvent) -> u64 {
|
||||
let mut hasher = DefaultHasher::new();
|
||||
let mut pubkeys: Vec<PublicKey> = rumor.tags.public_keys().copied().collect();
|
||||
let mut pubkeys: Vec<PublicKey> = rumor.tags.public_keys().collect();
|
||||
pubkeys.push(rumor.pubkey);
|
||||
pubkeys.sort();
|
||||
pubkeys.dedup();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -2,8 +2,9 @@ use std::cmp::Ordering;
|
||||
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::*;
|
||||
@@ -21,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 {
|
||||
@@ -41,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
|
||||
}
|
||||
@@ -171,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 {
|
||||
@@ -205,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();
|
||||
@@ -354,17 +356,7 @@ 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 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 opts = SubscribeAutoCloseOptions::default()
|
||||
@@ -410,6 +402,10 @@ impl Room {
|
||||
.await?
|
||||
.into_iter()
|
||||
.filter_map(|event| UnsignedEvent::from_json(&event.content).ok())
|
||||
.filter(|event| {
|
||||
// Only process private direct messages and file messages
|
||||
event.kind == Kind::PrivateDirectMessage || event.kind == Kind::Custom(15)
|
||||
})
|
||||
.sorted_by_key(|message| message.created_at)
|
||||
.collect();
|
||||
|
||||
@@ -431,7 +427,7 @@ impl Room {
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
|
||||
// Get current user's public key
|
||||
let sender = nostr.read(cx).signer().public_key()?;
|
||||
let sender = nostr.read(cx).signer_pubkey(cx)?;
|
||||
|
||||
// Get all members, excluding the sender
|
||||
let members: Vec<Person> = self
|
||||
@@ -446,9 +442,7 @@ impl Room {
|
||||
|
||||
// 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
|
||||
@@ -458,19 +452,20 @@ impl Room {
|
||||
|
||||
// Add all receiver tags
|
||||
for member in members.into_iter() {
|
||||
tags.push(Tag::from_standardized_without_cell(
|
||||
TagStandard::PublicKey {
|
||||
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();
|
||||
@@ -481,13 +476,18 @@ impl Room {
|
||||
/// 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();
|
||||
|
||||
// Get current user's public key
|
||||
let public_key = nostr.read(cx).signer().public_key()?;
|
||||
let user_signer = nostr.read(cx).signer(cx)?;
|
||||
let public_key = nostr.read(cx).signer_pubkey(cx)?;
|
||||
|
||||
let persons = PersonRegistry::global(cx);
|
||||
let sender = persons.read(cx).get(&public_key, cx);
|
||||
|
||||
// Get all members (excluding sender)
|
||||
@@ -502,9 +502,6 @@ 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();
|
||||
|
||||
@@ -515,45 +512,46 @@ impl Room {
|
||||
|
||||
// Handle encryption signer requirements
|
||||
if signer_kind.encryption() {
|
||||
// Receiver didn't set up a decoupled encryption key
|
||||
if announcement.is_none() {
|
||||
reports.push(SendReport::new(public_key).error(NO_DEKEY));
|
||||
continue;
|
||||
}
|
||||
|
||||
// Sender didn't set up a decoupled encryption key
|
||||
if encryption_signer.is_none() {
|
||||
reports.push(SendReport::new(sender.public_key()).error(USER_NO_DEKEY));
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Determine receiver and signer
|
||||
let (receiver, signer) = match signer_kind {
|
||||
// Determine the signer to use
|
||||
let signer = match signer_kind {
|
||||
SignerKind::Auto => {
|
||||
if let Some(announcement) = announcement {
|
||||
if let Some(enc_signer) = encryption_signer.as_ref() {
|
||||
(announcement.public_key(), enc_signer.clone())
|
||||
} else {
|
||||
(member.public_key(), user_signer.clone())
|
||||
}
|
||||
if announcement.is_some()
|
||||
&& let Some(encryption_signer) = encryption_signer.clone()
|
||||
{
|
||||
// Safe to unwrap due to earlier checks
|
||||
encryption_signer
|
||||
} else {
|
||||
(member.public_key(), user_signer.clone())
|
||||
user_signer.clone()
|
||||
}
|
||||
}
|
||||
SignerKind::Encryption => {
|
||||
// Safe to unwrap due to earlier checks
|
||||
(
|
||||
announcement.unwrap().public_key(),
|
||||
encryption_signer.as_ref().unwrap().clone(),
|
||||
)
|
||||
encryption_signer.as_ref().unwrap().clone()
|
||||
}
|
||||
SignerKind::User => (member.public_key(), user_signer.clone()),
|
||||
SignerKind::User => user_signer.clone(),
|
||||
};
|
||||
|
||||
match send_gift_wrap(&client, &signer, &receiver, &rumor, 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);
|
||||
}
|
||||
}
|
||||
@@ -562,11 +560,32 @@ impl Room {
|
||||
// Send backup to current user if needed
|
||||
if backup && sents >= 1 {
|
||||
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, public_key).await {
|
||||
Ok((report, _)) => reports.push(report),
|
||||
Err(report) => reports.push(report),
|
||||
// Determine the signer to use
|
||||
let signer = match signer_kind {
|
||||
SignerKind::Auto => {
|
||||
if sender.announcement().is_some()
|
||||
&& let Some(encryption_signer) = encryption_signer.clone()
|
||||
{
|
||||
// Safe to unwrap due to earlier checks
|
||||
encryption_signer
|
||||
} else {
|
||||
user_signer.clone()
|
||||
}
|
||||
}
|
||||
SignerKind::Encryption => {
|
||||
// Safe to unwrap due to earlier checks
|
||||
encryption_signer.as_ref().unwrap().clone()
|
||||
}
|
||||
SignerKind::User => user_signer.clone(),
|
||||
};
|
||||
|
||||
match send_gift_wrap(&client, &signer, &sender, &rumor, signer_kind).await {
|
||||
Ok(report) => reports.push(report),
|
||||
Err(error) => {
|
||||
let report = SendReport::new(public_key).error(error.to_string());
|
||||
reports.push(report);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -576,33 +595,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: &Keys,
|
||||
receiver: &Person,
|
||||
rumor: &UnsignedEvent,
|
||||
public_key: PublicKey,
|
||||
) -> Result<(SendReport, bool), SendReport>
|
||||
where
|
||||
T: NostrSigner + 'static,
|
||||
{
|
||||
match EventBuilder::gift_wrap(signer, receiver, rumor.clone(), []).await {
|
||||
Ok(event) => {
|
||||
match client
|
||||
.send_event(&event)
|
||||
.to_nip17()
|
||||
.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())),
|
||||
config: &SignerKind,
|
||||
) -> Result<SendReport, Error> {
|
||||
let k_tag = Tag::custom("k", vec!["14"]);
|
||||
let mut extra_tags = vec![k_tag];
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
@@ -10,8 +10,8 @@ pub enum Command {
|
||||
ChangeSubject(String),
|
||||
ChangeSigner(SignerKind),
|
||||
ToggleBackup,
|
||||
Subject,
|
||||
Copy(PublicKey),
|
||||
Relays(PublicKey),
|
||||
Njump(PublicKey),
|
||||
Trace(EventId),
|
||||
}
|
||||
|
||||
@@ -3,15 +3,15 @@ use std::sync::Arc;
|
||||
|
||||
pub use actions::*;
|
||||
use anyhow::{Context as AnyhowContext, Error};
|
||||
use chat::{Message, RenderedMessage, Room, RoomEvent, SendReport, SendStatus};
|
||||
use common::RenderedTimestamp;
|
||||
use chat::{ChatRegistry, Message, Room, RoomEvent, SendReport, SendStatus};
|
||||
use common::{TimestampExt, coop_cache};
|
||||
use gpui::prelude::FluentBuilder;
|
||||
use gpui::{
|
||||
AnyElement, App, AppContext, ClipboardItem, Context, Entity, EventEmitter, FocusHandle,
|
||||
Focusable, InteractiveElement, IntoElement, ListAlignment, ListOffset, ListState, MouseButton,
|
||||
ObjectFit, ParentElement, PathPromptOptions, Render, SharedString, StatefulInteractiveElement,
|
||||
Styled, StyledImage, Subscription, Task, WeakEntity, Window, deferred, div, img, list, px, red,
|
||||
relative, svg, white,
|
||||
ObjectFit, ParentElement, PathPromptOptions, Render, SharedString, SharedUri,
|
||||
StatefulInteractiveElement, Styled, StyledImage, Subscription, Task, WeakEntity, Window, div,
|
||||
img, list, px, red, relative, svg, white,
|
||||
};
|
||||
use itertools::Itertools;
|
||||
use nostr_sdk::prelude::*;
|
||||
@@ -24,7 +24,7 @@ use theme::ActiveTheme;
|
||||
use ui::avatar::Avatar;
|
||||
use ui::button::{Button, ButtonVariants};
|
||||
use ui::dock::{Panel, PanelEvent};
|
||||
use ui::input::{InputEvent, InputState, TextInput};
|
||||
use ui::input::{Input, InputEvent, InputState};
|
||||
use ui::menu::DropdownMenu;
|
||||
use ui::notification::Notification;
|
||||
use ui::scroll::Scrollbar;
|
||||
@@ -38,9 +38,6 @@ use crate::text::RenderedText;
|
||||
mod actions;
|
||||
mod text;
|
||||
|
||||
const ANNOUNCEMENT: &str =
|
||||
"This conversation is private. Only members can see each other's messages.";
|
||||
|
||||
pub fn init(room: WeakEntity<Room>, window: &mut Window, cx: &mut App) -> Entity<ChatPanel> {
|
||||
cx.new(|cx| ChatPanel::new(room, window, cx))
|
||||
}
|
||||
@@ -101,7 +98,7 @@ impl ChatPanel {
|
||||
let reports_by_id = cx.new(|_| BTreeMap::new());
|
||||
|
||||
// Define list of messages
|
||||
let messages = BTreeSet::from([Message::system()]);
|
||||
let messages = BTreeSet::default();
|
||||
let list_state = ListState::new(messages.len(), ListAlignment::Bottom, px(1024.));
|
||||
|
||||
// Get room id and name
|
||||
@@ -119,7 +116,6 @@ impl ChatPanel {
|
||||
InputState::new(window, cx)
|
||||
.placeholder(format!("Message {}", name))
|
||||
.auto_grow(1, 20)
|
||||
.prevent_new_line_on_enter()
|
||||
.clean_on_escape()
|
||||
});
|
||||
|
||||
@@ -154,6 +150,7 @@ impl ChatPanel {
|
||||
|
||||
// Define all functions that will run after the current cycle
|
||||
cx.defer_in(window, |this, window, cx| {
|
||||
this.connect(cx);
|
||||
this.handle_notifications(cx);
|
||||
this.subscribe_room_events(window, cx);
|
||||
this.get_messages(window, cx);
|
||||
@@ -179,6 +176,14 @@ impl ChatPanel {
|
||||
}
|
||||
}
|
||||
|
||||
/// Get messaging relays and announcement for each member
|
||||
fn connect(&mut self, cx: &mut Context<Self>) {
|
||||
if let Some(room) = self.room.upgrade() {
|
||||
let task = room.read(cx).connect(cx);
|
||||
self.tasks.push(task);
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle nostr notifications
|
||||
fn handle_notifications(&mut self, cx: &mut Context<Self>) {
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
@@ -192,15 +197,12 @@ impl ChatPanel {
|
||||
let mut notifications = client.notifications();
|
||||
|
||||
while let Some(notification) = notifications.next().await {
|
||||
if let ClientNotification::Message {
|
||||
message:
|
||||
RelayMessage::Ok {
|
||||
event_id,
|
||||
status,
|
||||
message,
|
||||
},
|
||||
relay_url,
|
||||
} = notification
|
||||
if let ClientNotification::Message { message, relay_url } = notification
|
||||
&& let RelayMessage::Ok {
|
||||
event_id,
|
||||
status,
|
||||
message,
|
||||
} = *message
|
||||
{
|
||||
let sent_ids = sent_ids.read().await;
|
||||
|
||||
@@ -229,7 +231,7 @@ impl ChatPanel {
|
||||
match &*status {
|
||||
SendStatus::Ok { id, relay } => {
|
||||
if output.id() == id {
|
||||
output.success.insert(relay.clone());
|
||||
output.success.insert(relay.clone(), EventSendStatus::Sent);
|
||||
}
|
||||
}
|
||||
SendStatus::Failed { id, relay, message } => {
|
||||
@@ -247,11 +249,13 @@ impl ChatPanel {
|
||||
}));
|
||||
}
|
||||
|
||||
/// Subscribe to room events
|
||||
fn subscribe_room_events(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
if let Some(room) = self.room.upgrade() {
|
||||
self.subscriptions.push(
|
||||
// Subscribe to room events
|
||||
cx.subscribe_in(&room, window, move |this, _room, event, window, cx| {
|
||||
self.subscriptions.push(cx.subscribe_in(
|
||||
&room,
|
||||
window,
|
||||
move |this, _room, event, window, cx| {
|
||||
match event {
|
||||
RoomEvent::Incoming(message) => {
|
||||
this.insert_message(message, false, cx);
|
||||
@@ -260,8 +264,8 @@ impl ChatPanel {
|
||||
this.get_messages(window, cx);
|
||||
}
|
||||
};
|
||||
}),
|
||||
);
|
||||
},
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -368,9 +372,13 @@ impl ChatPanel {
|
||||
/// Send message in the background and wait for the response
|
||||
fn send_and_wait(&mut self, rumor: UnsignedEvent, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let sent_ids = self.sent_ids.clone();
|
||||
|
||||
// This can't fail, because we already ensured that the ID is set
|
||||
let id = rumor.id.unwrap();
|
||||
|
||||
// Add empty reports
|
||||
self.insert_reports(id, vec![], cx);
|
||||
|
||||
// Upgrade room reference
|
||||
let Some(room) = self.room.upgrade() else {
|
||||
return;
|
||||
@@ -419,7 +427,7 @@ impl ChatPanel {
|
||||
/// Insert reports
|
||||
fn insert_reports(&mut self, id: EventId, reports: Vec<SendReport>, cx: &mut Context<Self>) {
|
||||
self.reports_by_id.update(cx, |this, cx| {
|
||||
this.insert(id, reports);
|
||||
this.entry(id).or_default().extend(reports);
|
||||
cx.notify();
|
||||
});
|
||||
}
|
||||
@@ -454,14 +462,6 @@ impl ChatPanel {
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if a message is pending
|
||||
fn sent_pending(&self, id: &EventId, cx: &App) -> bool {
|
||||
self.reports_by_id
|
||||
.read(cx)
|
||||
.get(id)
|
||||
.is_some_and(|reports| reports.iter().all(|r| r.pending()))
|
||||
}
|
||||
|
||||
/// Check if a message has any reports
|
||||
fn has_reports(&self, id: &EventId, cx: &App) -> bool {
|
||||
self.reports_by_id.read(cx).get(id).is_some()
|
||||
@@ -473,25 +473,13 @@ impl ChatPanel {
|
||||
}
|
||||
|
||||
/// Get a message by its ID
|
||||
fn message(&self, id: &EventId) -> Option<&RenderedMessage> {
|
||||
self.messages.iter().find_map(|msg| {
|
||||
if let Message::User(rendered) = msg {
|
||||
if &rendered.id == id {
|
||||
return Some(rendered);
|
||||
}
|
||||
}
|
||||
None
|
||||
})
|
||||
fn message(&self, id: &EventId) -> Option<&Message> {
|
||||
self.messages.iter().find(|msg| &msg.id == id)
|
||||
}
|
||||
|
||||
fn scroll_to(&self, id: EventId) {
|
||||
if let Some(ix) = self.messages.iter().position(|m| {
|
||||
if let Message::User(msg) = m {
|
||||
msg.id == id
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}) {
|
||||
/// Scroll to a message by its ID
|
||||
fn scroll_to(&self, id: &EventId) {
|
||||
if let Some(ix) = self.messages.iter().position(|msg| &msg.id == id) {
|
||||
self.list_state.scroll_to_reveal_item(ix);
|
||||
}
|
||||
}
|
||||
@@ -611,13 +599,19 @@ impl ChatPanel {
|
||||
})
|
||||
.is_err()
|
||||
{
|
||||
window.push_notification(
|
||||
Notification::error("Failed to change subject").autohide(false),
|
||||
cx,
|
||||
);
|
||||
window.push_notification(Notification::error("Failed to change subject"), cx);
|
||||
}
|
||||
}
|
||||
Command::ChangeSigner(kind) => {
|
||||
let settings = AppSettings::global(cx);
|
||||
let is_nip4e_enabled = settings.read(cx).is_nip4e_enabled(cx);
|
||||
let is_force_nip4e = *kind == SignerKind::Encryption || *kind == SignerKind::Auto;
|
||||
|
||||
if !is_nip4e_enabled && is_force_nip4e {
|
||||
window.push_notification("Decoupling Encryption Key is not enabled", cx);
|
||||
return;
|
||||
}
|
||||
|
||||
if self
|
||||
.room
|
||||
.update(cx, |this, cx| {
|
||||
@@ -625,10 +619,7 @@ impl ChatPanel {
|
||||
})
|
||||
.is_err()
|
||||
{
|
||||
window.push_notification(
|
||||
Notification::error("Failed to change signer").autohide(false),
|
||||
cx,
|
||||
);
|
||||
window.push_notification(Notification::error("Failed to change signer"), cx);
|
||||
}
|
||||
}
|
||||
Command::ToggleBackup => {
|
||||
@@ -639,15 +630,9 @@ impl ChatPanel {
|
||||
})
|
||||
.is_err()
|
||||
{
|
||||
window.push_notification(
|
||||
Notification::error("Failed to toggle backup").autohide(false),
|
||||
cx,
|
||||
);
|
||||
window.push_notification(Notification::error("Failed to toggle backup"), cx);
|
||||
}
|
||||
}
|
||||
Command::Subject => {
|
||||
self.open_subject(window, cx);
|
||||
}
|
||||
Command::Copy(public_key) => {
|
||||
self.copy_author(public_key, cx);
|
||||
}
|
||||
@@ -657,47 +642,53 @@ impl ChatPanel {
|
||||
Command::Njump(public_key) => {
|
||||
self.open_njump(public_key, cx);
|
||||
}
|
||||
Command::Trace(id) => {
|
||||
self.open_trace(id, window, cx);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn open_subject(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let subject_input = self.subject_input.clone();
|
||||
fn open_trace(&mut self, id: &EventId, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let chat = ChatRegistry::global(cx);
|
||||
let seen_on = chat.read(cx).rumor_seen_on(id);
|
||||
|
||||
window.open_modal(cx, move |this, _window, cx| {
|
||||
let subject = subject_input.read(cx).value();
|
||||
|
||||
this.title("Change subject")
|
||||
.show_close(true)
|
||||
.confirm()
|
||||
.child(
|
||||
v_flex()
|
||||
.gap_2()
|
||||
.child(
|
||||
v_flex()
|
||||
.gap_1p5()
|
||||
.child(
|
||||
div()
|
||||
.text_sm()
|
||||
.text_color(cx.theme().text_muted)
|
||||
.child(SharedString::from("Subject:")),
|
||||
)
|
||||
.child(TextInput::new(&subject_input).small()),
|
||||
this.title("Seen on").show_close(true).child(
|
||||
v_flex()
|
||||
.gap_1()
|
||||
.when_none(&seen_on, |this| {
|
||||
this.child(
|
||||
h_flex()
|
||||
.h_10()
|
||||
.justify_center()
|
||||
.text_sm()
|
||||
.bg(cx.theme().elevated_surface_background)
|
||||
.rounded(cx.theme().radius)
|
||||
.child("Message isn't traced yet"),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.italic()
|
||||
.text_xs()
|
||||
.text_color(cx.theme().text_placeholder)
|
||||
.child(SharedString::from(
|
||||
"Subject will be updated when you send a new message.",
|
||||
)),
|
||||
),
|
||||
)
|
||||
.on_ok(move |_ev, window, cx| {
|
||||
window
|
||||
.dispatch_action(Box::new(Command::ChangeSubject(subject.to_string())), cx);
|
||||
true
|
||||
})
|
||||
})
|
||||
.when_some(seen_on.as_ref(), |this, relays| {
|
||||
this.children({
|
||||
let mut items = vec![];
|
||||
|
||||
for url in relays.iter() {
|
||||
items.push(
|
||||
h_flex()
|
||||
.h_7()
|
||||
.px_2()
|
||||
.gap_2()
|
||||
.bg(cx.theme().elevated_surface_background)
|
||||
.rounded(cx.theme().radius)
|
||||
.text_sm()
|
||||
.child(div().size_1p5().rounded_full().bg(gpui::green()))
|
||||
.child(SharedString::from(url.to_string())),
|
||||
);
|
||||
}
|
||||
|
||||
items
|
||||
})
|
||||
}),
|
||||
)
|
||||
});
|
||||
}
|
||||
|
||||
@@ -736,9 +727,11 @@ impl ChatPanel {
|
||||
cx.open_url(&content);
|
||||
}
|
||||
|
||||
fn render_announcement(&self, ix: usize, cx: &Context<Self>) -> AnyElement {
|
||||
fn render_announcement(&self, cx: &Context<Self>) -> AnyElement {
|
||||
const MSG: &str =
|
||||
"This conversation is private. Only members can see each other's messages.";
|
||||
|
||||
v_flex()
|
||||
.id(ix)
|
||||
.h_40()
|
||||
.w_full()
|
||||
.gap_3()
|
||||
@@ -755,7 +748,7 @@ impl ChatPanel {
|
||||
.size_12()
|
||||
.text_color(cx.theme().ghost_element_active),
|
||||
)
|
||||
.child(SharedString::from(ANNOUNCEMENT))
|
||||
.child(MSG)
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
@@ -792,6 +785,34 @@ impl ChatPanel {
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
fn is_group_start(&self, ix: usize) -> bool {
|
||||
// 5 minutes
|
||||
const GROUP_WINDOW: u64 = 300;
|
||||
|
||||
if ix == 0 {
|
||||
return true;
|
||||
}
|
||||
|
||||
let mut iter = self.messages.iter();
|
||||
|
||||
if let Some(previous) = iter.nth(ix - 1)
|
||||
&& let Some(current) = iter.next()
|
||||
{
|
||||
if current.author != previous.author {
|
||||
return true;
|
||||
}
|
||||
|
||||
let gap = current
|
||||
.created_at
|
||||
.as_secs()
|
||||
.saturating_sub(previous.created_at.as_secs());
|
||||
|
||||
return gap > GROUP_WINDOW;
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
fn render_message(
|
||||
&mut self,
|
||||
ix: usize,
|
||||
@@ -799,24 +820,17 @@ impl ChatPanel {
|
||||
cx: &mut Context<Self>,
|
||||
) -> AnyElement {
|
||||
if let Some(message) = self.messages.iter().nth(ix) {
|
||||
match message {
|
||||
Message::User(rendered) => {
|
||||
let persons = PersonRegistry::global(cx);
|
||||
let text = self
|
||||
.rendered_texts_by_id
|
||||
.entry(rendered.id)
|
||||
.or_insert_with(|| {
|
||||
RenderedText::new(&rendered.content, &rendered.mentions, &persons, cx)
|
||||
})
|
||||
.element(ix.into(), window, cx);
|
||||
let persons = PersonRegistry::global(cx);
|
||||
let show_author = self.is_group_start(ix);
|
||||
let text = self
|
||||
.rendered_texts_by_id
|
||||
.entry(message.id)
|
||||
.or_insert_with(|| {
|
||||
RenderedText::new(&message.content, &message.mentions, &persons, cx)
|
||||
})
|
||||
.element(ix.into(), window, cx);
|
||||
|
||||
self.render_text_message(ix, rendered, text, cx)
|
||||
}
|
||||
Message::Warning(content, _timestamp) => {
|
||||
self.render_warning(ix, SharedString::from(content), cx)
|
||||
}
|
||||
Message::System(_timestamp) => self.render_announcement(ix, cx),
|
||||
}
|
||||
self.render_text_message(ix, message, text, show_author, cx)
|
||||
} else {
|
||||
self.render_warning(ix, SharedString::from("Message not found"), cx)
|
||||
}
|
||||
@@ -825,8 +839,9 @@ impl ChatPanel {
|
||||
fn render_text_message(
|
||||
&self,
|
||||
ix: usize,
|
||||
message: &RenderedMessage,
|
||||
message: &Message,
|
||||
rendered_text: AnyElement,
|
||||
show_author: bool,
|
||||
cx: &Context<Self>,
|
||||
) -> AnyElement {
|
||||
let id = message.id;
|
||||
@@ -835,8 +850,6 @@ impl ChatPanel {
|
||||
|
||||
let replies = message.replies_to.as_slice();
|
||||
let has_replies = !replies.is_empty();
|
||||
|
||||
let sent_pending = self.sent_pending(&id, cx);
|
||||
let has_reports = self.has_reports(&id, cx);
|
||||
|
||||
// Hide avatar setting
|
||||
@@ -854,17 +867,21 @@ impl ChatPanel {
|
||||
.flex()
|
||||
.gap_3()
|
||||
.when(!hide_avatar, |this| {
|
||||
this.child(
|
||||
Avatar::new(author.avatar())
|
||||
.flex_shrink_0()
|
||||
.relative()
|
||||
.dropdown_menu(move |this, _window, _cx| {
|
||||
this.menu("Public Key", Box::new(Command::Copy(pk)))
|
||||
.menu("View Relays", Box::new(Command::Relays(pk)))
|
||||
.separator()
|
||||
.menu("View on njump.me", Box::new(Command::Njump(pk)))
|
||||
}),
|
||||
)
|
||||
if show_author {
|
||||
this.child(
|
||||
Avatar::new(author.avatar())
|
||||
.flex_shrink_0()
|
||||
.relative()
|
||||
.dropdown_menu(move |this, _window, _cx| {
|
||||
this.menu("Public Key", Box::new(Command::Copy(pk)))
|
||||
.menu("View Relays", Box::new(Command::Relays(pk)))
|
||||
.separator()
|
||||
.menu("View on njump.me", Box::new(Command::Njump(pk)))
|
||||
}),
|
||||
)
|
||||
} else {
|
||||
this.child(div().flex_shrink_0().w(px(32.)))
|
||||
}
|
||||
})
|
||||
.child(
|
||||
v_flex()
|
||||
@@ -872,29 +889,29 @@ impl ChatPanel {
|
||||
.w_full()
|
||||
.flex_initial()
|
||||
.overflow_hidden()
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_2()
|
||||
.text_sm()
|
||||
.text_color(cx.theme().text_placeholder)
|
||||
.child(
|
||||
div()
|
||||
.font_semibold()
|
||||
.text_color(cx.theme().text)
|
||||
.child(author.name()),
|
||||
)
|
||||
.child(message.created_at.to_human_time())
|
||||
.when(sent_pending, |this| {
|
||||
this.child(SharedString::from("• Sending..."))
|
||||
})
|
||||
.when(has_reports, |this| {
|
||||
this.child(deferred(self.render_sent_reports(&id, cx)))
|
||||
}),
|
||||
)
|
||||
.when(show_author, |this| {
|
||||
this.child(
|
||||
h_flex()
|
||||
.gap_2()
|
||||
.text_sm()
|
||||
.text_color(cx.theme().text_placeholder)
|
||||
.child(
|
||||
div()
|
||||
.font_semibold()
|
||||
.text_color(cx.theme().text)
|
||||
.child(author.name()),
|
||||
)
|
||||
.child(message.created_at.to_human_time())
|
||||
.when(has_reports, |this| {
|
||||
this.child(self.render_sent_reports(&id, cx))
|
||||
}),
|
||||
)
|
||||
})
|
||||
.when(has_replies, |this| {
|
||||
this.children(self.render_message_replies(replies, cx))
|
||||
})
|
||||
.child(rendered_text),
|
||||
.child(rendered_text)
|
||||
.child(self.render_media(&message.media, cx)),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
@@ -921,6 +938,55 @@ impl ChatPanel {
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
fn render_media(&self, media: &[SharedUri], cx: &Context<Self>) -> impl IntoElement {
|
||||
// No media: return empty div
|
||||
if media.is_empty() {
|
||||
return div();
|
||||
};
|
||||
|
||||
// Single media item: render full-width image
|
||||
if media.len() == 1 {
|
||||
return div().child(
|
||||
img(media[0].clone())
|
||||
.border_1()
|
||||
.border_color(cx.theme().border_variant)
|
||||
.h(px(250.))
|
||||
.object_fit(ObjectFit::Cover)
|
||||
.rounded(cx.theme().radius),
|
||||
);
|
||||
}
|
||||
|
||||
// Multiple media items: render in a row
|
||||
div()
|
||||
.w_full()
|
||||
.flex_1()
|
||||
.flex()
|
||||
.flex_row()
|
||||
.flex_wrap()
|
||||
.gap_2()
|
||||
.children({
|
||||
let mut items = vec![];
|
||||
|
||||
for (ix, item) in media.iter().enumerate() {
|
||||
items.push(
|
||||
div()
|
||||
.id(format!("media-{ix}"))
|
||||
.flex_grow_0()
|
||||
.flex_shrink_0()
|
||||
.child(
|
||||
img(item.clone())
|
||||
.h_32()
|
||||
.border_1()
|
||||
.border_color(cx.theme().border_variant)
|
||||
.rounded(cx.theme().radius),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
items
|
||||
})
|
||||
}
|
||||
|
||||
fn render_message_replies(
|
||||
&self,
|
||||
replies: &[EventId],
|
||||
@@ -958,7 +1024,7 @@ impl ChatPanel {
|
||||
.on_click({
|
||||
let id = *id;
|
||||
cx.listener(move |this, _event, _window, _cx| {
|
||||
this.scroll_to(id);
|
||||
this.scroll_to(&id);
|
||||
})
|
||||
}),
|
||||
);
|
||||
@@ -970,20 +1036,26 @@ impl ChatPanel {
|
||||
fn render_sent_reports(&self, id: &EventId, cx: &App) -> impl IntoElement {
|
||||
let reports = self.sent_reports(id, cx);
|
||||
|
||||
let pending = reports
|
||||
.as_ref()
|
||||
.is_some_and(|reports| reports.is_empty() || reports.iter().any(|r| r.pending()));
|
||||
|
||||
let success = reports
|
||||
.as_ref()
|
||||
.is_some_and(|reports| reports.iter().any(|r| r.success()));
|
||||
.is_some_and(|reports| !reports.is_empty() && reports.iter().any(|r| r.success()));
|
||||
|
||||
let failed = reports
|
||||
.as_ref()
|
||||
.is_some_and(|reports| reports.iter().all(|r| r.failed()));
|
||||
.is_some_and(|reports| !reports.is_empty() && reports.iter().all(|r| r.failed()));
|
||||
|
||||
let label = if success {
|
||||
SharedString::from("• Sent")
|
||||
} else if failed {
|
||||
SharedString::from("• Failed")
|
||||
} else {
|
||||
SharedString::from("• Error")
|
||||
} else if pending {
|
||||
SharedString::from("• Sending...")
|
||||
} else {
|
||||
SharedString::from("• Unknown")
|
||||
};
|
||||
|
||||
div()
|
||||
@@ -991,22 +1063,24 @@ impl ChatPanel {
|
||||
.child(label)
|
||||
.when(failed, |this| this.text_color(cx.theme().text_danger))
|
||||
.when_some(reports, |this, reports| {
|
||||
this.on_click(move |_e, window, cx| {
|
||||
let reports = reports.clone();
|
||||
this.when(!pending, |this| {
|
||||
this.on_click(move |_e, window, cx| {
|
||||
let reports = reports.clone();
|
||||
|
||||
window.open_modal(cx, move |this, _window, cx| {
|
||||
this.title(SharedString::from("Sent Reports"))
|
||||
.show_close(true)
|
||||
.child(v_flex().gap_4().children({
|
||||
let mut items = Vec::with_capacity(reports.len());
|
||||
window.open_modal(cx, move |this, _window, cx| {
|
||||
this.title(SharedString::from("Sent Reports"))
|
||||
.show_close(true)
|
||||
.child(v_flex().gap_4().children({
|
||||
let mut items = Vec::with_capacity(reports.len());
|
||||
|
||||
for report in reports.iter() {
|
||||
items.push(Self::render_report(report, cx))
|
||||
}
|
||||
for report in reports.iter() {
|
||||
items.push(Self::render_report(report, cx))
|
||||
}
|
||||
|
||||
items
|
||||
}))
|
||||
});
|
||||
items
|
||||
}))
|
||||
});
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -1099,7 +1173,7 @@ impl ChatPanel {
|
||||
.text_xs()
|
||||
.font_semibold()
|
||||
.line_height(relative(1.25))
|
||||
.child(SharedString::from(url.to_string())),
|
||||
.child(SharedString::from(url.0.to_string())),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
@@ -1168,15 +1242,10 @@ impl ChatPanel {
|
||||
.ghost()
|
||||
.dropdown_menu({
|
||||
let public_key = *public_key;
|
||||
let _id = *id;
|
||||
let id = *id;
|
||||
move |this, _window, _cx| {
|
||||
this.menu("Copy author", Box::new(Command::Copy(public_key)))
|
||||
/*
|
||||
.menu(
|
||||
"Trace",
|
||||
Box::new(Command::Trace(id)),
|
||||
)
|
||||
*/
|
||||
.menu("Seen on", Box::new(Command::Trace(id)))
|
||||
}
|
||||
}),
|
||||
)
|
||||
@@ -1348,7 +1417,7 @@ impl ChatPanel {
|
||||
.icon(IconName::Emoji)
|
||||
.ghost()
|
||||
.large()
|
||||
.dropdown_menu_with_anchor(gpui::Corner::BottomLeft, move |this, _window, _cx| {
|
||||
.dropdown_menu_with_anchor(gpui::Anchor::BottomLeft, move |this, _window, _cx| {
|
||||
this.horizontal()
|
||||
.menu("👍", Box::new(Command::Insert("👍")))
|
||||
.menu("👎", Box::new(Command::Insert("👎")))
|
||||
@@ -1412,6 +1481,7 @@ impl Focusable for ChatPanel {
|
||||
impl Render for ChatPanel {
|
||||
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
v_flex()
|
||||
.image_cache(coop_cache(self.id.clone(), 100))
|
||||
.on_action(cx.listener(Self::on_command))
|
||||
.size_full()
|
||||
.when(*self.subject_bar.read(cx), |this| {
|
||||
@@ -1424,7 +1494,7 @@ impl Render for ChatPanel {
|
||||
.border_b_1()
|
||||
.border_color(cx.theme().border)
|
||||
.child(
|
||||
TextInput::new(&self.subject_input)
|
||||
Input::new(&self.subject_input)
|
||||
.text_sm()
|
||||
.small()
|
||||
.bordered(false),
|
||||
@@ -1445,15 +1515,28 @@ impl Render for ChatPanel {
|
||||
v_flex()
|
||||
.flex_1()
|
||||
.relative()
|
||||
.child(
|
||||
list(
|
||||
self.list_state.clone(),
|
||||
cx.processor(move |this, ix, window, cx| {
|
||||
this.render_message(ix, window, cx)
|
||||
}),
|
||||
)
|
||||
.size_full(),
|
||||
)
|
||||
.map(|this| {
|
||||
if self.messages.is_empty() {
|
||||
this.child(
|
||||
div()
|
||||
.size_full()
|
||||
.flex()
|
||||
.items_center()
|
||||
.justify_end()
|
||||
.child(self.render_announcement(cx)),
|
||||
)
|
||||
} else {
|
||||
this.child(
|
||||
list(
|
||||
self.list_state.clone(),
|
||||
cx.processor(move |this, ix, window, cx| {
|
||||
this.render_message(ix, window, cx)
|
||||
}),
|
||||
)
|
||||
.size_full(),
|
||||
)
|
||||
}
|
||||
})
|
||||
.child(Scrollbar::vertical(&self.list_state)),
|
||||
)
|
||||
.child(
|
||||
@@ -1479,12 +1562,7 @@ impl Render for ChatPanel {
|
||||
this.upload(window, cx);
|
||||
})),
|
||||
)
|
||||
.child(
|
||||
TextInput::new(&self.input)
|
||||
.appearance(false)
|
||||
.text_sm()
|
||||
.flex_1(),
|
||||
)
|
||||
.child(Input::new(&self.input).appearance(false).flex_1())
|
||||
.child(
|
||||
h_flex()
|
||||
.pl_1()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -20,3 +20,4 @@ log.workspace = true
|
||||
dirs = "5.0"
|
||||
qrcode = "0.14.1"
|
||||
bech32 = "0.11.1"
|
||||
regex = "1.10"
|
||||
|
||||
135
crates/common/src/caching.rs
Normal file
@@ -0,0 +1,135 @@
|
||||
use std::collections::{HashMap, VecDeque};
|
||||
use std::mem::take;
|
||||
|
||||
use futures::FutureExt;
|
||||
use gpui::{
|
||||
App, AppContext, Asset, AssetLogger, ElementId, Entity, ImageAssetLoader, ImageCache,
|
||||
ImageCacheItem, ImageCacheProvider, ImageSource, Resource, hash,
|
||||
};
|
||||
|
||||
pub fn coop_cache(id: impl Into<ElementId>, max_items: usize) -> CoopImageCacheProvider {
|
||||
CoopImageCacheProvider {
|
||||
id: id.into(),
|
||||
max_items,
|
||||
}
|
||||
}
|
||||
|
||||
pub struct CoopImageCacheProvider {
|
||||
id: ElementId,
|
||||
max_items: usize,
|
||||
}
|
||||
|
||||
impl ImageCacheProvider for CoopImageCacheProvider {
|
||||
fn provide(&mut self, window: &mut gpui::Window, cx: &mut App) -> gpui::AnyImageCache {
|
||||
window
|
||||
.with_global_id(self.id.clone(), |id, window| {
|
||||
window.with_element_state(id, |cache, _| {
|
||||
let cache = cache.unwrap_or_else(|| CoopImageCache::new(self.max_items, cx));
|
||||
(cache.clone(), cache)
|
||||
})
|
||||
})
|
||||
.into()
|
||||
}
|
||||
}
|
||||
|
||||
pub struct CoopImageCache {
|
||||
max_items: usize,
|
||||
usage_list: VecDeque<u64>,
|
||||
cache: HashMap<u64, (ImageCacheItem, Resource)>,
|
||||
}
|
||||
|
||||
impl CoopImageCache {
|
||||
pub fn new(max_items: usize, cx: &mut App) -> Entity<Self> {
|
||||
cx.new(|cx| {
|
||||
log::info!("Creating CoopImageCache");
|
||||
cx.on_release(|this: &mut Self, cx| {
|
||||
for (ix, (mut image, resource)) in take(&mut this.cache) {
|
||||
if let Some(Ok(image)) = image.get() {
|
||||
log::info!("Dropping image {ix}");
|
||||
cx.drop_image(image, None);
|
||||
}
|
||||
ImageSource::Resource(resource).remove_asset(cx);
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
|
||||
CoopImageCache {
|
||||
max_items,
|
||||
usage_list: VecDeque::with_capacity(max_items),
|
||||
cache: HashMap::with_capacity(max_items),
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl ImageCache for CoopImageCache {
|
||||
fn load(
|
||||
&mut self,
|
||||
resource: &Resource,
|
||||
window: &mut gpui::Window,
|
||||
cx: &mut gpui::App,
|
||||
) -> Option<Result<std::sync::Arc<gpui::RenderImage>, gpui::ImageCacheError>> {
|
||||
let hash = hash(resource);
|
||||
|
||||
if let Some(item) = self.cache.get_mut(&hash) {
|
||||
let current_idx = self
|
||||
.usage_list
|
||||
.iter()
|
||||
.position(|item| *item == hash)
|
||||
.expect("cache has an item usage_list doesn't");
|
||||
|
||||
self.usage_list.remove(current_idx);
|
||||
self.usage_list.push_front(hash);
|
||||
|
||||
return item.0.get();
|
||||
}
|
||||
|
||||
let load_future = AssetLogger::<ImageAssetLoader>::load(resource.clone(), cx);
|
||||
let task = cx.background_executor().spawn(load_future).shared();
|
||||
|
||||
if self.usage_list.len() >= self.max_items {
|
||||
log::info!("Image cache is full, evicting oldest item");
|
||||
|
||||
if let Some(oldest) = self.usage_list.pop_back() {
|
||||
let mut image = self
|
||||
.cache
|
||||
.remove(&oldest)
|
||||
.expect("usage_list has an item cache doesn't");
|
||||
|
||||
if let Some(Ok(image)) = image.0.get() {
|
||||
log::info!("requesting image to be dropped");
|
||||
cx.drop_image(image, Some(window));
|
||||
}
|
||||
|
||||
ImageSource::Resource(image.1).remove_asset(cx);
|
||||
}
|
||||
}
|
||||
|
||||
self.cache.insert(
|
||||
hash,
|
||||
(
|
||||
gpui::ImageCacheItem::Loading(task.clone()),
|
||||
resource.clone(),
|
||||
),
|
||||
);
|
||||
self.usage_list.push_front(hash);
|
||||
|
||||
let entity = window.current_view();
|
||||
|
||||
window
|
||||
.spawn(cx, async move |cx| {
|
||||
let result = task.await;
|
||||
|
||||
if let Err(err) = result {
|
||||
log::error!("error loading image into cache: {:?}", err);
|
||||
}
|
||||
|
||||
cx.on_next_frame(move |_, cx| {
|
||||
cx.notify(entity);
|
||||
});
|
||||
})
|
||||
.detach();
|
||||
|
||||
None
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,10 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::{Error, anyhow};
|
||||
use chrono::{Local, TimeZone};
|
||||
use gpui::{Image, ImageFormat, SharedString};
|
||||
use nostr_sdk::prelude::*;
|
||||
use qrcode::render::svg;
|
||||
use qrcode::QrCode;
|
||||
use qrcode::render::svg;
|
||||
|
||||
const NOW: &str = "now";
|
||||
const SECONDS_IN_MINUTE: i64 = 60;
|
||||
@@ -13,12 +12,12 @@ const MINUTES_IN_HOUR: i64 = 60;
|
||||
const HOURS_IN_DAY: i64 = 24;
|
||||
const DAYS_IN_MONTH: i64 = 30;
|
||||
|
||||
pub trait RenderedTimestamp {
|
||||
pub trait TimestampExt {
|
||||
fn to_human_time(&self) -> SharedString;
|
||||
fn to_ago(&self) -> SharedString;
|
||||
}
|
||||
|
||||
impl RenderedTimestamp for Timestamp {
|
||||
impl TimestampExt for Timestamp {
|
||||
fn to_human_time(&self) -> SharedString {
|
||||
let input_time = match Local.timestamp_opt(self.as_secs() as i64, 0) {
|
||||
chrono::LocalResult::Single(time) => time,
|
||||
@@ -61,23 +60,11 @@ impl RenderedTimestamp for Timestamp {
|
||||
}
|
||||
}
|
||||
|
||||
pub trait TextUtils {
|
||||
fn to_public_key(&self) -> Result<PublicKey, Error>;
|
||||
pub trait StringExt {
|
||||
fn to_qr(&self) -> Option<Arc<Image>>;
|
||||
}
|
||||
|
||||
impl<T: AsRef<str>> TextUtils for T {
|
||||
fn to_public_key(&self) -> Result<PublicKey, Error> {
|
||||
let s = self.as_ref();
|
||||
if s.starts_with("nprofile1") {
|
||||
Ok(Nip19Profile::from_bech32(s)?.public_key)
|
||||
} else if s.starts_with("npub1") {
|
||||
Ok(PublicKey::parse(s)?)
|
||||
} else {
|
||||
Err(anyhow!("Invalid public key"))
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: AsRef<str>> StringExt for T {
|
||||
fn to_qr(&self) -> Option<Arc<Image>> {
|
||||
let s = self.as_ref();
|
||||
let code = QrCode::new(s).unwrap();
|
||||
|
||||
@@ -3,12 +3,12 @@ use std::hash::{DefaultHasher, Hash, Hasher};
|
||||
use itertools::Itertools;
|
||||
use nostr_sdk::prelude::*;
|
||||
|
||||
pub trait EventUtils {
|
||||
pub trait EventExt {
|
||||
fn uniq_id(&self) -> u64;
|
||||
fn extract_public_keys(&self) -> Vec<PublicKey>;
|
||||
}
|
||||
|
||||
impl EventUtils for Event {
|
||||
impl EventExt for Event {
|
||||
fn uniq_id(&self) -> u64 {
|
||||
let mut hasher = DefaultHasher::new();
|
||||
let mut pubkeys: Vec<PublicKey> = self.extract_public_keys();
|
||||
@@ -18,14 +18,14 @@ impl EventUtils for Event {
|
||||
}
|
||||
|
||||
fn extract_public_keys(&self) -> Vec<PublicKey> {
|
||||
let mut public_keys: Vec<PublicKey> = self.tags.public_keys().copied().collect();
|
||||
let mut public_keys: Vec<PublicKey> = self.tags.public_keys().collect();
|
||||
public_keys.push(self.pubkey);
|
||||
|
||||
public_keys.into_iter().unique().collect()
|
||||
}
|
||||
}
|
||||
|
||||
impl EventUtils for UnsignedEvent {
|
||||
impl EventExt for UnsignedEvent {
|
||||
fn uniq_id(&self) -> u64 {
|
||||
let mut hasher = DefaultHasher::new();
|
||||
let mut pubkeys: Vec<PublicKey> = vec![];
|
||||
@@ -46,7 +46,7 @@ impl EventUtils for UnsignedEvent {
|
||||
}
|
||||
|
||||
fn extract_public_keys(&self) -> Vec<PublicKey> {
|
||||
let mut public_keys: Vec<PublicKey> = self.tags.public_keys().copied().collect();
|
||||
let mut public_keys: Vec<PublicKey> = self.tags.public_keys().collect();
|
||||
public_keys.push(self.pubkey);
|
||||
public_keys.into_iter().unique().sorted().collect()
|
||||
}
|
||||
|
||||
@@ -1,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;
|
||||
|
||||
117
crates/common/src/media_extractor.rs
Normal file
@@ -0,0 +1,117 @@
|
||||
use gpui::SharedUri;
|
||||
use regex::Regex;
|
||||
|
||||
/// Extracts media URLs from a string and returns both the extracted URLs
|
||||
/// and the string with media URLs removed
|
||||
pub struct MediaExtractor {
|
||||
image_regex: Regex,
|
||||
video_regex: Regex,
|
||||
}
|
||||
|
||||
impl MediaExtractor {
|
||||
/// Creates a new MediaExtractor with compiled regex patterns
|
||||
pub fn new() -> Self {
|
||||
MediaExtractor {
|
||||
// Match common image extensions
|
||||
image_regex: Regex::new(
|
||||
r#"(?i)\bhttps?://[^\s<>"']+\.(?:jpg|jpeg|png|gif|bmp|webp|svg|ico)(?:\?[^\s<>"']*)?\b"#,
|
||||
).unwrap(),
|
||||
// Match common video extensions
|
||||
video_regex: Regex::new(
|
||||
r#"(?i)\bhttps?://[^\s<>"']+\.(?:mp4|mov|avi|mkv|webm|flv|wmv|m4v|3gp)(?:\?[^\s<>"']*)?\b"#,
|
||||
).unwrap(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Extracts all media URLs from a string
|
||||
pub fn extract_media_urls(&self, text: &str) -> Vec<SharedUri> {
|
||||
let mut urls = Vec::new();
|
||||
|
||||
// Extract image URLs
|
||||
for capture in self.image_regex.find_iter(text) {
|
||||
urls.push(capture.as_str().to_string().into());
|
||||
}
|
||||
|
||||
// Extract video URLs
|
||||
// for capture in self.video_regex.find_iter(text) {
|
||||
// urls.push(capture.as_str().to_string().into());
|
||||
// }
|
||||
|
||||
urls
|
||||
}
|
||||
|
||||
/// Removes all media URLs from a string and returns the cleaned text
|
||||
pub fn remove_media_urls(&self, text: &str) -> String {
|
||||
let mut result = text.to_string();
|
||||
|
||||
// Remove image URLs
|
||||
result = self.image_regex.replace_all(&result, "").to_string();
|
||||
|
||||
// Remove video URLs
|
||||
// result = self.video_regex.replace_all(&result, "").to_string();
|
||||
|
||||
// Clean up extra whitespace that might result from removal
|
||||
self.cleanup_text(&result)
|
||||
}
|
||||
|
||||
/// Extracts media URLs and removes them from the string, returning both
|
||||
pub fn extract_and_remove(&self, text: &str) -> (Vec<SharedUri>, String) {
|
||||
let urls = self.extract_media_urls(text);
|
||||
let cleaned_text = self.remove_media_urls(text);
|
||||
(urls, cleaned_text)
|
||||
}
|
||||
|
||||
/// Helper function to clean up text after URL removal
|
||||
fn cleanup_text(&self, text: &str) -> String {
|
||||
let text = text.trim();
|
||||
|
||||
// Remove multiple consecutive spaces
|
||||
let re = Regex::new(r"\s+").unwrap();
|
||||
re.replace_all(text, " ").trim().to_string()
|
||||
}
|
||||
|
||||
/// Validates if a URL is a valid media URL
|
||||
pub fn is_media_url(&self, url: &str) -> bool {
|
||||
self.image_regex.is_match(url) || self.video_regex.is_match(url)
|
||||
}
|
||||
|
||||
/// Categorizes extracted URLs into images and videos
|
||||
pub fn categorize_urls(&self, urls: &[SharedUri]) -> (Vec<SharedUri>, Vec<SharedUri>) {
|
||||
let mut images = Vec::new();
|
||||
let mut videos = Vec::new();
|
||||
|
||||
for url in urls {
|
||||
if self.image_regex.is_match(url) {
|
||||
images.push(url.clone());
|
||||
} else if self.video_regex.is_match(url) {
|
||||
videos.push(url.clone());
|
||||
}
|
||||
}
|
||||
|
||||
(images, videos)
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for MediaExtractor {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// Convenience function for one-time extraction and removal
|
||||
pub fn extract_and_remove_media_urls(text: &str) -> (Vec<SharedUri>, String) {
|
||||
let extractor = MediaExtractor::new();
|
||||
extractor.extract_and_remove(text)
|
||||
}
|
||||
|
||||
/// Convenience function for just extracting media URLs
|
||||
pub fn extract_media_urls(text: &str) -> Vec<SharedUri> {
|
||||
let extractor = MediaExtractor::new();
|
||||
extractor.extract_media_urls(text)
|
||||
}
|
||||
|
||||
/// Convenience function for just removing media URLs
|
||||
pub fn remove_media_urls(text: &str) -> String {
|
||||
let extractor = MediaExtractor::new();
|
||||
extractor.remove_media_urls(text)
|
||||
}
|
||||
@@ -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"))
|
||||
}
|
||||
|
||||
@@ -1,257 +0,0 @@
|
||||
use anyhow::Error;
|
||||
use gpui::prelude::FluentBuilder;
|
||||
use gpui::{
|
||||
App, AppContext, Context, Entity, InteractiveElement, IntoElement, ParentElement, Render,
|
||||
SharedString, StatefulInteractiveElement, Styled, Subscription, Task, Window, div, px,
|
||||
};
|
||||
use nostr_sdk::prelude::*;
|
||||
use person::PersonRegistry;
|
||||
use state::{NostrRegistry, StateEvent};
|
||||
use theme::ActiveTheme;
|
||||
use ui::avatar::Avatar;
|
||||
use ui::button::{Button, ButtonVariants};
|
||||
use ui::indicator::Indicator;
|
||||
use ui::{Disableable, Icon, IconName, Sizable, WindowExtension, h_flex, v_flex};
|
||||
|
||||
use crate::dialogs::connect::ConnectSigner;
|
||||
use crate::dialogs::import::ImportKey;
|
||||
|
||||
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 {
|
||||
StateEvent::SignerSet => {
|
||||
window.close_all_modals(cx);
|
||||
window.refresh();
|
||||
}
|
||||
StateEvent::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().text_danger)
|
||||
.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);
|
||||
})),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,115 +0,0 @@
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use common::TextUtils;
|
||||
use gpui::prelude::FluentBuilder;
|
||||
use gpui::{
|
||||
AppContext, Context, Entity, Image, IntoElement, ParentElement, Render, SharedString, Styled,
|
||||
Subscription, Window, div, img, px,
|
||||
};
|
||||
use nostr_connect::prelude::*;
|
||||
use state::{
|
||||
CLIENT_NAME, CoopAuthUrlHandler, NOSTR_CONNECT_RELAY, NOSTR_CONNECT_TIMEOUT, NostrRegistry,
|
||||
StateEvent,
|
||||
};
|
||||
use theme::ActiveTheme;
|
||||
use ui::v_flex;
|
||||
|
||||
pub struct ConnectSigner {
|
||||
/// QR Code
|
||||
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).keys();
|
||||
|
||||
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 StateEvent::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().text_danger)
|
||||
.child(error.clone()),
|
||||
)
|
||||
})
|
||||
.child(
|
||||
div()
|
||||
.text_xs()
|
||||
.text_color(cx.theme().text_muted)
|
||||
.child(SharedString::from(MSG)),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
pub mod accounts;
|
||||
pub mod screening;
|
||||
pub mod settings;
|
||||
|
||||
mod connect;
|
||||
mod import;
|
||||
@@ -10,6 +10,7 @@ state = { path = "../state" }
|
||||
person = { path = "../person" }
|
||||
ui = { path = "../ui" }
|
||||
theme = { path = "../theme" }
|
||||
settings = { path = "../settings" }
|
||||
|
||||
gpui.workspace = true
|
||||
nostr-sdk.workspace = true
|
||||
|
||||
@@ -4,7 +4,7 @@ use std::rc::Rc;
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::{Error, anyhow};
|
||||
use common::EventUtils;
|
||||
use common::EventExt;
|
||||
use gpui::{App, AppContext, Context, Entity, Global, Task, Window};
|
||||
use nostr_sdk::prelude::*;
|
||||
use smallvec::{SmallVec, smallvec};
|
||||
@@ -135,7 +135,7 @@ impl PersonRegistry {
|
||||
continue;
|
||||
};
|
||||
|
||||
if let RelayMessage::Event { event, .. } = message {
|
||||
if let RelayMessage::Event { event, .. } = *message {
|
||||
// Skip if the event has already been processed
|
||||
if !processed.insert(event.id) {
|
||||
continue;
|
||||
@@ -242,7 +242,7 @@ impl PersonRegistry {
|
||||
|
||||
/// Set messaging relays for a person
|
||||
fn set_messaging_relays(&mut self, event: &Event, cx: &mut App) {
|
||||
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| {
|
||||
|
||||
@@ -123,16 +123,16 @@ impl Person {
|
||||
|
||||
/// Get profile name
|
||||
pub fn name(&self) -> SharedString {
|
||||
if let Some(display_name) = self.metadata().display_name.as_ref() {
|
||||
if !display_name.is_empty() {
|
||||
return SharedString::from(display_name);
|
||||
}
|
||||
if let Some(display_name) = self.metadata().display_name.as_ref()
|
||||
&& !display_name.is_empty()
|
||||
{
|
||||
return SharedString::from(display_name);
|
||||
}
|
||||
|
||||
if let Some(name) = self.metadata().name.as_ref() {
|
||||
if !name.is_empty() {
|
||||
return SharedString::from(name);
|
||||
}
|
||||
if let Some(name) = self.metadata().name.as_ref()
|
||||
&& !name.is_empty()
|
||||
{
|
||||
return SharedString::from(name);
|
||||
}
|
||||
|
||||
SharedString::from(shorten_pubkey(self.public_key(), 4))
|
||||
|
||||
@@ -15,9 +15,9 @@ 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::{Disableable, IconName, Sizable, WindowExtension, v_flex};
|
||||
use ui::button::Button;
|
||||
use ui::notification::{Notification, NotificationKind};
|
||||
use ui::{Disableable, WindowExtension, v_flex};
|
||||
|
||||
const AUTH_MESSAGE: &str =
|
||||
"Approve the authentication request to allow Coop to continue sending or receiving events.";
|
||||
@@ -100,7 +100,7 @@ impl RelayAuth {
|
||||
|
||||
while let Some(notification) = notifications.next().await {
|
||||
if let ClientNotification::Message { relay_url, message } = notification {
|
||||
match message {
|
||||
match *message {
|
||||
RelayMessage::Auth { challenge } => {
|
||||
if challenges.insert(challenge.clone()) {
|
||||
let request = Arc::new(AuthRequest::new(challenge, relay_url));
|
||||
@@ -193,15 +193,20 @@ impl RelayAuth {
|
||||
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();
|
||||
|
||||
let Some(signer) = nostr.read(cx).signer(cx) else {
|
||||
return Task::ready(Err(anyhow!("Signer is required")));
|
||||
};
|
||||
|
||||
// Get all pending events for the relay
|
||||
let req = req.clone();
|
||||
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?;
|
||||
let event = EventBuilder::auth(req.challenge(), req.url().clone())
|
||||
.finalize_async(&signer)
|
||||
.await?;
|
||||
|
||||
// Get the event ID
|
||||
let id = event.id;
|
||||
@@ -217,35 +222,33 @@ impl RelayAuth {
|
||||
.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?;
|
||||
RelayNotification::Message { message } => {
|
||||
if let RelayMessage::Ok { event_id, .. } = *message {
|
||||
if id != event_id {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Re-send pending events
|
||||
for id in pending_events {
|
||||
if let Some(event) = client.database().event_by_id(&id).await? {
|
||||
relay.send_event(&event).await?;
|
||||
// Get all subscriptions
|
||||
let subscriptions = relay.subscriptions().await;
|
||||
|
||||
// Re-subscribe to previous subscriptions
|
||||
for (id, filters) in subscriptions.into_iter() {
|
||||
if !filters.is_empty() {
|
||||
relay.send_msg(ClientMessage::req(id, filters)).await?;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Ok(());
|
||||
// Re-send pending events
|
||||
for id in pending_events {
|
||||
if let Some(event) = client.database().event_by_id(&id).await? {
|
||||
relay.send_event(&event).await?;
|
||||
}
|
||||
}
|
||||
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
RelayNotification::AuthenticationFailed => break,
|
||||
_ => {}
|
||||
@@ -272,30 +275,22 @@ impl RelayAuth {
|
||||
this.update_in(cx, |this, window, cx| {
|
||||
window.clear_notification_by_id::<AuthNotification>(challenge, cx);
|
||||
|
||||
match result {
|
||||
Ok(_) => {
|
||||
// Clear pending events for the authenticated relay
|
||||
this.clear_pending_events(url, cx);
|
||||
if let Err(e) = result {
|
||||
window
|
||||
.push_notification(Notification::error(e.to_string()).autohide(false), cx);
|
||||
} else {
|
||||
// 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);
|
||||
});
|
||||
let domain = url.domain().unwrap_or_default();
|
||||
let msg = format!("Relay {} has been authenticated", domain);
|
||||
|
||||
window.push_notification(
|
||||
Notification::success(format!(
|
||||
"Relay {} has been authenticated",
|
||||
url.domain().unwrap_or_default()
|
||||
)),
|
||||
cx,
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
window.push_notification(
|
||||
Notification::error(e.to_string()).autohide(false),
|
||||
cx,
|
||||
);
|
||||
}
|
||||
window.push_notification(Notification::success(msg), cx);
|
||||
|
||||
// Save the authenticated relay to automatically authenticate future requests
|
||||
settings.update(cx, |this, cx| {
|
||||
this.add_trusted_relay(url, cx);
|
||||
});
|
||||
}
|
||||
})
|
||||
.ok();
|
||||
@@ -327,8 +322,8 @@ impl RelayAuth {
|
||||
Notification::new()
|
||||
.type_id::<AuthNotification>(challenge)
|
||||
.autohide(false)
|
||||
.icon(IconName::Warning)
|
||||
.title(SharedString::from("Authentication Required"))
|
||||
.with_kind(NotificationKind::Info)
|
||||
.title("Authentication Required")
|
||||
.content(move |_this, _window, cx| {
|
||||
v_flex()
|
||||
.gap_2()
|
||||
@@ -345,7 +340,7 @@ impl RelayAuth {
|
||||
.rounded_sm()
|
||||
.text_xs()
|
||||
.bg(cx.theme().elevated_surface_background)
|
||||
.text_color(cx.theme().text_accent)
|
||||
.text_color(cx.theme().text)
|
||||
.child(url.clone()),
|
||||
)
|
||||
.into_any_element()
|
||||
@@ -356,8 +351,6 @@ impl RelayAuth {
|
||||
|
||||
Button::new("approve")
|
||||
.label("Approve")
|
||||
.small()
|
||||
.primary()
|
||||
.loading(loading.get())
|
||||
.disabled(loading.get())
|
||||
.on_click({
|
||||
|
||||
@@ -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,15 +213,17 @@ 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
|
||||
@@ -249,19 +253,16 @@ impl AppSettings {
|
||||
|
||||
/// Save settings
|
||||
pub fn save(&mut self, cx: &mut Context<Self>) {
|
||||
let settings = self.values.clone();
|
||||
let settings = self.inner.read(cx);
|
||||
|
||||
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();
|
||||
if let Ok(content) = serde_json::to_string(&settings) {
|
||||
cx.background_spawn(async move {
|
||||
let path = config_dir().join(".settings");
|
||||
// Write settings to file
|
||||
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();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,8 +10,7 @@ common = { path = "../common" }
|
||||
nostr.workspace = true
|
||||
nostr-sdk.workspace = true
|
||||
nostr-lmdb.workspace = true
|
||||
nostr-memory.workspace = true
|
||||
nostr-gossip-sqlite.workspace = true
|
||||
nostr-gossip-memory.workspace = true
|
||||
nostr-connect.workspace = true
|
||||
nostr-blossom.workspace = true
|
||||
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
use std::sync::OnceLock;
|
||||
|
||||
/// Client name (Application name)
|
||||
pub const CLIENT_NAME: &str = "Coop";
|
||||
|
||||
@@ -15,6 +13,9 @@ pub const KEYRING: &str = "Coop Safe Storage";
|
||||
/// 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,25 +37,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; 4] = [
|
||||
pub const BOOTSTRAP_RELAYS: [&str; 3] = [
|
||||
"wss://relay.damus.io",
|
||||
"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})")
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,27 +1,23 @@
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::{Context as AnyhowContext, Error, anyhow};
|
||||
use anyhow::{Error, anyhow};
|
||||
use common::config_dir;
|
||||
use gpui::{App, AppContext, Context, Entity, EventEmitter, Global, SharedString, Task, Window};
|
||||
use nostr_connect::prelude::*;
|
||||
use nostr_gossip_sqlite::prelude::*;
|
||||
use nostr_gossip_memory::prelude::*;
|
||||
use nostr_lmdb::prelude::*;
|
||||
use nostr_memory::prelude::*;
|
||||
use nostr_sdk::prelude::*;
|
||||
|
||||
mod blossom;
|
||||
mod constants;
|
||||
mod device;
|
||||
mod nip05;
|
||||
mod signer;
|
||||
mod nip4e;
|
||||
|
||||
pub use blossom::*;
|
||||
pub use constants::*;
|
||||
pub use device::*;
|
||||
pub use nip4e::*;
|
||||
pub use nip05::*;
|
||||
pub use signer::*;
|
||||
|
||||
pub fn init(window: &mut Window, cx: &mut App) {
|
||||
// rustls uses the `aws_lc_rs` provider by default
|
||||
@@ -48,35 +44,30 @@ pub enum StateEvent {
|
||||
Connecting,
|
||||
/// Connected to the bootstrapping relay
|
||||
Connected,
|
||||
/// User has not set up NIP-65 relays
|
||||
RelayNotConfigured,
|
||||
/// Connected to NIP-65 relays
|
||||
RelayConnected,
|
||||
/// A new signer has been set
|
||||
SignerSet,
|
||||
/// An error occurred
|
||||
Error(SharedString),
|
||||
}
|
||||
|
||||
impl StateEvent {
|
||||
pub fn error<T>(error: T) -> Self
|
||||
where
|
||||
T: Into<SharedString>,
|
||||
{
|
||||
Self::Error(error.into())
|
||||
}
|
||||
}
|
||||
|
||||
/// Nostr Registry
|
||||
#[derive(Debug)]
|
||||
pub struct NostrRegistry {
|
||||
/// Nostr client
|
||||
client: Client,
|
||||
|
||||
/// Nostr signer
|
||||
signer: Arc<CoopSigner>,
|
||||
|
||||
/// Local public keys
|
||||
npubs: Entity<Vec<PublicKey>>,
|
||||
|
||||
/// App keys
|
||||
///
|
||||
/// Used for Nostr Connect and NIP-4e operations
|
||||
app_keys: Keys,
|
||||
/// Currently active signer
|
||||
pub signer: Entity<Option<Keys>>,
|
||||
|
||||
/// Tasks for asynchronous operations
|
||||
tasks: Vec<Task<()>>,
|
||||
tasks: Vec<Task<Result<(), Error>>>,
|
||||
}
|
||||
|
||||
impl EventEmitter<StateEvent> for NostrRegistry {}
|
||||
@@ -94,46 +85,25 @@ impl NostrRegistry {
|
||||
|
||||
/// Create a new nostr instance
|
||||
fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||
// Construct the nostr signer
|
||||
let app_keys = get_or_init_app_keys().unwrap_or(Keys::generate());
|
||||
let signer = Arc::new(CoopSigner::new(app_keys.clone()));
|
||||
let signer = cx.new(|_| None);
|
||||
|
||||
// Construct the nostr npubs entity
|
||||
let npubs = cx.new(|_| vec![]);
|
||||
|
||||
// Construct the nostr gossip instance
|
||||
let gossip = cx.foreground_executor().block_on(async move {
|
||||
NostrGossipSqlite::open(config_dir().join("gossip"))
|
||||
// Construct the nostr lmdb instance
|
||||
let lmdb = cx.foreground_executor().block_on(async move {
|
||||
NostrLmdb::open(config_dir().join("nostr"))
|
||||
.await
|
||||
.expect("Failed to initialize gossip instance")
|
||||
.expect("Failed to initialize database")
|
||||
});
|
||||
|
||||
// Construct the nostr client builder
|
||||
let mut builder = ClientBuilder::default()
|
||||
.signer(signer.clone())
|
||||
.gossip(gossip)
|
||||
.automatic_authentication(false)
|
||||
.verify_subscriptions(false)
|
||||
.connect_timeout(Duration::from_secs(TIMEOUT))
|
||||
// Construct the nostr client
|
||||
let client = ClientBuilder::default()
|
||||
.database(lmdb)
|
||||
.gossip(NostrGossipMemory::unbounded())
|
||||
.gossip_config(GossipConfig::default().no_background_refresh())
|
||||
.connect_timeout(Duration::from_secs(10))
|
||||
.sleep_when_idle(SleepWhenIdle::Enabled {
|
||||
timeout: Duration::from_secs(600),
|
||||
});
|
||||
|
||||
// Add database if not in debug mode
|
||||
if !cfg!(debug_assertions) {
|
||||
// Construct the nostr lmdb instance
|
||||
let lmdb = cx.foreground_executor().block_on(async move {
|
||||
NostrLmdb::open(config_dir().join("nostr"))
|
||||
.await
|
||||
.expect("Failed to initialize database")
|
||||
});
|
||||
builder = builder.database(lmdb);
|
||||
} else {
|
||||
builder = builder.database(MemoryDatabase::unbounded())
|
||||
}
|
||||
|
||||
// Build the nostr client
|
||||
let client = builder.build();
|
||||
})
|
||||
.build();
|
||||
|
||||
// Run at the end of current cycle
|
||||
cx.defer_in(window, |this, _window, cx| {
|
||||
@@ -143,8 +113,6 @@ impl NostrRegistry {
|
||||
Self {
|
||||
client,
|
||||
signer,
|
||||
npubs,
|
||||
app_keys,
|
||||
tasks: vec![],
|
||||
}
|
||||
}
|
||||
@@ -154,19 +122,22 @@ impl NostrRegistry {
|
||||
self.client.clone()
|
||||
}
|
||||
|
||||
/// Get the nostr signer
|
||||
pub fn signer(&self) -> Arc<CoopSigner> {
|
||||
self.signer.clone()
|
||||
/// Get the signer
|
||||
pub fn signer(&self, cx: &App) -> Option<Keys> {
|
||||
self.signer.read(cx).clone()
|
||||
}
|
||||
|
||||
/// Get the npubs entity
|
||||
pub fn npubs(&self) -> Entity<Vec<PublicKey>> {
|
||||
self.npubs.clone()
|
||||
/// Get the public key of the signer
|
||||
pub fn signer_pubkey(&self, cx: &App) -> Option<PublicKey> {
|
||||
self.signer.read(cx).as_ref().map(|s| s.public_key())
|
||||
}
|
||||
|
||||
/// Get the app keys
|
||||
pub fn keys(&self) -> Keys {
|
||||
self.app_keys.clone()
|
||||
/// Set the signer to the given keys
|
||||
pub fn set_signer(&mut self, new_keys: Keys, cx: &mut Context<Self>) {
|
||||
self.signer.update(cx, |this, cx| {
|
||||
*this = Some(new_keys);
|
||||
cx.notify();
|
||||
});
|
||||
}
|
||||
|
||||
/// Connect to the bootstrapping relays
|
||||
@@ -174,9 +145,12 @@ impl NostrRegistry {
|
||||
let client = self.client();
|
||||
|
||||
let task: Task<Result<(), Error>> = cx.background_spawn(async move {
|
||||
// Add search relay to the relay pool
|
||||
for url in SEARCH_RELAYS.into_iter() {
|
||||
client.add_relay(url).await?;
|
||||
// Add indexer relay to the relay pool
|
||||
for url in INDEXER_RELAYS.into_iter() {
|
||||
client
|
||||
.add_relay(url)
|
||||
.capabilities(RelayCapabilities::DISCOVERY)
|
||||
.await?;
|
||||
}
|
||||
|
||||
// Add bootstrap relay to the relay pool
|
||||
@@ -193,425 +167,23 @@ impl NostrRegistry {
|
||||
// Emit connecting event
|
||||
cx.emit(StateEvent::Connecting);
|
||||
|
||||
self.tasks
|
||||
.push(cx.spawn(async move |this, cx| match task.await {
|
||||
Ok(_) => {
|
||||
this.update(cx, |this, cx| {
|
||||
cx.emit(StateEvent::Connected);
|
||||
this.get_npubs(cx);
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
Err(e) => {
|
||||
this.update(cx, |_this, cx| {
|
||||
cx.emit(StateEvent::Error(SharedString::from(e.to_string())));
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
/// Get all used npubs
|
||||
fn get_npubs(&mut self, cx: &mut Context<Self>) {
|
||||
let npubs = self.npubs.downgrade();
|
||||
|
||||
let task: Task<Result<Vec<PublicKey>, Error>> = cx.background_spawn(async move {
|
||||
let dir = config_dir().join("keys");
|
||||
// Ensure keys directory exists
|
||||
smol::fs::create_dir_all(&dir).await?;
|
||||
|
||||
let mut files = smol::fs::read_dir(&dir).await?;
|
||||
let mut entries = Vec::new();
|
||||
|
||||
while let Some(Ok(entry)) = files.next().await {
|
||||
let metadata = entry.metadata().await?;
|
||||
let modified_time = metadata.modified()?;
|
||||
let name = entry
|
||||
.file_name()
|
||||
.into_string()
|
||||
.unwrap()
|
||||
.replace(".npub", "");
|
||||
|
||||
entries.push((modified_time, name));
|
||||
}
|
||||
|
||||
// Sort by modification time (most recent first)
|
||||
entries.sort_by(|a, b| b.0.cmp(&a.0));
|
||||
|
||||
let mut npubs = Vec::new();
|
||||
|
||||
for (_, name) in entries {
|
||||
let public_key = PublicKey::parse(&name)?;
|
||||
npubs.push(public_key);
|
||||
}
|
||||
|
||||
Ok(npubs)
|
||||
});
|
||||
|
||||
self.tasks.push(cx.spawn(async move |this, cx| {
|
||||
match task.await {
|
||||
Ok(public_keys) => match public_keys.is_empty() {
|
||||
true => {
|
||||
this.update(cx, |this, cx| {
|
||||
this.create_identity(cx);
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
false => {
|
||||
// TODO: auto login
|
||||
npubs
|
||||
.update(cx, |this, cx| {
|
||||
this.extend(public_keys);
|
||||
cx.notify();
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
},
|
||||
Err(e) => {
|
||||
this.update(cx, |_this, cx| {
|
||||
cx.emit(StateEvent::Error(SharedString::from(e.to_string())));
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
if let Err(e) = task.await {
|
||||
this.update(cx, |_this, cx| {
|
||||
cx.emit(StateEvent::error(e.to_string()));
|
||||
})?;
|
||||
} else {
|
||||
this.update(cx, |_this, cx| {
|
||||
cx.emit(StateEvent::Connected);
|
||||
})?;
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
/// Create a new identity
|
||||
fn create_identity(&mut self, cx: &mut Context<Self>) {
|
||||
let client = self.client();
|
||||
let keys = Keys::generate();
|
||||
let async_keys = keys.clone();
|
||||
|
||||
let username = keys.public_key().to_bech32().unwrap();
|
||||
let secret = keys.secret_key().to_secret_bytes();
|
||||
|
||||
// Create a write credential task
|
||||
let write_credential = cx.write_credentials(&username, &username, &secret);
|
||||
|
||||
// Run async tasks in background
|
||||
let task: Task<Result<(), Error>> = cx.background_spawn(async move {
|
||||
let signer = async_keys.into_nostr_signer();
|
||||
|
||||
// Construct relay list event
|
||||
let relay_list = default_relay_list();
|
||||
let event = EventBuilder::relay_list(relay_list).sign(&signer).await?;
|
||||
|
||||
// Publish relay list
|
||||
client
|
||||
.send_event(&event)
|
||||
.to(BOOTSTRAP_RELAYS)
|
||||
.ok_timeout(Duration::from_secs(TIMEOUT))
|
||||
.await?;
|
||||
|
||||
// Construct the default metadata
|
||||
let name = petname::petname(2, "-").unwrap_or("Cooper".to_string());
|
||||
let avatar = Url::parse(&format!("https://avatar.vercel.sh/{name}")).unwrap();
|
||||
let metadata = Metadata::new().display_name(&name).picture(avatar);
|
||||
let event = EventBuilder::metadata(&metadata).sign(&signer).await?;
|
||||
|
||||
// Publish metadata event
|
||||
client
|
||||
.send_event(&event)
|
||||
.to_nip65()
|
||||
.ack_policy(AckPolicy::none())
|
||||
.await?;
|
||||
|
||||
// Construct the default contact list
|
||||
let contacts = vec![Contact::new(PublicKey::parse(COOP_PUBKEY).unwrap())];
|
||||
let event = EventBuilder::contact_list(contacts).sign(&signer).await?;
|
||||
|
||||
// Publish contact list event
|
||||
client
|
||||
.send_event(&event)
|
||||
.to_nip65()
|
||||
.ack_policy(AckPolicy::none())
|
||||
.await?;
|
||||
|
||||
// Construct the default messaging relay list
|
||||
let relays = default_messaging_relays();
|
||||
let event = EventBuilder::nip17_relay_list(relays).sign(&signer).await?;
|
||||
|
||||
// Publish messaging relay list event
|
||||
client
|
||||
.send_event(&event)
|
||||
.to_nip65()
|
||||
.ack_policy(AckPolicy::none())
|
||||
.await?;
|
||||
|
||||
// Write user's credentials to the system keyring
|
||||
write_credential.await?;
|
||||
|
||||
Ok(())
|
||||
});
|
||||
|
||||
self.tasks.push(cx.spawn(async move |this, cx| {
|
||||
match task.await {
|
||||
Ok(_) => {
|
||||
this.update(cx, |this, cx| {
|
||||
this.set_signer(keys, cx);
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
Err(e) => {
|
||||
this.update(cx, |_this, cx| {
|
||||
cx.emit(StateEvent::Error(SharedString::from(e.to_string())));
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
};
|
||||
}));
|
||||
}
|
||||
|
||||
/// Get the signer in keyring by username
|
||||
pub fn get_signer(
|
||||
&self,
|
||||
public_key: &PublicKey,
|
||||
cx: &App,
|
||||
) -> Task<Result<Arc<dyn NostrSigner>, Error>> {
|
||||
let username = public_key.to_bech32().unwrap();
|
||||
let app_keys = self.app_keys.clone();
|
||||
let read_credential = cx.read_credentials(&username);
|
||||
|
||||
cx.spawn(async move |_cx| {
|
||||
let (_, secret) = read_credential
|
||||
.await
|
||||
.map_err(|_| anyhow!("Failed to get signer. Please re-import the secret key"))?
|
||||
.ok_or_else(|| anyhow!("Failed to get signer. Please re-import the secret key"))?;
|
||||
|
||||
// Try to parse as a direct secret key first
|
||||
if let Ok(secret_key) = SecretKey::from_slice(&secret) {
|
||||
return Ok(Keys::new(secret_key).into_nostr_signer());
|
||||
}
|
||||
|
||||
// Convert the secret into string
|
||||
let sec = String::from_utf8(secret)
|
||||
.map_err(|_| anyhow!("Failed to parse secret as UTF-8"))?;
|
||||
|
||||
// Try to parse as a NIP-46 URI
|
||||
let uri =
|
||||
NostrConnectUri::parse(&sec).map_err(|_| anyhow!("Failed to parse NIP-46 URI"))?;
|
||||
|
||||
let timeout = Duration::from_secs(NOSTR_CONNECT_TIMEOUT);
|
||||
let mut nip46 = NostrConnect::new(uri, app_keys, timeout, None)?;
|
||||
|
||||
// Set the auth URL handler
|
||||
nip46.auth_url_handler(CoopAuthUrlHandler);
|
||||
|
||||
Ok(nip46.into_nostr_signer())
|
||||
})
|
||||
}
|
||||
|
||||
/// Set the signer for the nostr client and verify the public key
|
||||
pub fn set_signer<T>(&mut self, new: T, cx: &mut Context<Self>)
|
||||
where
|
||||
T: NostrSigner + 'static,
|
||||
{
|
||||
let client = self.client();
|
||||
let signer = self.signer();
|
||||
|
||||
// Create a task to update the signer and verify the public key
|
||||
let task: Task<Result<PublicKey, Error>> = cx.background_spawn(async move {
|
||||
// Update signer and unsubscribe
|
||||
signer.switch(new).await;
|
||||
client.unsubscribe_all().await?;
|
||||
|
||||
// Verify and get public key
|
||||
let signer = client.signer().context("Signer not found")?;
|
||||
let public_key = signer.get_public_key().await?;
|
||||
|
||||
let npub = public_key.to_bech32().unwrap();
|
||||
let keys_dir = config_dir().join("keys");
|
||||
|
||||
// Ensure keys directory exists
|
||||
smol::fs::create_dir_all(&keys_dir).await?;
|
||||
|
||||
let key_path = keys_dir.join(format!("{}.npub", npub));
|
||||
smol::fs::write(key_path, "").await?;
|
||||
|
||||
log::info!("Signer's public key: {}", public_key);
|
||||
Ok(public_key)
|
||||
});
|
||||
|
||||
self.tasks.push(cx.spawn(async move |this, cx| {
|
||||
match task.await {
|
||||
Ok(public_key) => {
|
||||
// Update states
|
||||
this.update(cx, |this, cx| {
|
||||
this.ensure_relay_list(&public_key, cx);
|
||||
// Add public key to npubs if not already present
|
||||
this.npubs.update(cx, |this, cx| {
|
||||
if !this.contains(&public_key) {
|
||||
this.push(public_key);
|
||||
cx.notify();
|
||||
}
|
||||
});
|
||||
// Emit signer changed event
|
||||
cx.emit(StateEvent::SignerSet);
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
Err(e) => {
|
||||
this.update(cx, |_this, cx| {
|
||||
cx.emit(StateEvent::Error(SharedString::from(e.to_string())));
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
};
|
||||
}));
|
||||
}
|
||||
|
||||
/// Remove a signer from the keyring
|
||||
pub fn remove_signer(&mut self, public_key: &PublicKey, cx: &mut Context<Self>) {
|
||||
let public_key = public_key.to_owned();
|
||||
let npub = public_key.to_bech32().unwrap();
|
||||
let keys_dir = config_dir().join("keys");
|
||||
|
||||
self.tasks.push(cx.spawn(async move |this, cx| {
|
||||
let key_path = keys_dir.join(format!("{}.npub", npub));
|
||||
smol::fs::remove_file(key_path).await.ok();
|
||||
|
||||
this.update(cx, |this, cx| {
|
||||
this.npubs().update(cx, |this, cx| {
|
||||
this.retain(|k| k != &public_key);
|
||||
cx.notify();
|
||||
});
|
||||
})
|
||||
.ok();
|
||||
}));
|
||||
}
|
||||
|
||||
/// Add a key signer to keyring
|
||||
pub fn add_key_signer(&mut self, keys: &Keys, cx: &mut Context<Self>) {
|
||||
let keys = keys.clone();
|
||||
let username = keys.public_key().to_bech32().unwrap();
|
||||
let secret = keys.secret_key().to_secret_bytes();
|
||||
|
||||
// Write the credential to the keyring
|
||||
let write_credential = cx.write_credentials(&username, "keys", &secret);
|
||||
|
||||
self.tasks.push(cx.spawn(async move |this, cx| {
|
||||
match write_credential.await {
|
||||
Ok(_) => {
|
||||
this.update(cx, |this, cx| {
|
||||
this.set_signer(keys, cx);
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
Err(e) => {
|
||||
this.update(cx, |_this, cx| {
|
||||
cx.emit(StateEvent::Error(SharedString::from(e.to_string())));
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
};
|
||||
}));
|
||||
}
|
||||
|
||||
/// Add a nostr connect signer to keyring
|
||||
pub fn add_nip46_signer(&mut self, nip46: &NostrConnect, cx: &mut Context<Self>) {
|
||||
let nip46 = nip46.clone();
|
||||
let async_nip46 = nip46.clone();
|
||||
|
||||
// Connect and verify the remote signer
|
||||
let task: Task<Result<(PublicKey, NostrConnectUri), Error>> =
|
||||
cx.background_spawn(async move {
|
||||
let uri = async_nip46.bunker_uri().await?;
|
||||
let public_key = async_nip46.get_public_key().await?;
|
||||
|
||||
Ok((public_key, uri))
|
||||
});
|
||||
|
||||
self.tasks.push(cx.spawn(async move |this, cx| {
|
||||
match task.await {
|
||||
Ok((public_key, uri)) => {
|
||||
let username = public_key.to_bech32().unwrap();
|
||||
let write_credential = this
|
||||
.read_with(cx, |_this, cx| {
|
||||
cx.write_credentials(
|
||||
&username,
|
||||
"nostrconnect",
|
||||
uri.to_string().as_bytes(),
|
||||
)
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
match write_credential.await {
|
||||
Ok(_) => {
|
||||
this.update(cx, |this, cx| {
|
||||
this.set_signer(nip46, cx);
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
Err(e) => {
|
||||
this.update(cx, |_this, cx| {
|
||||
cx.emit(StateEvent::Error(SharedString::from(e.to_string())));
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
this.update(cx, |_this, cx| {
|
||||
cx.emit(StateEvent::Error(SharedString::from(e.to_string())));
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
};
|
||||
}));
|
||||
}
|
||||
|
||||
pub fn ensure_relay_list(&mut self, public_key: &PublicKey, cx: &mut Context<Self>) {
|
||||
let task = self.get_event(public_key, Kind::RelayList, cx);
|
||||
|
||||
self.tasks.push(cx.spawn(async move |this, cx| {
|
||||
match task.await {
|
||||
Ok(_) => {
|
||||
this.update(cx, |_this, cx| {
|
||||
cx.emit(StateEvent::RelayConnected);
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
Err(e) => {
|
||||
this.update(cx, |_this, cx| {
|
||||
cx.emit(StateEvent::RelayNotConfigured);
|
||||
cx.emit(StateEvent::Error(SharedString::from(e.to_string())));
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
};
|
||||
}));
|
||||
}
|
||||
|
||||
/// Get an event with the given author and kind.
|
||||
pub fn get_event(
|
||||
&self,
|
||||
author: &PublicKey,
|
||||
kind: Kind,
|
||||
cx: &App,
|
||||
) -> Task<Result<Event, Error>> {
|
||||
let client = self.client();
|
||||
let public_key = *author;
|
||||
|
||||
cx.background_spawn(async move {
|
||||
let filter = Filter::new().kind(kind).author(public_key).limit(1);
|
||||
let mut stream = client
|
||||
.stream_events(filter)
|
||||
.timeout(Duration::from_millis(800))
|
||||
.await?;
|
||||
|
||||
while let Some((_url, res)) = stream.next().await {
|
||||
if let Ok(event) = res {
|
||||
return Ok(event);
|
||||
}
|
||||
}
|
||||
|
||||
Err(anyhow!("No event found"))
|
||||
})
|
||||
}
|
||||
|
||||
/// Get the public key of a NIP-05 address
|
||||
pub fn get_address(&self, addr: Nip05Address, cx: &App) -> Task<Result<PublicKey, Error>> {
|
||||
pub fn query_address(&self, addr: Nip05Address, cx: &App) -> Task<Result<PublicKey, Error>> {
|
||||
let client = self.client();
|
||||
let http_client = cx.http_client();
|
||||
|
||||
@@ -648,7 +220,7 @@ impl NostrRegistry {
|
||||
|
||||
// Get the address task if the query is a valid NIP-05 address
|
||||
let address_task = if let Ok(addr) = Nip05Address::parse(&query) {
|
||||
Some(self.get_address(addr, cx))
|
||||
Some(self.query_address(addr, cx))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
@@ -657,10 +229,22 @@ impl NostrRegistry {
|
||||
let mut results: Vec<PublicKey> = Vec::with_capacity(FIND_LIMIT);
|
||||
|
||||
// Return early if the query is a valid NIP-05 address
|
||||
if let Some(task) = address_task {
|
||||
if let Ok(public_key) = task.await {
|
||||
results.push(public_key);
|
||||
return Ok(results);
|
||||
if let Some(task) = address_task
|
||||
&& let Ok(public_key) = task.await
|
||||
{
|
||||
results.push(public_key);
|
||||
return Ok(results);
|
||||
}
|
||||
|
||||
// Add search relay to the relay pool
|
||||
for url in SEARCH_RELAYS.into_iter() {
|
||||
if client.relay(url).await.is_ok() {
|
||||
client
|
||||
.add_relay(url)
|
||||
.capabilities(RelayCapabilities::READ)
|
||||
.await?;
|
||||
} else {
|
||||
return Err(anyhow!("Failed to add search relay: {}", url));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -708,13 +292,19 @@ impl NostrRegistry {
|
||||
let client = self.client();
|
||||
let query = query.to_string();
|
||||
|
||||
let Some(signer) = self.signer.read(cx).clone() else {
|
||||
return Task::ready(Err(anyhow!("Signer is required")));
|
||||
};
|
||||
|
||||
cx.background_spawn(async move {
|
||||
// Construct a vertex request event
|
||||
let builder = EventBuilder::new(Kind::Custom(5315), "").tags(vec![
|
||||
Tag::custom(TagKind::custom("param"), vec!["search", &query]),
|
||||
Tag::custom(TagKind::custom("param"), vec!["limit", "10"]),
|
||||
]);
|
||||
let event = client.sign_event_builder(builder).await?;
|
||||
let event = EventBuilder::new(Kind::Custom(5315), "")
|
||||
.tags(vec![
|
||||
Tag::custom("param", vec!["search", &query]),
|
||||
Tag::custom("param", vec!["limit", "10"]),
|
||||
])
|
||||
.finalize_async(&signer)
|
||||
.await?;
|
||||
|
||||
// Send the event to vertex relays
|
||||
let output = client.send_event(&event).to(WOT_RELAYS).await?;
|
||||
@@ -764,74 +354,3 @@ impl NostrRegistry {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Get or create a new app keys
|
||||
fn get_or_init_app_keys() -> Result<Keys, Error> {
|
||||
let dir = config_dir().join(".app_keys");
|
||||
|
||||
let content = match std::fs::read(&dir) {
|
||||
Ok(content) => content,
|
||||
Err(_) => {
|
||||
// Generate new keys if file doesn't exist
|
||||
let keys = Keys::generate();
|
||||
let secret_key = keys.secret_key();
|
||||
|
||||
// Create directory and write secret key
|
||||
std::fs::create_dir_all(dir.parent().unwrap())?;
|
||||
std::fs::write(&dir, secret_key.to_secret_bytes())?;
|
||||
|
||||
return Ok(keys);
|
||||
}
|
||||
};
|
||||
|
||||
let secret_key = SecretKey::from_slice(&content)?;
|
||||
let keys = Keys::new(secret_key);
|
||||
|
||||
Ok(keys)
|
||||
}
|
||||
|
||||
fn default_relay_list() -> Vec<(RelayUrl, Option<RelayMetadata>)> {
|
||||
vec![
|
||||
(
|
||||
RelayUrl::parse("wss://relay.nostr.net").unwrap(),
|
||||
Some(RelayMetadata::Write),
|
||||
),
|
||||
(
|
||||
RelayUrl::parse("wss://relay.primal.net").unwrap(),
|
||||
Some(RelayMetadata::Write),
|
||||
),
|
||||
(
|
||||
RelayUrl::parse("wss://relay.damus.io").unwrap(),
|
||||
Some(RelayMetadata::Read),
|
||||
),
|
||||
(
|
||||
RelayUrl::parse("wss://nos.lol").unwrap(),
|
||||
Some(RelayMetadata::Read),
|
||||
),
|
||||
(
|
||||
RelayUrl::parse("wss://nostr.superfriends.online").unwrap(),
|
||||
None,
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
fn default_messaging_relays() -> Vec<RelayUrl> {
|
||||
vec![
|
||||
RelayUrl::parse("wss://nos.lol").unwrap(),
|
||||
RelayUrl::parse("wss://nip17.com").unwrap(),
|
||||
RelayUrl::parse("wss://relay.0xchat.com").unwrap(),
|
||||
]
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CoopAuthUrlHandler;
|
||||
|
||||
impl AuthUrlHandler for CoopAuthUrlHandler {
|
||||
#[allow(mismatched_lifetime_syntaxes)]
|
||||
fn on_auth_url(&self, auth_url: Url) -> BoxedFuture<Result<()>> {
|
||||
Box::pin(async move {
|
||||
webbrowser::open(auth_url.as_str())?;
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -1,134 +0,0 @@
|
||||
use std::borrow::Cow;
|
||||
use std::result::Result;
|
||||
use std::sync::Arc;
|
||||
|
||||
use nostr_sdk::prelude::*;
|
||||
use smol::lock::RwLock;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct CoopSigner {
|
||||
/// User's signer
|
||||
signer: RwLock<Arc<dyn NostrSigner>>,
|
||||
|
||||
/// User's signer public key
|
||||
signer_pkey: RwLock<Option<PublicKey>>,
|
||||
|
||||
/// Specific signer for encryption purposes
|
||||
encryption_signer: RwLock<Option<Arc<dyn NostrSigner>>>,
|
||||
}
|
||||
|
||||
impl CoopSigner {
|
||||
pub fn new<T>(signer: T) -> Self
|
||||
where
|
||||
T: IntoNostrSigner,
|
||||
{
|
||||
Self {
|
||||
signer: RwLock::new(signer.into_nostr_signer()),
|
||||
signer_pkey: RwLock::new(None),
|
||||
encryption_signer: RwLock::new(None),
|
||||
}
|
||||
}
|
||||
|
||||
/// 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)
|
||||
where
|
||||
T: IntoNostrSigner,
|
||||
{
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
unsigned: UnsignedEvent,
|
||||
) -> BoxedFuture<'a, Result<Event, SignerError>> {
|
||||
Box::pin(async move { self.get().await.sign_event(unsigned).await })
|
||||
}
|
||||
|
||||
fn nip04_encrypt<'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>(
|
||||
&'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 })
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
use std::fmt::{self, Debug, Display, Formatter};
|
||||
|
||||
use gpui::{AbsoluteLength, Axis, Corner, Length, Pixels};
|
||||
use gpui::{AbsoluteLength, Axis, Length, Pixels};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// A enum for defining the placement of the element.
|
||||
@@ -49,141 +49,6 @@ impl Placement {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 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.
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
use gpui::{Pixels, px};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use gpui::{Anchor, Pixels, px};
|
||||
|
||||
use crate::{Anchor, Edges, TITLEBAR_HEIGHT};
|
||||
use crate::{Edges, TITLEBAR_HEIGHT};
|
||||
|
||||
/// The settings for notifications.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct NotificationSettings {
|
||||
/// The placement of the notification, default: [`Anchor::TopRight`]
|
||||
pub placement: Anchor,
|
||||
|
||||
@@ -22,5 +22,6 @@ 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" }
|
||||
tree-sitter = "0.26"
|
||||
|
||||
@@ -1,191 +0,0 @@
|
||||
Copyright 2024 Longbridge <https://longbridge.com>
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
|
||||
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
@@ -1,332 +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::{
|
||||
AnyElement, App, Axis, Bounds, Display, Edges, Element, GlobalElementId, Half,
|
||||
InspectorElementId, IntoElement, LayoutId, ParentElement, Pixels, Point, Position, Size, Style,
|
||||
Window, point, px,
|
||||
};
|
||||
use smallvec::SmallVec;
|
||||
use theme::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 }
|
||||
}
|
||||
}
|
||||
@@ -271,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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -70,10 +70,10 @@ impl StackPanel {
|
||||
return false;
|
||||
}
|
||||
|
||||
if let Some(parent) = &self.parent {
|
||||
if let Some(parent) = parent.upgrade() {
|
||||
return parent.read(cx).is_last_panel(cx);
|
||||
}
|
||||
if let Some(parent) = &self.parent
|
||||
&& let Some(parent) = parent.upgrade()
|
||||
{
|
||||
return parent.read(cx).is_last_panel(cx);
|
||||
}
|
||||
|
||||
true
|
||||
@@ -297,12 +297,11 @@ impl StackPanel {
|
||||
|
||||
/// Find the first top left in the stack.
|
||||
pub fn left_top_tab_panel(&self, check_parent: bool, cx: &App) -> Option<Entity<TabPanel>> {
|
||||
if check_parent {
|
||||
if let Some(parent) = self.parent.as_ref().and_then(|parent| parent.upgrade()) {
|
||||
if let Some(panel) = parent.read(cx).left_top_tab_panel(true, cx) {
|
||||
return Some(panel);
|
||||
}
|
||||
}
|
||||
if check_parent
|
||||
&& let Some(parent) = self.parent.as_ref().and_then(|parent| parent.upgrade())
|
||||
&& let Some(panel) = parent.read(cx).left_top_tab_panel(true, cx)
|
||||
{
|
||||
return Some(panel);
|
||||
}
|
||||
|
||||
let first_panel = self.panels.first();
|
||||
@@ -321,12 +320,11 @@ impl StackPanel {
|
||||
|
||||
/// Find the first top right in the stack.
|
||||
pub fn right_top_tab_panel(&self, check_parent: bool, cx: &App) -> Option<Entity<TabPanel>> {
|
||||
if check_parent {
|
||||
if let Some(parent) = self.parent.as_ref().and_then(|parent| parent.upgrade()) {
|
||||
if let Some(panel) = parent.read(cx).right_top_tab_panel(true, cx) {
|
||||
return Some(panel);
|
||||
}
|
||||
}
|
||||
if check_parent
|
||||
&& let Some(parent) = self.parent.as_ref().and_then(|parent| parent.upgrade())
|
||||
&& let Some(panel) = parent.read(cx).right_top_tab_panel(true, cx)
|
||||
{
|
||||
return Some(panel);
|
||||
}
|
||||
|
||||
let panel = if self.axis.is_vertical() {
|
||||
|
||||
@@ -2,7 +2,7 @@ use std::sync::Arc;
|
||||
|
||||
use gpui::prelude::FluentBuilder;
|
||||
use gpui::{
|
||||
App, AppContext, Context, Corner, DefiniteLength, DismissEvent, DragMoveEvent, Empty, Entity,
|
||||
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,
|
||||
@@ -232,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;
|
||||
@@ -372,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
|
||||
@@ -462,7 +460,7 @@ impl TabPanel {
|
||||
})
|
||||
}
|
||||
})
|
||||
.anchor(Corner::TopRight),
|
||||
.anchor(Anchor::TopRight),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -752,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();
|
||||
@@ -1103,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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()];
|
||||
|
||||
@@ -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()
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
use std::fmt::Debug;
|
||||
|
||||
use crate::history::HistoryItem;
|
||||
use crate::input::cursor::Selection;
|
||||
use crate::{history::HistoryItem, input::Selection};
|
||||
|
||||
#[derive(Debug, PartialEq, Clone)]
|
||||
pub struct Change {
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
use gpui::{App, Styled};
|
||||
use theme::ActiveTheme;
|
||||
|
||||
use crate::button::{Button, ButtonVariants};
|
||||
use crate::{Icon, IconName, Sizable};
|
||||
use crate::button::{Button, ButtonVariants as _};
|
||||
use crate::{Icon, IconName, Sizable as _};
|
||||
|
||||
#[inline]
|
||||
pub(crate) fn clear_button(cx: &App) -> Button {
|
||||
Button::new("clean")
|
||||
.icon(Icon::new(IconName::CloseCircle))
|
||||
.tooltip("Clear")
|
||||
.small()
|
||||
.transparent()
|
||||
.text_color(cx.theme().text_muted)
|
||||
.ghost()
|
||||
.xsmall()
|
||||
.tab_stop(false)
|
||||
.text_color(cx.theme().icon_muted)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use std::ops::Range;
|
||||
use std::ops::{Range, RangeBounds};
|
||||
|
||||
/// A selection in the text, represented by start and end byte indices.
|
||||
#[derive(Debug, Copy, Clone, PartialEq, Eq, Default)]
|
||||
@@ -42,5 +42,12 @@ impl From<Selection> for Range<usize> {
|
||||
value.start..value.end
|
||||
}
|
||||
}
|
||||
impl RangeBounds<usize> for Selection {
|
||||
fn start_bound(&self) -> std::ops::Bound<&usize> {
|
||||
std::ops::Bound::Included(&self.start)
|
||||
}
|
||||
|
||||
pub type Position = lsp_types::Position;
|
||||
fn end_bound(&self) -> std::ops::Bound<&usize> {
|
||||
std::ops::Bound::Excluded(&self.end)
|
||||
}
|
||||
}
|
||||
|
||||
336
crates/ui/src/input/display_map/display_map.rs
Normal file
@@ -0,0 +1,336 @@
|
||||
/// DisplayMap: Public facade for Editor/Input display mapping.
|
||||
///
|
||||
/// This combines WrapMap and FoldMap to provide a unified API:
|
||||
/// - BufferPoint ↔ DisplayPoint conversion
|
||||
/// - Fold management (candidates, toggle, query)
|
||||
/// - Automatic projection updates on text/layout changes
|
||||
use std::ops::Range;
|
||||
|
||||
use gpui::{App, Font, Pixels};
|
||||
use ropey::Rope;
|
||||
|
||||
use super::fold_map::FoldMap;
|
||||
use super::folding::FoldRange;
|
||||
use super::text_wrapper::{LineItem, WrapDisplayPoint};
|
||||
use super::wrap_map::WrapMap;
|
||||
use super::{BufferPoint, DisplayPoint};
|
||||
use crate::input::display_map::WrapPoint;
|
||||
use crate::input::rope_ext::RopeExt as _;
|
||||
use crate::input::Point as TreeSitterPoint;
|
||||
|
||||
/// DisplayMap is the main interface for Editor/Input coordinate mapping.
|
||||
///
|
||||
/// It manages the two-layer projection:
|
||||
/// 1. Buffer → Wrap (soft-wrapping)
|
||||
/// 2. Wrap → Display (folding)
|
||||
///
|
||||
/// Editor/Input only needs to work with BufferPoint and DisplayPoint.
|
||||
pub struct DisplayMap {
|
||||
wrap_map: WrapMap,
|
||||
fold_map: FoldMap,
|
||||
}
|
||||
|
||||
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),
|
||||
fold_map: FoldMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== Core Coordinate Mapping ====================
|
||||
|
||||
/// Convert buffer position to display position
|
||||
pub fn buffer_pos_to_display_pos(&self, pos: BufferPoint) -> DisplayPoint {
|
||||
// Buffer → Wrap
|
||||
let wrap_pos = self.wrap_map.buffer_pos_to_wrap_pos(pos);
|
||||
|
||||
// Wrap → Display
|
||||
if let Some(display_row) = self.fold_map.wrap_row_to_display_row(wrap_pos.row) {
|
||||
DisplayPoint::new(display_row, wrap_pos.col)
|
||||
} else {
|
||||
// Cursor is in a folded region, find nearest visible row
|
||||
let display_row = self.fold_map.nearest_visible_display_row(wrap_pos.row);
|
||||
DisplayPoint::new(display_row, 0) // Column 0 at fold boundary
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert display position to buffer position
|
||||
pub fn display_pos_to_buffer_pos(&self, pos: DisplayPoint) -> BufferPoint {
|
||||
// Display → Wrap
|
||||
let wrap_row = self.fold_map.display_row_to_wrap_row(pos.row).unwrap_or(0);
|
||||
|
||||
// Wrap → Buffer
|
||||
let wrap_pos = WrapPoint::new(wrap_row, pos.col);
|
||||
self.wrap_map.wrap_pos_to_buffer_pos(wrap_pos)
|
||||
}
|
||||
|
||||
/// Get total number of visible display rows
|
||||
#[inline]
|
||||
pub fn display_row_count(&self) -> usize {
|
||||
self.fold_map.display_row_count()
|
||||
}
|
||||
|
||||
/// Get the buffer line for a given display row
|
||||
pub fn display_row_to_buffer_line(&self, display_row: usize) -> usize {
|
||||
// Display → Wrap
|
||||
let wrap_row = self
|
||||
.fold_map
|
||||
.display_row_to_wrap_row(display_row)
|
||||
.unwrap_or(0);
|
||||
|
||||
// Wrap → Buffer line
|
||||
self.wrap_map.wrap_row_to_buffer_line(wrap_row)
|
||||
}
|
||||
|
||||
/// Get the display row range for a buffer line: [start, end)
|
||||
/// Returns None if the buffer line is completely hidden
|
||||
pub fn buffer_line_to_display_row_range(&self, line: usize) -> Option<Range<usize>> {
|
||||
// Buffer line → Wrap row range
|
||||
let wrap_row_range = self.wrap_map.buffer_line_to_wrap_row_range(line);
|
||||
|
||||
// Find first and last visible display rows in this range
|
||||
let mut first_display_row = None;
|
||||
let mut last_display_row = None;
|
||||
|
||||
for wrap_row in wrap_row_range {
|
||||
if let Some(display_row) = self.fold_map.wrap_row_to_display_row(wrap_row) {
|
||||
if first_display_row.is_none() {
|
||||
first_display_row = Some(display_row);
|
||||
}
|
||||
last_display_row = Some(display_row);
|
||||
}
|
||||
}
|
||||
|
||||
if let (Some(start), Some(end)) = (first_display_row, last_display_row) {
|
||||
Some(start..end + 1)
|
||||
} else {
|
||||
None // Completely folded
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if a buffer line is completely hidden
|
||||
#[inline]
|
||||
pub fn is_buffer_line_hidden(&self, line: usize) -> bool {
|
||||
self.buffer_line_to_display_row_range(line).is_none()
|
||||
}
|
||||
|
||||
/// Set fold candidates (from tree-sitter/LSP)
|
||||
pub fn set_fold_candidates(&mut self, candidates: Vec<FoldRange>) {
|
||||
self.fold_map.set_candidates(candidates);
|
||||
self.rebuild_fold_projection();
|
||||
}
|
||||
|
||||
/// Set a fold at the given start_line (must be in candidates)
|
||||
pub fn set_folded(&mut self, start_line: usize, folded: bool) {
|
||||
self.fold_map.set_folded(start_line, folded);
|
||||
self.rebuild_fold_projection();
|
||||
}
|
||||
|
||||
/// Toggle fold at the given start_line
|
||||
pub fn toggle_fold(&mut self, start_line: usize) {
|
||||
self.fold_map.toggle_fold(start_line);
|
||||
self.rebuild_fold_projection();
|
||||
}
|
||||
|
||||
/// Check if a line is currently folded
|
||||
#[inline]
|
||||
pub fn is_folded_at(&self, start_line: usize) -> bool {
|
||||
self.fold_map.is_folded_at(start_line)
|
||||
}
|
||||
|
||||
/// Check if a line is a fold candidate
|
||||
#[inline]
|
||||
pub fn is_fold_candidate(&self, start_line: usize) -> bool {
|
||||
self.fold_map.is_fold_candidate(start_line)
|
||||
}
|
||||
|
||||
/// Get all currently folded ranges
|
||||
#[inline]
|
||||
pub fn folded_ranges(&self) -> &[FoldRange] {
|
||||
self.fold_map.folded_ranges()
|
||||
}
|
||||
|
||||
/// Clear all folds
|
||||
pub fn clear_folds(&mut self) {
|
||||
self.fold_map.clear_folds();
|
||||
self.rebuild_fold_projection();
|
||||
}
|
||||
|
||||
// ==================== Text and Layout Updates ====================
|
||||
|
||||
/// Adjust folds and candidates for a text edit before updating the wrap map.
|
||||
///
|
||||
/// Must be called with the OLD text (before replacement) and the edit range/new_text
|
||||
/// so we can compute which old lines were affected.
|
||||
pub fn adjust_folds_for_edit(&mut self, old_text: &Rope, range: &Range<usize>, new_text: &str) {
|
||||
if self.fold_map.folded_ranges().is_empty() && self.fold_map.fold_candidates().is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
let edit_start_line = old_text.offset_to_point(range.start).row;
|
||||
let edit_end_line = old_text.offset_to_point(range.end.min(old_text.len())).row;
|
||||
|
||||
let old_lines_in_range = edit_end_line.saturating_sub(edit_start_line);
|
||||
let new_lines_in_range = new_text.chars().filter(|c| *c == '\n').count();
|
||||
let line_delta = new_lines_in_range as isize - old_lines_in_range as isize;
|
||||
|
||||
self.fold_map
|
||||
.adjust_folds_for_edit(edit_start_line, edit_end_line, line_delta);
|
||||
}
|
||||
|
||||
/// Incrementally update fold candidates after a text edit.
|
||||
///
|
||||
/// Extracts new fold candidates only within the edited byte range
|
||||
/// and merges them with existing (already adjusted) candidates.
|
||||
pub fn update_fold_candidates_for_edit(
|
||||
&mut self,
|
||||
tree: &super::folding::Tree,
|
||||
edit_byte_range: Range<usize>,
|
||||
new_text: &Rope,
|
||||
) {
|
||||
let new_start_line = new_text.offset_to_point(edit_byte_range.start).row;
|
||||
let new_end_line = new_text
|
||||
.offset_to_point(edit_byte_range.end.min(new_text.len()))
|
||||
.row;
|
||||
|
||||
let new_candidates = super::folding::extract_fold_ranges_in_range(tree, edit_byte_range);
|
||||
self.fold_map
|
||||
.merge_candidates_for_edit(new_start_line, new_end_line, new_candidates);
|
||||
}
|
||||
|
||||
/// 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);
|
||||
self.rebuild_fold_projection();
|
||||
}
|
||||
|
||||
/// 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);
|
||||
self.rebuild_fold_projection();
|
||||
}
|
||||
|
||||
/// 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);
|
||||
self.rebuild_fold_projection();
|
||||
}
|
||||
|
||||
/// Ensure text is prepared (initializes wrapper if needed)
|
||||
pub fn ensure_text_prepared(&mut self, text: &Rope, cx: &mut App) {
|
||||
let did_initialize = self.wrap_map.ensure_text_prepared(text, cx);
|
||||
if did_initialize {
|
||||
self.rebuild_fold_projection();
|
||||
}
|
||||
}
|
||||
|
||||
/// Initialize with text
|
||||
pub fn set_text(&mut self, text: &Rope, cx: &mut App) {
|
||||
self.wrap_map.set_text(text, cx);
|
||||
self.rebuild_fold_projection();
|
||||
}
|
||||
|
||||
// ==================== Internal Helpers ====================
|
||||
|
||||
/// Rebuild fold projection after wrap_map or fold state changes
|
||||
/// Only rebuilds if there are actually folded ranges
|
||||
fn rebuild_fold_projection(&mut self) {
|
||||
if !self.fold_map.folded_ranges().is_empty() {
|
||||
self.fold_map.rebuild(&self.wrap_map);
|
||||
} else {
|
||||
// No active folds: identity mapping (wrap_row == display_row).
|
||||
// Just update cached count so query methods work without Vec allocation.
|
||||
self.fold_map
|
||||
.mark_dirty_with_wrap_count(self.wrap_map.wrap_row_count());
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== Wrap Display Point Operations ====================
|
||||
|
||||
/// 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)
|
||||
}
|
||||
|
||||
/// Convert a wrap row to a display row (skipping folded rows).
|
||||
/// Returns None if the wrap row is folded.
|
||||
#[inline]
|
||||
pub fn wrap_row_to_display_row(&self, wrap_row: usize) -> Option<usize> {
|
||||
self.fold_map.wrap_row_to_display_row(wrap_row)
|
||||
}
|
||||
|
||||
/// Find the nearest visible display row for a given wrap row.
|
||||
#[inline]
|
||||
pub fn nearest_visible_display_row(&self, wrap_row: usize) -> usize {
|
||||
self.fold_map.nearest_visible_display_row(wrap_row)
|
||||
}
|
||||
|
||||
/// Convert a display row to a wrap row.
|
||||
#[inline]
|
||||
pub fn display_row_to_wrap_row(&self, display_row: usize) -> Option<usize> {
|
||||
self.fold_map.display_row_to_wrap_row(display_row)
|
||||
}
|
||||
|
||||
/// Get the longest row index (by byte length).
|
||||
#[inline]
|
||||
pub(crate) fn longest_row(&self) -> usize {
|
||||
self.wrap_map.wrapper().longest_row.row
|
||||
}
|
||||
|
||||
// ==================== Access Methods ====================
|
||||
|
||||
/// 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 (not folded)
|
||||
#[inline]
|
||||
pub fn visible_wrap_row_count_for_buffer_line(&self, line: usize) -> usize {
|
||||
self.wrap_map
|
||||
.visible_wrap_row_count_for_line(line, &self.fold_map)
|
||||
}
|
||||
|
||||
/// Get the wrap row count (before folding)
|
||||
#[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()
|
||||
}
|
||||
}
|
||||
343
crates/ui/src/input/display_map/fold_map.rs
Normal file
@@ -0,0 +1,343 @@
|
||||
/// FoldMap: Folding projection layer (Wrap rows → Display rows).
|
||||
///
|
||||
/// This module manages code folding by:
|
||||
/// - Filtering out wrap rows that belong to folded regions
|
||||
/// - Maintaining bidirectional mapping: wrap_row ↔ display_row
|
||||
/// - Handling fold state changes and rebuilding the projection
|
||||
use super::folding::FoldRange;
|
||||
use super::wrap_map::WrapMap;
|
||||
|
||||
/// FoldMap projects wrap rows to display rows by hiding folded regions.
|
||||
pub struct FoldMap {
|
||||
/// Mapping: display_row → wrap_row
|
||||
/// index = display_row, value = actual wrap_row
|
||||
visible_wrap_rows: Vec<usize>,
|
||||
|
||||
/// Reverse mapping: wrap_row → display_row
|
||||
/// index = wrap_row, value = Some(display_row) if visible, None if folded
|
||||
wrap_row_to_display_row: Vec<Option<usize>>,
|
||||
|
||||
/// Candidate fold ranges (from tree-sitter/LSP)
|
||||
/// Sorted by start_line, unique start_line
|
||||
candidates: Vec<FoldRange>,
|
||||
|
||||
/// Currently folded ranges
|
||||
/// Subset of candidates, sorted by start_line
|
||||
folded: Vec<FoldRange>,
|
||||
|
||||
/// Flag indicating if the fold projection needs rebuilding
|
||||
/// Used for lazy evaluation to avoid expensive rebuilds on every text change
|
||||
needs_rebuild: bool,
|
||||
|
||||
/// Cached wrap_row_count from last rebuild
|
||||
/// Used to detect if WrapMap changed and rebuild is needed
|
||||
cached_wrap_row_count: usize,
|
||||
}
|
||||
|
||||
impl FoldMap {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
visible_wrap_rows: Vec::new(),
|
||||
wrap_row_to_display_row: Vec::new(),
|
||||
candidates: Vec::new(),
|
||||
folded: Vec::new(),
|
||||
needs_rebuild: true,
|
||||
cached_wrap_row_count: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Update cached wrap_row_count without full rebuild.
|
||||
/// Used when no folds are active (identity mapping assumed).
|
||||
pub(super) fn mark_dirty_with_wrap_count(&mut self, wrap_row_count: usize) {
|
||||
self.needs_rebuild = true;
|
||||
self.cached_wrap_row_count = wrap_row_count;
|
||||
}
|
||||
|
||||
/// Get total number of visible display rows
|
||||
pub fn display_row_count(&self) -> usize {
|
||||
if self.folded.is_empty() {
|
||||
return self.cached_wrap_row_count;
|
||||
}
|
||||
self.visible_wrap_rows.len()
|
||||
}
|
||||
|
||||
/// Convert wrap_row to display_row
|
||||
/// Returns None if the wrap_row is hidden by folding
|
||||
pub fn wrap_row_to_display_row(&self, wrap_row: usize) -> Option<usize> {
|
||||
if self.folded.is_empty() {
|
||||
return if wrap_row < self.cached_wrap_row_count {
|
||||
Some(wrap_row)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
}
|
||||
self.wrap_row_to_display_row
|
||||
.get(wrap_row)
|
||||
.copied()
|
||||
.flatten()
|
||||
}
|
||||
|
||||
/// Convert display_row to wrap_row
|
||||
pub fn display_row_to_wrap_row(&self, display_row: usize) -> Option<usize> {
|
||||
if self.folded.is_empty() {
|
||||
return if display_row < self.cached_wrap_row_count {
|
||||
Some(display_row)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
}
|
||||
self.visible_wrap_rows.get(display_row).copied()
|
||||
}
|
||||
|
||||
/// Find the nearest visible display_row for a given wrap_row
|
||||
pub fn nearest_visible_display_row(&self, wrap_row: usize) -> usize {
|
||||
if self.folded.is_empty() {
|
||||
return wrap_row.min(self.cached_wrap_row_count.saturating_sub(1));
|
||||
}
|
||||
|
||||
if let Some(dr) = self.wrap_row_to_display_row(wrap_row) {
|
||||
return dr;
|
||||
}
|
||||
|
||||
match self.visible_wrap_rows.binary_search(&wrap_row) {
|
||||
Ok(idx) => idx,
|
||||
Err(insert_pos) => insert_pos.saturating_sub(1),
|
||||
}
|
||||
}
|
||||
|
||||
/// Set fold candidates (from tree-sitter/LSP), full replacement.
|
||||
pub fn set_candidates(&mut self, mut candidates: Vec<FoldRange>) {
|
||||
// Sort and deduplicate by start_line
|
||||
candidates.sort_by_key(|r| r.start_line);
|
||||
candidates.dedup_by_key(|r| r.start_line);
|
||||
self.candidates = candidates;
|
||||
|
||||
// Remove any folded ranges that are no longer in candidates
|
||||
self.folded.retain(|fold| {
|
||||
self.candidates
|
||||
.iter()
|
||||
.any(|c| c.start_line == fold.start_line)
|
||||
});
|
||||
}
|
||||
|
||||
/// Merge new candidates extracted from an edited region into existing candidates.
|
||||
///
|
||||
/// Replaces candidates within [edit_start_line, edit_end_line] with `new_candidates`,
|
||||
/// keeping candidates outside the edit range intact.
|
||||
pub fn merge_candidates_for_edit(
|
||||
&mut self,
|
||||
edit_start_line: usize,
|
||||
edit_end_line: usize,
|
||||
new_candidates: Vec<FoldRange>,
|
||||
) {
|
||||
// Remove old candidates within the edit range (already done by adjust_folds_for_edit)
|
||||
// But do it again in case adjust wasn't called or range differs
|
||||
self.candidates
|
||||
.retain(|c| c.start_line < edit_start_line || c.start_line > edit_end_line);
|
||||
|
||||
// Add new candidates
|
||||
self.candidates.extend(new_candidates);
|
||||
self.candidates.sort_by_key(|r| r.start_line);
|
||||
self.candidates.dedup_by_key(|r| r.start_line);
|
||||
}
|
||||
|
||||
/// Set a fold at the given start_line (must be in candidates)
|
||||
pub fn set_folded(&mut self, start_line: usize, folded: bool) {
|
||||
if folded {
|
||||
// Find the candidate range for this start_line
|
||||
if let Some(candidate) = self.candidates.iter().find(|c| c.start_line == start_line) {
|
||||
// Add to folded if not already present
|
||||
if !self.folded.iter().any(|f| f.start_line == start_line) {
|
||||
self.folded.push(*candidate);
|
||||
self.folded.sort_by_key(|r| r.start_line);
|
||||
self.needs_rebuild = true;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Remove from folded
|
||||
self.folded.retain(|f| f.start_line != start_line);
|
||||
self.needs_rebuild = true;
|
||||
}
|
||||
}
|
||||
|
||||
/// Toggle fold at the given start_line
|
||||
pub fn toggle_fold(&mut self, start_line: usize) {
|
||||
let is_folded = self.is_folded_at(start_line);
|
||||
self.set_folded(start_line, !is_folded);
|
||||
}
|
||||
|
||||
/// Check if a line is currently folded
|
||||
pub fn is_folded_at(&self, start_line: usize) -> bool {
|
||||
self.folded.iter().any(|f| f.start_line == start_line)
|
||||
}
|
||||
|
||||
/// Check if a line is a fold candidate
|
||||
pub fn is_fold_candidate(&self, start_line: usize) -> bool {
|
||||
self.candidates.iter().any(|c| c.start_line == start_line)
|
||||
}
|
||||
|
||||
/// Get all fold candidates
|
||||
#[inline]
|
||||
pub fn fold_candidates(&self) -> &[FoldRange] {
|
||||
&self.candidates
|
||||
}
|
||||
|
||||
/// Get all currently folded ranges
|
||||
#[inline]
|
||||
pub fn folded_ranges(&self) -> &[FoldRange] {
|
||||
&self.folded
|
||||
}
|
||||
|
||||
/// Clear all folds
|
||||
#[inline]
|
||||
pub fn clear_folds(&mut self) {
|
||||
self.folded.clear();
|
||||
}
|
||||
|
||||
/// Adjust folds and candidates after a text edit.
|
||||
///
|
||||
/// - Folds/candidates overlapping the edited line range are removed
|
||||
/// - Folds/candidates after the edit are shifted by line_delta
|
||||
///
|
||||
/// This avoids expensive full tree traversal on every keystroke.
|
||||
pub fn adjust_folds_for_edit(
|
||||
&mut self,
|
||||
edit_start_line: usize,
|
||||
edit_end_line: usize,
|
||||
line_delta: isize,
|
||||
) {
|
||||
// Adjust folded ranges
|
||||
if !self.folded.is_empty() {
|
||||
self.folded.retain(|fold| {
|
||||
!(fold.start_line <= edit_end_line && fold.end_line >= edit_start_line)
|
||||
});
|
||||
|
||||
if line_delta != 0 {
|
||||
for fold in &mut self.folded {
|
||||
if fold.start_line > edit_end_line {
|
||||
fold.start_line = (fold.start_line as isize + line_delta).max(0) as usize;
|
||||
fold.end_line = (fold.end_line as isize + line_delta).max(0) as usize;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Adjust candidates the same way
|
||||
if !self.candidates.is_empty() {
|
||||
self.candidates
|
||||
.retain(|c| !(c.start_line <= edit_end_line && c.end_line >= edit_start_line));
|
||||
|
||||
if line_delta != 0 {
|
||||
for c in &mut self.candidates {
|
||||
if c.start_line > edit_end_line {
|
||||
c.start_line = (c.start_line as isize + line_delta).max(0) as usize;
|
||||
c.end_line = (c.end_line as isize + line_delta).max(0) as usize;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
self.needs_rebuild = true;
|
||||
}
|
||||
|
||||
/// Rebuild the fold mapping after wrap_map or fold state changes
|
||||
///
|
||||
/// This is the core algorithm that projects wrap rows to display rows.
|
||||
pub fn rebuild(&mut self, wrap_map: &WrapMap) {
|
||||
let wrap_row_count = wrap_map.wrap_row_count();
|
||||
|
||||
// Performance optimization: skip rebuild if nothing changed
|
||||
if !self.needs_rebuild && wrap_row_count == self.cached_wrap_row_count {
|
||||
return;
|
||||
}
|
||||
|
||||
self.cached_wrap_row_count = wrap_row_count;
|
||||
|
||||
self.visible_wrap_rows.clear();
|
||||
self.wrap_row_to_display_row = vec![None; wrap_row_count];
|
||||
|
||||
if self.folded.is_empty() {
|
||||
// Fast path: no folds, all wrap rows are visible
|
||||
self.visible_wrap_rows = (0..wrap_row_count).collect();
|
||||
for (display_row, &wrap_row) in self.visible_wrap_rows.iter().enumerate() {
|
||||
self.wrap_row_to_display_row[wrap_row] = Some(display_row);
|
||||
}
|
||||
self.needs_rebuild = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Build set of hidden wrap_row ranges from folded buffer lines
|
||||
let mut hidden_ranges = Vec::new();
|
||||
for fold in &self.folded {
|
||||
// Hide wrap rows from (start_line + 1) to (end_line - 1) (inclusive)
|
||||
// Both the first line and last line of the fold remain visible
|
||||
let hide_start_line = fold.start_line + 1;
|
||||
let hide_end_line = fold.end_line.saturating_sub(1);
|
||||
|
||||
if hide_start_line > hide_end_line {
|
||||
continue; // No middle lines to hide (0 or 1 lines between start and end)
|
||||
}
|
||||
|
||||
// Get wrap_row ranges for the hidden buffer lines
|
||||
let start_wrap_row = wrap_map.buffer_line_to_first_wrap_row(hide_start_line);
|
||||
let end_wrap_row = if hide_end_line + 1 < wrap_map.buffer_line_count() {
|
||||
wrap_map.buffer_line_to_first_wrap_row(hide_end_line + 1)
|
||||
} else {
|
||||
wrap_row_count
|
||||
};
|
||||
|
||||
if start_wrap_row < end_wrap_row {
|
||||
hidden_ranges.push(start_wrap_row..end_wrap_row);
|
||||
}
|
||||
}
|
||||
|
||||
// Merge overlapping hidden ranges
|
||||
hidden_ranges.sort_by_key(|r| r.start);
|
||||
let mut merged_hidden = Vec::new();
|
||||
for range in hidden_ranges {
|
||||
if let Some(last) = merged_hidden.last_mut() {
|
||||
if range.start <= *last {
|
||||
// Overlapping or adjacent, merge
|
||||
*last = (*last).max(range.end);
|
||||
} else {
|
||||
merged_hidden.push(range.start);
|
||||
merged_hidden.push(range.end);
|
||||
}
|
||||
} else {
|
||||
merged_hidden.push(range.start);
|
||||
merged_hidden.push(range.end);
|
||||
}
|
||||
}
|
||||
|
||||
// Scan all wrap rows and filter out hidden ones
|
||||
let mut display_row = 0;
|
||||
let mut hidden_iter = merged_hidden.chunks_exact(2);
|
||||
let mut current_hidden = hidden_iter.next();
|
||||
|
||||
for wrap_row in 0..wrap_row_count {
|
||||
// Check if wrap_row is in current hidden range
|
||||
let is_hidden = if let Some(&[start, end]) = current_hidden {
|
||||
if wrap_row >= end {
|
||||
current_hidden = hidden_iter.next();
|
||||
if let Some(&[new_start, new_end]) = current_hidden {
|
||||
wrap_row >= new_start && wrap_row < new_end
|
||||
} else {
|
||||
false
|
||||
}
|
||||
} else {
|
||||
wrap_row >= start && wrap_row < end
|
||||
}
|
||||
} else {
|
||||
false
|
||||
};
|
||||
|
||||
if !is_hidden {
|
||||
self.visible_wrap_rows.push(wrap_row);
|
||||
self.wrap_row_to_display_row[wrap_row] = Some(display_row);
|
||||
display_row += 1;
|
||||
}
|
||||
}
|
||||
|
||||
self.needs_rebuild = false;
|
||||
}
|
||||
}
|
||||
96
crates/ui/src/input/display_map/folding.rs
Normal file
@@ -0,0 +1,96 @@
|
||||
use std::ops::Range;
|
||||
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
use tree_sitter::Node;
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
pub use tree_sitter::Tree;
|
||||
|
||||
#[cfg(target_family = "wasm")]
|
||||
/// Stub type for tree-sitter Tree on WASM (tree-sitter not available).
|
||||
pub struct Tree;
|
||||
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
/// Minimum line span for a node to be considered foldable.
|
||||
const MIN_FOLD_LINES: usize = 2;
|
||||
|
||||
/// A fold range representing a foldable code region.
|
||||
///
|
||||
/// The fold range spans from start_line to end_line (inclusive).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub struct FoldRange {
|
||||
/// Start line (inclusive)
|
||||
pub start_line: usize,
|
||||
/// End line (inclusive)
|
||||
pub end_line: usize,
|
||||
}
|
||||
|
||||
impl FoldRange {
|
||||
pub fn new(start_line: usize, end_line: usize) -> Self {
|
||||
assert!(
|
||||
start_line <= end_line,
|
||||
"fold start_line must be <= end_line"
|
||||
);
|
||||
Self {
|
||||
start_line,
|
||||
end_line,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
/// Check if a named node qualifies as a fold candidate.
|
||||
///
|
||||
/// Uses a structural heuristic: any **named** node spanning ≥ MIN_FOLD_LINES
|
||||
/// is foldable. tree-sitter already parses code into semantic units (functions,
|
||||
/// classes, blocks, etc.), so named nodes naturally correspond to meaningful
|
||||
/// foldable regions across all languages without a per-language node-type list.
|
||||
fn is_foldable_node(node: &Node) -> bool {
|
||||
let start = node.start_position().row;
|
||||
let end = node.end_position().row;
|
||||
end.saturating_sub(start) >= MIN_FOLD_LINES
|
||||
}
|
||||
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
/// Extract fold ranges only within a byte range (for incremental updates after edits).
|
||||
///
|
||||
/// Skips subtrees entirely outside the range, making it O(nodes in range)
|
||||
/// instead of O(all nodes in tree).
|
||||
pub fn extract_fold_ranges_in_range(tree: &Tree, byte_range: Range<usize>) -> Vec<FoldRange> {
|
||||
let mut ranges = Vec::new();
|
||||
let root = tree.root_node();
|
||||
let mut cursor = root.walk();
|
||||
// Skip the root, it's not foldable. Use named_children to skip literal tokens.
|
||||
for child in root.named_children(&mut cursor) {
|
||||
collect_foldable_nodes_in_range(child, &byte_range, &mut ranges);
|
||||
}
|
||||
|
||||
ranges.sort_by_key(|r| r.start_line);
|
||||
ranges.dedup_by_key(|r| r.start_line);
|
||||
ranges
|
||||
}
|
||||
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
/// Recursively collect foldable nodes, skipping subtrees outside byte_range.
|
||||
fn collect_foldable_nodes_in_range(
|
||||
node: Node,
|
||||
byte_range: &Range<usize>,
|
||||
ranges: &mut Vec<FoldRange>,
|
||||
) {
|
||||
if node.end_byte() <= byte_range.start || node.start_byte() >= byte_range.end {
|
||||
return;
|
||||
}
|
||||
|
||||
if !is_foldable_node(&node) {
|
||||
return;
|
||||
}
|
||||
|
||||
ranges.push(FoldRange {
|
||||
start_line: node.start_position().row,
|
||||
end_line: node.end_position().row,
|
||||
});
|
||||
|
||||
let mut cursor = node.walk();
|
||||
for child in node.named_children(&mut cursor) {
|
||||
collect_foldable_nodes_in_range(child, byte_range, ranges);
|
||||
}
|
||||
}
|
||||
61
crates/ui/src/input/display_map/mod.rs
Normal file
@@ -0,0 +1,61 @@
|
||||
#[allow(clippy::module_inception)]
|
||||
mod display_map;
|
||||
mod fold_map;
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
mod folding;
|
||||
#[cfg(target_family = "wasm")]
|
||||
pub mod folding;
|
||||
mod text_wrapper;
|
||||
mod wrap_map;
|
||||
|
||||
// Re-export public API
|
||||
// Re-export FoldRange and extract_fold_ranges
|
||||
pub use folding::FoldRange;
|
||||
|
||||
pub use self::display_map::DisplayMap;
|
||||
pub(crate) use self::text_wrapper::LineLayout;
|
||||
|
||||
/// Position in the buffer (logical text).
|
||||
///
|
||||
/// - `line`: 0-based logical line number (split by `\n`)
|
||||
/// - `col`: 0-based column offset (byte offset)
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub struct BufferPoint {
|
||||
pub line: usize,
|
||||
pub col: usize,
|
||||
}
|
||||
|
||||
impl BufferPoint {
|
||||
pub fn new(line: usize, col: usize) -> Self {
|
||||
Self { line, col }
|
||||
}
|
||||
}
|
||||
|
||||
/// Position after soft-wrapping but before folding (internal).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub(super) struct WrapPoint {
|
||||
pub row: usize,
|
||||
pub col: usize,
|
||||
}
|
||||
|
||||
impl WrapPoint {
|
||||
pub fn new(row: usize, col: usize) -> Self {
|
||||
Self { row, col }
|
||||
}
|
||||
}
|
||||
|
||||
/// Final display position (after soft-wrapping and folding).
|
||||
///
|
||||
/// - `row`: 0-based display row (final visible row)
|
||||
/// - `col`: 0-based display column
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub struct DisplayPoint {
|
||||
pub row: usize,
|
||||
pub col: usize,
|
||||
}
|
||||
|
||||
impl DisplayPoint {
|
||||
pub fn new(row: usize, col: usize) -> Self {
|
||||
Self { row, col }
|
||||
}
|
||||
}
|
||||
582
crates/ui/src/input/display_map/text_wrapper.rs
Normal file
@@ -0,0 +1,582 @@
|
||||
use std::ops::Range;
|
||||
|
||||
use gpui::{
|
||||
App, Font, Half, LineFragment, Pixels, Point, ShapedLine, Size, TextAlign, Window, point, px,
|
||||
size,
|
||||
};
|
||||
use ropey::Rope;
|
||||
use smallvec::SmallVec;
|
||||
|
||||
use crate::input::{LastLayout, Point as TreeSitterPoint, RopeExt, WhitespaceIndicators};
|
||||
|
||||
/// A line with soft wrapped lines info.
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct LineItem {
|
||||
/// The original line text, without end `\n`.
|
||||
line: Rope,
|
||||
/// The soft wrapped lines relative byte range (0..line.len) of this line (Include first line).
|
||||
///
|
||||
/// Not contains the line end `\n`.
|
||||
pub(crate) wrapped_lines: Vec<Range<usize>>,
|
||||
}
|
||||
|
||||
impl LineItem {
|
||||
/// Get the bytes length of this line.
|
||||
#[inline]
|
||||
pub(crate) fn len(&self) -> usize {
|
||||
self.line.len()
|
||||
}
|
||||
|
||||
/// Get number of soft wrapped lines of this line (include the first line).
|
||||
#[inline]
|
||||
pub(crate) fn lines_len(&self) -> usize {
|
||||
self.wrapped_lines.len()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub(crate) struct LongestRow {
|
||||
/// The 0-based row index.
|
||||
pub row: usize,
|
||||
/// The bytes length of the longest line.
|
||||
pub len: usize,
|
||||
}
|
||||
|
||||
/// Used to prepare the text with soft wrap to be get lines to displayed in the Editor.
|
||||
///
|
||||
/// After use lines to calculate the scroll size of the Editor.
|
||||
pub(crate) struct TextWrapper {
|
||||
text: Rope,
|
||||
/// Total wrapped lines (Inlucde the first line), value is start and end index of the line.
|
||||
soft_lines: usize,
|
||||
font: Font,
|
||||
font_size: Pixels,
|
||||
/// If is none, it means the text is not wrapped
|
||||
wrap_width: Option<Pixels>,
|
||||
/// The longest (row, bytes len) in characters, used to calculate the horizontal scroll width.
|
||||
pub(crate) longest_row: LongestRow,
|
||||
/// The lines by split \n
|
||||
pub(crate) lines: Vec<LineItem>,
|
||||
|
||||
_initialized: bool,
|
||||
}
|
||||
|
||||
#[allow(unused)]
|
||||
impl TextWrapper {
|
||||
pub(crate) fn new(font: Font, font_size: Pixels, wrap_width: Option<Pixels>) -> Self {
|
||||
Self {
|
||||
text: Rope::new(),
|
||||
font,
|
||||
font_size,
|
||||
wrap_width,
|
||||
soft_lines: 0,
|
||||
longest_row: LongestRow::default(),
|
||||
lines: Vec::new(),
|
||||
_initialized: false,
|
||||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub(crate) fn set_default_text(&mut self, text: &Rope) {
|
||||
self.text = text.clone();
|
||||
}
|
||||
|
||||
/// Get reference to the rope text.
|
||||
#[inline]
|
||||
pub(crate) fn text(&self) -> &Rope {
|
||||
&self.text
|
||||
}
|
||||
|
||||
/// Get the total number of lines including wrapped lines.
|
||||
#[inline]
|
||||
pub(crate) fn len(&self) -> usize {
|
||||
self.soft_lines
|
||||
}
|
||||
|
||||
/// Get the line item by row index.
|
||||
#[inline]
|
||||
pub(crate) fn line(&self, row: usize) -> Option<&LineItem> {
|
||||
self.lines.get(row)
|
||||
}
|
||||
|
||||
pub(crate) fn set_wrap_width(&mut self, wrap_width: Option<Pixels>, cx: &mut App) {
|
||||
if wrap_width == self.wrap_width {
|
||||
return;
|
||||
}
|
||||
|
||||
self.wrap_width = wrap_width;
|
||||
self.update_all(&self.text.clone(), cx);
|
||||
}
|
||||
|
||||
pub(crate) fn set_font(&mut self, font: Font, font_size: Pixels, cx: &mut App) {
|
||||
if self.font.eq(&font) && self.font_size == font_size {
|
||||
return;
|
||||
}
|
||||
|
||||
self.font = font;
|
||||
self.font_size = font_size;
|
||||
self.update_all(&self.text.clone(), cx);
|
||||
}
|
||||
|
||||
pub(crate) fn prepare_if_need(&mut self, text: &Rope, cx: &mut App) -> bool {
|
||||
if self._initialized {
|
||||
return false;
|
||||
}
|
||||
self._initialized = true;
|
||||
self.update_all(text, cx);
|
||||
true
|
||||
}
|
||||
|
||||
/// Update the text wrapper and recalculate the wrapped lines.
|
||||
///
|
||||
/// If the `text` is the same as the current text, do nothing.
|
||||
///
|
||||
/// - `changed_text`: The text [`Rope`] that has changed.
|
||||
/// - `range`: The `selected_range` before change.
|
||||
/// - `new_text`: The inserted text.
|
||||
/// - `force`: Whether to force the update, if false, the update will be skipped if the text is the same.
|
||||
/// - `cx`: The application context.
|
||||
pub(crate) fn update(
|
||||
&mut self,
|
||||
changed_text: &Rope,
|
||||
range: &Range<usize>,
|
||||
new_text: &Rope,
|
||||
cx: &mut App,
|
||||
) {
|
||||
let mut line_wrapper = cx
|
||||
.text_system()
|
||||
.line_wrapper(self.font.clone(), self.font_size);
|
||||
self._update(
|
||||
changed_text,
|
||||
range,
|
||||
new_text,
|
||||
&mut |line_str, wrap_width| {
|
||||
line_wrapper
|
||||
.wrap_line(&[LineFragment::text(line_str)], wrap_width)
|
||||
.collect()
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
fn _update<F>(
|
||||
&mut self,
|
||||
changed_text: &Rope,
|
||||
range: &Range<usize>,
|
||||
new_text: &Rope,
|
||||
wrap_line: &mut F,
|
||||
) where
|
||||
F: FnMut(&str, Pixels) -> Vec<gpui::Boundary>,
|
||||
{
|
||||
// Remove the old changed lines.
|
||||
let start_row = self.text.offset_to_point(range.start).row;
|
||||
let start_row = start_row.min(self.lines.len().saturating_sub(1));
|
||||
let end_row = self.text.offset_to_point(range.end).row;
|
||||
let end_row = end_row.min(self.lines.len().saturating_sub(1));
|
||||
let rows_range = start_row..=end_row;
|
||||
|
||||
if rows_range.contains(&self.longest_row.row) {
|
||||
self.longest_row = LongestRow::default();
|
||||
}
|
||||
|
||||
let mut longest_row_ix = self.longest_row.row;
|
||||
let mut longest_row_len = self.longest_row.len;
|
||||
|
||||
// To add the new lines.
|
||||
let new_start_row = changed_text.offset_to_point(range.start).row;
|
||||
let new_start_offset = changed_text.line_start_offset(new_start_row);
|
||||
let new_end_row = changed_text
|
||||
.offset_to_point(range.start + new_text.len())
|
||||
.row;
|
||||
let new_end_offset = changed_text.line_end_offset(new_end_row);
|
||||
let new_range = new_start_offset..new_end_offset;
|
||||
|
||||
let mut new_lines = vec![];
|
||||
let wrap_width = self.wrap_width;
|
||||
|
||||
// line not contains `\n`.
|
||||
for (ix, line) in Rope::from(changed_text.slice(new_range))
|
||||
.iter_lines()
|
||||
.enumerate()
|
||||
{
|
||||
let line_str = line.to_string();
|
||||
let mut wrapped_lines = vec![];
|
||||
let mut prev_boundary_ix = 0;
|
||||
|
||||
if line_str.len() > longest_row_len {
|
||||
longest_row_ix = new_start_row + ix;
|
||||
longest_row_len = line_str.len();
|
||||
}
|
||||
|
||||
// If wrap_width is Pixels::MAX, skip wrapping to disable word wrap
|
||||
if let Some(wrap_width) = wrap_width {
|
||||
// Here only have wrapped line, if there is no wrap meet, the `line_wraps` result will empty.
|
||||
for boundary in wrap_line(&line_str, wrap_width) {
|
||||
wrapped_lines.push(prev_boundary_ix..boundary.ix);
|
||||
prev_boundary_ix = boundary.ix;
|
||||
}
|
||||
}
|
||||
|
||||
// Reset of the line
|
||||
if !line_str[prev_boundary_ix..].is_empty() || prev_boundary_ix == 0 {
|
||||
wrapped_lines.push(prev_boundary_ix..line.len());
|
||||
}
|
||||
|
||||
new_lines.push(LineItem {
|
||||
line: Rope::from(line),
|
||||
wrapped_lines,
|
||||
});
|
||||
}
|
||||
|
||||
if self.lines.is_empty() {
|
||||
self.lines = new_lines;
|
||||
} else {
|
||||
self.lines.splice(rows_range, new_lines);
|
||||
}
|
||||
|
||||
self.text = changed_text.clone();
|
||||
self.soft_lines = self.lines.iter().map(|l| l.lines_len()).sum();
|
||||
self.longest_row = LongestRow {
|
||||
row: longest_row_ix,
|
||||
len: longest_row_len,
|
||||
}
|
||||
}
|
||||
|
||||
/// Update the text wrapper and recalculate the wrapped lines.
|
||||
///
|
||||
/// If the `text` is the same as the current text, do nothing.
|
||||
fn update_all(&mut self, text: &Rope, cx: &mut App) {
|
||||
self.update(text, &(0..text.len()), text, cx);
|
||||
}
|
||||
|
||||
/// Return display point (with soft wrap) from the given byte offset in the text.
|
||||
///
|
||||
/// Panics if the `offset` is out of bounds.
|
||||
pub(crate) fn offset_to_display_point(&self, offset: usize) -> WrapDisplayPoint {
|
||||
let row = self.text.offset_to_point(offset).row;
|
||||
let start = self.text.line_start_offset(row);
|
||||
let line = &self.lines[row];
|
||||
|
||||
let mut wrapped_row = self
|
||||
.lines
|
||||
.iter()
|
||||
.take(row)
|
||||
.map(|l| l.lines_len())
|
||||
.sum::<usize>();
|
||||
|
||||
let local_offset = offset.saturating_sub(start);
|
||||
for (ix, range) in line.wrapped_lines.iter().enumerate() {
|
||||
if range.contains(&local_offset) {
|
||||
return WrapDisplayPoint::new(
|
||||
wrapped_row + ix,
|
||||
ix,
|
||||
local_offset.saturating_sub(range.start),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Otherwise return the eof of the line.
|
||||
let last_range = line.wrapped_lines.last().unwrap_or(&(0..0));
|
||||
let ix = line.lines_len().saturating_sub(1);
|
||||
|
||||
WrapDisplayPoint::new(wrapped_row + ix, ix, last_range.len())
|
||||
}
|
||||
|
||||
/// Return byte offset in the text from the given display point (with soft wrap).
|
||||
///
|
||||
/// Panics if the `point.row` is out of bounds.
|
||||
pub(crate) fn display_point_to_offset(&self, point: WrapDisplayPoint) -> usize {
|
||||
let mut wrapped_row = 0;
|
||||
for (row, line) in self.lines.iter().enumerate() {
|
||||
if wrapped_row + line.lines_len() > point.row {
|
||||
let line_start = self.text.line_start_offset(row);
|
||||
let local_row = point.row.saturating_sub(wrapped_row);
|
||||
if let Some(range) = line.wrapped_lines.get(local_row) {
|
||||
return line_start + (range.start + point.column).min(range.end);
|
||||
} else {
|
||||
// If not found, return the end of the line.
|
||||
return line_start + line.len();
|
||||
}
|
||||
}
|
||||
|
||||
wrapped_row += line.lines_len();
|
||||
}
|
||||
|
||||
self.text.len()
|
||||
}
|
||||
|
||||
pub(crate) fn display_point_to_point(&self, point: WrapDisplayPoint) -> TreeSitterPoint {
|
||||
let offset = self.display_point_to_offset(point);
|
||||
self.text.offset_to_point(offset)
|
||||
}
|
||||
|
||||
pub(crate) fn point_to_display_point(&self, point: TreeSitterPoint) -> WrapDisplayPoint {
|
||||
let offset = self.text.point_to_offset(point);
|
||||
self.offset_to_display_point(offset)
|
||||
}
|
||||
}
|
||||
|
||||
/// A display point within the soft-wrapped text.
|
||||
///
|
||||
/// This represents a position in the text after soft-wrapping,
|
||||
/// with an additional `local_row` field tracking the wrap line
|
||||
/// within the original buffer line.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) struct WrapDisplayPoint {
|
||||
/// The 0-based soft wrapped row index in the text.
|
||||
pub row: usize,
|
||||
/// The 0-based row index in local line (include first line).
|
||||
///
|
||||
/// This value only valid when return from [`TextWrapper::offset_to_display_point`], otherwise it will be ignored.
|
||||
pub local_row: usize,
|
||||
/// The 0-based column byte index in the display line (with soft wrap).
|
||||
pub column: usize,
|
||||
}
|
||||
|
||||
impl WrapDisplayPoint {
|
||||
pub fn new(row: usize, local_row: usize, column: usize) -> Self {
|
||||
Self {
|
||||
row,
|
||||
local_row,
|
||||
column,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The layout info of a line with soft wrapped lines.
|
||||
pub(crate) struct LineLayout {
|
||||
/// Total bytes length of this line.
|
||||
len: usize,
|
||||
/// The soft wrapped lines of this line (Include the first line).
|
||||
pub(crate) wrapped_lines: SmallVec<[ShapedLine; 1]>,
|
||||
pub(crate) longest_width: Pixels,
|
||||
pub(crate) whitespace_indicators: Option<WhitespaceIndicators>,
|
||||
/// Whitespace indicators: (line_index, x_position, is_tab)
|
||||
pub(crate) whitespace_chars: Vec<(usize, Pixels, bool)>,
|
||||
}
|
||||
|
||||
impl LineLayout {
|
||||
pub(crate) fn new() -> Self {
|
||||
Self {
|
||||
len: 0,
|
||||
longest_width: px(0.),
|
||||
wrapped_lines: SmallVec::new(),
|
||||
whitespace_chars: Vec::new(),
|
||||
whitespace_indicators: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn lines(mut self, wrapped_lines: SmallVec<[ShapedLine; 1]>) -> Self {
|
||||
self.set_wrapped_lines(wrapped_lines);
|
||||
self
|
||||
}
|
||||
|
||||
pub(crate) fn set_wrapped_lines(&mut self, wrapped_lines: SmallVec<[ShapedLine; 1]>) {
|
||||
self.len = wrapped_lines.iter().map(|l| l.len).sum();
|
||||
let width = wrapped_lines
|
||||
.iter()
|
||||
.map(|l| l.width)
|
||||
.max()
|
||||
.unwrap_or_default();
|
||||
self.longest_width = width;
|
||||
self.wrapped_lines = wrapped_lines;
|
||||
}
|
||||
|
||||
pub(crate) fn with_whitespaces(mut self, indicators: Option<WhitespaceIndicators>) -> Self {
|
||||
self.whitespace_indicators = indicators;
|
||||
let Some(indicators) = self.whitespace_indicators.as_ref() else {
|
||||
return self;
|
||||
};
|
||||
|
||||
let space_indicator_offset = indicators.space.width.half();
|
||||
|
||||
for (line_index, wrapped_line) in self.wrapped_lines.iter().enumerate() {
|
||||
for (relative_offset, c) in wrapped_line.text.char_indices() {
|
||||
if matches!(c, ' ' | '\t') {
|
||||
let is_tab = c == '\t';
|
||||
let start_x = wrapped_line.x_for_index(relative_offset);
|
||||
let end_x = wrapped_line.x_for_index(relative_offset + c.len_utf8());
|
||||
// Center the indicator in the actual character's space
|
||||
let x_position = if c == ' ' {
|
||||
(start_x + end_x).half() - space_indicator_offset
|
||||
} else {
|
||||
start_x
|
||||
};
|
||||
|
||||
self.whitespace_chars.push((line_index, x_position, is_tab));
|
||||
}
|
||||
}
|
||||
}
|
||||
self
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub(crate) fn len(&self) -> usize {
|
||||
self.len
|
||||
}
|
||||
|
||||
/// Get the position (x, y) for the given index in this line layout.
|
||||
///
|
||||
/// - The `offset` is a local byte index in this line layout.
|
||||
/// - When `line_end_affinity` is true, an offset at a soft wrap boundary is placed at
|
||||
/// the end of the current visual line rather than the start of the next one.
|
||||
/// - The return value is relative to the top-left corner of this line layout, start from (0, 0)
|
||||
pub(crate) fn position_for_index(
|
||||
&self,
|
||||
offset: usize,
|
||||
last_layout: &LastLayout,
|
||||
line_end_affinity: bool,
|
||||
) -> Option<Point<Pixels>> {
|
||||
let mut acc_len = 0;
|
||||
let mut offset_y = px(0.);
|
||||
|
||||
let x_offset = last_layout.alignment_offset(self.longest_width);
|
||||
|
||||
for (i, line) in self.wrapped_lines.iter().enumerate() {
|
||||
let is_last = i + 1 == self.wrapped_lines.len();
|
||||
|
||||
let matches = if line.len == 0 {
|
||||
// Empty visual lines still own their boundary offset.
|
||||
offset == acc_len
|
||||
} else if is_last || line_end_affinity {
|
||||
// Inclusive: cursor can sit at end of this visual line.
|
||||
offset >= acc_len && offset <= acc_len + line.len
|
||||
} else {
|
||||
// Exclusive: boundary offset belongs to the next visual line.
|
||||
offset >= acc_len && offset < acc_len + line.len
|
||||
};
|
||||
|
||||
if matches {
|
||||
let x = line.x_for_index(offset.saturating_sub(acc_len)) + x_offset;
|
||||
return Some(point(x, offset_y));
|
||||
}
|
||||
|
||||
// Always advance by actual line length. The last line gets +1 so the
|
||||
// cursor can be placed after the final character.
|
||||
acc_len += if is_last { line.len + 1 } else { line.len };
|
||||
offset_y += last_layout.line_height;
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
/// Get the closest index for the given x in this line layout.
|
||||
pub(crate) fn closest_index_for_x(&self, x: Pixels, last_layout: &LastLayout) -> usize {
|
||||
let mut acc_len = 0;
|
||||
let x_offset = last_layout.alignment_offset(self.longest_width);
|
||||
let x = x - x_offset;
|
||||
|
||||
for (i, line) in self.wrapped_lines.iter().enumerate() {
|
||||
let is_last = i + 1 == self.wrapped_lines.len();
|
||||
if x <= line.width {
|
||||
let mut ix = line.closest_index_for_x(x);
|
||||
if !is_last && ix == line.text.len() {
|
||||
// For soft wrap line, we can't put the cursor at the end of the line.
|
||||
let c_len = line.text.chars().last().map(|c| c.len_utf8()).unwrap_or(0);
|
||||
ix = ix.saturating_sub(c_len);
|
||||
}
|
||||
|
||||
return acc_len + ix;
|
||||
}
|
||||
acc_len += line.text.len();
|
||||
}
|
||||
|
||||
acc_len
|
||||
}
|
||||
|
||||
/// Get the index for the given position (x, y) in this line layout.
|
||||
///
|
||||
/// The `pos` is relative to the top-left corner of this line layout, start from (0, 0)
|
||||
/// The return value is a local byte index in this line layout, start from 0.
|
||||
pub(crate) fn closest_index_for_position(
|
||||
&self,
|
||||
pos: Point<Pixels>,
|
||||
last_layout: &LastLayout,
|
||||
) -> Option<usize> {
|
||||
let mut offset = 0;
|
||||
let mut line_top = px(0.);
|
||||
let x_offset = last_layout.alignment_offset(self.longest_width);
|
||||
for (i, line) in self.wrapped_lines.iter().enumerate() {
|
||||
let is_last = i + 1 == self.wrapped_lines.len();
|
||||
let line_bottom = line_top + last_layout.line_height;
|
||||
if pos.y >= line_top && pos.y < line_bottom {
|
||||
let mut ix = line.closest_index_for_x(pos.x - x_offset);
|
||||
if !is_last && ix == line.text.len() {
|
||||
// For soft wrap line, we can't put the cursor at the end of the line.
|
||||
let c_len = line.text.chars().last().map(|c| c.len_utf8()).unwrap_or(0);
|
||||
ix = ix.saturating_sub(c_len);
|
||||
}
|
||||
return Some(offset + ix);
|
||||
}
|
||||
|
||||
offset += line.text.len();
|
||||
line_top = line_bottom;
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
pub(crate) fn index_for_position(
|
||||
&self,
|
||||
pos: Point<Pixels>,
|
||||
last_layout: &LastLayout,
|
||||
) -> Option<usize> {
|
||||
let mut offset = 0;
|
||||
let mut line_top = px(0.);
|
||||
let x_offset = last_layout.alignment_offset(self.longest_width);
|
||||
for line in self.wrapped_lines.iter() {
|
||||
let line_bottom = line_top + last_layout.line_height;
|
||||
if pos.y >= line_top && pos.y < line_bottom {
|
||||
let ix = line.index_for_x(pos.x - x_offset)?;
|
||||
return Some(offset + ix);
|
||||
}
|
||||
|
||||
offset += line.text.len();
|
||||
line_top = line_bottom;
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
pub(crate) fn size(&self, line_height: Pixels) -> Size<Pixels> {
|
||||
size(self.longest_width, self.wrapped_lines.len() * line_height)
|
||||
}
|
||||
|
||||
pub(crate) fn paint(
|
||||
&self,
|
||||
pos: Point<Pixels>,
|
||||
line_height: Pixels,
|
||||
text_align: TextAlign,
|
||||
align_width: Option<Pixels>,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) {
|
||||
for (ix, line) in self.wrapped_lines.iter().enumerate() {
|
||||
_ = line.paint(
|
||||
pos + point(px(0.), ix * line_height),
|
||||
line_height,
|
||||
text_align,
|
||||
align_width,
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
}
|
||||
|
||||
// Paint whitespace indicators
|
||||
if let Some(indicators) = self.whitespace_indicators.as_ref() {
|
||||
for (line_index, x_position, is_tab) in &self.whitespace_chars {
|
||||
let invisible = if *is_tab {
|
||||
indicators.tab.clone()
|
||||
} else {
|
||||
indicators.space.clone()
|
||||
};
|
||||
|
||||
let origin = point(
|
||||
pos.x + *x_position,
|
||||
pos.y + *line_index as f32 * line_height,
|
||||
);
|
||||
|
||||
_ = invisible.paint(origin, line_height, text_align, align_width, window, cx);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
222
crates/ui/src/input/display_map/wrap_map.rs
Normal file
@@ -0,0 +1,222 @@
|
||||
/// 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::fold_map::FoldMap;
|
||||
use super::text_wrapper::{LineItem, TextWrapper, WrapDisplayPoint};
|
||||
use super::{BufferPoint, WrapPoint};
|
||||
use crate::input::rope_ext::RopeExt;
|
||||
|
||||
/// 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()
|
||||
}
|
||||
|
||||
/// Convert buffer position to wrap position
|
||||
pub(super) fn buffer_pos_to_wrap_pos(&self, pos: BufferPoint) -> WrapPoint {
|
||||
let BufferPoint { line, col } = pos;
|
||||
|
||||
// Clamp to valid range
|
||||
let line = line.min(self.buffer_line_count().saturating_sub(1));
|
||||
let line_item = self.wrapper.lines.get(line);
|
||||
|
||||
let col = if let Some(line_item) = line_item {
|
||||
col.min(line_item.len())
|
||||
} else {
|
||||
0
|
||||
};
|
||||
|
||||
// Calculate offset in rope
|
||||
let line_start_offset = self.wrapper.text().line_start_offset(line);
|
||||
let offset = line_start_offset + col;
|
||||
|
||||
// Use TextWrapper's existing conversion
|
||||
let display_point = self.wrapper.offset_to_display_point(offset);
|
||||
|
||||
WrapPoint::new(display_point.row, display_point.column)
|
||||
}
|
||||
|
||||
/// Convert wrap position to buffer position
|
||||
pub(super) fn wrap_pos_to_buffer_pos(&self, pos: WrapPoint) -> BufferPoint {
|
||||
let WrapPoint { row, col } = pos;
|
||||
|
||||
// Clamp wrap_row to valid range
|
||||
let row = row.min(self.wrap_row_count().saturating_sub(1));
|
||||
|
||||
// Use TextWrapper's existing conversion
|
||||
let display_point = WrapDisplayPoint::new(row, 0, col);
|
||||
let offset = self.wrapper.display_point_to_offset(display_point);
|
||||
|
||||
// Convert offset to buffer position
|
||||
let point = self.wrapper.text().offset_to_point(offset);
|
||||
let line_start = self.wrapper.text().line_start_offset(point.row);
|
||||
let col = offset.saturating_sub(line_start);
|
||||
|
||||
BufferPoint::new(point.row, col)
|
||||
}
|
||||
|
||||
/// 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.
|
||||
// Checking wrap_row_count is essential because soft-wrap can change the number of
|
||||
// wrap rows per line without changing the buffer line count.
|
||||
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 (not folded)
|
||||
pub fn visible_wrap_row_count_for_line(&self, line: usize, fold_map: &FoldMap) -> usize {
|
||||
let wrap_range = self.buffer_line_to_wrap_row_range(line);
|
||||
wrap_range
|
||||
.filter(|&wr| fold_map.wrap_row_to_display_row(wr).is_some())
|
||||
.count()
|
||||
}
|
||||
}
|
||||
424
crates/ui/src/input/indent.rs
Normal file
@@ -0,0 +1,424 @@
|
||||
use gpui::{
|
||||
Bounds, Context, EntityInputHandler as _, Hsla, Path, PathBuilder, Pixels, SharedString,
|
||||
TextRun, TextStyle, Window, point, px,
|
||||
};
|
||||
use ropey::RopeSlice;
|
||||
|
||||
use crate::input::element::TextElement;
|
||||
use crate::input::mode::InputMode;
|
||||
use crate::input::{Indent, IndentInline, InputState, LastLayout, Outdent, OutdentInline, RopeExt};
|
||||
|
||||
#[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 InputMode {
|
||||
#[inline]
|
||||
pub(super) fn is_indentable(&self) -> bool {
|
||||
match self {
|
||||
InputMode::PlainText { multi_line, .. } | InputMode::CodeEditor { multi_line, .. } => {
|
||||
*multi_line
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub(super) fn has_indent_guides(&self) -> bool {
|
||||
match self {
|
||||
InputMode::CodeEditor {
|
||||
indent_guides,
|
||||
multi_line,
|
||||
..
|
||||
} => *indent_guides && *multi_line,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub(super) fn tab_size(&self) -> TabSize {
|
||||
match self {
|
||||
InputMode::PlainText { tab, .. } => *tab,
|
||||
InputMode::CodeEditor { tab, .. } => *tab,
|
||||
_ => TabSize::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl TextElement {
|
||||
/// Measure the indent width in pixels for given column count.
|
||||
fn measure_indent_width(&self, style: &TextStyle, column: usize, window: &Window) -> Pixels {
|
||||
let font_size = style.font_size.to_pixels(window.rem_size());
|
||||
let layout = window.text_system().shape_line(
|
||||
SharedString::from(" ".repeat(column)),
|
||||
font_size,
|
||||
&[TextRun {
|
||||
len: column,
|
||||
font: style.font(),
|
||||
color: Hsla::default(),
|
||||
background_color: None,
|
||||
strikethrough: None,
|
||||
underline: None,
|
||||
}],
|
||||
None,
|
||||
);
|
||||
|
||||
layout.width
|
||||
}
|
||||
|
||||
pub(super) fn layout_indent_guides(
|
||||
&self,
|
||||
state: &InputState,
|
||||
bounds: &Bounds<Pixels>,
|
||||
last_layout: &LastLayout,
|
||||
text_style: &TextStyle,
|
||||
window: &mut Window,
|
||||
) -> Option<Path<Pixels>> {
|
||||
if !state.mode.has_indent_guides() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let indent_width =
|
||||
self.measure_indent_width(text_style, state.mode.tab_size().tab_size, window);
|
||||
|
||||
let tab_size = state.mode.tab_size();
|
||||
let line_height = last_layout.line_height;
|
||||
let mut builder = PathBuilder::stroke(px(1.));
|
||||
let mut offset_y = last_layout.visible_top;
|
||||
let mut last_indents = vec![];
|
||||
|
||||
for (&buffer_line, line_layout) in last_layout
|
||||
.visible_buffer_lines
|
||||
.iter()
|
||||
.zip(last_layout.lines.iter())
|
||||
{
|
||||
let line = state.text.slice_line(buffer_line);
|
||||
let mut current_indents = vec![];
|
||||
if line.len() > 0 {
|
||||
let indent_count = tab_size.indent_count(&line);
|
||||
for offset in (0..indent_count).step_by(tab_size.tab_size) {
|
||||
let x = if indent_count > 0 {
|
||||
indent_width * offset as f32 / tab_size.tab_size as f32
|
||||
} else {
|
||||
px(0.)
|
||||
};
|
||||
|
||||
let pos = point(x + last_layout.line_number_width, offset_y);
|
||||
|
||||
builder.move_to(pos);
|
||||
builder.line_to(point(pos.x, pos.y + line_height));
|
||||
current_indents.push(pos.x);
|
||||
}
|
||||
} else if !last_indents.is_empty() {
|
||||
for x in &last_indents {
|
||||
let pos = point(*x, offset_y);
|
||||
builder.move_to(pos);
|
||||
builder.line_to(point(pos.x, pos.y + line_height));
|
||||
}
|
||||
current_indents = last_indents.clone();
|
||||
}
|
||||
|
||||
offset_y += line_layout.wrapped_lines.len() * line_height;
|
||||
last_indents = current_indents;
|
||||
}
|
||||
|
||||
builder.translate(bounds.origin);
|
||||
let path = builder.build().unwrap();
|
||||
Some(path)
|
||||
}
|
||||
}
|
||||
|
||||
impl InputState {
|
||||
/// Set whether to show indent guides in code editor mode, default is true.
|
||||
///
|
||||
/// Only for [`InputMode::CodeEditor`] mode.
|
||||
pub fn indent_guides(mut self, indent_guides: bool) -> Self {
|
||||
debug_assert!(self.mode.is_code_editor() && self.mode.is_multi_line());
|
||||
if let InputMode::CodeEditor {
|
||||
indent_guides: l, ..
|
||||
} = &mut self.mode
|
||||
{
|
||||
*l = indent_guides;
|
||||
}
|
||||
self
|
||||
}
|
||||
|
||||
/// Set indent guides in code editor mode.
|
||||
///
|
||||
/// Only for [`InputMode::CodeEditor`] mode.
|
||||
pub fn set_indent_guides(
|
||||
&mut self,
|
||||
indent_guides: bool,
|
||||
_: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
debug_assert!(self.mode.is_code_editor());
|
||||
if let InputMode::CodeEditor {
|
||||
indent_guides: l, ..
|
||||
} = &mut self.mode
|
||||
{
|
||||
*l = indent_guides;
|
||||
}
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// Set the tab size for the input.
|
||||
///
|
||||
/// Only for [`InputMode::PlainText`] and [`InputMode::CodeEditor`] mode with multi_line.
|
||||
pub fn tab_size(mut self, tab: TabSize) -> Self {
|
||||
debug_assert!(self.mode.is_multi_line() || self.mode.is_code_editor());
|
||||
match &mut self.mode {
|
||||
InputMode::PlainText { tab: t, .. } => *t = tab,
|
||||
InputMode::CodeEditor { tab: t, .. } => *t = tab,
|
||||
_ => {}
|
||||
}
|
||||
self
|
||||
}
|
||||
|
||||
pub(super) fn indent_inline(
|
||||
&mut self,
|
||||
_: &IndentInline,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
self.indent(false, window, cx);
|
||||
}
|
||||
|
||||
pub(super) fn indent_block(&mut self, _: &Indent, window: &mut Window, cx: &mut Context<Self>) {
|
||||
self.indent(true, window, cx);
|
||||
}
|
||||
|
||||
pub(super) fn outdent_inline(
|
||||
&mut self,
|
||||
_: &OutdentInline,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
self.outdent(false, window, cx);
|
||||
}
|
||||
|
||||
pub(super) fn outdent_block(
|
||||
&mut self,
|
||||
_: &Outdent,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
self.outdent(true, window, cx);
|
||||
}
|
||||
|
||||
pub(super) fn indent(&mut self, block: bool, window: &mut Window, cx: &mut Context<Self>) {
|
||||
if !self.mode.is_indentable() {
|
||||
cx.propagate();
|
||||
return;
|
||||
};
|
||||
|
||||
let tab_indent = self.mode.tab_size().to_string();
|
||||
let selected_range = self.selected_range;
|
||||
let mut added_len = 0;
|
||||
let is_selected = !self.selected_range.is_empty();
|
||||
|
||||
if is_selected || block {
|
||||
let start_offset = self.start_of_line_of_selection(window, cx);
|
||||
let mut offset = start_offset;
|
||||
|
||||
let selected_text = self
|
||||
.text_for_range(
|
||||
self.range_to_utf16(&(offset..selected_range.end)),
|
||||
&mut None,
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
.unwrap_or("".into());
|
||||
|
||||
for line in selected_text.split('\n') {
|
||||
self.replace_text_in_range_silent(
|
||||
Some(self.range_to_utf16(&(offset..offset))),
|
||||
&tab_indent,
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
added_len += tab_indent.len();
|
||||
// +1 for "\n", the `\r` is included in the `line`.
|
||||
offset += line.len() + tab_indent.len() + 1;
|
||||
}
|
||||
|
||||
if is_selected {
|
||||
self.selected_range = (start_offset..selected_range.end + added_len).into();
|
||||
} else {
|
||||
self.selected_range =
|
||||
(selected_range.start + added_len..selected_range.end + added_len).into();
|
||||
}
|
||||
} else {
|
||||
// Selected none
|
||||
let offset = self.selected_range.start;
|
||||
self.replace_text_in_range_silent(
|
||||
Some(self.range_to_utf16(&(offset..offset))),
|
||||
&tab_indent,
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
added_len = tab_indent.len();
|
||||
|
||||
self.selected_range =
|
||||
(selected_range.start + added_len..selected_range.end + added_len).into();
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn outdent(&mut self, block: bool, window: &mut Window, cx: &mut Context<Self>) {
|
||||
if !self.mode.is_indentable() {
|
||||
cx.propagate();
|
||||
return;
|
||||
};
|
||||
|
||||
let tab_indent = self.mode.tab_size().to_string();
|
||||
let selected_range = self.selected_range;
|
||||
let mut removed_len = 0;
|
||||
let is_selected = !self.selected_range.is_empty();
|
||||
|
||||
if is_selected || block {
|
||||
let start_offset = self.start_of_line_of_selection(window, cx);
|
||||
let mut offset = start_offset;
|
||||
|
||||
let selected_text = self
|
||||
.text_for_range(
|
||||
self.range_to_utf16(&(offset..selected_range.end)),
|
||||
&mut None,
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
.unwrap_or("".into());
|
||||
|
||||
for line in selected_text.split('\n') {
|
||||
if line.starts_with(tab_indent.as_ref()) {
|
||||
self.replace_text_in_range_silent(
|
||||
Some(self.range_to_utf16(&(offset..offset + tab_indent.len()))),
|
||||
"",
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
removed_len += tab_indent.len();
|
||||
|
||||
// +1 for "\n"
|
||||
offset += line.len().saturating_sub(tab_indent.len()) + 1;
|
||||
} else {
|
||||
offset += line.len() + 1;
|
||||
}
|
||||
}
|
||||
|
||||
if is_selected {
|
||||
self.selected_range =
|
||||
(start_offset..selected_range.end.saturating_sub(removed_len)).into();
|
||||
} else {
|
||||
self.selected_range = (selected_range.start.saturating_sub(removed_len)
|
||||
..selected_range.end.saturating_sub(removed_len))
|
||||
.into();
|
||||
}
|
||||
} else {
|
||||
// Selected none
|
||||
let start_offset = self.selected_range.start;
|
||||
let offset = self.start_of_line_of_selection(window, cx);
|
||||
let offset = self.offset_from_utf16(self.offset_to_utf16(offset));
|
||||
// FIXME: To improve performance
|
||||
if self
|
||||
.text
|
||||
.slice(offset..self.text.len())
|
||||
.to_string()
|
||||
.starts_with(tab_indent.as_ref())
|
||||
{
|
||||
self.replace_text_in_range_silent(
|
||||
Some(self.range_to_utf16(&(offset..offset + tab_indent.len()))),
|
||||
"",
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
removed_len = tab_indent.len();
|
||||
let new_offset = start_offset.saturating_sub(removed_len);
|
||||
self.selected_range = (new_offset..new_offset).into();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use ropey::RopeSlice;
|
||||
|
||||
use super::TabSize;
|
||||
|
||||
#[test]
|
||||
fn test_tab_size() {
|
||||
let tab = TabSize {
|
||||
tab_size: 2,
|
||||
hard_tabs: false,
|
||||
};
|
||||
assert_eq!(tab.to_string(), " ");
|
||||
let tab = TabSize {
|
||||
tab_size: 4,
|
||||
hard_tabs: false,
|
||||
};
|
||||
assert_eq!(tab.to_string(), " ");
|
||||
|
||||
let tab = TabSize {
|
||||
tab_size: 2,
|
||||
hard_tabs: true,
|
||||
};
|
||||
assert_eq!(tab.to_string(), "\t");
|
||||
let tab = TabSize {
|
||||
tab_size: 4,
|
||||
hard_tabs: true,
|
||||
};
|
||||
assert_eq!(tab.to_string(), "\t");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_tab_size_indent_count() {
|
||||
let tab = TabSize {
|
||||
tab_size: 4,
|
||||
hard_tabs: false,
|
||||
};
|
||||
assert_eq!(tab.indent_count(&RopeSlice::from("abc")), 0);
|
||||
assert_eq!(tab.indent_count(&RopeSlice::from(" abc")), 2);
|
||||
assert_eq!(tab.indent_count(&RopeSlice::from(" abc")), 4);
|
||||
assert_eq!(tab.indent_count(&RopeSlice::from("\tabc")), 4);
|
||||
assert_eq!(tab.indent_count(&RopeSlice::from(" \tabc")), 6);
|
||||
assert_eq!(tab.indent_count(&RopeSlice::from(" \t abc ")), 6);
|
||||
assert_eq!(tab.indent_count(&RopeSlice::from("abc")), 0);
|
||||
}
|
||||
}
|
||||
@@ -1,19 +1,30 @@
|
||||
use gpui::prelude::FluentBuilder as _;
|
||||
use gpui::{
|
||||
div, px, relative, AnyElement, App, DefiniteLength, Entity, InteractiveElement as _,
|
||||
AnyElement, App, DefiniteLength, Edges, EdgesRefinement, Entity, Hsla, InteractiveElement as _,
|
||||
IntoElement, MouseButton, ParentElement as _, Rems, RenderOnce, StyleRefinement, Styled,
|
||||
Window,
|
||||
TextAlign, Window, div, px, relative,
|
||||
};
|
||||
use theme::ActiveTheme;
|
||||
|
||||
use super::clear_button::clear_button;
|
||||
use super::state::{InputState, CONTEXT};
|
||||
use crate::button::{Button, ButtonVariants};
|
||||
use super::InputState;
|
||||
use super::element::EditorScrollbar;
|
||||
use crate::button::{Button, ButtonVariants as _};
|
||||
use crate::indicator::Indicator;
|
||||
use crate::{h_flex, IconName, Sizable, Size, StyleSized, StyledExt};
|
||||
use crate::input::clear_button;
|
||||
use crate::{IconName, Selectable, Sizable, Size, StyleSized, StyledExt, h_flex, v_flex};
|
||||
|
||||
/// Returns `(background, foreground)` colors for input-like components.
|
||||
pub(crate) fn input_style(disabled: bool, cx: &App) -> (Hsla, Hsla) {
|
||||
if disabled {
|
||||
(cx.theme().surface_background, cx.theme().text_muted)
|
||||
} else {
|
||||
(cx.theme().elevated_surface_background, cx.theme().text)
|
||||
}
|
||||
}
|
||||
|
||||
/// A text input element bind to an [`InputState`].
|
||||
#[derive(IntoElement)]
|
||||
pub struct TextInput {
|
||||
pub struct Input {
|
||||
state: Entity<InputState>,
|
||||
style: StyleRefinement,
|
||||
size: Size,
|
||||
@@ -26,17 +37,30 @@ pub struct TextInput {
|
||||
disabled: bool,
|
||||
bordered: bool,
|
||||
focus_bordered: bool,
|
||||
tab_index: isize,
|
||||
selected: bool,
|
||||
}
|
||||
|
||||
impl Sizable for TextInput {
|
||||
impl Sizable for Input {
|
||||
fn with_size(mut self, size: impl Into<Size>) -> Self {
|
||||
self.size = size.into();
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl TextInput {
|
||||
/// Create a new [`TextInput`] element bind to the [`InputState`].
|
||||
impl Selectable for Input {
|
||||
fn selected(mut self, selected: bool) -> Self {
|
||||
self.selected = selected;
|
||||
self
|
||||
}
|
||||
|
||||
fn is_selected(&self) -> bool {
|
||||
self.selected
|
||||
}
|
||||
}
|
||||
|
||||
impl Input {
|
||||
/// Create a new [`Input`] element bind to the [`InputState`].
|
||||
pub fn new(state: &Entity<InputState>) -> Self {
|
||||
Self {
|
||||
state: state.clone(),
|
||||
@@ -51,6 +75,8 @@ impl TextInput {
|
||||
disabled: false,
|
||||
bordered: true,
|
||||
focus_bordered: true,
|
||||
tab_index: 0,
|
||||
selected: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -94,9 +120,9 @@ impl TextInput {
|
||||
self
|
||||
}
|
||||
|
||||
/// Set true to show the clear button when the input field is not empty.
|
||||
pub fn cleanable(mut self) -> Self {
|
||||
self.cleanable = true;
|
||||
/// Set whether to show the clear button when the input field is not empty, default is false.
|
||||
pub fn cleanable(mut self, cleanable: bool) -> Self {
|
||||
self.cleanable = cleanable;
|
||||
self
|
||||
}
|
||||
|
||||
@@ -112,79 +138,123 @@ 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 {
|
||||
let (bg, _) = input_style(state.disabled, cx);
|
||||
|
||||
let bg = if state.mode.is_code_editor() {
|
||||
cx.theme().surface_background
|
||||
} else {
|
||||
cx.theme().elevated_surface_background
|
||||
bg
|
||||
};
|
||||
|
||||
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 +275,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 +327,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 +336,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 +371,7 @@ impl RenderOnce for TextInput {
|
||||
move |_, window, cx| {
|
||||
state.update(cx, |state, cx| {
|
||||
state.clean(window, cx);
|
||||
state.focus(window, cx);
|
||||
})
|
||||
}
|
||||
}))
|
||||
@@ -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
|
||||
};
|
||||
|
||||
@@ -1,15 +1,29 @@
|
||||
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::*;
|
||||
#[cfg(target_family = "wasm")]
|
||||
pub use display_map::folding::Tree;
|
||||
pub use display_map::{BufferPoint, DisplayMap, DisplayPoint, FoldRange};
|
||||
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::*;
|
||||
|
||||
@@ -1,54 +1,122 @@
|
||||
use gpui::SharedString;
|
||||
use std::cell::RefCell;
|
||||
use std::rc::Rc;
|
||||
|
||||
use super::text_wrapper::TextWrapper;
|
||||
use gpui::{SharedString, Task};
|
||||
use ropey::Rope;
|
||||
|
||||
#[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,
|
||||
use super::display_map::DisplayMap;
|
||||
use crate::input::TabSize;
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub(super) struct PendingBackgroundParse {
|
||||
pub parse_task: Rc<RefCell<Option<Task<()>>>>,
|
||||
pub language: SharedString,
|
||||
pub text: Rope,
|
||||
pub is_folding: 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 {
|
||||
#[derive(Clone)]
|
||||
pub(crate) enum InputMode {
|
||||
/// A plain text input mode.
|
||||
PlainText {
|
||||
multi_line: bool,
|
||||
tab: TabSize,
|
||||
rows: usize,
|
||||
},
|
||||
/// An auto grow input mode.
|
||||
AutoGrow {
|
||||
rows: usize,
|
||||
min_rows: usize,
|
||||
max_rows: usize,
|
||||
},
|
||||
/// A code editor input mode.
|
||||
CodeEditor {
|
||||
multi_line: bool,
|
||||
tab: TabSize,
|
||||
rows: usize,
|
||||
/// Show line number
|
||||
line_number: bool,
|
||||
language: SharedString,
|
||||
indent_guides: bool,
|
||||
folding: bool,
|
||||
parse_task: Rc<RefCell<Option<Task<()>>>>,
|
||||
},
|
||||
}
|
||||
|
||||
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: TabSize::default(),
|
||||
rows: 1,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a code editor input mode with default settings.
|
||||
pub(super) fn code_editor(language: impl Into<SharedString>) -> Self {
|
||||
InputMode::CodeEditor {
|
||||
rows: 2,
|
||||
multi_line: true,
|
||||
tab: TabSize::default(),
|
||||
language: language.into(),
|
||||
line_number: true,
|
||||
indent_guides: true,
|
||||
folding: true,
|
||||
parse_task: Rc::new(RefCell::new(None)),
|
||||
}
|
||||
}
|
||||
|
||||
/// 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::CodeEditor { 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]
|
||||
pub(super) fn is_code_editor(&self) -> bool {
|
||||
matches!(self, InputMode::CodeEditor { .. })
|
||||
}
|
||||
|
||||
/// Return true if the mode is code editor and `folding: true`, `multi_line: true`.
|
||||
#[inline]
|
||||
pub(crate) fn is_folding(&self) -> bool {
|
||||
if cfg!(target_family = "wasm") {
|
||||
return false;
|
||||
}
|
||||
|
||||
matches!(
|
||||
self,
|
||||
InputMode::CodeEditor {
|
||||
folding: true,
|
||||
multi_line: true,
|
||||
..
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
#[inline]
|
||||
@@ -58,15 +126,19 @@ 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::CodeEditor { 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::CodeEditor { rows, .. } => {
|
||||
*rows = new_rows;
|
||||
}
|
||||
InputMode::AutoGrow {
|
||||
@@ -76,25 +148,28 @@ 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::CodeEditor { rows, .. } => *rows,
|
||||
InputMode::AutoGrow { rows, .. } => *rows,
|
||||
_ => 1,
|
||||
}
|
||||
.max(1)
|
||||
}
|
||||
@@ -103,7 +178,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 +186,26 @@ 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,
|
||||
}
|
||||
}
|
||||
|
||||
/// Return false if the mode is not [`InputMode::CodeEditor`].
|
||||
#[inline]
|
||||
pub(super) fn tab_size(&self) -> Option<&TabSize> {
|
||||
pub(super) fn line_number(&self) -> bool {
|
||||
match self {
|
||||
InputMode::MultiLine { tab, .. } => Some(tab),
|
||||
_ => None,
|
||||
InputMode::CodeEditor {
|
||||
line_number,
|
||||
multi_line,
|
||||
..
|
||||
} => *line_number && *multi_line,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
264
crates/ui/src/input/movement.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
337
crates/ui/src/input/popovers/code_action_menu.rs
Normal file
@@ -0,0 +1,337 @@
|
||||
use std::rc::Rc;
|
||||
|
||||
use gpui::prelude::FluentBuilder;
|
||||
use gpui::{
|
||||
Action, AnyElement, App, AppContext, Context, DismissEvent, Empty, Entity, EventEmitter,
|
||||
InteractiveElement as _, IntoElement, ParentElement, Pixels, Point, Render, RenderOnce,
|
||||
SharedString, Styled, StyledText, Subscription, Window, deferred, div, px, relative,
|
||||
};
|
||||
use lsp_types::CodeAction;
|
||||
use theme::ActiveTheme;
|
||||
|
||||
const MAX_MENU_WIDTH: Pixels = px(320.);
|
||||
const MAX_MENU_HEIGHT: Pixels = px(480.);
|
||||
|
||||
use crate::input::popovers::editor_popover;
|
||||
use crate::input::{self, InputState};
|
||||
use crate::list::{List, ListDelegate, ListEvent, ListState};
|
||||
use crate::{IndexPath, Selectable, actions, h_flex};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct CodeActionItem {
|
||||
/// The `id` of the `CodeActionProvider` that provided this item.
|
||||
pub(crate) provider_id: SharedString,
|
||||
pub(crate) action: CodeAction,
|
||||
}
|
||||
|
||||
struct MenuDelegate {
|
||||
menu: Entity<CodeActionMenu>,
|
||||
items: Vec<Rc<CodeActionItem>>,
|
||||
selected_ix: usize,
|
||||
}
|
||||
|
||||
impl MenuDelegate {
|
||||
fn set_items(&mut self, items: Vec<CodeActionItem>) {
|
||||
self.items = items.into_iter().map(Rc::new).collect();
|
||||
self.selected_ix = 0;
|
||||
}
|
||||
|
||||
fn selected_item(&self) -> Option<&Rc<CodeActionItem>> {
|
||||
self.items.get(self.selected_ix)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(IntoElement)]
|
||||
struct MenuItem {
|
||||
ix: usize,
|
||||
item: Rc<CodeActionItem>,
|
||||
children: Vec<AnyElement>,
|
||||
selected: bool,
|
||||
}
|
||||
|
||||
impl MenuItem {
|
||||
fn new(ix: usize, item: Rc<CodeActionItem>) -> Self {
|
||||
Self {
|
||||
ix,
|
||||
item,
|
||||
children: vec![],
|
||||
selected: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
impl Selectable for MenuItem {
|
||||
fn selected(mut self, selected: bool) -> Self {
|
||||
self.selected = selected;
|
||||
self
|
||||
}
|
||||
|
||||
fn is_selected(&self) -> bool {
|
||||
self.selected
|
||||
}
|
||||
}
|
||||
|
||||
impl ParentElement for MenuItem {
|
||||
fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
|
||||
self.children.extend(elements);
|
||||
}
|
||||
}
|
||||
impl RenderOnce for MenuItem {
|
||||
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let item = self.item;
|
||||
|
||||
let highlights = vec![];
|
||||
|
||||
h_flex()
|
||||
.id(self.ix)
|
||||
.gap_2()
|
||||
.p_1()
|
||||
.text_xs()
|
||||
.line_height(relative(1.))
|
||||
.rounded(cx.theme().radius)
|
||||
.hover(|this| this.bg(cx.theme().secondary_hover))
|
||||
.when(self.selected, |this| {
|
||||
this.bg(cx.theme().secondary_background)
|
||||
.text_color(cx.theme().secondary_foreground)
|
||||
})
|
||||
.child(
|
||||
div().child(StyledText::new(item.action.title.clone()).with_highlights(highlights)),
|
||||
)
|
||||
.children(self.children)
|
||||
}
|
||||
}
|
||||
|
||||
impl EventEmitter<DismissEvent> for MenuDelegate {}
|
||||
|
||||
impl ListDelegate for MenuDelegate {
|
||||
type Item = MenuItem;
|
||||
|
||||
fn items_count(&self, _: usize, _: &gpui::App) -> usize {
|
||||
self.items.len()
|
||||
}
|
||||
|
||||
fn render_item(
|
||||
&mut self,
|
||||
ix: crate::IndexPath,
|
||||
_: &mut Window,
|
||||
_: &mut Context<ListState<Self>>,
|
||||
) -> Option<Self::Item> {
|
||||
let item = self.items.get(ix.row)?;
|
||||
Some(MenuItem::new(ix.row, item.clone()))
|
||||
}
|
||||
|
||||
fn set_selected_index(
|
||||
&mut self,
|
||||
ix: Option<crate::IndexPath>,
|
||||
_: &mut Window,
|
||||
cx: &mut Context<ListState<Self>>,
|
||||
) {
|
||||
self.selected_ix = ix.map(|i| i.row).unwrap_or(0);
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn confirm(&mut self, _: bool, window: &mut Window, cx: &mut Context<ListState<Self>>) {
|
||||
let Some(item) = self.selected_item() else {
|
||||
return;
|
||||
};
|
||||
|
||||
self.menu.update(cx, |this, cx| {
|
||||
this.select_item(&item, window, cx);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// A context menu for code completions and code actions.
|
||||
pub struct CodeActionMenu {
|
||||
offset: usize,
|
||||
state: Entity<InputState>,
|
||||
list: Entity<ListState<MenuDelegate>>,
|
||||
open: bool,
|
||||
|
||||
_subscriptions: Vec<Subscription>,
|
||||
}
|
||||
|
||||
impl CodeActionMenu {
|
||||
/// Creates a new `CompletionMenu` with the given offset and completion items.
|
||||
///
|
||||
/// NOTE: This element should not call from InputState::new, unless that will stack overflow.
|
||||
pub(crate) fn new(
|
||||
state: Entity<InputState>,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) -> Entity<Self> {
|
||||
cx.new(|cx| {
|
||||
let view = cx.entity();
|
||||
let menu = MenuDelegate {
|
||||
menu: view,
|
||||
items: vec![],
|
||||
selected_ix: 0,
|
||||
};
|
||||
|
||||
let list = cx.new(|cx| ListState::new(menu, window, cx));
|
||||
|
||||
let _subscriptions =
|
||||
vec![
|
||||
cx.subscribe(&list, |this: &mut Self, _, ev: &ListEvent, cx| {
|
||||
match ev {
|
||||
ListEvent::Confirm(_) => {
|
||||
this.hide(cx);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
cx.notify();
|
||||
}),
|
||||
];
|
||||
|
||||
Self {
|
||||
offset: 0,
|
||||
state,
|
||||
list,
|
||||
open: false,
|
||||
_subscriptions,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn select_item(&mut self, item: &CodeActionItem, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let state = self.state.clone();
|
||||
let item = item.clone();
|
||||
|
||||
cx.spawn_in(window, {
|
||||
async move |_, cx| {
|
||||
state.update_in(cx, |state, window, cx| {
|
||||
state.perform_code_action(&item, window, cx);
|
||||
})
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
|
||||
self.hide(cx);
|
||||
}
|
||||
|
||||
pub(crate) fn handle_action(
|
||||
&mut self,
|
||||
action: Box<dyn Action>,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) -> bool {
|
||||
if !self.open {
|
||||
return false;
|
||||
}
|
||||
|
||||
cx.propagate();
|
||||
if input::Enter::is_primary(&*action) {
|
||||
self.on_action_enter(window, cx);
|
||||
} else if action.partial_eq(&input::Escape) {
|
||||
self.on_action_escape(window, cx);
|
||||
} else if action.partial_eq(&input::MoveUp) {
|
||||
self.on_action_up(window, cx);
|
||||
} else if action.partial_eq(&input::MoveDown) {
|
||||
self.on_action_down(window, cx);
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
|
||||
true
|
||||
}
|
||||
|
||||
fn on_action_enter(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let Some(item) = self.list.read(cx).delegate().selected_item().cloned() else {
|
||||
return;
|
||||
};
|
||||
self.select_item(&item, window, cx);
|
||||
}
|
||||
|
||||
fn on_action_escape(&mut self, _: &mut Window, cx: &mut Context<Self>) {
|
||||
self.hide(cx);
|
||||
}
|
||||
|
||||
fn on_action_up(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
self.list.update(cx, |this, cx| {
|
||||
this.on_action_select_prev(&actions::SelectUp, window, cx)
|
||||
});
|
||||
}
|
||||
|
||||
fn on_action_down(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
self.list.update(cx, |this, cx| {
|
||||
this.on_action_select_next(&actions::SelectDown, window, cx)
|
||||
});
|
||||
}
|
||||
|
||||
pub(crate) fn is_open(&self) -> bool {
|
||||
self.open
|
||||
}
|
||||
|
||||
/// Hide the completion menu and reset the trigger start offset.
|
||||
pub(crate) fn hide(&mut self, cx: &mut Context<Self>) {
|
||||
self.open = false;
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
pub(crate) fn show(
|
||||
&mut self,
|
||||
offset: usize,
|
||||
items: impl Into<Vec<CodeActionItem>>,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let items = items.into();
|
||||
self.offset = offset;
|
||||
self.open = true;
|
||||
self.list.update(cx, |this, cx| {
|
||||
this.delegate_mut().set_items(items);
|
||||
this.set_selected_index(Some(IndexPath::new(0)), window, cx);
|
||||
});
|
||||
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn origin(&self, cx: &App) -> Option<Point<Pixels>> {
|
||||
let state = self.state.read(cx);
|
||||
let Some(last_layout) = state.last_layout.as_ref() else {
|
||||
return None;
|
||||
};
|
||||
let Some(cursor_origin) = last_layout.cursor_bounds.map(|b| b.origin) else {
|
||||
return None;
|
||||
};
|
||||
|
||||
let scroll_origin = self.state.read(cx).scroll_handle.offset();
|
||||
|
||||
Some(
|
||||
scroll_origin + cursor_origin - state.input_bounds.origin
|
||||
+ Point::new(-px(4.), last_layout.line_height + px(4.)),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for CodeActionMenu {
|
||||
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
if !self.open {
|
||||
return Empty.into_any_element();
|
||||
}
|
||||
|
||||
if self.list.read(cx).delegate().items.is_empty() {
|
||||
self.open = false;
|
||||
return Empty.into_any_element();
|
||||
}
|
||||
|
||||
let Some(pos) = self.origin(cx) else {
|
||||
return Empty.into_any_element();
|
||||
};
|
||||
|
||||
let max_width = MAX_MENU_WIDTH.min(window.bounds().size.width - pos.x);
|
||||
|
||||
deferred(
|
||||
editor_popover("code-action-menu", cx)
|
||||
.absolute()
|
||||
.left(pos.x)
|
||||
.top(pos.y)
|
||||
.max_w(max_width)
|
||||
.min_w(px(120.))
|
||||
.child(List::new(&self.list).max_h(MAX_MENU_HEIGHT))
|
||||
.on_mouse_down_out(cx.listener(|this, _, _, cx| {
|
||||
this.hide(cx);
|
||||
})),
|
||||
)
|
||||
.into_any_element()
|
||||
}
|
||||
}
|
||||
446
crates/ui/src/input/popovers/completion_menu.rs
Normal file
@@ -0,0 +1,446 @@
|
||||
use std::rc::Rc;
|
||||
|
||||
use gpui::prelude::FluentBuilder;
|
||||
use gpui::{
|
||||
Action, AnyElement, App, AppContext, Context, DismissEvent, Empty, Entity, EventEmitter,
|
||||
Half as _, HighlightStyle, InteractiveElement as _, IntoElement, ParentElement, Pixels, Point,
|
||||
Render, RenderOnce, SharedString, Styled, StyledText, Subscription, Window, deferred, div, px,
|
||||
relative,
|
||||
};
|
||||
use lsp_types::{CompletionItem, CompletionTextEdit};
|
||||
use theme::ActiveTheme;
|
||||
|
||||
const MAX_MENU_WIDTH: Pixels = px(320.);
|
||||
const MAX_MENU_HEIGHT: Pixels = px(240.);
|
||||
const POPOVER_GAP: Pixels = px(4.);
|
||||
|
||||
use crate::input::popovers::{editor_popover, render_markdown};
|
||||
use crate::input::{self, InputState, RopeExt};
|
||||
use crate::list::{List, ListDelegate, ListEvent, ListState};
|
||||
use crate::{IndexPath, Selectable, actions, h_flex};
|
||||
|
||||
struct ContextMenuDelegate {
|
||||
query: SharedString,
|
||||
menu: Entity<CompletionMenu>,
|
||||
items: Vec<Rc<CompletionItem>>,
|
||||
selected_ix: usize,
|
||||
}
|
||||
|
||||
impl ContextMenuDelegate {
|
||||
fn set_items(&mut self, items: Vec<CompletionItem>) {
|
||||
self.items = items.into_iter().map(Rc::new).collect();
|
||||
self.selected_ix = 0;
|
||||
}
|
||||
|
||||
fn selected_item(&self) -> Option<&Rc<CompletionItem>> {
|
||||
self.items.get(self.selected_ix)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(IntoElement)]
|
||||
struct CompletionMenuItem {
|
||||
ix: usize,
|
||||
item: Rc<CompletionItem>,
|
||||
children: Vec<AnyElement>,
|
||||
selected: bool,
|
||||
highlight_prefix: SharedString,
|
||||
}
|
||||
|
||||
impl CompletionMenuItem {
|
||||
fn new(ix: usize, item: Rc<CompletionItem>) -> Self {
|
||||
Self {
|
||||
ix,
|
||||
item,
|
||||
children: vec![],
|
||||
selected: false,
|
||||
highlight_prefix: "".into(),
|
||||
}
|
||||
}
|
||||
|
||||
fn highlight_prefix(mut self, s: impl Into<SharedString>) -> Self {
|
||||
self.highlight_prefix = s.into();
|
||||
self
|
||||
}
|
||||
}
|
||||
impl Selectable for CompletionMenuItem {
|
||||
fn selected(mut self, selected: bool) -> Self {
|
||||
self.selected = selected;
|
||||
self
|
||||
}
|
||||
|
||||
fn is_selected(&self) -> bool {
|
||||
self.selected
|
||||
}
|
||||
}
|
||||
|
||||
impl ParentElement for CompletionMenuItem {
|
||||
fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
|
||||
self.children.extend(elements);
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for CompletionMenuItem {
|
||||
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let item = self.item;
|
||||
|
||||
let matched_len = item
|
||||
.filter_text
|
||||
.as_ref()
|
||||
.map(|s| s.len())
|
||||
.unwrap_or(self.highlight_prefix.len())
|
||||
.min(item.label.len());
|
||||
|
||||
let highlights = vec![(
|
||||
0..matched_len,
|
||||
HighlightStyle {
|
||||
color: Some(cx.theme().selection),
|
||||
..Default::default()
|
||||
},
|
||||
)];
|
||||
|
||||
h_flex()
|
||||
.id(self.ix)
|
||||
.gap_2()
|
||||
.p_1()
|
||||
.text_xs()
|
||||
.line_height(relative(1.))
|
||||
.rounded(cx.theme().radius.half())
|
||||
.when(item.deprecated.unwrap_or(false), |this| this.line_through())
|
||||
.hover(|this| this.bg(cx.theme().secondary_hover))
|
||||
.when(self.selected, |this| {
|
||||
this.bg(cx.theme().secondary_background)
|
||||
.text_color(cx.theme().secondary_foreground)
|
||||
})
|
||||
.child(div().child(StyledText::new(item.label.clone()).with_highlights(highlights)))
|
||||
.children(self.children)
|
||||
}
|
||||
}
|
||||
|
||||
impl EventEmitter<DismissEvent> for ContextMenuDelegate {}
|
||||
|
||||
impl ListDelegate for ContextMenuDelegate {
|
||||
type Item = CompletionMenuItem;
|
||||
|
||||
fn items_count(&self, _: usize, _: &gpui::App) -> usize {
|
||||
self.items.len()
|
||||
}
|
||||
|
||||
fn render_item(
|
||||
&mut self,
|
||||
ix: crate::IndexPath,
|
||||
_: &mut Window,
|
||||
_: &mut Context<ListState<Self>>,
|
||||
) -> Option<Self::Item> {
|
||||
let item = self.items.get(ix.row)?;
|
||||
Some(CompletionMenuItem::new(ix.row, item.clone()).highlight_prefix(self.query.clone()))
|
||||
}
|
||||
|
||||
fn set_selected_index(
|
||||
&mut self,
|
||||
ix: Option<crate::IndexPath>,
|
||||
_: &mut Window,
|
||||
cx: &mut Context<ListState<Self>>,
|
||||
) {
|
||||
self.selected_ix = ix.map(|i| i.row).unwrap_or(0);
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn confirm(&mut self, _: bool, window: &mut Window, cx: &mut Context<ListState<Self>>) {
|
||||
let Some(item) = self.selected_item() else {
|
||||
return;
|
||||
};
|
||||
|
||||
self.menu.update(cx, |this, cx| {
|
||||
this.select_item(&item, window, cx);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// A context menu for code completions and code actions.
|
||||
pub struct CompletionMenu {
|
||||
offset: usize,
|
||||
editor: Entity<InputState>,
|
||||
list: Entity<ListState<ContextMenuDelegate>>,
|
||||
open: bool,
|
||||
|
||||
/// The offset of the first character that triggered the completion.
|
||||
pub(crate) trigger_start_offset: Option<usize>,
|
||||
query: SharedString,
|
||||
_subscriptions: Vec<Subscription>,
|
||||
}
|
||||
|
||||
impl CompletionMenu {
|
||||
/// Creates a new `CompletionMenu` with the given offset and completion items.
|
||||
///
|
||||
/// NOTE: This element should not call from InputState::new, unless that will stack overflow.
|
||||
pub(crate) fn new(
|
||||
editor: Entity<InputState>,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) -> Entity<Self> {
|
||||
cx.new(|cx| {
|
||||
let view = cx.entity();
|
||||
let menu = ContextMenuDelegate {
|
||||
query: SharedString::default(),
|
||||
menu: view,
|
||||
items: vec![],
|
||||
selected_ix: 0,
|
||||
};
|
||||
|
||||
let list = cx.new(|cx| ListState::new(menu, window, cx));
|
||||
|
||||
let _subscriptions =
|
||||
vec![
|
||||
cx.subscribe(&list, |this: &mut Self, _, ev: &ListEvent, cx| {
|
||||
match ev {
|
||||
ListEvent::Confirm(_) => {
|
||||
this.hide(cx);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
cx.notify();
|
||||
}),
|
||||
];
|
||||
|
||||
Self {
|
||||
offset: 0,
|
||||
editor,
|
||||
list,
|
||||
open: false,
|
||||
trigger_start_offset: None,
|
||||
query: SharedString::default(),
|
||||
_subscriptions,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn select_item(&mut self, item: &CompletionItem, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let offset = self.offset;
|
||||
let item = item.clone();
|
||||
let mut range = self.trigger_start_offset.unwrap_or(self.offset)..self.offset;
|
||||
|
||||
let editor = self.editor.clone();
|
||||
|
||||
cx.spawn_in(window, async move |_, cx| {
|
||||
editor.update_in(cx, |editor, window, cx| {
|
||||
editor.completion_inserting = true;
|
||||
|
||||
let mut new_text = item.label.clone();
|
||||
if let Some(text_edit) = item.text_edit.as_ref() {
|
||||
match text_edit {
|
||||
CompletionTextEdit::Edit(edit) => {
|
||||
new_text = edit.new_text.clone();
|
||||
range.start = editor.text.position_to_offset(&edit.range.start);
|
||||
range.end = editor.text.position_to_offset(&edit.range.end);
|
||||
}
|
||||
CompletionTextEdit::InsertAndReplace(edit) => {
|
||||
new_text = edit.new_text.clone();
|
||||
range.start = editor.text.position_to_offset(&edit.replace.start);
|
||||
range.end = editor.text.position_to_offset(&edit.replace.end);
|
||||
}
|
||||
}
|
||||
} else if let Some(insert_text) = item.insert_text.clone() {
|
||||
new_text = insert_text;
|
||||
range = offset..offset;
|
||||
}
|
||||
|
||||
editor.replace_text_in_range_silent(
|
||||
Some(editor.range_to_utf16(&range)),
|
||||
&new_text,
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
editor.completion_inserting = false;
|
||||
// FIXME: Input not get the focus
|
||||
editor.focus(window, cx);
|
||||
})
|
||||
})
|
||||
.detach();
|
||||
|
||||
self.hide(cx);
|
||||
}
|
||||
|
||||
pub(crate) fn handle_action(
|
||||
&mut self,
|
||||
action: Box<dyn Action>,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) -> bool {
|
||||
if !self.open {
|
||||
return false;
|
||||
}
|
||||
|
||||
cx.propagate();
|
||||
if input::Enter::is_primary(&*action) {
|
||||
self.on_action_enter(window, cx);
|
||||
} else if action.partial_eq(&input::Escape) {
|
||||
self.on_action_escape(window, cx);
|
||||
} else if action.partial_eq(&input::MoveUp) {
|
||||
self.on_action_up(window, cx);
|
||||
} else if action.partial_eq(&input::MoveDown) {
|
||||
self.on_action_down(window, cx);
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
|
||||
true
|
||||
}
|
||||
|
||||
fn on_action_enter(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let Some(item) = self.list.read(cx).delegate().selected_item().cloned() else {
|
||||
return;
|
||||
};
|
||||
self.select_item(&item, window, cx);
|
||||
}
|
||||
|
||||
fn on_action_escape(&mut self, _: &mut Window, cx: &mut Context<Self>) {
|
||||
self.hide(cx);
|
||||
}
|
||||
|
||||
fn on_action_up(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
self.list.update(cx, |this, cx| {
|
||||
this.on_action_select_prev(&actions::SelectUp, window, cx)
|
||||
});
|
||||
}
|
||||
|
||||
fn on_action_down(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
self.list.update(cx, |this, cx| {
|
||||
this.on_action_select_next(&actions::SelectDown, window, cx)
|
||||
});
|
||||
}
|
||||
|
||||
pub(crate) fn is_open(&self) -> bool {
|
||||
self.open
|
||||
}
|
||||
|
||||
/// Hide the completion menu and reset the trigger start offset.
|
||||
pub(crate) fn hide(&mut self, cx: &mut Context<Self>) {
|
||||
self.open = false;
|
||||
self.trigger_start_offset = None;
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// Sets the trigger start offset if it is not already set.
|
||||
pub(crate) fn update_query(&mut self, start_offset: usize, query: impl Into<SharedString>) {
|
||||
if self.trigger_start_offset.is_none() {
|
||||
self.trigger_start_offset = Some(start_offset);
|
||||
}
|
||||
self.query = query.into();
|
||||
}
|
||||
|
||||
pub(crate) fn show(
|
||||
&mut self,
|
||||
offset: usize,
|
||||
items: impl Into<Vec<CompletionItem>>,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let items = items.into();
|
||||
self.offset = offset;
|
||||
self.open = true;
|
||||
self.list.update(cx, |this, cx| {
|
||||
let longest_ix = items
|
||||
.iter()
|
||||
.enumerate()
|
||||
.max_by_key(|(_, item)| {
|
||||
item.label.len() + item.detail.as_ref().map(|d| d.len()).unwrap_or(0)
|
||||
})
|
||||
.map(|(ix, _)| ix)
|
||||
.unwrap_or(0);
|
||||
|
||||
this.delegate_mut().query = self.query.clone();
|
||||
this.delegate_mut().set_items(items);
|
||||
this.set_selected_index(Some(IndexPath::new(0)), window, cx);
|
||||
this.set_item_to_measure_index(IndexPath::new(longest_ix), window, cx);
|
||||
});
|
||||
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn origin(&self, cx: &App) -> Option<Point<Pixels>> {
|
||||
let editor = self.editor.read(cx);
|
||||
let Some(last_layout) = editor.last_layout.as_ref() else {
|
||||
return None;
|
||||
};
|
||||
let Some(cursor_origin) = last_layout.cursor_bounds.map(|b| b.origin) else {
|
||||
return None;
|
||||
};
|
||||
|
||||
let scroll_origin = self.editor.read(cx).scroll_handle.offset();
|
||||
|
||||
Some(
|
||||
scroll_origin + cursor_origin - editor.input_bounds.origin
|
||||
+ Point::new(-px(4.), last_layout.line_height + px(4.)),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for CompletionMenu {
|
||||
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
if !self.open {
|
||||
return Empty.into_any_element();
|
||||
}
|
||||
|
||||
if self.list.read(cx).delegate().items.is_empty() {
|
||||
self.open = false;
|
||||
return Empty.into_any_element();
|
||||
}
|
||||
|
||||
let Some(pos) = self.origin(cx) else {
|
||||
return Empty.into_any_element();
|
||||
};
|
||||
|
||||
let selected_documentation = self
|
||||
.list
|
||||
.read(cx)
|
||||
.delegate()
|
||||
.selected_item()
|
||||
.and_then(|item| item.documentation.clone());
|
||||
|
||||
let max_width = MAX_MENU_WIDTH.min(window.bounds().size.width - pos.x);
|
||||
let abs_pos = self.editor.read(cx).input_bounds.origin + pos;
|
||||
let vertical_layout =
|
||||
abs_pos.x + MAX_MENU_WIDTH + POPOVER_GAP + MAX_MENU_WIDTH + POPOVER_GAP
|
||||
> window.bounds().size.width;
|
||||
|
||||
deferred(
|
||||
div()
|
||||
.absolute()
|
||||
.left(pos.x)
|
||||
.top(pos.y)
|
||||
.flex()
|
||||
.flex_row()
|
||||
.gap(POPOVER_GAP)
|
||||
.items_start()
|
||||
.when(vertical_layout, |this| this.flex_col())
|
||||
.child(
|
||||
editor_popover("completion-menu", cx)
|
||||
.max_w(max_width)
|
||||
.min_w(px(120.))
|
||||
.child(List::new(&self.list).max_h(MAX_MENU_HEIGHT)),
|
||||
)
|
||||
.when_some(selected_documentation, |this, documentation| {
|
||||
let mut doc = match documentation {
|
||||
lsp_types::Documentation::String(s) => s.clone(),
|
||||
lsp_types::Documentation::MarkupContent(mc) => mc.value.clone(),
|
||||
};
|
||||
if vertical_layout {
|
||||
doc = doc.split("\n").next().unwrap_or_default().to_string();
|
||||
}
|
||||
|
||||
this.child(
|
||||
div().child(
|
||||
editor_popover("completion-menu", cx)
|
||||
.w(MAX_MENU_WIDTH)
|
||||
.px_2()
|
||||
.child(render_markdown("doc", doc, window, cx)),
|
||||
),
|
||||
)
|
||||
})
|
||||
.on_mouse_down_out(cx.listener(|this, _, _, cx| {
|
||||
this.hide(cx);
|
||||
})),
|
||||
)
|
||||
.into_any_element()
|
||||
}
|
||||
}
|
||||
142
crates/ui/src/input/popovers/context_menu.rs
Normal file
@@ -0,0 +1,142 @@
|
||||
use gpui::prelude::FluentBuilder as _;
|
||||
use gpui::{
|
||||
Anchor, App, AppContext as _, Context, DismissEvent, Entity, IntoElement, MouseDownEvent,
|
||||
ParentElement as _, Pixels, Point, Render, Styled, Subscription, Window, anchored, deferred,
|
||||
div, px,
|
||||
};
|
||||
|
||||
use crate::input::popovers::ContextMenu;
|
||||
use crate::input::{self, InputState};
|
||||
use crate::menu::PopupMenu;
|
||||
|
||||
/// Context menu for mouse right clicks.
|
||||
pub(crate) struct InputContextMenu {
|
||||
editor: Entity<InputState>,
|
||||
menu: Entity<PopupMenu>,
|
||||
mouse_position: Point<Pixels>,
|
||||
open: bool,
|
||||
|
||||
_subscriptions: Vec<Subscription>,
|
||||
}
|
||||
|
||||
impl InputState {
|
||||
pub(crate) fn handle_right_click_menu(
|
||||
&mut self,
|
||||
event: &MouseDownEvent,
|
||||
offset: usize,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
// Show Mouse context menu
|
||||
if !self.selected_range.contains(offset) {
|
||||
self.move_to(offset, None, cx);
|
||||
}
|
||||
|
||||
self.context_menu_content = Some(ContextMenu::RightClick(self.context_menu.clone()));
|
||||
|
||||
let is_code_editor = self.mode.is_code_editor();
|
||||
if is_code_editor {
|
||||
self.handle_hover_definition(offset, window, cx);
|
||||
}
|
||||
|
||||
let is_enable = !self.disabled;
|
||||
let has_goto_definition = is_enable && self.lsp.definition_provider.is_some();
|
||||
let has_code_action = is_enable && !self.lsp.code_action_providers.is_empty();
|
||||
let is_selected = !self.selected_range.is_empty();
|
||||
let has_paste = is_enable && cx.read_from_clipboard().is_some();
|
||||
|
||||
let action_context = self.focus_handle.clone();
|
||||
self.context_menu.update(cx, |this, cx| {
|
||||
this.mouse_position = event.position;
|
||||
this.menu.update(cx, |menu, cx| {
|
||||
let new_menu = if let Some(builder) = &self.context_menu_builder {
|
||||
builder(PopupMenu::new(cx), window, cx)
|
||||
} else {
|
||||
PopupMenu::new(cx)
|
||||
.when(is_code_editor, |m| {
|
||||
m.menu_with_enable(
|
||||
"Go to Definition",
|
||||
Box::new(input::GoToDefinition),
|
||||
has_goto_definition,
|
||||
)
|
||||
.menu_with_enable(
|
||||
"Show Code Actions",
|
||||
Box::new(input::ToggleCodeActions),
|
||||
has_code_action,
|
||||
)
|
||||
.separator()
|
||||
})
|
||||
.menu_with_enable("Cut", Box::new(input::Cut), is_enable && is_selected)
|
||||
.menu_with_enable("Copy", Box::new(input::Copy), is_selected)
|
||||
.menu_with_enable("Paste", Box::new(input::Paste), has_paste)
|
||||
.separator()
|
||||
.menu("Select All", Box::new(input::SelectAll))
|
||||
};
|
||||
|
||||
menu.menu_items = new_menu.menu_items;
|
||||
menu.action_context = Some(action_context);
|
||||
cx.notify();
|
||||
});
|
||||
cx.defer_in(window, |this, _, cx| {
|
||||
this.open = true;
|
||||
cx.notify();
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
impl InputContextMenu {
|
||||
pub(crate) fn new(
|
||||
editor: Entity<InputState>,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) -> Entity<Self> {
|
||||
cx.new(|cx| {
|
||||
let menu = cx.new(|cx| PopupMenu::new(cx).small());
|
||||
|
||||
let _subscriptions = vec![cx.subscribe_in(&menu, window, {
|
||||
move |this: &mut Self, _, _: &DismissEvent, window, cx| {
|
||||
this.close(window, cx);
|
||||
}
|
||||
})];
|
||||
|
||||
Self {
|
||||
editor,
|
||||
menu,
|
||||
mouse_position: Point::default(),
|
||||
open: false,
|
||||
_subscriptions,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub(crate) fn is_open(&self) -> bool {
|
||||
self.open
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub(crate) fn close(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
self.open = false;
|
||||
self.editor.update(cx, |this, cx| {
|
||||
this.focus(window, cx);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for InputContextMenu {
|
||||
fn render(&mut self, _: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
|
||||
if !self.open {
|
||||
return div().into_any_element();
|
||||
}
|
||||
|
||||
deferred(
|
||||
anchored()
|
||||
.snap_to_window_with_margin(px(8.))
|
||||
.anchor(Anchor::TopLeft)
|
||||
.position(self.mouse_position)
|
||||
.child(div().cursor_default().child(self.menu.clone())),
|
||||
)
|
||||
.into_any_element()
|
||||
}
|
||||
}
|
||||
95
crates/ui/src/input/popovers/diagnostic_popover.rs
Normal file
@@ -0,0 +1,95 @@
|
||||
use std::rc::Rc;
|
||||
|
||||
use gpui::{
|
||||
prelude::FluentBuilder as _, px, App, AppContext as _, Bounds, Context, Empty, Entity,
|
||||
IntoElement, Pixels, Point, Render, Styled, Window,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
highlighter::DiagnosticEntry,
|
||||
input::{
|
||||
popovers::{render_markdown, Popover},
|
||||
InputState,
|
||||
},
|
||||
};
|
||||
|
||||
pub struct DiagnosticPopover {
|
||||
state: Entity<InputState>,
|
||||
pub(crate) diagnostic: Rc<DiagnosticEntry>,
|
||||
bounds: Bounds<Pixels>,
|
||||
open: bool,
|
||||
}
|
||||
|
||||
impl DiagnosticPopover {
|
||||
pub fn new(
|
||||
diagnostic: &DiagnosticEntry,
|
||||
state: Entity<InputState>,
|
||||
cx: &mut App,
|
||||
) -> Entity<Self> {
|
||||
let diagnostic = Rc::new(diagnostic.clone());
|
||||
|
||||
cx.new(|_| Self {
|
||||
diagnostic,
|
||||
state,
|
||||
bounds: Bounds::default(),
|
||||
open: true,
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn show(&mut self, cx: &mut Context<Self>) {
|
||||
self.open = true;
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
pub(crate) fn hide(&mut self, cx: &mut Context<Self>) {
|
||||
self.open = false;
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
pub(crate) fn check_to_hide(&mut self, mouse_position: Point<Pixels>, cx: &mut Context<Self>) {
|
||||
if !self.open {
|
||||
return;
|
||||
}
|
||||
|
||||
let padding = px(5.);
|
||||
let bounds = Bounds {
|
||||
origin: self.bounds.origin.map(|v| v - padding),
|
||||
size: self.bounds.size.map(|v| v + padding * 2.),
|
||||
};
|
||||
|
||||
if !bounds.contains(&mouse_position) {
|
||||
self.hide(cx);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for DiagnosticPopover {
|
||||
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
if !self.open {
|
||||
return Empty.into_any_element();
|
||||
}
|
||||
|
||||
let message = self.diagnostic.message.clone();
|
||||
|
||||
let (border, bg, fg) = (
|
||||
self.diagnostic.severity.border(cx),
|
||||
self.diagnostic.severity.bg(cx),
|
||||
self.diagnostic.severity.fg(cx),
|
||||
);
|
||||
|
||||
Popover::new(
|
||||
"diagnostic-popover",
|
||||
self.state.clone(),
|
||||
self.diagnostic.range.clone(),
|
||||
move |window, cx| render_markdown("message", message.clone(), window, cx),
|
||||
)
|
||||
.when(!self.open, |this| this.invisible())
|
||||
.px_1()
|
||||
.py_0p5()
|
||||
.bg(bg)
|
||||
.text_color(fg)
|
||||
.border_1()
|
||||
.border_color(border)
|
||||
.into_any_element()
|
||||
}
|
||||
}
|
||||
292
crates/ui/src/input/popovers/hover_popover.rs
Normal file
@@ -0,0 +1,292 @@
|
||||
use std::{ops::Range, rc::Rc};
|
||||
|
||||
use gpui::{
|
||||
AnyElement, App, AppContext as _, AvailableSpace, Bounds, Element, ElementId, Entity,
|
||||
InteractiveElement, IntoElement, MouseDownEvent, MouseMoveEvent, ParentElement as _, Pixels,
|
||||
Render, StatefulInteractiveElement as _, StyleRefinement, Styled, Window, deferred, div, point,
|
||||
px,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
StyledExt,
|
||||
input::{InputState, popovers::render_markdown},
|
||||
};
|
||||
|
||||
pub struct HoverPopover {
|
||||
editor: Entity<InputState>,
|
||||
/// The symbol range byte of the hover trigger.
|
||||
pub(crate) symbol_range: Range<usize>,
|
||||
pub(crate) hover: Rc<lsp_types::Hover>,
|
||||
}
|
||||
|
||||
impl HoverPopover {
|
||||
pub fn new(
|
||||
editor: Entity<InputState>,
|
||||
symbol_range: Range<usize>,
|
||||
hover: &lsp_types::Hover,
|
||||
cx: &mut App,
|
||||
) -> Entity<Self> {
|
||||
let hover = Rc::new(hover.clone());
|
||||
|
||||
cx.new(|_| Self {
|
||||
editor,
|
||||
symbol_range,
|
||||
hover,
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn is_same(&self, offset: usize) -> bool {
|
||||
self.symbol_range.contains(&offset)
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for HoverPopover {
|
||||
fn render(&mut self, _: &mut Window, _: &mut gpui::Context<Self>) -> impl IntoElement {
|
||||
let contents = match self.hover.contents.clone() {
|
||||
lsp_types::HoverContents::Scalar(scalar) => match scalar {
|
||||
lsp_types::MarkedString::String(s) => s,
|
||||
lsp_types::MarkedString::LanguageString(ls) => ls.value,
|
||||
},
|
||||
lsp_types::HoverContents::Array(arr) => arr
|
||||
.into_iter()
|
||||
.map(|item| match item {
|
||||
lsp_types::MarkedString::String(s) => s,
|
||||
lsp_types::MarkedString::LanguageString(ls) => ls.value,
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n\n"),
|
||||
lsp_types::HoverContents::Markup(markup) => markup.value,
|
||||
};
|
||||
|
||||
Popover::new(
|
||||
"hover-popover",
|
||||
self.editor.clone(),
|
||||
self.symbol_range.clone(),
|
||||
move |window, cx| render_markdown("message", contents.clone(), window, cx),
|
||||
)
|
||||
.into_any_element()
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) struct Popover {
|
||||
id: ElementId,
|
||||
style: StyleRefinement,
|
||||
editor: Entity<InputState>,
|
||||
range: Range<usize>,
|
||||
width_limit: Range<Pixels>,
|
||||
content_builder: Box<dyn Fn(&mut Window, &mut App) -> AnyElement>,
|
||||
}
|
||||
|
||||
impl Styled for Popover {
|
||||
fn style(&mut self) -> &mut StyleRefinement {
|
||||
&mut self.style
|
||||
}
|
||||
}
|
||||
|
||||
impl Popover {
|
||||
pub fn new<F, E>(
|
||||
id: impl Into<ElementId>,
|
||||
editor: Entity<InputState>,
|
||||
range: Range<usize>,
|
||||
f: F,
|
||||
) -> Self
|
||||
where
|
||||
F: Fn(&mut Window, &mut App) -> E + 'static,
|
||||
E: IntoElement,
|
||||
{
|
||||
Self {
|
||||
id: id.into(),
|
||||
editor,
|
||||
range,
|
||||
style: StyleRefinement::default(),
|
||||
width_limit: px(200.)..px(500.),
|
||||
content_builder: Box::new(move |window, cx| (f)(window, cx).into_any_element()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the bounds of the range in the editor, if it is visible.
|
||||
fn trigger_bounds(&self, cx: &App) -> Option<Bounds<Pixels>> {
|
||||
let editor = self.editor.read(cx);
|
||||
let Some(last_layout) = editor.last_layout.as_ref() else {
|
||||
return None;
|
||||
};
|
||||
|
||||
let Some(last_bounds) = editor.last_bounds else {
|
||||
return None;
|
||||
};
|
||||
|
||||
let (_, _, start_pos) = editor.line_and_position_for_offset(self.range.start);
|
||||
let (_, _, end_pos) = editor.line_and_position_for_offset(self.range.end);
|
||||
|
||||
let Some(start_pos) = start_pos else {
|
||||
return None;
|
||||
};
|
||||
let Some(end_pos) = end_pos else {
|
||||
return None;
|
||||
};
|
||||
|
||||
Some(Bounds::from_corners(
|
||||
last_bounds.origin + start_pos,
|
||||
last_bounds.origin + end_pos + point(px(0.), last_layout.line_height),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
impl IntoElement for Popover {
|
||||
type Element = Self;
|
||||
|
||||
fn into_element(self) -> Self::Element {
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) struct PopoverLayoutState {
|
||||
bounds: Bounds<Pixels>,
|
||||
element: Option<AnyElement>,
|
||||
}
|
||||
|
||||
impl Element for Popover {
|
||||
type RequestLayoutState = PopoverLayoutState;
|
||||
type PrepaintState = ();
|
||||
|
||||
fn id(&self) -> Option<ElementId> {
|
||||
Some(self.id.clone())
|
||||
}
|
||||
|
||||
fn source_location(&self) -> Option<&'static std::panic::Location<'static>> {
|
||||
None
|
||||
}
|
||||
|
||||
fn request_layout(
|
||||
&mut self,
|
||||
_: Option<&gpui::GlobalElementId>,
|
||||
_: Option<&gpui::InspectorElementId>,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) -> (gpui::LayoutId, Self::RequestLayoutState) {
|
||||
let trigger_bounds = match self.trigger_bounds(cx) {
|
||||
Some(bounds) => bounds,
|
||||
None => {
|
||||
return (
|
||||
div().into_any_element().request_layout(window, cx),
|
||||
PopoverLayoutState {
|
||||
bounds: Bounds::default(),
|
||||
element: None,
|
||||
},
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
let max_width = self
|
||||
.width_limit
|
||||
.end
|
||||
.min(window.bounds().size.width - SNAP_TO_EDGE * 2)
|
||||
.max(px(200.));
|
||||
let max_height = (window.bounds().size.height - SNAP_TO_EDGE * 2).min(px(320.));
|
||||
|
||||
let mut popover = deferred(
|
||||
div()
|
||||
.id("hover-popover-content")
|
||||
.flex_none()
|
||||
.occlude()
|
||||
.p_1()
|
||||
.text_xs()
|
||||
.popover_style(cx)
|
||||
.shadow_md()
|
||||
.max_w(max_width)
|
||||
.max_h(max_height)
|
||||
.overflow_y_scroll()
|
||||
.refine_style(&self.style)
|
||||
.child((self.content_builder)(window, cx)),
|
||||
)
|
||||
.into_any_element();
|
||||
|
||||
let popover_size = popover.layout_as_root(AvailableSpace::min_size(), window, cx);
|
||||
const SNAP_TO_EDGE: Pixels = px(8.);
|
||||
let top_space = trigger_bounds.top() - SNAP_TO_EDGE;
|
||||
let right_space = window.bounds().size.width - trigger_bounds.left() - SNAP_TO_EDGE;
|
||||
|
||||
let mut pos = point(
|
||||
trigger_bounds.left(),
|
||||
trigger_bounds.top() - popover_size.height,
|
||||
);
|
||||
if popover_size.height > top_space {
|
||||
pos.y = trigger_bounds.bottom();
|
||||
}
|
||||
if popover_size.width > right_space {
|
||||
pos.x = trigger_bounds.right() - popover_size.width;
|
||||
}
|
||||
|
||||
let mut empty = div().into_any_element();
|
||||
let layout_id = empty.request_layout(window, cx);
|
||||
(
|
||||
layout_id,
|
||||
PopoverLayoutState {
|
||||
bounds: Bounds {
|
||||
origin: pos,
|
||||
size: popover_size,
|
||||
},
|
||||
element: Some(popover),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fn prepaint(
|
||||
&mut self,
|
||||
_: Option<&gpui::GlobalElementId>,
|
||||
_: Option<&gpui::InspectorElementId>,
|
||||
_: Bounds<Pixels>,
|
||||
request_layout: &mut Self::RequestLayoutState,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) -> Self::PrepaintState {
|
||||
let bounds = request_layout.bounds;
|
||||
let Some(popover) = request_layout.element.as_mut() else {
|
||||
return;
|
||||
};
|
||||
|
||||
window.with_absolute_element_offset(bounds.origin, |window| {
|
||||
popover.prepaint(window, cx);
|
||||
})
|
||||
}
|
||||
|
||||
fn paint(
|
||||
&mut self,
|
||||
_: Option<&gpui::GlobalElementId>,
|
||||
_: Option<&gpui::InspectorElementId>,
|
||||
_: Bounds<Pixels>,
|
||||
request_layout: &mut Self::RequestLayoutState,
|
||||
_: &mut Self::PrepaintState,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) {
|
||||
let bounds = request_layout.bounds;
|
||||
let Some(popover) = request_layout.element.as_mut() else {
|
||||
return;
|
||||
};
|
||||
|
||||
popover.paint(window, cx);
|
||||
|
||||
let editor = self.editor.clone();
|
||||
// Mouse down out to hide.
|
||||
window.on_mouse_event(move |event: &MouseDownEvent, _, _, cx| {
|
||||
if !bounds.contains(&event.position) {
|
||||
let _ = editor.update(cx, |editor, cx| {
|
||||
editor.clear_hover_state(cx);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Mouse out of trigger + popover bounds
|
||||
let editor = self.editor.clone();
|
||||
let trigger_bounds = self.trigger_bounds(cx).unwrap_or(bounds);
|
||||
let keep_open_region = trigger_bounds.union(&bounds);
|
||||
window.on_mouse_event(move |event: &MouseMoveEvent, _, _, cx| {
|
||||
if !keep_open_region.contains(&event.position) {
|
||||
let _ = editor.update(cx, |editor, cx| {
|
||||
editor.clear_hover_state(cx);
|
||||
});
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
41
crates/ui/src/input/popovers/mod.rs
Normal file
@@ -0,0 +1,41 @@
|
||||
mod code_action_menu;
|
||||
mod completion_menu;
|
||||
mod context_menu;
|
||||
mod diagnostic_popover;
|
||||
mod hover_popover;
|
||||
|
||||
pub(crate) use code_action_menu::*;
|
||||
pub(crate) use completion_menu::*;
|
||||
pub(crate) use context_menu::*;
|
||||
pub(crate) use diagnostic_popover::*;
|
||||
use gpui::{
|
||||
App, Div, ElementId, Entity, InteractiveElement as _, IntoElement, SharedString, Stateful,
|
||||
StyleRefinement, Styled as _, Window, div, px, rems,
|
||||
};
|
||||
pub(crate) use hover_popover::*;
|
||||
|
||||
use crate::StyledExt as _;
|
||||
|
||||
pub(crate) enum ContextMenu {
|
||||
Completion(Entity<CompletionMenu>),
|
||||
CodeAction(Entity<CodeActionMenu>),
|
||||
RightClick(Entity<InputContextMenu>),
|
||||
}
|
||||
|
||||
impl ContextMenu {
|
||||
pub(crate) fn is_open(&self, cx: &App) -> bool {
|
||||
match self {
|
||||
ContextMenu::Completion(menu) => menu.read(cx).is_open(),
|
||||
ContextMenu::CodeAction(menu) => menu.read(cx).is_open(),
|
||||
ContextMenu::RightClick(menu) => menu.read(cx).is_open(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn render(&self) -> impl IntoElement {
|
||||
match self {
|
||||
ContextMenu::Completion(menu) => menu.clone().into_any_element(),
|
||||
ContextMenu::CodeAction(menu) => menu.clone().into_any_element(),
|
||||
ContextMenu::RightClick(menu) => menu.clone().into_any_element(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
140
crates/ui/src/input/selection.rs
Normal 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
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
pub use anchored::*;
|
||||
pub use element_ext::ElementExt;
|
||||
pub use event::InteractiveElementExt;
|
||||
pub use focusable::FocusableCycle;
|
||||
@@ -34,7 +33,6 @@ pub mod switch;
|
||||
pub mod tab;
|
||||
pub mod tooltip;
|
||||
|
||||
mod anchored;
|
||||
mod element_ext;
|
||||
mod event;
|
||||
mod focusable;
|
||||
|
||||
@@ -3,21 +3,21 @@ use std::time::Duration;
|
||||
|
||||
use gpui::prelude::FluentBuilder;
|
||||
use gpui::{
|
||||
div, px, size, uniform_list, App, AppContext, AvailableSpace, ClickEvent, Context,
|
||||
DefiniteLength, EdgesRefinement, Entity, EventEmitter, FocusHandle, Focusable,
|
||||
InteractiveElement, IntoElement, KeyBinding, Length, ListSizingBehavior, MouseButton,
|
||||
ParentElement, Render, RenderOnce, ScrollStrategy, SharedString, StatefulInteractiveElement,
|
||||
StyleRefinement, Styled, Subscription, Task, UniformListScrollHandle, Window,
|
||||
App, AppContext, AvailableSpace, ClickEvent, Context, DefiniteLength, EdgesRefinement, Entity,
|
||||
EventEmitter, FocusHandle, Focusable, InteractiveElement, IntoElement, KeyBinding, Length,
|
||||
ListSizingBehavior, MouseButton, ParentElement, Render, RenderOnce, ScrollStrategy,
|
||||
SharedString, StatefulInteractiveElement, StyleRefinement, Styled, Subscription, Task,
|
||||
UniformListScrollHandle, Window, div, px, size, uniform_list,
|
||||
};
|
||||
use smol::Timer;
|
||||
use theme::ActiveTheme;
|
||||
|
||||
use crate::actions::{Cancel, Confirm, SelectDown, SelectUp};
|
||||
use crate::input::{InputEvent, InputState, TextInput};
|
||||
use crate::list::cache::{MeasuredEntrySize, RowEntry, RowsCache};
|
||||
use crate::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");
|
||||
@@ -288,7 +288,7 @@ where
|
||||
});
|
||||
});
|
||||
}
|
||||
InputEvent::PressEnter { secondary } => self.on_action_confirm(
|
||||
InputEvent::PressEnter { secondary, .. } => self.on_action_confirm(
|
||||
&Confirm {
|
||||
secondary: *secondary,
|
||||
},
|
||||
@@ -498,7 +498,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 +578,10 @@ where
|
||||
self.prepare_items_if_needed(window, cx);
|
||||
|
||||
// Scroll to the selected item if it is set.
|
||||
if let Some((ix, strategy)) = self.deferred_scroll_to_index.take() {
|
||||
if let Some(item_ix) = self.rows_cache.position_of(&ix) {
|
||||
self.scroll_handle.scroll_to_item(item_ix, strategy);
|
||||
}
|
||||
if let Some((ix, strategy)) = self.deferred_scroll_to_index.take()
|
||||
&& let Some(item_ix) = self.rows_cache.position_of(&ix)
|
||||
{
|
||||
self.scroll_handle.scroll_to_item(item_ix, strategy);
|
||||
}
|
||||
|
||||
let loading = self.delegate().loading(cx);
|
||||
@@ -632,10 +632,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),
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
use gpui::prelude::FluentBuilder;
|
||||
use gpui::{
|
||||
anchored, deferred, div, px, App, AppContext as _, ClickEvent, Context, DismissEvent, Entity,
|
||||
Focusable, InteractiveElement as _, IntoElement, KeyBinding, MouseButton, OwnedMenu,
|
||||
ParentElement, Render, SharedString, StatefulInteractiveElement, Styled, Subscription, Window,
|
||||
App, AppContext as _, ClickEvent, Context, DismissEvent, Entity, Focusable,
|
||||
InteractiveElement as _, IntoElement, KeyBinding, MouseButton, OwnedMenu, ParentElement,
|
||||
Render, SharedString, StatefulInteractiveElement, Styled, Subscription, Window, anchored,
|
||||
deferred, div, px,
|
||||
};
|
||||
|
||||
use crate::actions::{Cancel, SelectLeft, SelectRight};
|
||||
use crate::button::{Button, ButtonVariants};
|
||||
use crate::menu::PopupMenu;
|
||||
use crate::{h_flex, Selectable, Sizable};
|
||||
use crate::{Selectable, Sizable, h_flex};
|
||||
|
||||
const CONTEXT: &str = "AppMenuBar";
|
||||
|
||||
@@ -241,7 +242,7 @@ impl Render for AppMenu {
|
||||
.when(is_selected, |this| {
|
||||
this.child(deferred(
|
||||
anchored()
|
||||
.anchor(gpui::Corner::TopLeft)
|
||||
.anchor(gpui::Anchor::TopLeft)
|
||||
.snap_to_window_with_margin(px(8.))
|
||||
.child(
|
||||
div()
|
||||
|
||||
@@ -3,10 +3,10 @@ use std::rc::Rc;
|
||||
|
||||
use gpui::prelude::FluentBuilder;
|
||||
use gpui::{
|
||||
anchored, deferred, div, px, AnyElement, App, Context, Corner, DismissEvent, Element,
|
||||
ElementId, Entity, Focusable, GlobalElementId, Hitbox, HitboxBehavior, InspectorElementId,
|
||||
InteractiveElement, IntoElement, MouseButton, MouseDownEvent, ParentElement, Pixels, Point,
|
||||
StyleRefinement, Styled, Subscription, Window,
|
||||
Anchor, AnyElement, App, Context, DismissEvent, Element, ElementId, Entity, Focusable,
|
||||
GlobalElementId, Hitbox, HitboxBehavior, InspectorElementId, InteractiveElement, IntoElement,
|
||||
MouseButton, MouseDownEvent, ParentElement, Pixels, Point, StyleRefinement, Styled,
|
||||
Subscription, Window, anchored, deferred, div, px,
|
||||
};
|
||||
|
||||
use crate::menu::PopupMenu;
|
||||
@@ -41,7 +41,7 @@ pub struct ContextMenu<E: ParentElement + Styled + Sized> {
|
||||
menu: Option<Rc<dyn Fn(PopupMenu, &mut Window, &mut Context<PopupMenu>) -> PopupMenu>>,
|
||||
// This is not in use, just for style refinement forwarding.
|
||||
_ignore_style: StyleRefinement,
|
||||
anchor: Corner,
|
||||
anchor: Anchor,
|
||||
}
|
||||
|
||||
impl<E: ParentElement + Styled> ContextMenu<E> {
|
||||
@@ -51,7 +51,7 @@ impl<E: ParentElement + Styled> ContextMenu<E> {
|
||||
id: id.into(),
|
||||
element: Some(element),
|
||||
menu: None,
|
||||
anchor: Corner::TopLeft,
|
||||
anchor: Anchor::TopLeft,
|
||||
_ignore_style: StyleRefinement::default(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use std::rc::Rc;
|
||||
|
||||
use gpui::{
|
||||
Context, Corner, DismissEvent, ElementId, Entity, Focusable, InteractiveElement, IntoElement,
|
||||
Anchor, Context, DismissEvent, ElementId, Entity, Focusable, InteractiveElement, IntoElement,
|
||||
RenderOnce, SharedString, StyleRefinement, Styled, Window,
|
||||
};
|
||||
|
||||
@@ -18,13 +18,13 @@ pub trait DropdownMenu: Styled + Selectable + InteractiveElement + IntoElement +
|
||||
self,
|
||||
f: impl Fn(PopupMenu, &mut Window, &mut Context<PopupMenu>) -> PopupMenu + 'static,
|
||||
) -> DropdownMenuPopover<Self> {
|
||||
self.dropdown_menu_with_anchor(Corner::TopLeft, f)
|
||||
self.dropdown_menu_with_anchor(Anchor::TopLeft, f)
|
||||
}
|
||||
|
||||
/// Create a dropdown menu with the given items, anchored to the given corner
|
||||
fn dropdown_menu_with_anchor(
|
||||
mut self,
|
||||
anchor: impl Into<Corner>,
|
||||
anchor: impl Into<Anchor>,
|
||||
f: impl Fn(PopupMenu, &mut Window, &mut Context<PopupMenu>) -> PopupMenu + 'static,
|
||||
) -> DropdownMenuPopover<Self> {
|
||||
let style = self.style().clone();
|
||||
@@ -42,7 +42,7 @@ impl DropdownMenu for Avatar {}
|
||||
pub struct DropdownMenuPopover<T: Selectable + IntoElement + 'static> {
|
||||
id: ElementId,
|
||||
style: StyleRefinement,
|
||||
anchor: Corner,
|
||||
anchor: Anchor,
|
||||
trigger: T,
|
||||
#[allow(clippy::type_complexity)]
|
||||
builder: Rc<dyn Fn(PopupMenu, &mut Window, &mut Context<PopupMenu>) -> PopupMenu>,
|
||||
@@ -54,7 +54,7 @@ where
|
||||
{
|
||||
fn new(
|
||||
id: ElementId,
|
||||
anchor: impl Into<Corner>,
|
||||
anchor: impl Into<Anchor>,
|
||||
trigger: T,
|
||||
builder: impl Fn(PopupMenu, &mut Window, &mut Context<PopupMenu>) -> PopupMenu + 'static,
|
||||
) -> Self {
|
||||
@@ -68,7 +68,7 @@ where
|
||||
}
|
||||
|
||||
/// Set the anchor corner for the dropdown menu popover.
|
||||
pub fn anchor(mut self, anchor: impl Into<Corner>) -> Self {
|
||||
pub fn anchor(mut self, anchor: impl Into<Anchor>) -> Self {
|
||||
self.anchor = anchor.into();
|
||||
self
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ use std::rc::Rc;
|
||||
|
||||
use gpui::prelude::FluentBuilder;
|
||||
use gpui::{
|
||||
Action, AnyElement, App, AppContext, Axis, Bounds, ClickEvent, Context, Corner, DismissEvent,
|
||||
Action, Anchor, AnyElement, App, AppContext, Axis, Bounds, ClickEvent, Context, DismissEvent,
|
||||
Edges, Entity, EventEmitter, FocusHandle, Focusable, Half, InteractiveElement, IntoElement,
|
||||
KeyBinding, MouseDownEvent, OwnedMenuItem, ParentElement, Pixels, Point, Render, ScrollHandle,
|
||||
SharedString, StatefulInteractiveElement, Styled, Subscription, WeakEntity, Window, anchored,
|
||||
@@ -299,7 +299,7 @@ pub struct PopupMenu {
|
||||
scroll_handle: ScrollHandle,
|
||||
|
||||
/// This will update on render
|
||||
submenu_anchor: (Corner, Pixels),
|
||||
submenu_anchor: (Anchor, Pixels),
|
||||
|
||||
_subscriptions: Vec<Subscription>,
|
||||
}
|
||||
@@ -322,7 +322,7 @@ impl PopupMenu {
|
||||
scroll_handle: ScrollHandle::default(),
|
||||
external_link_icon: true,
|
||||
size: Size::default(),
|
||||
submenu_anchor: (Corner::TopLeft, Pixels::ZERO),
|
||||
submenu_anchor: (Anchor::TopLeft, Pixels::ZERO),
|
||||
_subscriptions: vec![],
|
||||
}
|
||||
}
|
||||
@@ -719,13 +719,13 @@ impl PopupMenu {
|
||||
}
|
||||
|
||||
pub(crate) fn active_submenu(&self) -> Option<Entity<PopupMenu>> {
|
||||
if let Some(ix) = self.selected_index {
|
||||
if let Some(item) = self.menu_items.get(ix) {
|
||||
return match item {
|
||||
PopupMenuItem::Submenu { menu, .. } => Some(menu.clone()),
|
||||
_ => None,
|
||||
};
|
||||
}
|
||||
if let Some(ix) = self.selected_index
|
||||
&& let Some(item) = self.menu_items.get(ix)
|
||||
{
|
||||
return match item {
|
||||
PopupMenuItem::Submenu { menu, .. } => Some(menu.clone()),
|
||||
_ => None,
|
||||
};
|
||||
}
|
||||
|
||||
None
|
||||
@@ -840,7 +840,7 @@ impl PopupMenu {
|
||||
}
|
||||
|
||||
fn select_left(&mut self, _: &SelectLeft, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let handled = if matches!(self.submenu_anchor.0, Corner::TopLeft | Corner::BottomLeft) {
|
||||
let handled = if matches!(self.submenu_anchor.0, Anchor::TopLeft | Anchor::BottomLeft) {
|
||||
self._unselect_submenu(window, cx)
|
||||
} else {
|
||||
self._select_submenu(window, cx)
|
||||
@@ -861,7 +861,7 @@ impl PopupMenu {
|
||||
}
|
||||
|
||||
fn select_right(&mut self, _: &SelectRight, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let handled = if matches!(self.submenu_anchor.0, Corner::TopLeft | Corner::BottomLeft) {
|
||||
let handled = if matches!(self.submenu_anchor.0, Anchor::TopLeft | Anchor::BottomLeft) {
|
||||
self._select_submenu(window, cx)
|
||||
} else {
|
||||
self._unselect_submenu(window, cx)
|
||||
@@ -930,8 +930,9 @@ impl PopupMenu {
|
||||
};
|
||||
|
||||
match parent.read(cx).submenu_anchor.0 {
|
||||
Corner::TopLeft | Corner::BottomLeft => Side::Left,
|
||||
Corner::TopRight | Corner::BottomRight => Side::Right,
|
||||
Anchor::TopLeft | Anchor::BottomLeft => Side::Left,
|
||||
Anchor::TopRight | Anchor::BottomRight => Side::Right,
|
||||
_ => Side::Left,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -965,12 +966,11 @@ impl PopupMenu {
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
// Do not dismiss, if click inside the parent menu
|
||||
if let Some(parent) = self.parent_menu.as_ref() {
|
||||
if let Some(parent) = parent.upgrade() {
|
||||
if parent.read(cx).bounds.contains(position) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
if let Some(parent) = self.parent_menu.as_ref()
|
||||
&& let Some(parent) = parent.upgrade()
|
||||
&& parent.read(cx).bounds.contains(position)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
self.dismiss(&Cancel, window, cx);
|
||||
@@ -1042,14 +1042,14 @@ impl PopupMenu {
|
||||
let bounds = self.bounds;
|
||||
let max_width = self.max_width();
|
||||
let (anchor, left) = if max_width + bounds.origin.x > window.bounds().size.width {
|
||||
(Corner::TopRight, -px(16.))
|
||||
(Anchor::TopRight, -px(16.))
|
||||
} else {
|
||||
(Corner::TopLeft, bounds.size.width - px(8.))
|
||||
(Anchor::TopLeft, bounds.size.width - px(8.))
|
||||
};
|
||||
|
||||
let is_bottom_pos = bounds.origin.y + bounds.size.height > window.bounds().size.height;
|
||||
self.submenu_anchor = if is_bottom_pos {
|
||||
(anchor.other_side_corner_along(gpui::Axis::Vertical), left)
|
||||
(anchor.other_side_along(gpui::Axis::Vertical), left)
|
||||
} else {
|
||||
(anchor, left)
|
||||
};
|
||||
@@ -1231,7 +1231,7 @@ impl PopupMenu {
|
||||
this.child({
|
||||
let (anchor, left) = self.submenu_anchor;
|
||||
let is_bottom_pos =
|
||||
matches!(anchor, Corner::BottomLeft | Corner::BottomRight);
|
||||
matches!(anchor, Anchor::BottomLeft | Anchor::BottomRight);
|
||||
anchored()
|
||||
.anchor(anchor)
|
||||
.child(
|
||||
|
||||
@@ -296,10 +296,10 @@ impl RenderOnce for Modal {
|
||||
let on_close = on_close.clone();
|
||||
|
||||
move |_, window, cx| {
|
||||
if let Some(on_ok) = &on_ok {
|
||||
if !on_ok(&ClickEvent::default(), window, cx) {
|
||||
return;
|
||||
}
|
||||
if let Some(on_ok) = &on_ok
|
||||
&& !on_ok(&ClickEvent::default(), window, cx)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
on_close(&ClickEvent::default(), window, cx);
|
||||
@@ -521,12 +521,14 @@ impl RenderOnce for Modal {
|
||||
offset: point(px(0.), px(20.)),
|
||||
blur_radius: px(25.),
|
||||
spread_radius: px(-5.),
|
||||
inset: false,
|
||||
},
|
||||
BoxShadow {
|
||||
color: hsla(0., 0., 0., 0.1 * delta),
|
||||
offset: point(px(0.), px(8.)),
|
||||
blur_radius: px(10.),
|
||||
spread_radius: px(-6.),
|
||||
inset: false,
|
||||
},
|
||||
];
|
||||
this.top(y + y_offset).shadow(shadow)
|
||||
|
||||
@@ -5,16 +5,16 @@ use std::time::Duration;
|
||||
|
||||
use gpui::prelude::FluentBuilder;
|
||||
use gpui::{
|
||||
Animation, AnimationExt, AnyElement, App, AppContext, ClickEvent, Context, DismissEvent,
|
||||
ElementId, Entity, EventEmitter, InteractiveElement as _, IntoElement, ParentElement as _,
|
||||
Render, SharedString, StatefulInteractiveElement, StyleRefinement, Styled, Subscription,
|
||||
Window, div, px, relative,
|
||||
Anchor, Animation, AnimationExt, AnyElement, App, AppContext, ClickEvent, Context,
|
||||
DismissEvent, ElementId, Entity, EventEmitter, InteractiveElement as _, IntoElement,
|
||||
ParentElement as _, Render, SharedString, StatefulInteractiveElement, StyleRefinement, Styled,
|
||||
Subscription, Window, div, px, relative,
|
||||
};
|
||||
use theme::{ActiveTheme, Anchor};
|
||||
use theme::ActiveTheme;
|
||||
|
||||
use crate::animation::cubic_bezier;
|
||||
use crate::button::{Button, ButtonVariants as _};
|
||||
use crate::{Icon, IconName, Sizable as _, StyledExt, h_flex, v_flex};
|
||||
use crate::{Icon, IconName, Sizable as _, Size, StyledExt, h_flex, v_flex};
|
||||
|
||||
#[derive(Debug, Clone, Copy, Default)]
|
||||
pub enum NotificationKind {
|
||||
@@ -28,12 +28,18 @@ pub enum NotificationKind {
|
||||
impl NotificationKind {
|
||||
fn icon(&self, cx: &App) -> Icon {
|
||||
match self {
|
||||
Self::Info => Icon::new(IconName::Info).text_color(cx.theme().icon),
|
||||
Self::Success => Icon::new(IconName::CheckCircle).text_color(cx.theme().icon_accent),
|
||||
Self::Warning => Icon::new(IconName::Warning).text_color(cx.theme().text_warning),
|
||||
Self::Error => {
|
||||
Icon::new(IconName::CloseCircle).text_color(cx.theme().danger_foreground)
|
||||
}
|
||||
Self::Info => Icon::new(IconName::Info)
|
||||
.with_size(Size::Medium)
|
||||
.text_color(cx.theme().icon),
|
||||
Self::Success => Icon::new(IconName::CheckCircle)
|
||||
.with_size(Size::Medium)
|
||||
.text_color(cx.theme().icon_accent),
|
||||
Self::Warning => Icon::new(IconName::Warning)
|
||||
.with_size(Size::Medium)
|
||||
.text_color(cx.theme().text_warning),
|
||||
Self::Error => Icon::new(IconName::CloseCircle)
|
||||
.with_size(Size::Medium)
|
||||
.text_color(cx.theme().danger_foreground),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -282,20 +288,21 @@ impl Styled for Notification {
|
||||
&mut self.style
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for Notification {
|
||||
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let closing = self.closing;
|
||||
let placement = cx.theme().notification.placement;
|
||||
|
||||
let content = self
|
||||
.content_builder
|
||||
.clone()
|
||||
.map(|builder| builder(self, window, cx));
|
||||
|
||||
let action = self
|
||||
.action_builder
|
||||
.clone()
|
||||
.map(|builder| builder(self, window, cx).small().mr_3p5());
|
||||
let action = self.action_builder.clone().map(|builder| {
|
||||
builder(self, window, cx)
|
||||
.small()
|
||||
.primary()
|
||||
.px_4()
|
||||
.font_medium()
|
||||
});
|
||||
|
||||
let icon = match self.kind {
|
||||
None => self.icon.clone(),
|
||||
@@ -312,6 +319,11 @@ impl Render for Notification {
|
||||
_ => cx.theme().text,
|
||||
};
|
||||
|
||||
let closing = self.closing;
|
||||
let has_title = self.title.is_some();
|
||||
let only_message = !has_title && content.is_none() && action.is_none();
|
||||
let placement = cx.theme().notification.placement;
|
||||
|
||||
h_flex()
|
||||
.id("notification")
|
||||
.group("")
|
||||
@@ -328,23 +340,38 @@ impl Render for Notification {
|
||||
.gap_2()
|
||||
.justify_start()
|
||||
.items_start()
|
||||
.when(only_message, |this| this.items_center())
|
||||
.refine_style(&self.style)
|
||||
.when_some(icon, |this, icon| {
|
||||
this.child(div().flex_shrink_0().child(icon))
|
||||
this.child(div().flex_shrink_0().size_5().child(icon))
|
||||
})
|
||||
.child(
|
||||
v_flex()
|
||||
.flex_1()
|
||||
.gap_1()
|
||||
.overflow_hidden()
|
||||
.when_some(self.title.clone(), |this, title| {
|
||||
this.child(div().text_sm().font_semibold().child(title))
|
||||
this.child(h_flex().h_5().text_sm().font_semibold().child(title))
|
||||
})
|
||||
.when_some(self.message.clone(), |this, message| {
|
||||
this.child(div().text_sm().line_height(relative(1.25)).child(message))
|
||||
this.child(
|
||||
div()
|
||||
.text_sm()
|
||||
.when(has_title, |this| this.text_color(cx.theme().text_muted))
|
||||
.line_height(relative(1.3))
|
||||
.child(message),
|
||||
)
|
||||
})
|
||||
.when_some(content, |this, content| this.child(content))
|
||||
.when_some(action, |this, action| {
|
||||
this.child(h_flex().flex_1().gap_1().justify_end().child(action))
|
||||
this.gap_2().child(
|
||||
h_flex()
|
||||
.mt_2()
|
||||
.w_full()
|
||||
.flex_1()
|
||||
.justify_end()
|
||||
.child(action),
|
||||
)
|
||||
}),
|
||||
)
|
||||
.child(
|
||||
@@ -403,12 +430,17 @@ impl Render for Notification {
|
||||
let y_offset = px(0.) + delta * px(45.);
|
||||
that.top(px(0.) + y_offset)
|
||||
}
|
||||
_ => that,
|
||||
}
|
||||
} else {
|
||||
let opacity = delta;
|
||||
let y_offset = match placement {
|
||||
placement if placement.is_top() => px(-45.) + delta * px(45.),
|
||||
placement if placement.is_bottom() => px(45.) - delta * px(45.),
|
||||
Anchor::TopLeft | Anchor::TopRight | Anchor::TopCenter => {
|
||||
px(-45.) + delta * px(45.)
|
||||
}
|
||||
Anchor::BottomLeft | Anchor::BottomRight | Anchor::BottomCenter => {
|
||||
px(45.) - delta * px(45.)
|
||||
}
|
||||
_ => px(0.),
|
||||
};
|
||||
this.top(px(0.) + y_offset)
|
||||
|
||||
@@ -2,15 +2,14 @@ use std::rc::Rc;
|
||||
|
||||
use gpui::prelude::FluentBuilder as _;
|
||||
use gpui::{
|
||||
AnyElement, App, Bounds, Context, Deferred, DismissEvent, Div, ElementId, EventEmitter,
|
||||
FocusHandle, Focusable, Half, InteractiveElement as _, IntoElement, KeyBinding, MouseButton,
|
||||
Anchor, AnyElement, App, Bounds, Context, Deferred, DismissEvent, Div, ElementId, EventEmitter,
|
||||
FocusHandle, Focusable, InteractiveElement as _, IntoElement, KeyBinding, MouseButton,
|
||||
ParentElement, Pixels, Point, Render, RenderOnce, Stateful, StyleRefinement, Styled,
|
||||
Subscription, Window, deferred, div, px,
|
||||
Subscription, Window, anchored, deferred, div, px,
|
||||
};
|
||||
use theme::Anchor;
|
||||
|
||||
use crate::actions::Cancel;
|
||||
use crate::{ElementExt, Selectable, StyledExt as _, anchored, v_flex};
|
||||
use crate::{ElementExt, Selectable, StyledExt as _, v_flex};
|
||||
|
||||
const CONTEXT: &str = "Popover";
|
||||
|
||||
@@ -175,19 +174,26 @@ impl Popover {
|
||||
self
|
||||
}
|
||||
|
||||
fn resolved_corner(anchor: Anchor, trigger_bounds: Bounds<Pixels>) -> Point<Pixels> {
|
||||
let offset = if anchor.is_center() {
|
||||
gpui::point(trigger_bounds.size.width.half(), px(0.))
|
||||
} else {
|
||||
Point::default()
|
||||
};
|
||||
|
||||
trigger_bounds.corner(anchor.swap_vertical().into())
|
||||
+ offset
|
||||
+ Point {
|
||||
x: px(0.),
|
||||
y: -trigger_bounds.size.height,
|
||||
}
|
||||
pub(crate) fn resolved_corner(anchor: Anchor, trigger_bounds: Bounds<Pixels>) -> Point<Pixels> {
|
||||
match anchor {
|
||||
Anchor::TopLeft => trigger_bounds.origin,
|
||||
Anchor::TopCenter => trigger_bounds.top_center(),
|
||||
Anchor::TopRight => trigger_bounds.top_right(),
|
||||
Anchor::BottomLeft => Point {
|
||||
x: trigger_bounds.origin.x,
|
||||
y: trigger_bounds.origin.y - trigger_bounds.size.height,
|
||||
},
|
||||
Anchor::BottomCenter => Point {
|
||||
x: trigger_bounds.top_center().x,
|
||||
y: trigger_bounds.origin.y - trigger_bounds.size.height,
|
||||
},
|
||||
Anchor::BottomRight => Point {
|
||||
x: trigger_bounds.top_right().x,
|
||||
y: trigger_bounds.origin.y - trigger_bounds.size.height,
|
||||
},
|
||||
// Fallback for LeftCenter/RightCenter – adjust as needed.
|
||||
_ => trigger_bounds.origin,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -330,6 +336,7 @@ impl Popover {
|
||||
.map(|this| match anchor {
|
||||
Anchor::TopLeft | Anchor::TopCenter | Anchor::TopRight => this.top_1(),
|
||||
Anchor::BottomLeft | Anchor::BottomCenter | Anchor::BottomRight => this.bottom_1(),
|
||||
Anchor::LeftCenter | Anchor::RightCenter => this.top_1(), // Fallback for centered
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use std::ops::Range;
|
||||
|
||||
use gpui::{
|
||||
px, Along, App, Axis, Bounds, Context, ElementId, EventEmitter, IsZero, Pixels, Window,
|
||||
Along, App, Axis, Bounds, Context, ElementId, EventEmitter, IsZero, Pixels, Window, px,
|
||||
};
|
||||
|
||||
mod panel;
|
||||
@@ -142,10 +142,10 @@ impl ResizableState {
|
||||
pub(crate) fn remove_panel(&mut self, panel_ix: usize, cx: &mut Context<Self>) {
|
||||
self.panels.remove(panel_ix);
|
||||
self.sizes.remove(panel_ix);
|
||||
if let Some(resizing_panel_ix) = self.resizing_panel_ix {
|
||||
if resizing_panel_ix > panel_ix {
|
||||
self.resizing_panel_ix = Some(resizing_panel_ix - 1);
|
||||
}
|
||||
if let Some(resizing_panel_ix) = self.resizing_panel_ix
|
||||
&& resizing_panel_ix > panel_ix
|
||||
{
|
||||
self.resizing_panel_ix = Some(resizing_panel_ix - 1);
|
||||
}
|
||||
self.adjust_to_container_size(cx);
|
||||
}
|
||||
|
||||
@@ -253,7 +253,7 @@ impl RenderOnce for ResizablePanel {
|
||||
div()
|
||||
.id(("resizable-panel", self.panel_ix))
|
||||
.flex()
|
||||
.flex_grow()
|
||||
.flex_grow_1()
|
||||
.size_full()
|
||||
.relative()
|
||||
.when(self.axis.is_vertical(), |this| {
|
||||
@@ -265,7 +265,7 @@ impl RenderOnce for ResizablePanel {
|
||||
// 1. initial_size is None, to use auto size.
|
||||
// 2. initial_size is Some and size is none, to use the initial size of the panel for first time render.
|
||||
// 3. initial_size is Some and size is Some, use `size`.
|
||||
.when(self.initial_size.is_none(), |this| this.flex_shrink())
|
||||
.when(self.initial_size.is_none(), |this| this.flex_shrink_1())
|
||||
.when_some(self.initial_size, |this, initial_size| {
|
||||
// The `self.size` is None, that mean the initial size for the panel,
|
||||
// so we need set `flex_shrink_0` To let it keep the initial size.
|
||||
|
||||
@@ -92,7 +92,7 @@ impl Root {
|
||||
pub fn render_notification_layer(
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) -> Option<impl IntoElement> {
|
||||
) -> Option<impl IntoElement + use<>> {
|
||||
let root = window.root::<Root>()??;
|
||||
|
||||
Some(
|
||||
@@ -105,7 +105,10 @@ impl Root {
|
||||
}
|
||||
|
||||
/// Render the modal layer.
|
||||
pub fn render_modal_layer(window: &mut Window, cx: &mut App) -> Option<impl IntoElement> {
|
||||
pub fn render_modal_layer(
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) -> Option<impl IntoElement + use<>> {
|
||||
let root = window.root::<Root>()??;
|
||||
let active_modals = root.read(cx).active_modals.clone();
|
||||
|
||||
@@ -139,10 +142,10 @@ impl Root {
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
if let Some(ix) = show_overlay_ix {
|
||||
if let Some(modal) = modals.get_mut(ix) {
|
||||
modal.overlay_visible = true;
|
||||
}
|
||||
if let Some(ix) = show_overlay_ix
|
||||
&& let Some(modal) = modals.get_mut(ix)
|
||||
{
|
||||
modal.overlay_visible = true;
|
||||
}
|
||||
|
||||
Some(div().children(modals))
|
||||
@@ -382,6 +385,7 @@ impl Render for Root {
|
||||
blur_radius: CLIENT_SIDE_DECORATION_SHADOW / 2.,
|
||||
spread_radius: px(0.),
|
||||
offset: point(px(0.0), px(0.0)),
|
||||
inset: false,
|
||||
}])
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -5,7 +5,7 @@ use std::rc::Rc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use gpui::{
|
||||
App, Axis, BorderStyle, Bounds, ContentMask, Corner, CursorStyle, Edges, Element, ElementId,
|
||||
Anchor, App, Axis, BorderStyle, Bounds, ContentMask, CursorStyle, Edges, Element, ElementId,
|
||||
GlobalElementId, Hitbox, HitboxBehavior, Hsla, InspectorElementId, IntoElement, IsZero,
|
||||
LayoutId, ListState, MouseDownEvent, MouseMoveEvent, MouseUpEvent, PaintQuad, Pixels, Point,
|
||||
Position, ScrollHandle, ScrollWheelEvent, Size, Style, UniformListScrollHandle, Window, fill,
|
||||
@@ -648,14 +648,14 @@ impl Element for Scrollbar {
|
||||
// The clickable area of the thumb
|
||||
let thumb_length = thumb_end - thumb_start - inset * 2;
|
||||
let thumb_bounds = if is_vertical {
|
||||
Bounds::from_corner_and_size(
|
||||
Corner::TopRight,
|
||||
Bounds::from_anchor_and_size(
|
||||
Anchor::TopRight,
|
||||
bounds.top_right() + point(-inset, inset + thumb_start),
|
||||
size(WIDTH, thumb_length),
|
||||
)
|
||||
} else {
|
||||
Bounds::from_corner_and_size(
|
||||
Corner::BottomLeft,
|
||||
Bounds::from_anchor_and_size(
|
||||
Anchor::BottomLeft,
|
||||
bounds.bottom_left() + point(inset + thumb_start, -inset),
|
||||
size(thumb_length, WIDTH),
|
||||
)
|
||||
@@ -663,14 +663,14 @@ impl Element for Scrollbar {
|
||||
|
||||
// The actual render area of the thumb
|
||||
let thumb_fill_bounds = if is_vertical {
|
||||
Bounds::from_corner_and_size(
|
||||
Corner::TopRight,
|
||||
Bounds::from_anchor_and_size(
|
||||
Anchor::TopRight,
|
||||
bounds.top_right() + point(-inset, inset + thumb_start),
|
||||
size(thumb_width, thumb_length),
|
||||
)
|
||||
} else {
|
||||
Bounds::from_corner_and_size(
|
||||
Corner::BottomLeft,
|
||||
Bounds::from_anchor_and_size(
|
||||
Anchor::BottomLeft,
|
||||
bounds.bottom_left() + point(inset + thumb_start, -inset),
|
||||
size(thumb_length, thumb_width),
|
||||
)
|
||||
|
||||
@@ -2,7 +2,7 @@ use std::rc::Rc;
|
||||
|
||||
use gpui::prelude::FluentBuilder as _;
|
||||
use gpui::{
|
||||
AnyElement, App, Corner, Div, Edges, ElementId, InteractiveElement, IntoElement, ParentElement,
|
||||
Anchor, AnyElement, App, Div, Edges, ElementId, InteractiveElement, IntoElement, ParentElement,
|
||||
RenderOnce, ScrollHandle, Stateful, StatefulInteractiveElement as _, StyleRefinement, Styled,
|
||||
Window, div, px,
|
||||
};
|
||||
@@ -282,7 +282,7 @@ impl RenderOnce for TabBar {
|
||||
|
||||
this
|
||||
})
|
||||
.anchor(Corner::TopRight),
|
||||
.anchor(Anchor::TopRight),
|
||||
)
|
||||
})
|
||||
.when_some(self.suffix, |this, suffix| this.child(suffix))
|
||||
|
||||
@@ -14,8 +14,8 @@ product-name = "Coop"
|
||||
description = "Chat Freely, Stay Private on Nostr"
|
||||
identifier = "su.reya.coop"
|
||||
category = "SocialNetworking"
|
||||
version = "1.0.0-beta1"
|
||||
out-dir = "../../dist"
|
||||
version = "1.0.0-beta5"
|
||||
out-dir = "../dist"
|
||||
before-packaging-command = "cargo build --release"
|
||||
resources = ["Cargo.toml", "src"]
|
||||
icons = [
|
||||
@@ -27,19 +27,19 @@ icons = [
|
||||
]
|
||||
|
||||
[dependencies]
|
||||
assets = { path = "../assets" }
|
||||
ui = { path = "../ui" }
|
||||
title_bar = { path = "../title_bar" }
|
||||
theme = { path = "../theme" }
|
||||
common = { path = "../common" }
|
||||
state = { path = "../state" }
|
||||
device = { path = "../device" }
|
||||
chat = { path = "../chat" }
|
||||
chat_ui = { path = "../chat_ui" }
|
||||
settings = { path = "../settings" }
|
||||
auto_update = { path = "../auto_update" }
|
||||
person = { path = "../person" }
|
||||
relay_auth = { path = "../relay_auth" }
|
||||
assets = { path = "../crates/assets" }
|
||||
ui = { path = "../crates/ui" }
|
||||
title_bar = { path = "../crates/title_bar" }
|
||||
theme = { path = "../crates/theme" }
|
||||
common = { path = "../crates/common" }
|
||||
state = { path = "../crates/state" }
|
||||
device = { path = "../crates/device" }
|
||||
chat = { path = "../crates/chat" }
|
||||
chat_ui = { path = "../crates/chat_ui" }
|
||||
settings = { path = "../crates/settings" }
|
||||
auto_update = { path = "../crates/auto_update" }
|
||||
person = { path = "../crates/person" }
|
||||
relay_auth = { path = "../crates/relay_auth" }
|
||||
|
||||
gpui.workspace = true
|
||||
gpui_platform.workspace = true
|
||||
@@ -62,9 +62,9 @@ smol.workspace = true
|
||||
futures.workspace = true
|
||||
oneshot.workspace = true
|
||||
webbrowser.workspace = true
|
||||
tracing-subscriber.workspace = true
|
||||
|
||||
indexset = "0.12.3"
|
||||
tracing-subscriber = { version = "0.3.18", features = ["fmt", "env-filter"] }
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
# Temporary workaround https://github.com/zed-industries/zed/issues/47168
|
||||
|
Before Width: | Height: | Size: 8.4 KiB After Width: | Height: | Size: 8.4 KiB |
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 1.6 KiB |
@@ -50,7 +50,7 @@
|
||||
},
|
||||
{
|
||||
"type": "dir",
|
||||
"path": "./crates/coop/resources"
|
||||
"path": "./desktop/resources"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 70 KiB After Width: | Height: | Size: 70 KiB |
|
Before Width: | Height: | Size: 120 KiB After Width: | Height: | Size: 120 KiB |
@@ -7,15 +7,14 @@ use gpui::{
|
||||
Subscription, Task, Window, div,
|
||||
};
|
||||
use nostr_connect::prelude::*;
|
||||
use smallvec::{SmallVec, smallvec};
|
||||
use state::{CoopAuthUrlHandler, NostrRegistry, StateEvent};
|
||||
use state::NostrRegistry;
|
||||
use theme::ActiveTheme;
|
||||
use ui::button::{Button, ButtonVariants};
|
||||
use ui::input::{InputEvent, InputState, TextInput};
|
||||
use ui::{Disableable, v_flex};
|
||||
use ui::input::{Input, InputEvent, InputState};
|
||||
use ui::{Disableable, WindowExtension, v_flex};
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct ImportKey {
|
||||
pub struct ImportIdentity {
|
||||
/// Secret key input
|
||||
key_input: Entity<InputState>,
|
||||
|
||||
@@ -25,73 +24,43 @@ pub struct ImportKey {
|
||||
/// Error message
|
||||
error: Entity<Option<SharedString>>,
|
||||
|
||||
/// Countdown timer for nostr connect
|
||||
countdown: Entity<Option<u64>>,
|
||||
|
||||
/// Whether the user is currently loading
|
||||
loading: bool,
|
||||
|
||||
/// Async tasks
|
||||
tasks: Vec<Task<Result<(), Error>>>,
|
||||
|
||||
/// Event subscriptions
|
||||
_subscriptions: SmallVec<[Subscription; 2]>,
|
||||
/// Input subscription
|
||||
_subscription: Option<Subscription>,
|
||||
}
|
||||
|
||||
impl ImportKey {
|
||||
impl ImportIdentity {
|
||||
pub fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let key_input = cx.new(|cx| InputState::new(window, cx).masked(true));
|
||||
let pass_input = cx.new(|cx| InputState::new(window, cx).masked(true));
|
||||
let error = cx.new(|_| None);
|
||||
let countdown = cx.new(|_| None);
|
||||
|
||||
let mut subscriptions = smallvec![];
|
||||
|
||||
subscriptions.push(
|
||||
// Subscribe to key input events and process login when the user presses enter
|
||||
let input_subscription =
|
||||
cx.subscribe_in(&key_input, window, |this, _input, event, window, cx| {
|
||||
if let InputEvent::PressEnter { .. } = event {
|
||||
this.login(window, cx);
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
subscriptions.push(
|
||||
// Subscribe to the nostr signer event
|
||||
cx.subscribe_in(&nostr, window, |this, _state, event, _window, cx| {
|
||||
if let StateEvent::Error(e) = event {
|
||||
this.set_error(e, cx);
|
||||
}
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
Self {
|
||||
key_input,
|
||||
pass_input,
|
||||
error,
|
||||
countdown,
|
||||
loading: false,
|
||||
tasks: vec![],
|
||||
_subscriptions: subscriptions,
|
||||
_subscription: Some(input_subscription),
|
||||
}
|
||||
}
|
||||
|
||||
fn login(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
if self.loading {
|
||||
return;
|
||||
};
|
||||
// Prevent duplicate login requests
|
||||
self.set_loading(true, cx);
|
||||
|
||||
let value = self.key_input.read(cx).value();
|
||||
let password = self.pass_input.read(cx).value();
|
||||
|
||||
if value.starts_with("bunker://") {
|
||||
self.bunker(&value, window, cx);
|
||||
return;
|
||||
}
|
||||
|
||||
if value.starts_with("ncryptsec1") {
|
||||
self.ncryptsec(value, password, window, cx);
|
||||
return;
|
||||
@@ -103,52 +72,14 @@ impl ImportKey {
|
||||
|
||||
// Update the signer
|
||||
nostr.update(cx, |this, cx| {
|
||||
this.add_key_signer(&keys, cx);
|
||||
this.set_signer(keys, cx);
|
||||
});
|
||||
window.close_modal(cx);
|
||||
} else {
|
||||
self.set_error("Invalid key", cx);
|
||||
}
|
||||
}
|
||||
|
||||
fn bunker(&mut self, content: &str, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let Ok(uri) = NostrConnectUri::parse(content) else {
|
||||
self.set_error("Bunker is not valid", cx);
|
||||
return;
|
||||
};
|
||||
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let app_keys = nostr.read(cx).keys();
|
||||
let timeout = Duration::from_secs(30);
|
||||
|
||||
// Construct the nostr connect signer
|
||||
let mut signer = NostrConnect::new(uri, app_keys.clone(), timeout, None).unwrap();
|
||||
|
||||
// Handle auth url with the default browser
|
||||
signer.auth_url_handler(CoopAuthUrlHandler);
|
||||
|
||||
// Set signer in the background
|
||||
nostr.update(cx, |this, cx| {
|
||||
this.add_nip46_signer(&signer, cx);
|
||||
});
|
||||
|
||||
// Start countdown
|
||||
self.tasks.push(cx.spawn_in(window, async move |this, cx| {
|
||||
for i in (0..=30).rev() {
|
||||
if i == 0 {
|
||||
this.update(cx, |this, cx| {
|
||||
this.set_countdown(None, cx);
|
||||
})?;
|
||||
} else {
|
||||
this.update(cx, |this, cx| {
|
||||
this.set_countdown(Some(i), cx);
|
||||
})?;
|
||||
}
|
||||
cx.background_executor().timer(Duration::from_secs(1)).await;
|
||||
}
|
||||
Ok(())
|
||||
}));
|
||||
}
|
||||
|
||||
fn ncryptsec<S>(&mut self, content: S, pwd: S, window: &mut Window, cx: &mut Context<Self>)
|
||||
where
|
||||
S: Into<String>,
|
||||
@@ -179,9 +110,10 @@ impl ImportKey {
|
||||
self.tasks.push(cx.spawn_in(window, async move |this, cx| {
|
||||
match task.await {
|
||||
Ok(keys) => {
|
||||
nostr.update(cx, |this, cx| {
|
||||
this.add_key_signer(&keys, cx);
|
||||
});
|
||||
nostr.update_in(cx, |this, window, cx| {
|
||||
this.set_signer(keys, cx);
|
||||
window.close_modal(cx);
|
||||
})?;
|
||||
}
|
||||
Err(e) => {
|
||||
this.update(cx, |this, cx| {
|
||||
@@ -198,12 +130,6 @@ impl ImportKey {
|
||||
where
|
||||
S: Into<SharedString>,
|
||||
{
|
||||
// Reset the log in state
|
||||
self.set_loading(false, cx);
|
||||
|
||||
// Reset the countdown
|
||||
self.set_countdown(None, cx);
|
||||
|
||||
// Update error message
|
||||
self.error.update(cx, |this, cx| {
|
||||
*this = Some(message.into());
|
||||
@@ -224,25 +150,14 @@ impl ImportKey {
|
||||
Ok(())
|
||||
}));
|
||||
}
|
||||
|
||||
fn set_loading(&mut self, status: bool, cx: &mut Context<Self>) {
|
||||
self.loading = status;
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn set_countdown(&mut self, i: Option<u64>, cx: &mut Context<Self>) {
|
||||
self.countdown.update(cx, |this, cx| {
|
||||
*this = i;
|
||||
cx.notify();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for ImportKey {
|
||||
impl Render for ImportIdentity {
|
||||
fn render(&mut self, _window: &mut gpui::Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
const MSG: &str = "Coop isn't stored your identity secret in local device. Everything will be reset on the next login.";
|
||||
|
||||
v_flex()
|
||||
.size_full()
|
||||
.p_4()
|
||||
.gap_2()
|
||||
.text_sm()
|
||||
.child(
|
||||
@@ -250,8 +165,8 @@ impl Render for ImportKey {
|
||||
.gap_1()
|
||||
.text_sm()
|
||||
.text_color(cx.theme().text_muted)
|
||||
.child("nsec or bunker://")
|
||||
.child(TextInput::new(&self.key_input)),
|
||||
.child("nsec or ncryptsec://")
|
||||
.child(Input::new(&self.key_input)),
|
||||
)
|
||||
.when(
|
||||
self.key_input.read(cx).value().starts_with("ncryptsec1"),
|
||||
@@ -262,10 +177,11 @@ impl Render for ImportKey {
|
||||
.text_sm()
|
||||
.text_color(cx.theme().text_muted)
|
||||
.child("Password:")
|
||||
.child(TextInput::new(&self.pass_input)),
|
||||
.child(Input::new(&self.pass_input)),
|
||||
)
|
||||
},
|
||||
)
|
||||
.child(div().text_xs().text_color(cx.theme().text_muted).child(MSG))
|
||||
.child(
|
||||
Button::new("login")
|
||||
.label("Continue")
|
||||
@@ -276,18 +192,6 @@ impl Render for ImportKey {
|
||||
this.login(window, cx);
|
||||
})),
|
||||
)
|
||||
.when_some(self.countdown.read(cx).as_ref(), |this, i| {
|
||||
this.child(
|
||||
div()
|
||||
.text_xs()
|
||||
.text_center()
|
||||
.text_color(cx.theme().text_muted)
|
||||
.child(SharedString::from(format!(
|
||||
"Approve connection request from your signer in {} seconds",
|
||||
i
|
||||
))),
|
||||
)
|
||||
})
|
||||
.when_some(self.error.read(cx).as_ref(), |this, error| {
|
||||
this.child(
|
||||
div()
|
||||
4
desktop/src/dialogs/mod.rs
Normal file
@@ -0,0 +1,4 @@
|
||||
pub mod import;
|
||||
pub mod restore;
|
||||
pub mod screening;
|
||||
pub mod settings;
|
||||
130
desktop/src/dialogs/restore.rs
Normal file
@@ -0,0 +1,130 @@
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::Error;
|
||||
use device::DeviceRegistry;
|
||||
use gpui::prelude::FluentBuilder;
|
||||
use gpui::{
|
||||
AppContext, Context, Entity, IntoElement, ParentElement, Render, SharedString, Styled,
|
||||
Subscription, Task, Window, div,
|
||||
};
|
||||
use nostr_connect::prelude::*;
|
||||
use theme::ActiveTheme;
|
||||
use ui::button::{Button, ButtonVariants};
|
||||
use ui::input::{Input, InputEvent, InputState};
|
||||
use ui::{WindowExtension, v_flex};
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct RestoreEncryption {
|
||||
/// Secret key input
|
||||
key_input: Entity<InputState>,
|
||||
|
||||
/// Error message
|
||||
error: Entity<Option<SharedString>>,
|
||||
|
||||
/// Async tasks
|
||||
tasks: Vec<Task<Result<(), Error>>>,
|
||||
|
||||
/// Event subscription
|
||||
_subscription: Option<Subscription>,
|
||||
}
|
||||
|
||||
impl RestoreEncryption {
|
||||
pub fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||
let key_input = cx.new(|cx| InputState::new(window, cx).masked(true));
|
||||
let error = cx.new(|_| None);
|
||||
|
||||
let subscription =
|
||||
cx.subscribe_in(&key_input, window, |this, _input, event, window, cx| {
|
||||
if let InputEvent::PressEnter { .. } = event {
|
||||
this.restore(window, cx);
|
||||
};
|
||||
});
|
||||
|
||||
Self {
|
||||
key_input,
|
||||
error,
|
||||
tasks: vec![],
|
||||
_subscription: Some(subscription),
|
||||
}
|
||||
}
|
||||
|
||||
fn restore(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let device = DeviceRegistry::global(cx);
|
||||
let content = self.key_input.read(cx).value();
|
||||
|
||||
if !content.is_empty() {
|
||||
self.set_error("Secret Key cannot be empty.", cx);
|
||||
}
|
||||
|
||||
let Ok(secret) = SecretKey::parse(&content) else {
|
||||
self.set_error("Secret Key is invalid.", cx);
|
||||
return;
|
||||
};
|
||||
|
||||
device.update(cx, |this, cx| {
|
||||
this.set_announcement(Keys::new(secret), cx);
|
||||
});
|
||||
|
||||
// Close the current modal
|
||||
window.close_modal(cx);
|
||||
}
|
||||
|
||||
fn set_error<S>(&mut self, message: S, cx: &mut Context<Self>)
|
||||
where
|
||||
S: Into<SharedString>,
|
||||
{
|
||||
// Update error message
|
||||
self.error.update(cx, |this, cx| {
|
||||
*this = Some(message.into());
|
||||
cx.notify();
|
||||
});
|
||||
|
||||
// Clear the error message after 3 secs
|
||||
self.tasks.push(cx.spawn(async move |this, cx| {
|
||||
cx.background_executor().timer(Duration::from_secs(3)).await;
|
||||
|
||||
this.update(cx, |this, cx| {
|
||||
this.error.update(cx, |this, cx| {
|
||||
*this = None;
|
||||
cx.notify();
|
||||
});
|
||||
})?;
|
||||
|
||||
Ok(())
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for RestoreEncryption {
|
||||
fn render(&mut self, _window: &mut gpui::Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
v_flex()
|
||||
.size_full()
|
||||
.gap_2()
|
||||
.text_sm()
|
||||
.child(
|
||||
v_flex()
|
||||
.gap_1()
|
||||
.text_sm()
|
||||
.text_color(cx.theme().text_muted)
|
||||
.child("Secret Key")
|
||||
.child(Input::new(&self.key_input)),
|
||||
)
|
||||
.child(
|
||||
Button::new("restore")
|
||||
.label("Restore")
|
||||
.primary()
|
||||
.on_click(cx.listener(move |this, _, window, cx| {
|
||||
this.restore(window, cx);
|
||||
})),
|
||||
)
|
||||
.when_some(self.error.read(cx).as_ref(), |this, error| {
|
||||
this.child(
|
||||
div()
|
||||
.text_xs()
|
||||
.text_center()
|
||||
.text_color(cx.theme().text_danger)
|
||||
.child(error.clone()),
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,22 +1,22 @@
|
||||
use std::collections::HashMap;
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::{Context as AnyhowContext, Error};
|
||||
use common::RenderedTimestamp;
|
||||
use anyhow::Error;
|
||||
use common::TimestampExt;
|
||||
use gpui::prelude::FluentBuilder;
|
||||
use gpui::{
|
||||
div, px, relative, uniform_list, App, AppContext, Context, Div, Entity, InteractiveElement,
|
||||
IntoElement, ParentElement, Render, SharedString, Styled, Subscription, Task, Window,
|
||||
App, AppContext, Context, Div, Entity, InteractiveElement, IntoElement, ParentElement, Render,
|
||||
SharedString, Styled, Subscription, Task, Window, div, px, relative, uniform_list,
|
||||
};
|
||||
use nostr_sdk::prelude::*;
|
||||
use person::{shorten_pubkey, Person, PersonRegistry};
|
||||
use smallvec::{smallvec, SmallVec};
|
||||
use state::{NostrAddress, NostrRegistry, BOOTSTRAP_RELAYS, TIMEOUT};
|
||||
use person::{Person, PersonRegistry, shorten_pubkey};
|
||||
use smallvec::{SmallVec, smallvec};
|
||||
use state::{BOOTSTRAP_RELAYS, NostrAddress, NostrRegistry, TIMEOUT};
|
||||
use theme::ActiveTheme;
|
||||
use ui::avatar::Avatar;
|
||||
use ui::button::{Button, ButtonVariants};
|
||||
use ui::indicator::Indicator;
|
||||
use ui::{h_flex, v_flex, Icon, IconName, Sizable, StyledExt, WindowExtension};
|
||||
use ui::{Icon, IconName, Sizable, StyledExt, WindowExtension, h_flex, v_flex};
|
||||
|
||||
pub fn init(public_key: PublicKey, window: &mut Window, cx: &mut App) -> Entity<Screening> {
|
||||
cx.new(|cx| Screening::new(public_key, window, cx))
|
||||
@@ -78,12 +78,13 @@ impl Screening {
|
||||
let client = nostr.read(cx).client();
|
||||
let public_key = self.public_key;
|
||||
|
||||
let task: Task<Result<bool, Error>> = cx.background_spawn(async move {
|
||||
let signer = client.signer().context("Signer not found")?;
|
||||
let signer_pubkey = signer.get_public_key().await?;
|
||||
let Some(current_user) = nostr.read(cx).signer_pubkey(cx) else {
|
||||
return;
|
||||
};
|
||||
|
||||
let task: Task<Result<bool, Error>> = cx.background_spawn(async move {
|
||||
// Check if user is in contact list
|
||||
let contacts = client.database().contacts_public_keys(signer_pubkey).await;
|
||||
let contacts = client.database().contacts_public_keys(current_user).await;
|
||||
let followed = contacts.unwrap_or_default().contains(&public_key);
|
||||
|
||||
Ok(followed)
|
||||
@@ -105,16 +106,17 @@ impl Screening {
|
||||
let client = nostr.read(cx).client();
|
||||
let public_key = self.public_key;
|
||||
|
||||
let task: Task<Result<Vec<PublicKey>, Error>> = cx.background_spawn(async move {
|
||||
let signer = client.signer().context("Signer not found")?;
|
||||
let signer_pubkey = signer.get_public_key().await?;
|
||||
let Some(current_user) = nostr.read(cx).signer_pubkey(cx) else {
|
||||
return;
|
||||
};
|
||||
|
||||
let task: Task<Result<Vec<PublicKey>, Error>> = cx.background_spawn(async move {
|
||||
// Check mutual contacts
|
||||
let filter = Filter::new().kind(Kind::ContactList).pubkey(public_key);
|
||||
let mut mutual_contacts = vec![];
|
||||
|
||||
if let Ok(events) = client.database().query(filter).await {
|
||||
for event in events.into_iter().filter(|ev| ev.pubkey != signer_pubkey) {
|
||||
for event in events.into_iter().filter(|ev| ev.pubkey != current_user) {
|
||||
mutual_contacts.push(event.pubkey);
|
||||
}
|
||||
}
|
||||
@@ -224,10 +226,20 @@ impl Screening {
|
||||
let client = nostr.read(cx).client();
|
||||
let public_key = self.public_key;
|
||||
|
||||
let Some(signer) = nostr.read(cx).signer(cx) else {
|
||||
return;
|
||||
};
|
||||
|
||||
let task: Task<Result<(), Error>> = cx.background_spawn(async move {
|
||||
let tag = Tag::public_key_report(public_key, Report::Impersonation);
|
||||
let builder = EventBuilder::report(vec![tag], "");
|
||||
let event = client.sign_event_builder(builder).await?;
|
||||
let tag = Nip56Tag::PublicKey {
|
||||
public_key,
|
||||
report: Report::Impersonation,
|
||||
}
|
||||
.to_tag();
|
||||
|
||||
let event = EventBuilder::report(vec![tag], "")
|
||||
.finalize_async(&signer)
|
||||
.await?;
|
||||
|
||||
// Send the report to the public relays
|
||||
client.send_event(&event).to(BOOTSTRAP_RELAYS).await?;
|
||||
@@ -4,10 +4,10 @@ use gpui::{
|
||||
Window, div, px,
|
||||
};
|
||||
use settings::{AppSettings, AuthMode};
|
||||
use theme::{ActiveTheme, ThemeMode};
|
||||
use theme::{ActiveTheme, Theme, ThemeMode};
|
||||
use ui::button::{Button, ButtonVariants};
|
||||
use ui::group_box::{GroupBox, GroupBoxVariants};
|
||||
use ui::input::{InputState, TextInput};
|
||||
use ui::input::{Input, InputState};
|
||||
use ui::menu::{DropdownMenu, PopupMenuItem};
|
||||
use ui::notification::Notification;
|
||||
use ui::switch::Switch;
|
||||
@@ -33,6 +33,7 @@ impl Preferences {
|
||||
Self { file_input }
|
||||
}
|
||||
|
||||
/// Update the file server (blossom) URL
|
||||
fn update_file_server(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let value = self.file_input.read(cx).value();
|
||||
|
||||
@@ -45,21 +46,26 @@ impl Preferences {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Set the theme mode (light or dark)
|
||||
fn set_theme_mode(mode: ThemeMode, window: &mut Window, cx: &mut App) {
|
||||
AppSettings::update_theme_mode(mode, cx);
|
||||
Theme::change(mode, Some(window), cx);
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for Preferences {
|
||||
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
const SCREENING: &str =
|
||||
"When opening a request, a popup will appear to help you identify the sender.";
|
||||
const AVATAR: &str =
|
||||
"Hide all avatar pictures to improve performance and protect your privacy.";
|
||||
const MODE: &str =
|
||||
"Choose whether to use the selected light or dark theme, or to follow the OS.";
|
||||
const SCREENING: &str = "Show an screening dialog to verify the unknown sender.";
|
||||
const AVATAR: &str = "Hide all avatar pictures to improve performance.";
|
||||
const MODE: &str = "Use the selected light or dark theme, or to follow the OS.";
|
||||
const NIP4E: &str = "Use a dedicated key to encrypt and decrypt messages.";
|
||||
const AUTH: &str = "Choose the authentication behavior for relays.";
|
||||
const RESET: &str = "Reset the theme to the default one.";
|
||||
|
||||
let screening = AppSettings::get_screening(cx);
|
||||
let hide_avatar = AppSettings::get_hide_avatar(cx);
|
||||
let nip4e = AppSettings::get_nip4e(cx);
|
||||
let auth_mode = AppSettings::get_auth_mode(cx);
|
||||
let theme_mode = AppSettings::get_theme_mode(cx);
|
||||
|
||||
@@ -160,23 +166,16 @@ impl Render for Preferences {
|
||||
.ghost_alt()
|
||||
.small()
|
||||
.dropdown_menu(|this, _window, _cx| {
|
||||
this.min_w(px(256.))
|
||||
.item(PopupMenuItem::new("Light").on_click(
|
||||
|_ev, _window, cx| {
|
||||
AppSettings::update_theme_mode(
|
||||
ThemeMode::Light,
|
||||
cx,
|
||||
);
|
||||
},
|
||||
))
|
||||
.item(PopupMenuItem::new("Dark").on_click(
|
||||
|_ev, _window, cx| {
|
||||
AppSettings::update_theme_mode(
|
||||
ThemeMode::Dark,
|
||||
cx,
|
||||
);
|
||||
},
|
||||
))
|
||||
this.item(PopupMenuItem::new("Light").on_click(
|
||||
|_, window, cx| {
|
||||
Self::set_theme_mode(ThemeMode::Light, window, cx);
|
||||
},
|
||||
))
|
||||
.item(
|
||||
PopupMenuItem::new("Dark").on_click(|_, window, cx| {
|
||||
Self::set_theme_mode(ThemeMode::Dark, window, cx);
|
||||
}),
|
||||
)
|
||||
}),
|
||||
),
|
||||
)
|
||||
@@ -207,6 +206,21 @@ impl Render for Preferences {
|
||||
),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
GroupBox::new()
|
||||
.id("experiments")
|
||||
.title("Experiments")
|
||||
.fill()
|
||||
.child(
|
||||
Switch::new("nip4e")
|
||||
.label("Decoupling Encryption Key")
|
||||
.description(NIP4E)
|
||||
.checked(nip4e)
|
||||
.on_click(move |_, _window, cx| {
|
||||
AppSettings::update_nip4e(!nip4e, cx);
|
||||
}),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
GroupBox::new()
|
||||
.id("media")
|
||||
@@ -218,7 +232,7 @@ impl Render for Preferences {
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_1()
|
||||
.child(TextInput::new(&self.file_input).text_xs().small())
|
||||
.child(Input::new(&self.file_input).text_xs().small())
|
||||
.child(
|
||||
Button::new("update-file-server")
|
||||
.icon(IconName::Check)
|
||||
@@ -44,6 +44,7 @@ fn main() {
|
||||
cx.set_menus(vec![Menu {
|
||||
name: "Coop".into(),
|
||||
items: vec![MenuItem::action("Quit", Quit)],
|
||||
disabled: false,
|
||||
}]);
|
||||
|
||||
// Set up the window bounds
|
||||
@@ -91,14 +92,14 @@ fn main() {
|
||||
// Initialize relay auth registry
|
||||
relay_auth::init(window, cx);
|
||||
|
||||
// Initialize app registry
|
||||
chat::init(window, cx);
|
||||
|
||||
// Initialize device signer
|
||||
//
|
||||
// NIP-4e: https://github.com/nostr-protocol/nips/blob/per-device-keys/4e.md
|
||||
device::init(window, cx);
|
||||
|
||||
// Initialize app registry
|
||||
chat::init(window, cx);
|
||||
|
||||
// Initialize auto update
|
||||
auto_update::init(window, cx);
|
||||
|
||||