chore: Improve Chat Performance (#35)
* refactor * optimistically update message list * fix * update * handle duplicate messages * update ui * refactor input * update multi line input * clean up
This commit is contained in:
780
Cargo.lock
generated
780
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
BIN
assets/brand/group.png
Normal file
BIN
assets/brand/group.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 6.9 KiB |
@@ -1,8 +1,9 @@
|
||||
use std::{
|
||||
cmp::Reverse,
|
||||
collections::{BTreeMap, BTreeSet, HashMap},
|
||||
collections::{BTreeMap, HashMap},
|
||||
};
|
||||
|
||||
use account::Account;
|
||||
use anyhow::Error;
|
||||
use common::room_hash;
|
||||
use fuzzy_matcher::{skim::SkimMatcherV2, FuzzyMatcher};
|
||||
@@ -36,19 +37,21 @@ impl Global for GlobalChatRegistry {}
|
||||
/// - Loading room data from the lmdb
|
||||
/// - Handling messages and room creation
|
||||
pub struct ChatRegistry {
|
||||
/// Collection of all chat rooms
|
||||
rooms: BTreeSet<Entity<Room>>,
|
||||
/// Map of user public keys to their profile metadata
|
||||
profiles: Entity<BTreeMap<PublicKey, Option<Metadata>>>,
|
||||
/// Collection of all chat rooms
|
||||
pub rooms: Vec<Entity<Room>>,
|
||||
/// Indicates if rooms are currently being loaded
|
||||
pub loading: bool,
|
||||
///
|
||||
/// Always equal to `true` when the app starts
|
||||
pub wait_for_eose: bool,
|
||||
/// Subscriptions for observing changes
|
||||
#[allow(dead_code)]
|
||||
subscriptions: SmallVec<[Subscription; 1]>,
|
||||
subscriptions: SmallVec<[Subscription; 2]>,
|
||||
}
|
||||
|
||||
impl ChatRegistry {
|
||||
/// Retrieve the global ChatRegistry instance
|
||||
/// Retrieve the Global ChatRegistry instance
|
||||
pub fn global(cx: &App) -> Entity<Self> {
|
||||
cx.global::<GlobalChatRegistry>().0.clone()
|
||||
}
|
||||
@@ -68,13 +71,21 @@ impl ChatRegistry {
|
||||
let profiles = cx.new(|_| BTreeMap::new());
|
||||
let mut subscriptions = smallvec![];
|
||||
|
||||
// Observe new Room creations to collect profile metadata
|
||||
subscriptions.push(cx.observe_new::<Room>(|this, _, cx| {
|
||||
let task = this.metadata(cx);
|
||||
// When the ChatRegistry is created, load all rooms from the local database
|
||||
subscriptions.push(cx.observe_new::<ChatRegistry>(|this, window, cx| {
|
||||
if let Some(window) = window {
|
||||
this.load_rooms(window, cx);
|
||||
}
|
||||
}));
|
||||
|
||||
cx.spawn(async move |_, cx| {
|
||||
// When any Room is created, load metadata for all members
|
||||
subscriptions.push(cx.observe_new::<Room>(|this, window, cx| {
|
||||
if let Some(window) = window {
|
||||
let task = this.load_metadata(cx);
|
||||
|
||||
cx.spawn_in(window, async move |_, cx| {
|
||||
if let Ok(data) = task.await {
|
||||
cx.update(|cx| {
|
||||
cx.update(|_, cx| {
|
||||
for (public_key, metadata) in data.into_iter() {
|
||||
Self::global(cx).update(cx, |this, cx| {
|
||||
this.add_profile(public_key, metadata, cx);
|
||||
@@ -85,11 +96,12 @@ impl ChatRegistry {
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
}));
|
||||
|
||||
Self {
|
||||
rooms: BTreeSet::new(),
|
||||
loading: true,
|
||||
rooms: vec![],
|
||||
wait_for_eose: true,
|
||||
profiles,
|
||||
subscriptions,
|
||||
}
|
||||
@@ -103,22 +115,13 @@ impl ChatRegistry {
|
||||
.cloned()
|
||||
}
|
||||
|
||||
/// Get all rooms grouped by their kind.
|
||||
pub fn rooms(&self, cx: &App) -> BTreeMap<RoomKind, Vec<Entity<Room>>> {
|
||||
let mut groups = BTreeMap::new();
|
||||
groups.insert(RoomKind::Ongoing, Vec::new());
|
||||
groups.insert(RoomKind::Trusted, Vec::new());
|
||||
groups.insert(RoomKind::Unknown, Vec::new());
|
||||
|
||||
for room in self.rooms.iter() {
|
||||
let kind = room.read(cx).kind;
|
||||
groups
|
||||
.entry(kind)
|
||||
.or_insert_with(Vec::new)
|
||||
.push(room.to_owned());
|
||||
}
|
||||
|
||||
groups
|
||||
/// Get rooms by its kind.
|
||||
pub fn rooms_by_kind(&self, kind: RoomKind, cx: &App) -> Vec<Entity<Room>> {
|
||||
self.rooms
|
||||
.iter()
|
||||
.filter(|room| room.read(cx).kind == kind)
|
||||
.cloned()
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Get the IDs of all rooms.
|
||||
@@ -149,13 +152,20 @@ impl ChatRegistry {
|
||||
/// 3. Determines each room's type based on message frequency and trust status
|
||||
/// 4. Creates Room entities for each unique room
|
||||
pub fn load_rooms(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
// [event] is the Nostr Event
|
||||
// [usize] is the total number of messages, used to determine an ongoing conversation
|
||||
// [bool] is used to determine if the room is trusted
|
||||
type Rooms = Vec<(Event, usize, bool)>;
|
||||
|
||||
let task: Task<Result<Rooms, Error>> = cx.background_spawn(async move {
|
||||
let client = get_client();
|
||||
let signer = client.signer().await?;
|
||||
let public_key = signer.get_public_key().await?;
|
||||
// If the user is not logged in, do nothing
|
||||
let Some(user) = Account::get_global(cx).profile_ref() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let client = get_client();
|
||||
let public_key = user.public_key();
|
||||
|
||||
let task: Task<Result<Rooms, Error>> = cx.background_spawn(async move {
|
||||
// Get messages sent by the user
|
||||
let send = Filter::new()
|
||||
.kind(Kind::PrivateDirectMessage)
|
||||
@@ -179,7 +189,9 @@ impl ChatRegistry {
|
||||
{
|
||||
let hash = room_hash(&event);
|
||||
|
||||
// Check if room's author is seen in any contact list
|
||||
let filter = Filter::new().kind(Kind::ContactList).pubkey(event.pubkey);
|
||||
// If room's author is seen at least once, mark as trusted
|
||||
let is_trust = client.database().count(filter).await? >= 1;
|
||||
|
||||
room_map
|
||||
@@ -202,7 +214,6 @@ impl ChatRegistry {
|
||||
|
||||
cx.spawn_in(window, async move |this, cx| {
|
||||
if let Ok(events) = task.await {
|
||||
cx.update(|_, cx| {
|
||||
this.update(cx, |this, cx| {
|
||||
let ids = this.room_ids(cx);
|
||||
let rooms: Vec<Entity<Room>> = events
|
||||
@@ -226,13 +237,11 @@ impl ChatRegistry {
|
||||
.collect();
|
||||
|
||||
this.rooms.extend(rooms);
|
||||
this.loading = false;
|
||||
this.wait_for_eose = false;
|
||||
|
||||
cx.notify();
|
||||
})
|
||||
.ok();
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
@@ -269,10 +278,24 @@ impl ChatRegistry {
|
||||
Profile::new(*public_key, metadata)
|
||||
}
|
||||
|
||||
/// Parse a Nostr event into a Room and push it to the registry
|
||||
/// Push a Room Entity to the global registry
|
||||
///
|
||||
/// Returns the ID of the room
|
||||
pub fn push_room(&mut self, room: Entity<Room>, cx: &mut Context<Self>) -> u64 {
|
||||
let id = room.read(cx).id;
|
||||
|
||||
if !self.rooms.iter().any(|this| this.read(cx) == room.read(cx)) {
|
||||
self.rooms.insert(0, room);
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
id
|
||||
}
|
||||
|
||||
/// Parse a Nostr event into a Coop Room and push it to the global registry
|
||||
///
|
||||
/// Returns the ID of the new room
|
||||
pub fn push_event(
|
||||
pub fn event_to_room(
|
||||
&mut self,
|
||||
event: &Event,
|
||||
window: &mut Window,
|
||||
@@ -282,7 +305,7 @@ impl ChatRegistry {
|
||||
let id = room.id;
|
||||
|
||||
if !self.rooms.iter().any(|this| this.read(cx) == &room) {
|
||||
self.rooms.insert(cx.new(|_| room));
|
||||
self.rooms.insert(0, cx.new(|_| room));
|
||||
cx.notify();
|
||||
} else {
|
||||
window.push_notification("Room already exists", cx);
|
||||
@@ -291,38 +314,25 @@ impl ChatRegistry {
|
||||
id
|
||||
}
|
||||
|
||||
/// Parse a nostr event into Room and push to the registry
|
||||
///
|
||||
/// Returns the ID of the new room
|
||||
pub fn push_room(&mut self, room: Entity<Room>, cx: &mut Context<Self>) -> u64 {
|
||||
let id = room.read(cx).id;
|
||||
|
||||
if !self.rooms.iter().any(|this| this.read(cx) == room.read(cx)) {
|
||||
self.rooms.insert(room);
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
id
|
||||
}
|
||||
|
||||
/// Push a new message to a room
|
||||
/// Parse a Nostr event into a Coop Message and push it to the belonging room
|
||||
///
|
||||
/// If the room doesn't exist, it will be created.
|
||||
/// Updates room ordering based on the most recent messages.
|
||||
pub fn push_message(&mut self, event: Event, window: &mut Window, cx: &mut Context<Self>) {
|
||||
pub fn event_to_message(&mut self, event: Event, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let id = room_hash(&event);
|
||||
|
||||
if let Some(room) = self.rooms.iter().find(|room| room.read(cx).id == id) {
|
||||
room.update(cx, |this, cx| {
|
||||
this.created_at(event.created_at, cx);
|
||||
|
||||
// Emit the new message to the room
|
||||
cx.defer_in(window, |this, window, cx| {
|
||||
this.emit_message(event, window, cx);
|
||||
});
|
||||
});
|
||||
cx.notify();
|
||||
} else {
|
||||
// Push the new room to the front of the list
|
||||
self.rooms.insert(cx.new(|_| Room::new(&event)));
|
||||
self.rooms.insert(0, cx.new(|_| Room::new(&event)));
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@ use chrono::{Local, TimeZone};
|
||||
use gpui::SharedString;
|
||||
use nostr_sdk::prelude::*;
|
||||
|
||||
use crate::room::SendError;
|
||||
|
||||
/// # Message
|
||||
///
|
||||
/// Represents a message in the application.
|
||||
@@ -18,8 +20,9 @@ pub struct Message {
|
||||
pub id: EventId,
|
||||
pub content: String,
|
||||
pub author: Profile,
|
||||
pub mentions: Vec<Profile>,
|
||||
pub created_at: Timestamp,
|
||||
pub mentions: Vec<Profile>,
|
||||
pub errors: Option<Vec<SendError>>,
|
||||
}
|
||||
|
||||
impl Message {
|
||||
@@ -42,23 +45,10 @@ impl Message {
|
||||
author,
|
||||
created_at,
|
||||
mentions: vec![],
|
||||
errors: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Adds or replaces mentions in the message
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `mentions` - New list of mentioned profiles
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// The same message with updated mentions
|
||||
pub fn with_mentions(mut self, mentions: impl IntoIterator<Item = Profile>) -> Self {
|
||||
self.mentions.extend(mentions);
|
||||
self
|
||||
}
|
||||
|
||||
/// Formats the message timestamp as a human-readable relative time
|
||||
///
|
||||
/// # Returns
|
||||
@@ -85,6 +75,34 @@ impl Message {
|
||||
}
|
||||
.into()
|
||||
}
|
||||
|
||||
/// Adds or replaces mentions in the message
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `mentions` - New list of mentioned profiles
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// The same message with updated mentions
|
||||
pub fn with_mentions(mut self, mentions: impl IntoIterator<Item = Profile>) -> Self {
|
||||
self.mentions.extend(mentions);
|
||||
self
|
||||
}
|
||||
|
||||
/// Adds or replaces errors in the message
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `errors` - New list of errors
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// The same message with updated errors
|
||||
pub fn with_errors(mut self, errors: Vec<SendError>) -> Self {
|
||||
self.errors = Some(errors);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// # RoomMessage
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
use std::{cmp::Ordering, sync::Arc};
|
||||
|
||||
use account::Account;
|
||||
use anyhow::Error;
|
||||
use anyhow::{anyhow, Error};
|
||||
use chrono::{Local, TimeZone};
|
||||
use common::{compare, profile::SharedProfile, room_hash};
|
||||
use global::get_client;
|
||||
use gpui::{App, AppContext, Context, EventEmitter, SharedString, Task, Window};
|
||||
use itertools::Itertools;
|
||||
use nostr_sdk::prelude::*;
|
||||
use smol::channel::Receiver;
|
||||
|
||||
use crate::{
|
||||
constants::{DAYS_IN_MONTH, HOURS_IN_DAY, MINUTES_IN_HOUR, NOW, SECONDS_IN_MINUTE},
|
||||
@@ -17,14 +16,12 @@ use crate::{
|
||||
};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct IncomingEvent {
|
||||
pub event: RoomMessage,
|
||||
}
|
||||
pub struct Incoming(pub Message);
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum SendStatus {
|
||||
Sent(EventId),
|
||||
Failed(Error),
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct SendError {
|
||||
pub profile: Profile,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Hash, Debug, PartialEq, Eq, PartialOrd, Ord, Default)]
|
||||
@@ -69,7 +66,7 @@ impl PartialEq for Room {
|
||||
}
|
||||
}
|
||||
|
||||
impl EventEmitter<IncomingEvent> for Room {}
|
||||
impl EventEmitter<Incoming> for Room {}
|
||||
|
||||
impl Room {
|
||||
/// Creates a new Room instance from a Nostr event
|
||||
@@ -87,6 +84,7 @@ impl Room {
|
||||
|
||||
// Get all pubkeys from the event's tags
|
||||
let mut pubkeys: Vec<PublicKey> = event.tags.public_keys().cloned().collect();
|
||||
// The author is always put at the end of the vector
|
||||
pubkeys.push(event.pubkey);
|
||||
|
||||
// Convert pubkeys into members
|
||||
@@ -267,11 +265,13 @@ impl Room {
|
||||
/// An Option<SharedString> containing the avatar:
|
||||
/// - For a direct message: the other person's avatar
|
||||
/// - For a group chat: None
|
||||
pub fn display_image(&self, cx: &App) -> Option<SharedString> {
|
||||
if !self.is_group() {
|
||||
Some(self.first_member(cx).shared_avatar())
|
||||
pub fn display_image(&self, cx: &App) -> SharedString {
|
||||
if let Some(picture) = self.picture.as_ref() {
|
||||
picture.clone()
|
||||
} else if !self.is_group() {
|
||||
self.first_member(cx).shared_avatar()
|
||||
} else {
|
||||
None
|
||||
"brand/group.png".into()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -327,12 +327,12 @@ impl Room {
|
||||
///
|
||||
/// A Task that resolves to Result<Vec<(PublicKey, Option<Metadata>)>, Error>
|
||||
#[allow(clippy::type_complexity)]
|
||||
pub fn metadata(
|
||||
pub fn load_metadata(
|
||||
&self,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Task<Result<Vec<(PublicKey, Option<Metadata>)>, Error>> {
|
||||
let client = get_client();
|
||||
let public_keys = self.members.clone();
|
||||
let public_keys = Arc::clone(&self.members);
|
||||
|
||||
cx.background_spawn(async move {
|
||||
let mut output = vec![];
|
||||
@@ -378,76 +378,6 @@ impl Room {
|
||||
})
|
||||
}
|
||||
|
||||
/// Sends a message to all members in the room
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `content` - The content of the message to send
|
||||
/// * `cx` - The App context
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// A Task that resolves to Result<Vec<String>, Error> where the
|
||||
/// strings contain error messages for any failed sends
|
||||
pub fn send_message(&self, content: String, cx: &App) -> Option<Receiver<SendStatus>> {
|
||||
let profile = Account::global(cx).read(cx).profile.clone()?;
|
||||
let public_key = profile.public_key();
|
||||
|
||||
let subject = self.subject.clone();
|
||||
let picture = self.picture.clone();
|
||||
let pubkeys = self.members.clone();
|
||||
|
||||
let (tx, rx) = smol::channel::bounded::<SendStatus>(pubkeys.len());
|
||||
|
||||
cx.background_spawn(async move {
|
||||
let client = get_client();
|
||||
|
||||
let mut tags: Vec<Tag> = pubkeys
|
||||
.iter()
|
||||
.filter_map(|pubkey| {
|
||||
if pubkey != &public_key {
|
||||
Some(Tag::public_key(*pubkey))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Add subject tag if it's present
|
||||
if let Some(subject) = subject {
|
||||
tags.push(Tag::from_standardized(TagStandard::Subject(
|
||||
subject.to_string(),
|
||||
)));
|
||||
}
|
||||
|
||||
// Add picture tag if it's present
|
||||
if let Some(picture) = picture {
|
||||
tags.push(Tag::custom(TagKind::custom("picture"), vec![picture]));
|
||||
}
|
||||
|
||||
for pubkey in pubkeys.iter() {
|
||||
match client
|
||||
.send_private_msg(*pubkey, &content, tags.clone())
|
||||
.await
|
||||
{
|
||||
Ok(output) => {
|
||||
if let Err(e) = tx.send(SendStatus::Sent(output.val)).await {
|
||||
log::error!("Failed to send message to {}: {}", pubkey, e);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
if let Err(e) = tx.send(SendStatus::Failed(e.into())).await {
|
||||
log::error!("Failed to send message to {}: {}", pubkey, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
|
||||
Some(rx)
|
||||
}
|
||||
|
||||
/// Loads all messages for this room from the database
|
||||
///
|
||||
/// # Arguments
|
||||
@@ -461,7 +391,6 @@ impl Room {
|
||||
pub fn load_messages(&self, cx: &App) -> Task<Result<Vec<RoomMessage>, Error>> {
|
||||
let client = get_client();
|
||||
let pubkeys = Arc::clone(&self.members);
|
||||
|
||||
let profiles: Vec<Profile> = pubkeys
|
||||
.iter()
|
||||
.map(|pubkey| ChatRegistry::global(cx).read(cx).profile(pubkey, cx))
|
||||
@@ -492,11 +421,11 @@ impl Room {
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
for event in events.into_iter() {
|
||||
let mut mentions = vec![];
|
||||
let id = event.id;
|
||||
let created_at = event.created_at;
|
||||
let content = event.content.clone();
|
||||
let tokens = parser.parse(&content);
|
||||
let mut mentions = vec![];
|
||||
|
||||
let author = profiles
|
||||
.iter()
|
||||
@@ -545,27 +474,150 @@ impl Room {
|
||||
///
|
||||
/// # Effects
|
||||
///
|
||||
/// Processes the event and emits an IncomingEvent to the UI when complete
|
||||
pub fn emit_message(&self, event: Event, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let pubkeys = self.members.clone();
|
||||
let profiles: Vec<Profile> = pubkeys
|
||||
/// Processes the event and emits an Incoming to the UI when complete
|
||||
pub fn emit_message(&self, event: Event, _window: &mut Window, cx: &mut Context<Self>) {
|
||||
let author = ChatRegistry::get_global(cx).profile(&event.pubkey, cx);
|
||||
let mentions = extract_mentions(&event.content, cx);
|
||||
let message =
|
||||
Message::new(event.id, event.content, author, event.created_at).with_mentions(mentions);
|
||||
|
||||
cx.emit(Incoming(message));
|
||||
}
|
||||
|
||||
/// Creates a temporary message for optimistic updates
|
||||
///
|
||||
/// This constructs an unsigned message with the current user as the author,
|
||||
/// extracts any mentions from the content, and packages it as a Message struct.
|
||||
/// The message will have a generated ID but hasn't been published to relays.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `content` - The message content text
|
||||
/// * `cx` - The application context containing user profile information
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// Returns `Some(Message)` containing the temporary message if the current user's profile is available,
|
||||
/// or `None` if no account is found.
|
||||
pub fn create_temp_message(&self, content: &str, cx: &App) -> Option<Message> {
|
||||
let profile = Account::get_global(cx).profile.clone()?;
|
||||
let public_key = profile.public_key();
|
||||
let builder = EventBuilder::private_msg_rumor(public_key, content);
|
||||
|
||||
// Create a unsigned event to convert to Coop Message
|
||||
let mut event = builder.build(public_key);
|
||||
event.ensure_id();
|
||||
|
||||
// Extract all mentions from content
|
||||
let mentions = extract_mentions(&event.content, cx);
|
||||
|
||||
Some(
|
||||
Message::new(event.id.unwrap(), event.content, profile, event.created_at)
|
||||
.with_mentions(mentions),
|
||||
)
|
||||
}
|
||||
|
||||
/// Sends a message to all members in the background task
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `content` - The content of the message to send
|
||||
/// * `cx` - The App context
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// A Task that resolves to Result<Vec<String>, Error> where the
|
||||
/// strings contain error messages for any failed sends
|
||||
pub fn send_in_background(&self, msg: &str, cx: &App) -> Task<Result<Vec<SendError>, Error>> {
|
||||
let content = msg.to_owned();
|
||||
let subject = self.subject.clone();
|
||||
let picture = self.picture.clone();
|
||||
let public_keys = Arc::clone(&self.members);
|
||||
|
||||
cx.background_spawn(async move {
|
||||
let client = get_client();
|
||||
let signer = client.signer().await?;
|
||||
let public_key = signer.get_public_key().await?;
|
||||
let mut reports = vec![];
|
||||
|
||||
let mut tags: Vec<Tag> = public_keys
|
||||
.iter()
|
||||
.map(|pubkey| ChatRegistry::global(cx).read(cx).profile(pubkey, cx))
|
||||
.filter_map(|pubkey| {
|
||||
if pubkey != &public_key {
|
||||
Some(Tag::public_key(*pubkey))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
let task: Task<Result<RoomMessage, Error>> = cx.background_spawn(async move {
|
||||
// Add subject tag if it's present
|
||||
if let Some(subject) = subject {
|
||||
tags.push(Tag::from_standardized(TagStandard::Subject(
|
||||
subject.to_string(),
|
||||
)));
|
||||
}
|
||||
|
||||
// Add picture tag if it's present
|
||||
if let Some(picture) = picture {
|
||||
tags.push(Tag::custom(TagKind::custom("picture"), vec![picture]));
|
||||
}
|
||||
|
||||
let Some((current_user, receivers)) = public_keys.split_last() else {
|
||||
return Err(anyhow!("Something is wrong. Cannot get receivers list."));
|
||||
};
|
||||
|
||||
for receiver in receivers.iter() {
|
||||
if let Err(e) = client
|
||||
.send_private_msg(*receiver, &content, tags.clone())
|
||||
.await
|
||||
{
|
||||
let metadata = client
|
||||
.database()
|
||||
.metadata(*receiver)
|
||||
.await?
|
||||
.unwrap_or_default();
|
||||
let profile = Profile::new(*receiver, metadata);
|
||||
let report = SendError {
|
||||
profile,
|
||||
message: e.to_string(),
|
||||
};
|
||||
|
||||
reports.push(report);
|
||||
}
|
||||
}
|
||||
|
||||
// Only send a backup message to current user if there are no issues when sending to others
|
||||
if reports.is_empty() {
|
||||
if let Err(e) = client
|
||||
.send_private_msg(*current_user, &content, tags.clone())
|
||||
.await
|
||||
{
|
||||
let metadata = client
|
||||
.database()
|
||||
.metadata(*current_user)
|
||||
.await?
|
||||
.unwrap_or_default();
|
||||
let profile = Profile::new(*current_user, metadata);
|
||||
let report = SendError {
|
||||
profile,
|
||||
message: e.to_string(),
|
||||
};
|
||||
reports.push(report);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(reports)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub fn extract_mentions(content: &str, cx: &App) -> Vec<Profile> {
|
||||
let parser = NostrParser::new();
|
||||
let id = event.id;
|
||||
let created_at = event.created_at;
|
||||
let content = event.content.clone();
|
||||
let tokens = parser.parse(&content);
|
||||
let tokens = parser.parse(content);
|
||||
let mut mentions = vec![];
|
||||
|
||||
let author = profiles
|
||||
.iter()
|
||||
.find(|profile| profile.public_key() == event.pubkey)
|
||||
.cloned()
|
||||
.unwrap_or_else(|| Profile::new(event.pubkey, Metadata::default()));
|
||||
let profiles = ChatRegistry::get_global(cx).profiles.read(cx);
|
||||
|
||||
let pubkey_tokens = tokens
|
||||
.filter_map(|token| match token {
|
||||
@@ -578,33 +630,11 @@ impl Room {
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
for pubkey in pubkey_tokens {
|
||||
mentions.push(
|
||||
profiles
|
||||
.iter()
|
||||
.find(|profile| profile.public_key() == pubkey)
|
||||
.cloned()
|
||||
.unwrap_or_else(|| Profile::new(pubkey, Metadata::default())),
|
||||
);
|
||||
for pubkey in pubkey_tokens.into_iter() {
|
||||
if let Some(metadata) = profiles.get(&pubkey).cloned() {
|
||||
mentions.push(Profile::new(pubkey, metadata.unwrap_or_default()));
|
||||
}
|
||||
}
|
||||
|
||||
let message = Message::new(id, content, author, created_at).with_mentions(mentions);
|
||||
let room_message = RoomMessage::user(message);
|
||||
|
||||
Ok(room_message)
|
||||
});
|
||||
|
||||
cx.spawn_in(window, async move |this, cx| {
|
||||
if let Ok(message) = task.await {
|
||||
cx.update(|_, cx| {
|
||||
this.update(cx, |_, cx| {
|
||||
cx.emit(IncomingEvent { event: message });
|
||||
})
|
||||
.ok();
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
mentions
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ use std::{
|
||||
};
|
||||
|
||||
use global::constants::NIP96_SERVER;
|
||||
use gpui::Image;
|
||||
use gpui::{Image, ImageFormat};
|
||||
use itertools::Itertools;
|
||||
use nostr_sdk::prelude::*;
|
||||
use qrcode_generator::QrCodeEcc;
|
||||
@@ -42,13 +42,9 @@ pub fn room_hash(event: &Event) -> u64 {
|
||||
hasher.finish()
|
||||
}
|
||||
|
||||
pub fn create_qr(data: &str) -> Result<Arc<Image>, anyhow::Error> {
|
||||
let qr = qrcode_generator::to_png_to_vec_from_str(data, QrCodeEcc::Medium, 256)?;
|
||||
let img = Arc::new(Image {
|
||||
format: gpui::ImageFormat::Png,
|
||||
bytes: qr.clone(),
|
||||
id: 1,
|
||||
});
|
||||
pub fn string_to_qr(data: &str) -> Result<Arc<Image>, anyhow::Error> {
|
||||
let bytes = qrcode_generator::to_png_to_vec_from_str(data, QrCodeEcc::Medium, 256)?;
|
||||
let img = Arc::new(Image::from_bytes(ImageFormat::Png, bytes));
|
||||
|
||||
Ok(img)
|
||||
}
|
||||
|
||||
@@ -20,7 +20,10 @@ use ui::{
|
||||
|
||||
use crate::{
|
||||
lru_cache::cache_provider,
|
||||
views::{chat, compose, login, new_account, onboarding, profile, relays, sidebar, welcome},
|
||||
views::{
|
||||
chat::{self, Chat},
|
||||
compose, login, new_account, onboarding, profile, relays, sidebar, welcome,
|
||||
},
|
||||
};
|
||||
|
||||
const IMAGE_CACHE_SIZE: usize = 200;
|
||||
@@ -79,7 +82,7 @@ pub struct ChatSpace {
|
||||
titlebar: bool,
|
||||
dock: Entity<DockArea>,
|
||||
#[allow(unused)]
|
||||
subscriptions: SmallVec<[Subscription; 1]>,
|
||||
subscriptions: SmallVec<[Subscription; 2]>,
|
||||
}
|
||||
|
||||
impl ChatSpace {
|
||||
@@ -109,6 +112,12 @@ impl ChatSpace {
|
||||
},
|
||||
));
|
||||
|
||||
subscriptions.push(cx.observe_new::<Chat>(|this, window, cx| {
|
||||
if let Some(window) = window {
|
||||
this.load_messages(window, cx);
|
||||
}
|
||||
}));
|
||||
|
||||
Self {
|
||||
dock,
|
||||
subscriptions,
|
||||
|
||||
@@ -315,28 +315,25 @@ fn main() {
|
||||
let auto_updater = AutoUpdater::global(cx);
|
||||
|
||||
match signal {
|
||||
Signal::Eose => {
|
||||
chats.update(cx, |this, cx| {
|
||||
this.load_rooms(window, cx);
|
||||
});
|
||||
}
|
||||
Signal::Event(event) => {
|
||||
chats.update(cx, |this, cx| {
|
||||
this.push_message(event, window, cx)
|
||||
this.event_to_message(event, window, cx);
|
||||
});
|
||||
}
|
||||
Signal::Metadata(data) => {
|
||||
chats.update(cx, |this, cx| {
|
||||
this.add_profile(data.0, data.1, cx)
|
||||
});
|
||||
}
|
||||
Signal::Eose => {
|
||||
chats.update(cx, |this, cx| {
|
||||
// This function maybe called multiple times
|
||||
// TODO: only handle the last EOSE signal
|
||||
this.load_rooms(window, cx)
|
||||
this.add_profile(data.0, data.1, cx);
|
||||
});
|
||||
}
|
||||
Signal::AppUpdates(event) => {
|
||||
// TODO: add settings for auto updates
|
||||
auto_updater.update(cx, |this, cx| {
|
||||
this.update(event, cx);
|
||||
})
|
||||
});
|
||||
}
|
||||
};
|
||||
})
|
||||
|
||||
@@ -3,19 +3,20 @@ use std::{collections::HashMap, sync::Arc};
|
||||
use anyhow::{anyhow, Error};
|
||||
use async_utility::task::spawn;
|
||||
use chats::{
|
||||
message::RoomMessage,
|
||||
room::{Room, SendStatus},
|
||||
message::{Message, RoomMessage},
|
||||
room::Room,
|
||||
ChatRegistry,
|
||||
};
|
||||
use common::{nip96_upload, profile::SharedProfile};
|
||||
use global::{constants::IMAGE_SERVICE, get_client};
|
||||
use gpui::{
|
||||
div, img, impl_internal_actions, list, prelude::FluentBuilder, px, red, relative, svg, white,
|
||||
AnyElement, App, AppContext, Context, Element, Empty, Entity, EventEmitter, Flatten,
|
||||
AnyElement, App, AppContext, Context, Div, Element, Empty, Entity, EventEmitter, Flatten,
|
||||
FocusHandle, Focusable, InteractiveElement, IntoElement, ListAlignment, ListState, ObjectFit,
|
||||
ParentElement, PathPromptOptions, Render, SharedString, StatefulInteractiveElement, Styled,
|
||||
StyledImage, Subscription, Window,
|
||||
};
|
||||
use itertools::Itertools;
|
||||
use nostr_sdk::prelude::*;
|
||||
use serde::Deserialize;
|
||||
use smallvec::{smallvec, SmallVec};
|
||||
@@ -25,16 +26,15 @@ use ui::{
|
||||
button::{Button, ButtonVariants},
|
||||
dock_area::panel::{Panel, PanelEvent},
|
||||
emoji_picker::EmojiPicker,
|
||||
input::{InputEvent, TextInput},
|
||||
input::{InputEvent, InputState, TextInput},
|
||||
notification::Notification,
|
||||
popup_menu::PopupMenu,
|
||||
text::RichText,
|
||||
v_flex, ContextModal, Disableable, Icon, IconName, Sizable, Size, StyledExt,
|
||||
v_flex, ContextModal, Disableable, Icon, IconName, Sizable, StyledExt,
|
||||
};
|
||||
|
||||
use crate::views::subject;
|
||||
|
||||
const ALERT: &str = "has not set up Messaging (DM) Relays, so they will NOT receive your messages.";
|
||||
const DESC: &str = "This conversation is private. Only members can see each other's messages.";
|
||||
|
||||
#[derive(Clone, PartialEq, Eq, Deserialize)]
|
||||
@@ -60,10 +60,10 @@ pub struct Chat {
|
||||
text_data: HashMap<EventId, RichText>,
|
||||
list_state: ListState,
|
||||
// New Message
|
||||
input: Entity<TextInput>,
|
||||
input: Entity<InputState>,
|
||||
// Media Attachment
|
||||
attaches: Entity<Option<Vec<Url>>>,
|
||||
is_uploading: bool,
|
||||
uploading: bool,
|
||||
#[allow(dead_code)]
|
||||
subscriptions: SmallVec<[Subscription; 2]>,
|
||||
}
|
||||
@@ -73,9 +73,13 @@ impl Chat {
|
||||
let messages = cx.new(|_| vec![RoomMessage::announcement()]);
|
||||
let attaches = cx.new(|_| None);
|
||||
let input = cx.new(|cx| {
|
||||
TextInput::new(window, cx)
|
||||
.text_size(Size::Small)
|
||||
InputState::new(window, cx)
|
||||
.placeholder("Message...")
|
||||
.multi_line()
|
||||
.prevent_new_line_on_enter()
|
||||
.rows(1)
|
||||
.clean_on_escape()
|
||||
.max_rows(20)
|
||||
});
|
||||
|
||||
cx.new(|cx| {
|
||||
@@ -84,19 +88,38 @@ impl Chat {
|
||||
subscriptions.push(cx.subscribe_in(
|
||||
&input,
|
||||
window,
|
||||
move |this: &mut Self, _, event, window, cx| {
|
||||
if let InputEvent::PressEnter = event {
|
||||
move |this: &mut Self, input, event, window, cx| {
|
||||
if let InputEvent::PressEnter { .. } = event {
|
||||
if input.read(cx).value().trim().is_empty() {
|
||||
window.push_notification("Cannot send an empty message", cx);
|
||||
} else {
|
||||
this.send_message(window, cx);
|
||||
}
|
||||
}
|
||||
},
|
||||
));
|
||||
|
||||
subscriptions.push(cx.subscribe_in(
|
||||
&room,
|
||||
window,
|
||||
move |this, _, event, _window, cx| {
|
||||
subscriptions.push(
|
||||
cx.subscribe_in(&room, window, move |this, _, incoming, _w, cx| {
|
||||
let created_at = &incoming.0.created_at.to_string()[..5];
|
||||
let content = incoming.0.content.as_str();
|
||||
let author = incoming.0.author.public_key();
|
||||
|
||||
// Check if the incoming message is the same as the new message created by optimistic update
|
||||
if this.messages.read(cx).iter().any(|msg| {
|
||||
if let RoomMessage::User(m) = msg {
|
||||
created_at == &m.created_at.to_string()[..5]
|
||||
&& m.content == content
|
||||
&& m.author.public_key() == author
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}) {
|
||||
return;
|
||||
}
|
||||
|
||||
let old_len = this.messages.read(cx).len();
|
||||
let message = event.event.clone();
|
||||
let message = RoomMessage::user(incoming.0.clone());
|
||||
|
||||
cx.update_entity(&this.messages, |this, cx| {
|
||||
this.extend(vec![message]);
|
||||
@@ -104,8 +127,8 @@ impl Chat {
|
||||
});
|
||||
|
||||
this.list_state.splice(old_len..old_len, 1);
|
||||
},
|
||||
));
|
||||
}),
|
||||
);
|
||||
|
||||
// Initialize list state
|
||||
// [item_count] always equal to 1 at the beginning
|
||||
@@ -119,9 +142,9 @@ impl Chat {
|
||||
}
|
||||
});
|
||||
|
||||
let this = Self {
|
||||
Self {
|
||||
focus_handle: cx.focus_handle(),
|
||||
is_uploading: false,
|
||||
uploading: false,
|
||||
id: id.to_string().into(),
|
||||
text_data: HashMap::new(),
|
||||
room,
|
||||
@@ -130,51 +153,18 @@ impl Chat {
|
||||
input,
|
||||
attaches,
|
||||
subscriptions,
|
||||
};
|
||||
|
||||
// Verify messaging relays of all members
|
||||
this.verify_messaging_relays(window, cx);
|
||||
|
||||
// Load all messages from database
|
||||
this.load_messages(window, cx);
|
||||
|
||||
this
|
||||
})
|
||||
}
|
||||
|
||||
fn verify_messaging_relays(&self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let room = self.room.read(cx);
|
||||
let task = room.messaging_relays(cx);
|
||||
|
||||
cx.spawn_in(window, async move |this, cx| {
|
||||
if let Ok(result) = task.await {
|
||||
this.update(cx, |this, cx| {
|
||||
result.into_iter().for_each(|item| {
|
||||
if !item.1 {
|
||||
let profile = this
|
||||
.room
|
||||
.read_with(cx, |this, _| this.profile_by_pubkey(&item.0, cx));
|
||||
|
||||
this.push_system_message(
|
||||
format!("{} {}", profile.shared_name(), ALERT),
|
||||
cx,
|
||||
);
|
||||
}
|
||||
});
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
fn load_messages(&self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
/// Load all messages belonging to this room
|
||||
pub(crate) fn load_messages(&self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let room = self.room.read(cx);
|
||||
let task = room.load_messages(cx);
|
||||
|
||||
cx.spawn_in(window, async move |this, cx| {
|
||||
if let Ok(events) = task.await {
|
||||
cx.update(|_, cx| {
|
||||
match task.await {
|
||||
Ok(events) => {
|
||||
this.update(cx, |this, cx| {
|
||||
let old_len = this.messages.read(cx).len();
|
||||
let new_len = events.len();
|
||||
@@ -191,13 +181,104 @@ impl Chat {
|
||||
cx.notify();
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
Err(e) => {
|
||||
cx.update(|window, cx| {
|
||||
window.push_notification(Notification::error(e.to_string()), cx);
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
/// Get user input message including all attachments
|
||||
fn message(&self, cx: &Context<Self>) -> String {
|
||||
let mut content = self.input.read(cx).value().trim().to_string();
|
||||
|
||||
// Get all attaches and merge its with message
|
||||
if let Some(attaches) = self.attaches.read(cx).as_ref() {
|
||||
if !attaches.is_empty() {
|
||||
content = format!(
|
||||
"{}\n{}",
|
||||
content,
|
||||
attaches
|
||||
.iter()
|
||||
.map(|url| url.to_string())
|
||||
.collect_vec()
|
||||
.join("\n")
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
content
|
||||
}
|
||||
|
||||
fn send_message(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
self.input.update(cx, |this, cx| {
|
||||
this.set_loading(true, cx);
|
||||
this.set_disabled(true, cx);
|
||||
});
|
||||
|
||||
let content = self.message(cx);
|
||||
let room = self.room.read(cx);
|
||||
let temp_message = room.create_temp_message(&content, cx);
|
||||
let send_message = room.send_in_background(&content, cx);
|
||||
|
||||
if let Some(message) = temp_message {
|
||||
let id = message.id;
|
||||
// Optimistically update message list
|
||||
self.push_user_message(message, cx);
|
||||
|
||||
// Reset the input state
|
||||
self.input.update(cx, |this, cx| {
|
||||
this.set_loading(false, cx);
|
||||
this.set_disabled(false, cx);
|
||||
this.set_value("", window, cx);
|
||||
});
|
||||
|
||||
// Continue sending the message in the background
|
||||
cx.spawn_in(window, async move |this, cx| {
|
||||
if let Ok(reports) = send_message.await {
|
||||
if !reports.is_empty() {
|
||||
this.update(cx, |this, cx| {
|
||||
this.messages.update(cx, |this, cx| {
|
||||
if let Some(msg) = this.iter_mut().find(|msg| {
|
||||
if let RoomMessage::User(m) = msg {
|
||||
m.id == id
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}) {
|
||||
if let RoomMessage::User(this) = msg {
|
||||
this.errors = Some(reports)
|
||||
}
|
||||
cx.notify();
|
||||
}
|
||||
});
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
}
|
||||
|
||||
fn push_user_message(&self, message: Message, cx: &mut Context<Self>) {
|
||||
let old_len = self.messages.read(cx).len();
|
||||
let message = RoomMessage::user(message);
|
||||
|
||||
cx.update_entity(&self.messages, |this, cx| {
|
||||
this.extend(vec![message]);
|
||||
cx.notify();
|
||||
});
|
||||
|
||||
self.list_state.splice(old_len..old_len, 1);
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
fn push_system_message(&self, content: String, cx: &mut Context<Self>) {
|
||||
let old_len = self.messages.read(cx).len();
|
||||
let message = RoomMessage::system(content.into());
|
||||
@@ -210,87 +291,19 @@ impl Chat {
|
||||
self.list_state.splice(old_len..old_len, 1);
|
||||
}
|
||||
|
||||
fn send_message(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let mut content = self.input.read(cx).text().to_string();
|
||||
|
||||
// Get all attaches and merge its with message
|
||||
if let Some(attaches) = self.attaches.read(cx).as_ref() {
|
||||
let merged = attaches
|
||||
.iter()
|
||||
.map(|url| url.to_string())
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
|
||||
content = format!("{}\n{}", content, merged)
|
||||
}
|
||||
|
||||
// Check if content is empty
|
||||
if content.is_empty() {
|
||||
window.push_notification("Cannot send an empty message", cx);
|
||||
fn upload_media(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
if self.uploading {
|
||||
return;
|
||||
}
|
||||
|
||||
// Update input state
|
||||
self.input.update(cx, |this, cx| {
|
||||
this.set_loading(true, cx);
|
||||
this.set_disabled(true, cx);
|
||||
});
|
||||
self.uploading(true, cx);
|
||||
|
||||
let room = self.room.read(cx);
|
||||
let task = room.send_message(content, cx);
|
||||
|
||||
cx.spawn_in(window, async move |this, cx| {
|
||||
let mut received = false;
|
||||
|
||||
match task {
|
||||
Some(rx) => {
|
||||
while let Ok(message) = rx.recv().await {
|
||||
if let SendStatus::Failed(error) = message {
|
||||
cx.update(|window, cx| {
|
||||
window.push_notification(
|
||||
Notification::error(error.to_string())
|
||||
.title("Message Failed to Send"),
|
||||
cx,
|
||||
);
|
||||
})
|
||||
.ok();
|
||||
} else if !received {
|
||||
cx.update(|window, cx| {
|
||||
this.update(cx, |this, cx| {
|
||||
this.input.update(cx, |this, cx| {
|
||||
this.set_loading(false, cx);
|
||||
this.set_disabled(false, cx);
|
||||
this.set_text("", window, cx);
|
||||
});
|
||||
received = true;
|
||||
})
|
||||
.ok();
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
}
|
||||
}
|
||||
None => {
|
||||
cx.update(|window, cx| {
|
||||
window.push_notification(Notification::error("User is not logged in"), cx);
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
fn upload_media(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let paths = cx.prompt_for_paths(PathPromptOptions {
|
||||
files: true,
|
||||
directories: false,
|
||||
multiple: false,
|
||||
});
|
||||
|
||||
// Show loading spinner
|
||||
self.set_loading(true, cx);
|
||||
|
||||
cx.spawn_in(window, async move |this, cx| {
|
||||
match Flatten::flatten(paths.await.map_err(|e| e.into())) {
|
||||
Ok(Some(mut paths)) => {
|
||||
@@ -300,20 +313,24 @@ impl Chat {
|
||||
|
||||
if let Ok(file_data) = fs::read(path).await {
|
||||
let client = get_client();
|
||||
let (tx, rx) = oneshot::channel::<Url>();
|
||||
let (tx, rx) = oneshot::channel::<Option<Url>>();
|
||||
|
||||
// spawn task via async_utility
|
||||
// Spawn task via async utility instead of GPUI context
|
||||
spawn(async move {
|
||||
if let Ok(url) = nip96_upload(client, file_data).await {
|
||||
_ = tx.send(url);
|
||||
let url = match nip96_upload(client, file_data).await {
|
||||
Ok(url) => Some(url),
|
||||
Err(e) => {
|
||||
log::error!("Upload error: {e}");
|
||||
None
|
||||
}
|
||||
};
|
||||
|
||||
_ = tx.send(url);
|
||||
});
|
||||
|
||||
if let Ok(url) = rx.await {
|
||||
cx.update(|_, cx| {
|
||||
if let Ok(Some(url)) = rx.await {
|
||||
this.update(cx, |this, cx| {
|
||||
this.set_loading(false, cx);
|
||||
|
||||
this.uploading(false, cx);
|
||||
this.attaches.update(cx, |this, cx| {
|
||||
if let Some(model) = this.as_mut() {
|
||||
model.push(url);
|
||||
@@ -324,21 +341,23 @@ impl Chat {
|
||||
});
|
||||
})
|
||||
.ok();
|
||||
} else {
|
||||
this.update(cx, |this, cx| {
|
||||
this.uploading(false, cx);
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(None) => {
|
||||
cx.update(|_, cx| {
|
||||
this.update(cx, |this, cx| {
|
||||
this.set_loading(false, cx);
|
||||
})
|
||||
.ok();
|
||||
this.uploading(false, cx);
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
Err(_) => {}
|
||||
Err(e) => {
|
||||
log::error!("System error: {e}")
|
||||
}
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
@@ -355,8 +374,8 @@ impl Chat {
|
||||
});
|
||||
}
|
||||
|
||||
fn set_loading(&mut self, status: bool, cx: &mut Context<Self>) {
|
||||
self.is_uploading = status;
|
||||
fn uploading(&mut self, status: bool, cx: &mut Context<Self>) {
|
||||
self.uploading = status;
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
@@ -370,7 +389,18 @@ impl Chat {
|
||||
return div().into_element();
|
||||
};
|
||||
|
||||
let text_data = &mut self.text_data;
|
||||
match message {
|
||||
RoomMessage::User(item) => self.render_user_msg(item, window, cx),
|
||||
RoomMessage::System(content) => self.render_system_msg(content, cx),
|
||||
RoomMessage::Announcement => self.render_announcement_msg(cx),
|
||||
}
|
||||
}
|
||||
|
||||
fn render_user_msg(&mut self, item: &Message, window: &mut Window, cx: &Context<Self>) -> Div {
|
||||
let texts = self
|
||||
.text_data
|
||||
.entry(item.id)
|
||||
.or_insert_with(|| RichText::new(item.content.to_owned(), &item.mentions));
|
||||
|
||||
div()
|
||||
.group("")
|
||||
@@ -380,14 +410,7 @@ impl Chat {
|
||||
.gap_3()
|
||||
.px_3()
|
||||
.py_2()
|
||||
.map(|this| match message {
|
||||
RoomMessage::User(item) => {
|
||||
let text = text_data
|
||||
.entry(item.id)
|
||||
.or_insert_with(|| RichText::new(item.content.to_owned(), &item.mentions));
|
||||
|
||||
this.hover(|this| this.bg(cx.theme().surface_background))
|
||||
.text_sm()
|
||||
.hover(|this| this.bg(cx.theme().surface_background))
|
||||
.child(
|
||||
div()
|
||||
.absolute()
|
||||
@@ -410,8 +433,12 @@ impl Chat {
|
||||
.flex()
|
||||
.items_baseline()
|
||||
.gap_2()
|
||||
.text_sm()
|
||||
.child(
|
||||
div().font_semibold().child(item.author.shared_name()),
|
||||
div()
|
||||
.font_semibold()
|
||||
.text_color(cx.theme().text)
|
||||
.child(item.author.shared_name()),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
@@ -419,10 +446,57 @@ impl Chat {
|
||||
.child(item.ago()),
|
||||
),
|
||||
)
|
||||
.child(text.element("body".into(), window, cx)),
|
||||
.child(texts.element("body".into(), window, cx))
|
||||
.when_some(item.errors.clone(), |this, errors| {
|
||||
this.child(
|
||||
div()
|
||||
.id("")
|
||||
.flex()
|
||||
.items_center()
|
||||
.gap_1()
|
||||
.text_color(gpui::red())
|
||||
.text_xs()
|
||||
.italic()
|
||||
.child(Icon::new(IconName::Info).small())
|
||||
.child("Failed to send message. Click to see details.")
|
||||
.on_click(move |_, window, cx| {
|
||||
let errors = errors.clone();
|
||||
|
||||
window.open_modal(cx, move |this, _window, cx| {
|
||||
this.title("Error Logs").child(
|
||||
div().flex().flex_col().gap_2().px_3().pb_3().children(
|
||||
errors.clone().into_iter().map(|error| {
|
||||
div()
|
||||
.text_sm()
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.items_baseline()
|
||||
.gap_1()
|
||||
.text_color(cx.theme().text_muted)
|
||||
.child("Send to:")
|
||||
.child(error.profile.shared_name()),
|
||||
)
|
||||
.child(error.message)
|
||||
}),
|
||||
),
|
||||
)
|
||||
});
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
}
|
||||
RoomMessage::System(content) => this
|
||||
|
||||
fn render_system_msg(&mut self, content: &SharedString, cx: &Context<Self>) -> Div {
|
||||
div()
|
||||
.group("")
|
||||
.w_full()
|
||||
.relative()
|
||||
.flex()
|
||||
.gap_3()
|
||||
.px_3()
|
||||
.py_2()
|
||||
.items_center()
|
||||
.child(
|
||||
div()
|
||||
@@ -437,8 +511,18 @@ impl Chat {
|
||||
.child(img("brand/avatar.png").size_8().flex_shrink_0())
|
||||
.text_sm()
|
||||
.text_color(red())
|
||||
.child(content.clone()),
|
||||
RoomMessage::Announcement => this
|
||||
.child(content.clone())
|
||||
}
|
||||
|
||||
fn render_announcement_msg(&mut self, cx: &Context<Self>) -> Div {
|
||||
div()
|
||||
.group("")
|
||||
.w_full()
|
||||
.relative()
|
||||
.flex()
|
||||
.gap_3()
|
||||
.px_3()
|
||||
.py_2()
|
||||
.w_full()
|
||||
.h_32()
|
||||
.flex()
|
||||
@@ -455,8 +539,7 @@ impl Chat {
|
||||
.size_10()
|
||||
.text_color(cx.theme().elevated_surface_background),
|
||||
)
|
||||
.child(DESC),
|
||||
})
|
||||
.child(DESC)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -474,27 +557,7 @@ impl Panel for Chat {
|
||||
.flex()
|
||||
.items_center()
|
||||
.gap_1p5()
|
||||
.map(|this| {
|
||||
if let Some(url) = url {
|
||||
this.child(img(url).size_5().flex_shrink_0())
|
||||
} else {
|
||||
this.child(
|
||||
div()
|
||||
.flex_shrink_0()
|
||||
.flex()
|
||||
.justify_center()
|
||||
.items_center()
|
||||
.size_5()
|
||||
.rounded_full()
|
||||
.bg(cx.theme().element_disabled)
|
||||
.child(
|
||||
Icon::new(IconName::UsersThreeFill)
|
||||
.xsmall()
|
||||
.text_color(cx.theme().icon_accent),
|
||||
),
|
||||
)
|
||||
}
|
||||
})
|
||||
.child(img(url).size_5().flex_shrink_0())
|
||||
.child(label)
|
||||
.into_any()
|
||||
})
|
||||
@@ -592,7 +655,7 @@ impl Render for Chat {
|
||||
div()
|
||||
.w_full()
|
||||
.flex()
|
||||
.items_center()
|
||||
.items_end()
|
||||
.gap_2p5()
|
||||
.child(
|
||||
div()
|
||||
@@ -604,8 +667,8 @@ impl Render for Chat {
|
||||
Button::new("upload")
|
||||
.icon(Icon::new(IconName::Upload))
|
||||
.ghost()
|
||||
.disabled(self.is_uploading)
|
||||
.loading(self.is_uploading)
|
||||
.disabled(self.uploading)
|
||||
.loading(self.uploading)
|
||||
.on_click(cx.listener(
|
||||
move |this, _, window, cx| {
|
||||
this.upload_media(window, cx);
|
||||
@@ -617,7 +680,7 @@ impl Render for Chat {
|
||||
.icon(IconName::EmojiFill),
|
||||
),
|
||||
)
|
||||
.child(self.input.clone()),
|
||||
.child(TextInput::new(&self.input)),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -21,8 +21,8 @@ use theme::ActiveTheme;
|
||||
use ui::{
|
||||
button::{Button, ButtonVariants},
|
||||
dock_area::dock::DockPlacement,
|
||||
input::{InputEvent, TextInput},
|
||||
ContextModal, Disableable, Icon, IconName, Sizable, Size, StyledExt,
|
||||
input::{InputEvent, InputState, TextInput},
|
||||
ContextModal, Disableable, Icon, IconName, Sizable, StyledExt,
|
||||
};
|
||||
|
||||
use crate::chatspace::{AddPanel, PanelKind};
|
||||
@@ -37,8 +37,8 @@ struct SelectContact(PublicKey);
|
||||
impl_internal_actions!(contacts, [SelectContact]);
|
||||
|
||||
pub struct Compose {
|
||||
title_input: Entity<TextInput>,
|
||||
user_input: Entity<TextInput>,
|
||||
title_input: Entity<InputState>,
|
||||
user_input: Entity<InputState>,
|
||||
contacts: Entity<Vec<Profile>>,
|
||||
selected: Entity<HashSet<PublicKey>>,
|
||||
focus_handle: FocusHandle,
|
||||
@@ -55,18 +55,9 @@ impl Compose {
|
||||
let selected = cx.new(|_| HashSet::new());
|
||||
let error_message = cx.new(|_| None);
|
||||
|
||||
let title_input = cx.new(|cx| {
|
||||
TextInput::new(window, cx)
|
||||
.appearance(false)
|
||||
.placeholder("Family... . (Optional)")
|
||||
.text_size(Size::Small)
|
||||
});
|
||||
|
||||
let user_input = cx.new(|cx| {
|
||||
TextInput::new(window, cx)
|
||||
.text_size(ui::Size::Small)
|
||||
.placeholder("npub1...")
|
||||
});
|
||||
let user_input = cx.new(|cx| InputState::new(window, cx).placeholder("npub1..."));
|
||||
let title_input =
|
||||
cx.new(|cx| InputState::new(window, cx).placeholder("Family...(Optional)"));
|
||||
|
||||
let mut subscriptions = smallvec![];
|
||||
|
||||
@@ -75,7 +66,7 @@ impl Compose {
|
||||
&user_input,
|
||||
window,
|
||||
move |this, _, input_event, window, cx| {
|
||||
if let InputEvent::PressEnter = input_event {
|
||||
if let InputEvent::PressEnter { .. } = input_event {
|
||||
this.add(window, cx);
|
||||
}
|
||||
},
|
||||
@@ -135,10 +126,10 @@ impl Compose {
|
||||
let mut tag_list: Vec<Tag> = pubkeys.iter().map(|pk| Tag::public_key(*pk)).collect();
|
||||
|
||||
// Add subject if it is present
|
||||
if !self.title_input.read(cx).text().is_empty() {
|
||||
if !self.title_input.read(cx).value().is_empty() {
|
||||
tag_list.push(Tag::custom(
|
||||
TagKind::Subject,
|
||||
vec![self.title_input.read(cx).text().to_string()],
|
||||
vec![self.title_input.read(cx).value().to_string()],
|
||||
));
|
||||
}
|
||||
|
||||
@@ -163,7 +154,7 @@ impl Compose {
|
||||
Ok(event) => {
|
||||
cx.update(|window, cx| {
|
||||
ChatRegistry::global(cx).update(cx, |chats, cx| {
|
||||
let id = chats.push_event(&event, window, cx);
|
||||
let id = chats.event_to_room(&event, window, cx);
|
||||
window.close_modal(cx);
|
||||
window.dispatch_action(
|
||||
Box::new(AddPanel::new(PanelKind::Room(id), DockPlacement::Center)),
|
||||
@@ -185,7 +176,7 @@ impl Compose {
|
||||
|
||||
fn add(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let client = get_client();
|
||||
let content = self.user_input.read(cx).text().to_string();
|
||||
let content = self.user_input.read(cx).value().to_string();
|
||||
|
||||
// Show loading spinner
|
||||
self.set_loading(true, cx);
|
||||
@@ -241,8 +232,7 @@ impl Compose {
|
||||
|
||||
// Clear input
|
||||
this.user_input.update(cx, |this, cx| {
|
||||
this.set_text("", window, cx);
|
||||
cx.notify();
|
||||
this.set_value("", window, cx);
|
||||
});
|
||||
})
|
||||
.ok();
|
||||
@@ -349,7 +339,7 @@ impl Render for Compose {
|
||||
.items_center()
|
||||
.gap_1()
|
||||
.child(div().pb_0p5().text_sm().font_semibold().child("Subject:"))
|
||||
.child(self.title_input.clone()),
|
||||
.child(TextInput::new(&self.title_input).small().appearance(false)),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
@@ -365,7 +355,7 @@ impl Render for Compose {
|
||||
.flex_col()
|
||||
.gap_2()
|
||||
.child(div().text_sm().font_semibold().child("To:"))
|
||||
.child(self.user_input.clone()),
|
||||
.child(TextInput::new(&self.user_input).small()),
|
||||
)
|
||||
.map(|this| {
|
||||
let contacts = self.contacts.read(cx).clone();
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use std::{sync::Arc, time::Duration};
|
||||
|
||||
use account::Account;
|
||||
use common::create_qr;
|
||||
use common::string_to_qr;
|
||||
use global::get_client_keys;
|
||||
use gpui::{
|
||||
div, img, prelude::FluentBuilder, red, relative, AnyElement, App, AppContext, Context, Entity,
|
||||
@@ -14,10 +14,10 @@ use theme::ActiveTheme;
|
||||
use ui::{
|
||||
button::{Button, ButtonVariants},
|
||||
dock_area::panel::{Panel, PanelEvent},
|
||||
input::{InputEvent, TextInput},
|
||||
input::{InputEvent, InputState, TextInput},
|
||||
notification::Notification,
|
||||
popup_menu::PopupMenu,
|
||||
ContextModal, Disableable, Sizable, Size, StyledExt,
|
||||
ContextModal, Disableable, Sizable, StyledExt,
|
||||
};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -38,12 +38,12 @@ pub fn init(window: &mut Window, cx: &mut App) -> Entity<Login> {
|
||||
|
||||
pub struct Login {
|
||||
// Inputs
|
||||
key_input: Entity<TextInput>,
|
||||
key_input: Entity<InputState>,
|
||||
error: Entity<Option<SharedString>>,
|
||||
is_logging_in: bool,
|
||||
// Nostr Connect
|
||||
qr: Entity<Option<Arc<Image>>>,
|
||||
connect_relay: Entity<TextInput>,
|
||||
connect_relay: Entity<InputState>,
|
||||
connect_client: Entity<Option<NostrConnectURI>>,
|
||||
// Keep track of all signers created by nostr connect
|
||||
signers: SmallVec<[NostrConnect; 3]>,
|
||||
@@ -66,26 +66,19 @@ impl Login {
|
||||
let error = cx.new(|_| None);
|
||||
let qr = cx.new(|_| None);
|
||||
|
||||
let key_input =
|
||||
cx.new(|cx| InputState::new(window, cx).placeholder("nsec... or bunker://..."));
|
||||
let connect_relay =
|
||||
cx.new(|cx| InputState::new(window, cx).default_value("wss://relay.nsec.app"));
|
||||
|
||||
let signers = smallvec![];
|
||||
let mut subscriptions = smallvec![];
|
||||
|
||||
let key_input = cx.new(|cx| {
|
||||
TextInput::new(window, cx)
|
||||
.text_size(Size::Small)
|
||||
.placeholder("nsec... or bunker://...")
|
||||
});
|
||||
|
||||
let connect_relay = cx.new(|cx| {
|
||||
let mut input = TextInput::new(window, cx).text_size(Size::XSmall).small();
|
||||
input.set_text("wss://relay.nsec.app", window, cx);
|
||||
input
|
||||
});
|
||||
|
||||
subscriptions.push(cx.subscribe_in(
|
||||
&key_input,
|
||||
window,
|
||||
move |this, _, event, window, cx| {
|
||||
if let InputEvent::PressEnter = event {
|
||||
if let InputEvent::PressEnter { .. } = event {
|
||||
this.login(window, cx);
|
||||
}
|
||||
},
|
||||
@@ -95,7 +88,7 @@ impl Login {
|
||||
&connect_relay,
|
||||
window,
|
||||
move |this, _, event, window, cx| {
|
||||
if let InputEvent::PressEnter = event {
|
||||
if let InputEvent::PressEnter { .. } = event {
|
||||
this.change_relay(window, cx);
|
||||
}
|
||||
},
|
||||
@@ -106,7 +99,7 @@ impl Login {
|
||||
let keys = get_client_keys().to_owned();
|
||||
|
||||
if let Some(uri) = uri.read(cx).clone() {
|
||||
if let Ok(qr) = create_qr(uri.to_string().as_str()) {
|
||||
if let Ok(qr) = string_to_qr(uri.to_string().as_str()) {
|
||||
this.qr.update(cx, |this, cx| {
|
||||
*this = Some(qr);
|
||||
cx.notify();
|
||||
@@ -179,7 +172,7 @@ impl Login {
|
||||
|
||||
self.set_logging_in(true, cx);
|
||||
|
||||
let content = self.key_input.read(cx).text();
|
||||
let content = self.key_input.read(cx).value();
|
||||
let account = Account::global(cx);
|
||||
|
||||
if content.starts_with("nsec1") {
|
||||
@@ -212,7 +205,7 @@ impl Login {
|
||||
|
||||
fn change_relay(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let Ok(relay_url) =
|
||||
RelayUrl::parse(self.connect_relay.read(cx).text().to_string().as_str())
|
||||
RelayUrl::parse(self.connect_relay.read(cx).value().to_string().as_str())
|
||||
else {
|
||||
window.push_notification(Notification::error("Relay URL is not valid."), cx);
|
||||
return;
|
||||
@@ -316,7 +309,7 @@ impl Render for Login {
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_3()
|
||||
.child(self.key_input.clone())
|
||||
.child(TextInput::new(&self.key_input))
|
||||
.child(
|
||||
Button::new("login")
|
||||
.label("Continue")
|
||||
@@ -401,7 +394,7 @@ impl Render for Login {
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.gap_1()
|
||||
.child(self.connect_relay.clone())
|
||||
.child(TextInput::new(&self.connect_relay).xsmall())
|
||||
.child(
|
||||
Button::new("change")
|
||||
.label("Change")
|
||||
|
||||
@@ -15,9 +15,9 @@ use theme::ActiveTheme;
|
||||
use ui::{
|
||||
button::{Button, ButtonVariants},
|
||||
dock_area::panel::{Panel, PanelEvent},
|
||||
input::TextInput,
|
||||
input::{InputState, TextInput},
|
||||
popup_menu::PopupMenu,
|
||||
Disableable, Icon, IconName, Sizable, Size, StyledExt,
|
||||
Disableable, Icon, IconName, Sizable, StyledExt,
|
||||
};
|
||||
|
||||
pub fn init(window: &mut Window, cx: &mut App) -> Entity<NewAccount> {
|
||||
@@ -25,9 +25,9 @@ pub fn init(window: &mut Window, cx: &mut App) -> Entity<NewAccount> {
|
||||
}
|
||||
|
||||
pub struct NewAccount {
|
||||
name_input: Entity<TextInput>,
|
||||
avatar_input: Entity<TextInput>,
|
||||
bio_input: Entity<TextInput>,
|
||||
name_input: Entity<InputState>,
|
||||
avatar_input: Entity<InputState>,
|
||||
bio_input: Entity<InputState>,
|
||||
is_uploading: bool,
|
||||
is_submitting: bool,
|
||||
// Panel
|
||||
@@ -43,22 +43,11 @@ impl NewAccount {
|
||||
}
|
||||
|
||||
fn view(window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||
let name_input = cx.new(|cx| {
|
||||
TextInput::new(window, cx)
|
||||
.text_size(Size::Small)
|
||||
.placeholder("Alice")
|
||||
});
|
||||
|
||||
let avatar_input = cx.new(|cx| {
|
||||
TextInput::new(window, cx)
|
||||
.text_size(Size::Small)
|
||||
.small()
|
||||
.placeholder("https://example.com/avatar.jpg")
|
||||
});
|
||||
|
||||
let name_input = cx.new(|cx| InputState::new(window, cx).placeholder("Alice"));
|
||||
let avatar_input =
|
||||
cx.new(|cx| InputState::new(window, cx).placeholder("https://example.com/avatar.jpg"));
|
||||
let bio_input = cx.new(|cx| {
|
||||
TextInput::new(window, cx)
|
||||
.text_size(Size::Small)
|
||||
InputState::new(window, cx)
|
||||
.multi_line()
|
||||
.placeholder("A short introduce about you.")
|
||||
});
|
||||
@@ -79,9 +68,9 @@ impl NewAccount {
|
||||
fn submit(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
self.set_submitting(true, cx);
|
||||
|
||||
let avatar = self.avatar_input.read(cx).text().to_string();
|
||||
let name = self.name_input.read(cx).text().to_string();
|
||||
let bio = self.bio_input.read(cx).text().to_string();
|
||||
let avatar = self.avatar_input.read(cx).value().to_string();
|
||||
let name = self.name_input.read(cx).value().to_string();
|
||||
let bio = self.bio_input.read(cx).value().to_string();
|
||||
|
||||
let mut metadata = Metadata::new().display_name(name).about(bio);
|
||||
|
||||
@@ -140,7 +129,7 @@ impl NewAccount {
|
||||
// Set avatar input
|
||||
avatar_input
|
||||
.update(cx, |this, cx| {
|
||||
this.set_text(url.to_string(), window, cx);
|
||||
this.set_value(url.to_string(), window, cx);
|
||||
})
|
||||
.ok();
|
||||
})
|
||||
@@ -241,14 +230,14 @@ impl Render for NewAccount {
|
||||
.justify_center()
|
||||
.gap_2()
|
||||
.map(|this| {
|
||||
if self.avatar_input.read(cx).text().is_empty() {
|
||||
if self.avatar_input.read(cx).value().is_empty() {
|
||||
this.child(img("brand/avatar.png").size_10().flex_shrink_0())
|
||||
} else {
|
||||
this.child(
|
||||
img(format!(
|
||||
"{}/?url={}&w=100&h=100&fit=cover&mask=circle&n=-1",
|
||||
IMAGE_SERVICE,
|
||||
self.avatar_input.read(cx).text()
|
||||
self.avatar_input.read(cx).value()
|
||||
))
|
||||
.size_10()
|
||||
.flex_shrink_0(),
|
||||
@@ -275,7 +264,7 @@ impl Render for NewAccount {
|
||||
.gap_1()
|
||||
.text_sm()
|
||||
.child("Name *:")
|
||||
.child(self.name_input.clone()),
|
||||
.child(TextInput::new(&self.name_input).small()),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
@@ -284,7 +273,7 @@ impl Render for NewAccount {
|
||||
.gap_1()
|
||||
.text_sm()
|
||||
.child("Bio:")
|
||||
.child(self.bio_input.clone()),
|
||||
.child(TextInput::new(&self.bio_input).small()),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
|
||||
@@ -11,8 +11,8 @@ use std::{str::FromStr, time::Duration};
|
||||
use theme::ActiveTheme;
|
||||
use ui::{
|
||||
button::{Button, ButtonVariants},
|
||||
input::TextInput,
|
||||
ContextModal, Disableable, IconName, Sizable, Size,
|
||||
input::{InputState, TextInput},
|
||||
ContextModal, Disableable, IconName, Sizable,
|
||||
};
|
||||
|
||||
pub fn init(window: &mut Window, cx: &mut App) -> Entity<Profile> {
|
||||
@@ -21,38 +21,23 @@ pub fn init(window: &mut Window, cx: &mut App) -> Entity<Profile> {
|
||||
|
||||
pub struct Profile {
|
||||
profile: Option<Metadata>,
|
||||
name_input: Entity<TextInput>,
|
||||
avatar_input: Entity<TextInput>,
|
||||
bio_input: Entity<TextInput>,
|
||||
website_input: Entity<TextInput>,
|
||||
name_input: Entity<InputState>,
|
||||
avatar_input: Entity<InputState>,
|
||||
bio_input: Entity<InputState>,
|
||||
website_input: Entity<InputState>,
|
||||
is_loading: bool,
|
||||
is_submitting: bool,
|
||||
}
|
||||
|
||||
impl Profile {
|
||||
pub fn new(window: &mut Window, cx: &mut App) -> Entity<Self> {
|
||||
let name_input = cx.new(|cx| {
|
||||
TextInput::new(window, cx)
|
||||
.text_size(Size::Small)
|
||||
.placeholder("Alice")
|
||||
});
|
||||
|
||||
let avatar_input = cx.new(|cx| {
|
||||
TextInput::new(window, cx)
|
||||
.text_size(Size::XSmall)
|
||||
.small()
|
||||
.placeholder("https://example.com/avatar.jpg")
|
||||
});
|
||||
|
||||
let website_input = cx.new(|cx| {
|
||||
TextInput::new(window, cx)
|
||||
.text_size(Size::Small)
|
||||
.placeholder("https://your-website.com")
|
||||
});
|
||||
|
||||
let name_input = cx.new(|cx| InputState::new(window, cx).placeholder("Alice"));
|
||||
let avatar_input =
|
||||
cx.new(|cx| InputState::new(window, cx).placeholder("https://example.com/avatar.jpg"));
|
||||
let website_input =
|
||||
cx.new(|cx| InputState::new(window, cx).placeholder("https://your-website.com"));
|
||||
let bio_input = cx.new(|cx| {
|
||||
TextInput::new(window, cx)
|
||||
.text_size(Size::Small)
|
||||
InputState::new(window, cx)
|
||||
.multi_line()
|
||||
.placeholder("A short introduce about you.")
|
||||
});
|
||||
@@ -85,26 +70,25 @@ impl Profile {
|
||||
this.update(cx, |this: &mut Profile, cx| {
|
||||
this.avatar_input.update(cx, |this, cx| {
|
||||
if let Some(avatar) = metadata.picture.as_ref() {
|
||||
this.set_text(avatar, window, cx);
|
||||
this.set_value(avatar, window, cx);
|
||||
}
|
||||
});
|
||||
this.bio_input.update(cx, |this, cx| {
|
||||
if let Some(bio) = metadata.about.as_ref() {
|
||||
this.set_text(bio, window, cx);
|
||||
this.set_value(bio, window, cx);
|
||||
}
|
||||
});
|
||||
this.name_input.update(cx, |this, cx| {
|
||||
if let Some(display_name) = metadata.display_name.as_ref() {
|
||||
this.set_text(display_name, window, cx);
|
||||
this.set_value(display_name, window, cx);
|
||||
}
|
||||
});
|
||||
this.website_input.update(cx, |this, cx| {
|
||||
if let Some(website) = metadata.website.as_ref() {
|
||||
this.set_text(website, window, cx);
|
||||
this.set_value(website, window, cx);
|
||||
}
|
||||
});
|
||||
this.profile = Some(metadata);
|
||||
|
||||
cx.notify();
|
||||
})
|
||||
.ok();
|
||||
@@ -155,7 +139,7 @@ impl Profile {
|
||||
// Set avatar input
|
||||
avatar_input
|
||||
.update(cx, |this, cx| {
|
||||
this.set_text(url.to_string(), window, cx);
|
||||
this.set_value(url.to_string(), window, cx);
|
||||
})
|
||||
.ok();
|
||||
})
|
||||
@@ -183,10 +167,10 @@ impl Profile {
|
||||
// Show loading spinner
|
||||
self.set_submitting(true, cx);
|
||||
|
||||
let avatar = self.avatar_input.read(cx).text().to_string();
|
||||
let name = self.name_input.read(cx).text().to_string();
|
||||
let bio = self.bio_input.read(cx).text().to_string();
|
||||
let website = self.website_input.read(cx).text().to_string();
|
||||
let avatar = self.avatar_input.read(cx).value().to_string();
|
||||
let name = self.name_input.read(cx).value().to_string();
|
||||
let bio = self.bio_input.read(cx).value().to_string();
|
||||
let website = self.website_input.read(cx).value().to_string();
|
||||
|
||||
let old_metadata = if let Some(metadata) = self.profile.as_ref() {
|
||||
metadata.clone()
|
||||
@@ -257,16 +241,14 @@ impl Render for Profile {
|
||||
.justify_center()
|
||||
.gap_2()
|
||||
.map(|this| {
|
||||
let picture = self.avatar_input.read(cx).text();
|
||||
|
||||
let picture = self.avatar_input.read(cx).value();
|
||||
if picture.is_empty() {
|
||||
this.child(img("brand/avatar.png").size_10().flex_shrink_0())
|
||||
} else {
|
||||
this.child(
|
||||
img(format!(
|
||||
"{}/?url={}&w=100&h=100&fit=cover&mask=circle&n=-1",
|
||||
IMAGE_SERVICE,
|
||||
self.avatar_input.read(cx).text()
|
||||
IMAGE_SERVICE, picture
|
||||
))
|
||||
.size_10()
|
||||
.flex_shrink_0(),
|
||||
@@ -293,7 +275,7 @@ impl Render for Profile {
|
||||
.gap_1()
|
||||
.text_sm()
|
||||
.child("Name:")
|
||||
.child(self.name_input.clone()),
|
||||
.child(TextInput::new(&self.name_input).small()),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
@@ -302,7 +284,7 @@ impl Render for Profile {
|
||||
.gap_1()
|
||||
.text_sm()
|
||||
.child("Website:")
|
||||
.child(self.website_input.clone()),
|
||||
.child(TextInput::new(&self.website_input).small()),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
@@ -311,7 +293,7 @@ impl Render for Profile {
|
||||
.gap_1()
|
||||
.text_sm()
|
||||
.child("Bio:")
|
||||
.child(self.bio_input.clone()),
|
||||
.child(TextInput::new(&self.bio_input).small()),
|
||||
)
|
||||
.child(
|
||||
div().py_3().child(
|
||||
|
||||
@@ -10,7 +10,7 @@ use smallvec::{smallvec, SmallVec};
|
||||
use theme::ActiveTheme;
|
||||
use ui::{
|
||||
button::{Button, ButtonVariants},
|
||||
input::{InputEvent, TextInput},
|
||||
input::{InputEvent, InputState, TextInput},
|
||||
ContextModal, Disableable, IconName, Sizable,
|
||||
};
|
||||
|
||||
@@ -24,7 +24,7 @@ pub fn init(window: &mut Window, cx: &mut App) -> Entity<Relays> {
|
||||
|
||||
pub struct Relays {
|
||||
relays: Entity<Vec<RelayUrl>>,
|
||||
input: Entity<TextInput>,
|
||||
input: Entity<InputState>,
|
||||
focus_handle: FocusHandle,
|
||||
is_loading: bool,
|
||||
#[allow(dead_code)]
|
||||
@@ -33,13 +33,7 @@ pub struct Relays {
|
||||
|
||||
impl Relays {
|
||||
pub fn new(window: &mut Window, cx: &mut App) -> Entity<Self> {
|
||||
let input = cx.new(|cx| {
|
||||
TextInput::new(window, cx)
|
||||
.text_size(ui::Size::XSmall)
|
||||
.small()
|
||||
.placeholder("wss://example.com")
|
||||
});
|
||||
|
||||
let input = cx.new(|cx| InputState::new(window, cx).placeholder("wss://example.com"));
|
||||
let relays = cx.new(|cx| {
|
||||
let task: Task<Result<Vec<RelayUrl>, Error>> = cx.background_spawn(async move {
|
||||
let client = get_client();
|
||||
@@ -92,8 +86,8 @@ impl Relays {
|
||||
subscriptions.push(cx.subscribe_in(
|
||||
&input,
|
||||
window,
|
||||
move |this: &mut Relays, _, input_event, window, cx| {
|
||||
if let InputEvent::PressEnter = input_event {
|
||||
move |this: &mut Relays, _, event, window, cx| {
|
||||
if let InputEvent::PressEnter { .. } = event {
|
||||
this.add(window, cx);
|
||||
}
|
||||
},
|
||||
@@ -190,7 +184,7 @@ impl Relays {
|
||||
}
|
||||
|
||||
fn add(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let value = self.input.read(cx).text().to_string();
|
||||
let value = self.input.read(cx).value().to_string();
|
||||
|
||||
if !value.starts_with("ws") {
|
||||
return;
|
||||
@@ -205,7 +199,7 @@ impl Relays {
|
||||
});
|
||||
|
||||
self.input.update(cx, |this, cx| {
|
||||
this.set_text("", window, cx);
|
||||
this.set_value("", window, cx);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -313,7 +307,7 @@ impl Render for Relays {
|
||||
.items_center()
|
||||
.w_full()
|
||||
.gap_2()
|
||||
.child(self.input.clone())
|
||||
.child(TextInput::new(&self.input).small())
|
||||
.child(
|
||||
Button::new("add_relay_btn")
|
||||
.icon(IconName::Plus)
|
||||
|
||||
@@ -264,8 +264,8 @@ impl FolderItem {
|
||||
self
|
||||
}
|
||||
|
||||
pub fn img(mut self, img: Option<Img>) -> Self {
|
||||
self.img = img;
|
||||
pub fn img(mut self, img: Img) -> Self {
|
||||
self.img = Some(img);
|
||||
self
|
||||
}
|
||||
|
||||
@@ -286,49 +286,43 @@ impl RenderOnce for FolderItem {
|
||||
.id(self.ix)
|
||||
.flex()
|
||||
.items_center()
|
||||
.justify_between()
|
||||
.gap_2()
|
||||
.text_sm()
|
||||
.rounded(cx.theme().radius)
|
||||
.child(div().size_6().flex_none().map(|this| {
|
||||
if let Some(img) = self.img {
|
||||
this.child(img.size_6().flex_none())
|
||||
} else {
|
||||
this.child(
|
||||
div()
|
||||
.size_6()
|
||||
.flex_none()
|
||||
.flex()
|
||||
.justify_center()
|
||||
.items_center()
|
||||
.rounded_full()
|
||||
.bg(cx.theme().element_background),
|
||||
)
|
||||
}
|
||||
}))
|
||||
.child(
|
||||
div()
|
||||
.flex_1()
|
||||
.flex()
|
||||
.items_center()
|
||||
.gap_2()
|
||||
.truncate()
|
||||
.font_medium()
|
||||
.map(|this| {
|
||||
if let Some(img) = self.img {
|
||||
this.child(img.size_6().flex_shrink_0())
|
||||
} else {
|
||||
this.child(
|
||||
div()
|
||||
.flex_shrink_0()
|
||||
.flex()
|
||||
.justify_center()
|
||||
.items_center()
|
||||
.size_5()
|
||||
.rounded_full()
|
||||
.bg(cx.theme().element_disabled)
|
||||
.child(
|
||||
Icon::new(IconName::UsersThreeFill)
|
||||
.xsmall()
|
||||
.text_color(cx.theme().text_accent),
|
||||
),
|
||||
)
|
||||
}
|
||||
.justify_between()
|
||||
.when_some(self.label, |this, label| {
|
||||
this.child(div().truncate().text_ellipsis().font_medium().child(label))
|
||||
})
|
||||
.when_some(self.label, |this, label| this.child(label)),
|
||||
)
|
||||
.when_some(self.description, |this, description| {
|
||||
this.child(
|
||||
div()
|
||||
.flex_shrink_0()
|
||||
.text_xs()
|
||||
.text_color(cx.theme().text_placeholder)
|
||||
.child(description),
|
||||
)
|
||||
})
|
||||
}),
|
||||
)
|
||||
.hover(|this| this.bg(cx.theme().elevated_surface_background))
|
||||
.on_click(move |ev, window, cx| handler(ev, window, cx))
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
use std::{
|
||||
cmp::Reverse,
|
||||
collections::{BTreeSet, HashSet},
|
||||
time::Duration,
|
||||
};
|
||||
@@ -29,7 +28,7 @@ use ui::{
|
||||
dock::DockPlacement,
|
||||
panel::{Panel, PanelEvent},
|
||||
},
|
||||
input::{InputEvent, TextInput},
|
||||
input::{InputEvent, InputState, TextInput},
|
||||
popup_menu::{PopupMenu, PopupMenuExt},
|
||||
skeleton::Skeleton,
|
||||
IconName, Sizable, StyledExt,
|
||||
@@ -61,13 +60,13 @@ pub enum SubItem {
|
||||
pub struct Sidebar {
|
||||
name: SharedString,
|
||||
// Search
|
||||
find_input: Entity<TextInput>,
|
||||
find_input: Entity<InputState>,
|
||||
find_debouncer: DebouncedDelay<Self>,
|
||||
finding: bool,
|
||||
local_result: Entity<Option<Vec<Entity<Room>>>>,
|
||||
global_result: Entity<Option<Vec<Entity<Room>>>>,
|
||||
// Layout
|
||||
split_into_folders: bool,
|
||||
folders: bool,
|
||||
active_items: HashSet<Item>,
|
||||
active_subitems: HashSet<SubItem>,
|
||||
// GPUI
|
||||
@@ -95,32 +94,16 @@ impl Sidebar {
|
||||
|
||||
let local_result = cx.new(|_| None);
|
||||
let global_result = cx.new(|_| None);
|
||||
let find_input = cx.new(|cx| {
|
||||
TextInput::new(window, cx)
|
||||
.small()
|
||||
.text_size(ui::Size::XSmall)
|
||||
.suffix(|window, cx| {
|
||||
Button::new("find")
|
||||
.icon(IconName::Search)
|
||||
.tooltip("Press Enter to search")
|
||||
.small()
|
||||
.custom(
|
||||
ButtonCustomVariant::new(window, cx)
|
||||
.active(gpui::transparent_black())
|
||||
.color(gpui::transparent_black())
|
||||
.hover(gpui::transparent_black())
|
||||
.foreground(cx.theme().text_placeholder),
|
||||
)
|
||||
})
|
||||
.placeholder("Find or start a conversation")
|
||||
});
|
||||
|
||||
let find_input =
|
||||
cx.new(|cx| InputState::new(window, cx).placeholder("Find or start a conversation"));
|
||||
|
||||
let mut subscriptions = smallvec![];
|
||||
|
||||
subscriptions.push(
|
||||
cx.subscribe_in(&find_input, window, |this, _, event, _, cx| {
|
||||
match event {
|
||||
InputEvent::PressEnter => this.search(cx),
|
||||
InputEvent::PressEnter { .. } => this.search(cx),
|
||||
InputEvent::Change(text) => {
|
||||
// Clear the result when input is empty
|
||||
if text.is_empty() {
|
||||
@@ -141,7 +124,7 @@ impl Sidebar {
|
||||
|
||||
Self {
|
||||
name: "Chat Sidebar".into(),
|
||||
split_into_folders: false,
|
||||
folders: false,
|
||||
find_debouncer: DebouncedDelay::new(),
|
||||
finding: false,
|
||||
find_input,
|
||||
@@ -170,7 +153,7 @@ impl Sidebar {
|
||||
}
|
||||
|
||||
fn toggle_folder(&mut self, cx: &mut Context<Self>) {
|
||||
self.split_into_folders = !self.split_into_folders;
|
||||
self.folders = !self.folders;
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
@@ -184,7 +167,7 @@ impl Sidebar {
|
||||
}
|
||||
|
||||
fn nip50_search(&self, cx: &App) -> Task<Result<BTreeSet<Room>, Error>> {
|
||||
let query = self.find_input.read(cx).text();
|
||||
let query = self.find_input.read(cx).value().clone();
|
||||
|
||||
cx.background_spawn(async move {
|
||||
let client = get_client();
|
||||
@@ -236,7 +219,7 @@ impl Sidebar {
|
||||
}
|
||||
|
||||
fn search(&mut self, cx: &mut Context<Self>) {
|
||||
let query = self.find_input.read(cx).text();
|
||||
let query = self.find_input.read(cx).value();
|
||||
let result = ChatRegistry::get_global(cx).search(query.as_ref(), cx);
|
||||
|
||||
// Return if query is empty
|
||||
@@ -336,7 +319,7 @@ impl Sidebar {
|
||||
div()
|
||||
.h_8()
|
||||
.w_full()
|
||||
.px_1()
|
||||
.px_2()
|
||||
.flex()
|
||||
.items_center()
|
||||
.gap_2()
|
||||
@@ -428,11 +411,7 @@ impl Focusable for Sidebar {
|
||||
impl Render for Sidebar {
|
||||
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let account = Account::get_global(cx).profile_ref();
|
||||
let registry = ChatRegistry::get_global(cx);
|
||||
|
||||
// Get all rooms
|
||||
let rooms = registry.rooms(cx);
|
||||
let loading = registry.loading;
|
||||
let chats = ChatRegistry::get_global(cx);
|
||||
|
||||
// Get search result
|
||||
let local_result = self.local_result.read(cx);
|
||||
@@ -513,11 +492,21 @@ impl Render for Sidebar {
|
||||
)
|
||||
})
|
||||
.child(
|
||||
div()
|
||||
.px_3()
|
||||
.h_7()
|
||||
.flex_none()
|
||||
.child(self.find_input.clone()),
|
||||
div().px_3().h_7().flex_none().child(
|
||||
TextInput::new(&self.find_input).small().suffix(
|
||||
Button::new("find")
|
||||
.icon(IconName::Search)
|
||||
.tooltip("Press Enter to search")
|
||||
.small()
|
||||
.custom(
|
||||
ButtonCustomVariant::new(window, cx)
|
||||
.active(gpui::transparent_black())
|
||||
.color(gpui::transparent_black())
|
||||
.hover(gpui::transparent_black())
|
||||
.foreground(cx.theme().text_placeholder),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
.when_some(global_result.as_ref(), |this, rooms| {
|
||||
this.child(
|
||||
@@ -550,7 +539,7 @@ impl Render for Sidebar {
|
||||
Button::new("menu")
|
||||
.tooltip("Toggle chat folders")
|
||||
.map(|this| {
|
||||
if self.split_into_folders {
|
||||
if self.folders {
|
||||
this.icon(IconName::FilterFill)
|
||||
} else {
|
||||
this.icon(IconName::Filter)
|
||||
@@ -569,24 +558,15 @@ impl Render for Sidebar {
|
||||
})),
|
||||
),
|
||||
)
|
||||
.when(loading, |this| this.children(self.render_skeleton(6)))
|
||||
.when(chats.wait_for_eose, |this| {
|
||||
this.px_2().children(self.render_skeleton(6))
|
||||
})
|
||||
.map(|this| {
|
||||
if let Some(rooms) = local_result {
|
||||
this.children(Self::render_items(rooms, cx))
|
||||
} else if !self.split_into_folders {
|
||||
let rooms = rooms
|
||||
.values()
|
||||
.flat_map(|v| v.iter().cloned())
|
||||
.sorted_by_key(|e| Reverse(e.read(cx).created_at))
|
||||
.collect_vec();
|
||||
|
||||
this.children(Self::render_items(&rooms, cx))
|
||||
} else if !self.folders {
|
||||
this.children(Self::render_items(&chats.rooms, cx))
|
||||
} else {
|
||||
let ongoing = rooms.get(&RoomKind::Ongoing);
|
||||
let trusted = rooms.get(&RoomKind::Trusted);
|
||||
let unknown = rooms.get(&RoomKind::Unknown);
|
||||
|
||||
this.when_some(ongoing, |this, rooms| {
|
||||
this.child(
|
||||
Folder::new("Ongoing")
|
||||
.icon(IconName::Folder)
|
||||
@@ -595,9 +575,11 @@ impl Render for Sidebar {
|
||||
.on_click(cx.listener(move |this, _, _, cx| {
|
||||
this.toggle_item(Item::Ongoing, cx);
|
||||
}))
|
||||
.children(Self::render_items(rooms, cx)),
|
||||
.children(Self::render_items(
|
||||
&chats.rooms_by_kind(RoomKind::Ongoing, cx),
|
||||
cx,
|
||||
)),
|
||||
)
|
||||
})
|
||||
.child(
|
||||
Parent::new("Incoming")
|
||||
.icon(IconName::Folder)
|
||||
@@ -606,38 +588,36 @@ impl Render for Sidebar {
|
||||
.on_click(cx.listener(move |this, _, _, cx| {
|
||||
this.toggle_item(Item::Incoming, cx);
|
||||
}))
|
||||
.when_some(trusted, |this, rooms| {
|
||||
this.child(
|
||||
.child(
|
||||
Folder::new("Trusted")
|
||||
.icon(IconName::Folder)
|
||||
.tooltip("Incoming messages from trusted contacts")
|
||||
.collapsed(
|
||||
!self
|
||||
.active_subitems
|
||||
.contains(&SubItem::Trusted),
|
||||
!self.active_subitems.contains(&SubItem::Trusted),
|
||||
)
|
||||
.on_click(cx.listener(move |this, _, _, cx| {
|
||||
this.toggle_subitem(SubItem::Trusted, cx);
|
||||
}))
|
||||
.children(Self::render_items(rooms, cx)),
|
||||
.children(Self::render_items(
|
||||
&chats.rooms_by_kind(RoomKind::Trusted, cx),
|
||||
cx,
|
||||
)),
|
||||
)
|
||||
})
|
||||
.when_some(unknown, |this, rooms| {
|
||||
this.child(
|
||||
.child(
|
||||
Folder::new("Unknown")
|
||||
.icon(IconName::Folder)
|
||||
.tooltip("Incoming messages from unknowns")
|
||||
.collapsed(
|
||||
!self
|
||||
.active_subitems
|
||||
.contains(&SubItem::Unknown),
|
||||
!self.active_subitems.contains(&SubItem::Unknown),
|
||||
)
|
||||
.on_click(cx.listener(move |this, _, _, cx| {
|
||||
this.toggle_subitem(SubItem::Unknown, cx);
|
||||
}))
|
||||
.children(Self::render_items(rooms, cx)),
|
||||
)
|
||||
}),
|
||||
.children(Self::render_items(
|
||||
&chats.rooms_by_kind(RoomKind::Unknown, cx),
|
||||
cx,
|
||||
)),
|
||||
),
|
||||
)
|
||||
}
|
||||
}),
|
||||
|
||||
@@ -6,8 +6,8 @@ use gpui::{
|
||||
use theme::ActiveTheme;
|
||||
use ui::{
|
||||
button::{Button, ButtonVariants},
|
||||
input::TextInput,
|
||||
ContextModal, Size,
|
||||
input::{InputState, TextInput},
|
||||
ContextModal, Sizable,
|
||||
};
|
||||
|
||||
pub fn init(
|
||||
@@ -21,7 +21,7 @@ pub fn init(
|
||||
|
||||
pub struct Subject {
|
||||
id: u64,
|
||||
input: Entity<TextInput>,
|
||||
input: Entity<InputState>,
|
||||
focus_handle: FocusHandle,
|
||||
}
|
||||
|
||||
@@ -33,11 +33,9 @@ impl Subject {
|
||||
cx: &mut App,
|
||||
) -> Entity<Self> {
|
||||
let input = cx.new(|cx| {
|
||||
let mut this = TextInput::new(window, cx).text_size(Size::Small);
|
||||
let mut this = InputState::new(window, cx).placeholder("Exciting Project...");
|
||||
if let Some(text) = subject.clone() {
|
||||
this.set_text(text, window, cx);
|
||||
} else {
|
||||
this.set_placeholder("prepare for holidays...");
|
||||
this.set_value(text, window, cx);
|
||||
}
|
||||
this
|
||||
});
|
||||
@@ -51,7 +49,7 @@ impl Subject {
|
||||
|
||||
pub fn update(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let registry = ChatRegistry::global(cx).read(cx);
|
||||
let subject = self.input.read(cx).text();
|
||||
let subject = self.input.read(cx).value().clone();
|
||||
|
||||
if let Some(room) = registry.room(&self.id, cx) {
|
||||
room.update(cx, |this, cx| {
|
||||
@@ -88,7 +86,7 @@ impl Render for Subject {
|
||||
.text_color(cx.theme().text_muted)
|
||||
.child("Subject:"),
|
||||
)
|
||||
.child(self.input.clone())
|
||||
.child(TextInput::new(&self.input).small())
|
||||
.child(
|
||||
div()
|
||||
.text_xs()
|
||||
|
||||
11
crates/ui/src/actions.rs
Normal file
11
crates/ui/src/actions.rs
Normal file
@@ -0,0 +1,11 @@
|
||||
use gpui::{actions, impl_internal_actions};
|
||||
use serde::Deserialize;
|
||||
|
||||
#[derive(Clone, PartialEq, Eq, Deserialize)]
|
||||
pub struct Confirm {
|
||||
/// Is confirm with secondary.
|
||||
pub secondary: bool,
|
||||
}
|
||||
|
||||
actions!(list, [Cancel, SelectPrev, SelectNext]);
|
||||
impl_internal_actions!(list, [Confirm]);
|
||||
@@ -1,28 +1,42 @@
|
||||
use gpui::{
|
||||
actions, anchored, canvas, deferred, div, prelude::FluentBuilder, px, rems, AnyElement, App,
|
||||
AppContext, Bounds, ClickEvent, Context, DismissEvent, ElementId, Entity, EventEmitter,
|
||||
FocusHandle, Focusable, InteractiveElement, IntoElement, KeyBinding, Length, ParentElement,
|
||||
Pixels, Render, SharedString, StatefulInteractiveElement, Styled, Subscription, Task,
|
||||
WeakEntity, Window,
|
||||
anchored, canvas, deferred, div, prelude::FluentBuilder, px, rems, AnyElement, App, AppContext,
|
||||
Bounds, ClickEvent, Context, DismissEvent, ElementId, Empty, Entity, EventEmitter, FocusHandle,
|
||||
Focusable, InteractiveElement, IntoElement, KeyBinding, Length, ParentElement, Pixels, Render,
|
||||
RenderOnce, SharedString, StatefulInteractiveElement, Styled, Subscription, Task, WeakEntity,
|
||||
Window,
|
||||
};
|
||||
use theme::ActiveTheme;
|
||||
|
||||
use crate::{
|
||||
actions::{Cancel, Confirm, SelectNext, SelectPrev},
|
||||
h_flex,
|
||||
list::{self, List, ListDelegate, ListItem},
|
||||
v_flex, Icon, IconName, Sizable, Size, StyleSized, StyledExt,
|
||||
input::clear_button::clear_button,
|
||||
list::{List, ListDelegate, ListItem},
|
||||
v_flex, Disableable as _, Icon, IconName, Sizable, Size, StyleSized,
|
||||
};
|
||||
|
||||
actions!(dropdown, [Up, Down, Enter, Escape]);
|
||||
#[derive(Clone)]
|
||||
pub enum ListEvent {
|
||||
/// Single click or move to selected row.
|
||||
SelectItem(usize),
|
||||
/// Double click on the row.
|
||||
ConfirmItem(usize),
|
||||
// Cancel the selection.
|
||||
Cancel,
|
||||
}
|
||||
|
||||
const CONTEXT: &str = "Dropdown";
|
||||
|
||||
pub fn init(cx: &mut App) {
|
||||
cx.bind_keys([
|
||||
KeyBinding::new("up", Up, Some(CONTEXT)),
|
||||
KeyBinding::new("down", Down, Some(CONTEXT)),
|
||||
KeyBinding::new("enter", Enter, Some(CONTEXT)),
|
||||
KeyBinding::new("escape", Escape, Some(CONTEXT)),
|
||||
KeyBinding::new("up", SelectPrev, Some(CONTEXT)),
|
||||
KeyBinding::new("down", SelectNext, Some(CONTEXT)),
|
||||
KeyBinding::new("enter", Confirm { secondary: false }, Some(CONTEXT)),
|
||||
KeyBinding::new(
|
||||
"secondary-enter",
|
||||
Confirm { secondary: true },
|
||||
Some(CONTEXT),
|
||||
),
|
||||
KeyBinding::new("escape", Cancel, Some(CONTEXT)),
|
||||
])
|
||||
}
|
||||
|
||||
@@ -30,6 +44,12 @@ pub fn init(cx: &mut App) {
|
||||
pub trait DropdownItem {
|
||||
type Value: Clone;
|
||||
fn title(&self) -> SharedString;
|
||||
/// Customize the display title used to selected item in Dropdown Input.
|
||||
///
|
||||
/// If return None, the title will be used.
|
||||
fn display_title(&self) -> Option<AnyElement> {
|
||||
None
|
||||
}
|
||||
fn value(&self) -> &Self::Value;
|
||||
}
|
||||
|
||||
@@ -80,12 +100,7 @@ pub trait DropdownDelegate: Sized {
|
||||
false
|
||||
}
|
||||
|
||||
fn perform_search(
|
||||
&mut self,
|
||||
_query: &str,
|
||||
_window: &mut Window,
|
||||
_cx: &mut Context<Dropdown<Self>>,
|
||||
) -> Task<()> {
|
||||
fn perform_search(&mut self, _query: &str, _window: &mut Window, _: &mut App) -> Task<()> {
|
||||
Task::ready(())
|
||||
}
|
||||
}
|
||||
@@ -112,7 +127,7 @@ impl<T: DropdownItem> DropdownDelegate for Vec<T> {
|
||||
|
||||
struct DropdownListDelegate<D: DropdownDelegate + 'static> {
|
||||
delegate: D,
|
||||
dropdown: WeakEntity<Dropdown<D>>,
|
||||
dropdown: WeakEntity<DropdownState<D>>,
|
||||
selected_index: Option<usize>,
|
||||
}
|
||||
|
||||
@@ -126,14 +141,10 @@ where
|
||||
self.delegate.len()
|
||||
}
|
||||
|
||||
fn confirmed_index(&self, _: &App) -> Option<usize> {
|
||||
self.selected_index
|
||||
}
|
||||
|
||||
fn render_item(
|
||||
&self,
|
||||
ix: usize,
|
||||
_window: &mut gpui::Window,
|
||||
_: &mut gpui::Window,
|
||||
cx: &mut gpui::Context<List<Self>>,
|
||||
) -> Option<Self::Item> {
|
||||
let selected = self.selected_index == Some(ix);
|
||||
@@ -145,9 +156,8 @@ where
|
||||
if let Some(item) = self.delegate.get(ix) {
|
||||
let list_item = ListItem::new(("list-item", ix))
|
||||
.check_icon(IconName::Check)
|
||||
.cursor_pointer()
|
||||
.selected(selected)
|
||||
.input_text_size(size)
|
||||
.input_font_size(size)
|
||||
.list_size(size)
|
||||
.child(div().whitespace_nowrap().child(item.title().to_string()));
|
||||
Some(list_item)
|
||||
@@ -166,9 +176,7 @@ where
|
||||
});
|
||||
}
|
||||
|
||||
fn confirm(&mut self, ix: Option<usize>, window: &mut Window, cx: &mut Context<List<Self>>) {
|
||||
self.selected_index = ix;
|
||||
|
||||
fn confirm(&mut self, _secondary: bool, window: &mut Window, cx: &mut Context<List<Self>>) {
|
||||
let selected_value = self
|
||||
.selected_index
|
||||
.and_then(|ix| self.delegate.get(ix))
|
||||
@@ -227,27 +235,35 @@ pub enum DropdownEvent<D: DropdownDelegate + 'static> {
|
||||
Confirm(Option<<D::Item as DropdownItem>::Value>),
|
||||
}
|
||||
|
||||
type Empty = Option<Box<dyn Fn(&Window, &App) -> AnyElement + 'static>>;
|
||||
type DropdownStateEmpty = Option<Box<dyn Fn(&Window, &App) -> AnyElement>>;
|
||||
|
||||
/// A Dropdown element.
|
||||
pub struct Dropdown<D: DropdownDelegate + 'static> {
|
||||
id: ElementId,
|
||||
/// State of the [`Dropdown`].
|
||||
pub struct DropdownState<D: DropdownDelegate + 'static> {
|
||||
focus_handle: FocusHandle,
|
||||
list: Entity<List<DropdownListDelegate<D>>>,
|
||||
size: Size,
|
||||
icon: Option<IconName>,
|
||||
open: bool,
|
||||
placeholder: Option<SharedString>,
|
||||
title_prefix: Option<SharedString>,
|
||||
selected_value: Option<<D::Item as DropdownItem>::Value>,
|
||||
empty: Empty,
|
||||
width: Length,
|
||||
menu_width: Length,
|
||||
empty: DropdownStateEmpty,
|
||||
/// Store the bounds of the input
|
||||
bounds: Bounds<Pixels>,
|
||||
open: bool,
|
||||
selected_value: Option<<D::Item as DropdownItem>::Value>,
|
||||
_subscriptions: Vec<Subscription>,
|
||||
}
|
||||
|
||||
/// A Dropdown element.
|
||||
#[derive(IntoElement)]
|
||||
pub struct Dropdown<D: DropdownDelegate + 'static> {
|
||||
id: ElementId,
|
||||
state: Entity<DropdownState<D>>,
|
||||
size: Size,
|
||||
icon: Option<Icon>,
|
||||
cleanable: bool,
|
||||
placeholder: Option<SharedString>,
|
||||
title_prefix: Option<SharedString>,
|
||||
empty: Option<AnyElement>,
|
||||
width: Length,
|
||||
menu_width: Length,
|
||||
disabled: bool,
|
||||
#[allow(dead_code)]
|
||||
subscriptions: Vec<Subscription>,
|
||||
}
|
||||
|
||||
pub struct SearchableVec<T> {
|
||||
@@ -258,7 +274,6 @@ pub struct SearchableVec<T> {
|
||||
impl<T: DropdownItem + Clone> SearchableVec<T> {
|
||||
pub fn new(items: impl Into<Vec<T>>) -> Self {
|
||||
let items = items.into();
|
||||
|
||||
Self {
|
||||
items: items.clone(),
|
||||
matched_items: items,
|
||||
@@ -295,12 +310,7 @@ impl<T: DropdownItem + Clone> DropdownDelegate for SearchableVec<T> {
|
||||
true
|
||||
}
|
||||
|
||||
fn perform_search(
|
||||
&mut self,
|
||||
query: &str,
|
||||
_window: &mut Window,
|
||||
_cx: &mut Context<Dropdown<Self>>,
|
||||
) -> Task<()> {
|
||||
fn perform_search(&mut self, query: &str, _window: &mut Window, _: &mut App) -> Task<()> {
|
||||
self.matched_items = self
|
||||
.items
|
||||
.iter()
|
||||
@@ -321,12 +331,11 @@ impl From<Vec<SharedString>> for SearchableVec<SharedString> {
|
||||
}
|
||||
}
|
||||
|
||||
impl<D> Dropdown<D>
|
||||
impl<D> DropdownState<D>
|
||||
where
|
||||
D: DropdownDelegate + 'static,
|
||||
{
|
||||
pub fn new(
|
||||
id: impl Into<ElementId>,
|
||||
delegate: D,
|
||||
selected_index: Option<usize>,
|
||||
window: &mut Window,
|
||||
@@ -342,83 +351,34 @@ where
|
||||
let searchable = delegate.delegate.can_search();
|
||||
|
||||
let list = cx.new(|cx| {
|
||||
let mut list = List::new(delegate, window, cx).max_h(rems(20.));
|
||||
let mut list = List::new(delegate, window, cx)
|
||||
.max_h(rems(20.))
|
||||
.reset_on_cancel(false);
|
||||
if !searchable {
|
||||
list = list.no_query();
|
||||
}
|
||||
list
|
||||
});
|
||||
|
||||
let subscriptions = vec![
|
||||
let _subscriptions = vec![
|
||||
cx.on_blur(&list.focus_handle(cx), window, Self::on_blur),
|
||||
cx.on_blur(&focus_handle, window, Self::on_blur),
|
||||
];
|
||||
|
||||
let mut this = Self {
|
||||
id: id.into(),
|
||||
focus_handle,
|
||||
placeholder: None,
|
||||
list,
|
||||
size: Size::Medium,
|
||||
icon: None,
|
||||
selected_value: None,
|
||||
open: false,
|
||||
title_prefix: None,
|
||||
empty: None,
|
||||
width: Length::Auto,
|
||||
menu_width: Length::Auto,
|
||||
bounds: Bounds::default(),
|
||||
disabled: false,
|
||||
subscriptions,
|
||||
empty: None,
|
||||
_subscriptions,
|
||||
};
|
||||
this.set_selected_index(selected_index, window, cx);
|
||||
this
|
||||
}
|
||||
|
||||
/// Set the width of the dropdown input, default: Length::Auto
|
||||
pub fn width(mut self, width: impl Into<Length>) -> Self {
|
||||
self.width = width.into();
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the width of the dropdown menu, default: Length::Auto
|
||||
pub fn menu_width(mut self, width: impl Into<Length>) -> Self {
|
||||
self.menu_width = width.into();
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the placeholder for display when dropdown value is empty.
|
||||
pub fn placeholder(mut self, placeholder: impl Into<SharedString>) -> Self {
|
||||
self.placeholder = Some(placeholder.into());
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the right icon for the dropdown input, instead of the default arrow icon.
|
||||
pub fn icon(mut self, icon: impl Into<IconName>) -> Self {
|
||||
self.icon = Some(icon.into());
|
||||
self
|
||||
}
|
||||
|
||||
/// Set title prefix for the dropdown.
|
||||
///
|
||||
/// e.g.: Country: United States
|
||||
///
|
||||
/// You should set the label is `Country: `
|
||||
pub fn title_prefix(mut self, prefix: impl Into<SharedString>) -> Self {
|
||||
self.title_prefix = Some(prefix.into());
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the disable state for the dropdown.
|
||||
pub fn disabled(mut self, disabled: bool) -> Self {
|
||||
self.disabled = disabled;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn set_disabled(&mut self, disabled: bool) {
|
||||
self.disabled = disabled;
|
||||
}
|
||||
|
||||
pub fn empty<E, F>(mut self, f: F) -> Self
|
||||
where
|
||||
E: IntoElement,
|
||||
@@ -453,13 +413,13 @@ where
|
||||
self.set_selected_index(selected_index, window, cx);
|
||||
}
|
||||
|
||||
pub fn selected_index(&self, _window: &Window, cx: &App) -> Option<usize> {
|
||||
pub fn selected_index(&self, cx: &App) -> Option<usize> {
|
||||
self.list.read(cx).selected_index()
|
||||
}
|
||||
|
||||
fn update_selected_value(&mut self, window: &Window, cx: &App) {
|
||||
fn update_selected_value(&mut self, _: &Window, cx: &App) {
|
||||
self.selected_value = self
|
||||
.selected_index(window, cx)
|
||||
.selected_index(cx)
|
||||
.and_then(|ix| self.list.read(cx).delegate().delegate.get(ix))
|
||||
.map(|item| item.value().clone());
|
||||
}
|
||||
@@ -482,24 +442,25 @@ where
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn up(&mut self, _: &Up, window: &mut Window, cx: &mut Context<Self>) {
|
||||
fn up(&mut self, _: &SelectPrev, window: &mut Window, cx: &mut Context<Self>) {
|
||||
if !self.open {
|
||||
return;
|
||||
}
|
||||
|
||||
self.list.focus_handle(cx).focus(window);
|
||||
window.dispatch_action(Box::new(list::SelectPrev), cx);
|
||||
cx.propagate();
|
||||
}
|
||||
|
||||
fn down(&mut self, _: &Down, window: &mut Window, cx: &mut Context<Self>) {
|
||||
fn down(&mut self, _: &SelectNext, window: &mut Window, cx: &mut Context<Self>) {
|
||||
if !self.open {
|
||||
self.open = true;
|
||||
}
|
||||
|
||||
self.list.focus_handle(cx).focus(window);
|
||||
window.dispatch_action(Box::new(list::SelectNext), cx);
|
||||
cx.propagate();
|
||||
}
|
||||
|
||||
fn enter(&mut self, _: &Enter, window: &mut Window, cx: &mut Context<Self>) {
|
||||
fn enter(&mut self, _: &Confirm, window: &mut Window, cx: &mut Context<Self>) {
|
||||
// Propagate the event to the parent view, for example to the Modal to support ENTER to confirm.
|
||||
cx.propagate();
|
||||
|
||||
@@ -508,7 +469,6 @@ where
|
||||
cx.notify();
|
||||
} else {
|
||||
self.list.focus_handle(cx).focus(window);
|
||||
window.dispatch_action(Box::new(list::Confirm), cx);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -522,39 +482,150 @@ where
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn escape(&mut self, _: &Escape, _window: &mut Window, cx: &mut Context<Self>) {
|
||||
// Propagate the event to the parent view, for example to the Modal to support ESC to close.
|
||||
fn escape(&mut self, _: &Cancel, _: &mut Window, cx: &mut Context<Self>) {
|
||||
if !self.open {
|
||||
cx.propagate();
|
||||
}
|
||||
|
||||
self.open = false;
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn display_title(&self, window: &Window, cx: &App) -> impl IntoElement {
|
||||
let title = if let Some(selected_index) = &self.selected_index(window, cx) {
|
||||
let title = self
|
||||
fn clean(&mut self, _: &ClickEvent, window: &mut Window, cx: &mut Context<Self>) {
|
||||
self.set_selected_index(None, window, cx);
|
||||
cx.emit(DropdownEvent::Confirm(None));
|
||||
}
|
||||
|
||||
/// Set the items for the dropdown.
|
||||
pub fn set_items(&mut self, items: D, _: &mut Window, cx: &mut Context<Self>)
|
||||
where
|
||||
D: DropdownDelegate + 'static,
|
||||
{
|
||||
self.list.update(cx, |list, _| {
|
||||
list.delegate_mut().delegate = items;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
impl<D> Render for DropdownState<D>
|
||||
where
|
||||
D: DropdownDelegate + 'static,
|
||||
{
|
||||
fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
|
||||
Empty
|
||||
}
|
||||
}
|
||||
|
||||
impl<D> Dropdown<D>
|
||||
where
|
||||
D: DropdownDelegate + 'static,
|
||||
{
|
||||
pub fn new(state: &Entity<DropdownState<D>>) -> Self {
|
||||
Self {
|
||||
id: ("dropdown", state.entity_id()).into(),
|
||||
state: state.clone(),
|
||||
placeholder: None,
|
||||
size: Size::Medium,
|
||||
icon: None,
|
||||
cleanable: false,
|
||||
title_prefix: None,
|
||||
empty: None,
|
||||
width: Length::Auto,
|
||||
menu_width: Length::Auto,
|
||||
disabled: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Set the width of the dropdown input, default: Length::Auto
|
||||
pub fn width(mut self, width: impl Into<Length>) -> Self {
|
||||
self.width = width.into();
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the width of the dropdown menu, default: Length::Auto
|
||||
pub fn menu_width(mut self, width: impl Into<Length>) -> Self {
|
||||
self.menu_width = width.into();
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the placeholder for display when dropdown value is empty.
|
||||
pub fn placeholder(mut self, placeholder: impl Into<SharedString>) -> Self {
|
||||
self.placeholder = Some(placeholder.into());
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the right icon for the dropdown input, instead of the default arrow icon.
|
||||
pub fn icon(mut self, icon: impl Into<Icon>) -> Self {
|
||||
self.icon = Some(icon.into());
|
||||
self
|
||||
}
|
||||
|
||||
/// Set title prefix for the dropdown.
|
||||
///
|
||||
/// e.g.: Country: United States
|
||||
///
|
||||
/// You should set the label is `Country: `
|
||||
pub fn title_prefix(mut self, prefix: impl Into<SharedString>) -> Self {
|
||||
self.title_prefix = Some(prefix.into());
|
||||
self
|
||||
}
|
||||
|
||||
/// Set true to show the clear button when the input field is not empty.
|
||||
pub fn cleanable(mut self) -> Self {
|
||||
self.cleanable = true;
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the disable state for the dropdown.
|
||||
pub fn disabled(mut self, disabled: bool) -> Self {
|
||||
self.disabled = disabled;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn empty(mut self, el: impl IntoElement) -> Self {
|
||||
self.empty = Some(el.into_any_element());
|
||||
self
|
||||
}
|
||||
|
||||
/// Returns the title element for the dropdown input.
|
||||
fn display_title(&self, _: &Window, cx: &App) -> impl IntoElement {
|
||||
let default_title = div()
|
||||
.text_color(cx.theme().text_accent)
|
||||
.child(
|
||||
self.placeholder
|
||||
.clone()
|
||||
.unwrap_or_else(|| "Please select".into()),
|
||||
)
|
||||
.when(self.disabled, |this| this.text_color(cx.theme().text_muted));
|
||||
|
||||
let Some(selected_index) = &self.state.read(cx).selected_index(cx) else {
|
||||
return default_title;
|
||||
};
|
||||
|
||||
let Some(title) = self
|
||||
.state
|
||||
.read(cx)
|
||||
.list
|
||||
.read(cx)
|
||||
.delegate()
|
||||
.delegate
|
||||
.get(*selected_index)
|
||||
.map(|item| item.title().to_string())
|
||||
.unwrap_or_default();
|
||||
|
||||
h_flex()
|
||||
.when_some(self.title_prefix.clone(), |this, prefix| this.child(prefix))
|
||||
.child(title.clone())
|
||||
.map(|item| {
|
||||
if let Some(el) = item.display_title() {
|
||||
el
|
||||
} else if let Some(prefix) = self.title_prefix.as_ref() {
|
||||
format!("{}{}", prefix, item.title()).into_any_element()
|
||||
} else {
|
||||
div().text_color(cx.theme().text_accent).child(
|
||||
self.placeholder
|
||||
.clone()
|
||||
.unwrap_or_else(|| "Please select".into()),
|
||||
)
|
||||
item.title().into_any_element()
|
||||
}
|
||||
})
|
||||
else {
|
||||
return default_title;
|
||||
};
|
||||
|
||||
title.when(self.disabled, |this| {
|
||||
this.cursor_not_allowed().text_color(cx.theme().text_muted)
|
||||
})
|
||||
div()
|
||||
.when(self.disabled, |this| this.text_color(cx.theme().text_muted))
|
||||
.child(title)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -568,11 +639,11 @@ where
|
||||
}
|
||||
}
|
||||
|
||||
impl<D> EventEmitter<DropdownEvent<D>> for Dropdown<D> where D: DropdownDelegate + 'static {}
|
||||
impl<D> EventEmitter<DismissEvent> for Dropdown<D> where D: DropdownDelegate + 'static {}
|
||||
impl<D> Focusable for Dropdown<D>
|
||||
impl<D> EventEmitter<DropdownEvent<D>> for DropdownState<D> where D: DropdownDelegate + 'static {}
|
||||
impl<D> EventEmitter<DismissEvent> for DropdownState<D> where D: DropdownDelegate + 'static {}
|
||||
impl<D> Focusable for DropdownState<D>
|
||||
where
|
||||
D: DropdownDelegate + 'static,
|
||||
D: DropdownDelegate,
|
||||
{
|
||||
fn focus_handle(&self, cx: &App) -> FocusHandle {
|
||||
if self.open {
|
||||
@@ -582,38 +653,55 @@ where
|
||||
}
|
||||
}
|
||||
}
|
||||
impl<D> Focusable for Dropdown<D>
|
||||
where
|
||||
D: DropdownDelegate,
|
||||
{
|
||||
fn focus_handle(&self, cx: &App) -> FocusHandle {
|
||||
self.state.focus_handle(cx)
|
||||
}
|
||||
}
|
||||
|
||||
impl<D> Render for Dropdown<D>
|
||||
impl<D> RenderOnce for Dropdown<D>
|
||||
where
|
||||
D: DropdownDelegate + 'static,
|
||||
{
|
||||
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let is_focused = self.focus_handle.is_focused(window);
|
||||
let view = cx.entity().clone();
|
||||
let bounds = self.bounds;
|
||||
let allow_open = !(self.open || self.disabled);
|
||||
let outline_visible = self.open || is_focused && !self.disabled;
|
||||
|
||||
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let is_focused = self.focus_handle(cx).is_focused(window);
|
||||
// If the size has change, set size to self.list, to change the QueryInput size.
|
||||
if self.list.read(cx).size != self.size {
|
||||
self.list
|
||||
.update(cx, |this, cx| this.set_size(self.size, window, cx))
|
||||
let old_size = self.state.read(cx).list.read(cx).size;
|
||||
if old_size != self.size {
|
||||
self.state
|
||||
.read(cx)
|
||||
.list
|
||||
.clone()
|
||||
.update(cx, |this, cx| this.set_size(self.size, window, cx));
|
||||
self.state.update(cx, |this, _| {
|
||||
this.size = self.size;
|
||||
});
|
||||
}
|
||||
|
||||
let state = self.state.read(cx);
|
||||
let show_clean = self.cleanable && state.selected_index(cx).is_some();
|
||||
let bounds = state.bounds;
|
||||
let allow_open = !(state.open || self.disabled);
|
||||
let outline_visible = state.open || is_focused && !self.disabled;
|
||||
let popup_radius = cx.theme().radius.min(px(8.));
|
||||
|
||||
div()
|
||||
.id(self.id.clone())
|
||||
.key_context(CONTEXT)
|
||||
.track_focus(&self.focus_handle)
|
||||
.on_action(cx.listener(Self::up))
|
||||
.on_action(cx.listener(Self::down))
|
||||
.on_action(cx.listener(Self::enter))
|
||||
.on_action(cx.listener(Self::escape))
|
||||
.track_focus(&self.focus_handle(cx))
|
||||
.on_action(window.listener_for(&self.state, DropdownState::up))
|
||||
.on_action(window.listener_for(&self.state, DropdownState::down))
|
||||
.on_action(window.listener_for(&self.state, DropdownState::enter))
|
||||
.on_action(window.listener_for(&self.state, DropdownState::escape))
|
||||
.size_full()
|
||||
.relative()
|
||||
.input_text_size(self.size)
|
||||
.input_font_size(self.size)
|
||||
.child(
|
||||
div()
|
||||
.id("dropdown-input")
|
||||
.id(ElementId::Name(format!("{}-input", self.id).into()))
|
||||
.relative()
|
||||
.flex()
|
||||
.items_center()
|
||||
@@ -623,23 +711,16 @@ where
|
||||
.border_color(cx.theme().border)
|
||||
.rounded(cx.theme().radius)
|
||||
.shadow_sm()
|
||||
.map(|this| {
|
||||
if self.disabled {
|
||||
this.cursor_not_allowed()
|
||||
} else {
|
||||
this.cursor_pointer()
|
||||
}
|
||||
})
|
||||
.overflow_hidden()
|
||||
.input_text_size(self.size)
|
||||
.input_font_size(self.size)
|
||||
.map(|this| match self.width {
|
||||
Length::Definite(l) => this.flex_none().w(l),
|
||||
Length::Auto => this.w_full(),
|
||||
})
|
||||
.when(outline_visible, |this| this.outline(window, cx))
|
||||
.when(outline_visible, |this| this.border_color(cx.theme().ring))
|
||||
.input_size(self.size)
|
||||
.when(allow_open, |this| {
|
||||
this.on_click(cx.listener(Self::toggle_menu))
|
||||
this.on_click(window.listener_for(&self.state, DropdownState::toggle_menu))
|
||||
})
|
||||
.child(
|
||||
h_flex()
|
||||
@@ -651,41 +732,52 @@ where
|
||||
div()
|
||||
.w_full()
|
||||
.overflow_hidden()
|
||||
.whitespace_nowrap()
|
||||
.truncate()
|
||||
.child(self.display_title(window, cx)),
|
||||
)
|
||||
.map(|this| {
|
||||
.when(show_clean, |this| {
|
||||
this.child(clear_button(cx).map(|this| {
|
||||
if self.disabled {
|
||||
this.disabled(true)
|
||||
} else {
|
||||
this.on_click(
|
||||
window.listener_for(&self.state, DropdownState::clean),
|
||||
)
|
||||
}
|
||||
}))
|
||||
})
|
||||
.when(!show_clean, |this| {
|
||||
let icon = match self.icon.clone() {
|
||||
Some(icon) => icon,
|
||||
None => {
|
||||
if self.open {
|
||||
IconName::CaretUp
|
||||
if state.open {
|
||||
Icon::new(IconName::CaretUp)
|
||||
} else {
|
||||
IconName::CaretDown
|
||||
Icon::new(IconName::CaretDown)
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
this.child(
|
||||
Icon::new(icon)
|
||||
.xsmall()
|
||||
.text_color(match self.disabled {
|
||||
true => cx.theme().icon_muted,
|
||||
false => cx.theme().icon,
|
||||
})
|
||||
.when(self.disabled, |this| this.cursor_not_allowed()),
|
||||
)
|
||||
this.child(icon.xsmall().text_color(match self.disabled {
|
||||
true => cx.theme().text_placeholder,
|
||||
false => cx.theme().text_muted,
|
||||
}))
|
||||
}),
|
||||
)
|
||||
.child(
|
||||
canvas(
|
||||
move |bounds, _, cx| view.update(cx, |r, _| r.bounds = bounds),
|
||||
{
|
||||
let state = self.state.clone();
|
||||
move |bounds, _, cx| state.update(cx, |r, _| r.bounds = bounds)
|
||||
},
|
||||
|_, _, _, _| {},
|
||||
)
|
||||
.absolute()
|
||||
.size_full(),
|
||||
),
|
||||
)
|
||||
.when(self.open, |this| {
|
||||
.when(state.open, |this| {
|
||||
this.child(
|
||||
deferred(
|
||||
anchored().snap_to_window_with_margin(px(8.)).child(
|
||||
@@ -701,17 +793,17 @@ where
|
||||
.mt_1p5()
|
||||
.bg(cx.theme().background)
|
||||
.border_1()
|
||||
.border_color(cx.theme().border_focused)
|
||||
.rounded(cx.theme().radius)
|
||||
.border_color(cx.theme().border)
|
||||
.rounded(popup_radius)
|
||||
.shadow_md()
|
||||
.on_mouse_down_out(|_, _, cx| {
|
||||
cx.dispatch_action(&Escape);
|
||||
})
|
||||
.child(self.list.clone()),
|
||||
.child(state.list.clone()),
|
||||
)
|
||||
.on_mouse_down_out(cx.listener(|this, _, window, cx| {
|
||||
this.escape(&Escape, window, cx);
|
||||
})),
|
||||
.on_mouse_down_out(window.listener_for(
|
||||
&self.state,
|
||||
|this, _, window, cx| {
|
||||
this.escape(&Cancel, window, cx);
|
||||
},
|
||||
)),
|
||||
),
|
||||
)
|
||||
.with_priority(1),
|
||||
|
||||
@@ -10,7 +10,7 @@ use theme::ActiveTheme;
|
||||
|
||||
use crate::{
|
||||
button::{Button, ButtonVariants},
|
||||
input::TextInput,
|
||||
input::InputState,
|
||||
popover::{Popover, PopoverContent},
|
||||
Icon,
|
||||
};
|
||||
@@ -24,12 +24,12 @@ impl_internal_actions!(emoji, [EmitEmoji]);
|
||||
pub struct EmojiPicker {
|
||||
icon: Option<Icon>,
|
||||
anchor: Option<Corner>,
|
||||
target_input: WeakEntity<TextInput>,
|
||||
target_input: WeakEntity<InputState>,
|
||||
emojis: Rc<Vec<SharedString>>,
|
||||
}
|
||||
|
||||
impl EmojiPicker {
|
||||
pub fn new(target_input: WeakEntity<TextInput>) -> Self {
|
||||
pub fn new(target_input: WeakEntity<InputState>) -> Self {
|
||||
let mut emojis: Vec<SharedString> = vec![];
|
||||
|
||||
emojis.extend(
|
||||
@@ -102,7 +102,7 @@ impl RenderOnce for EmojiPicker {
|
||||
move |_, window, cx| {
|
||||
if let Some(input) = input.as_ref() {
|
||||
input.update(cx, |this, cx| {
|
||||
let current = this.text();
|
||||
let current = this.value();
|
||||
let new_text = if current.is_empty() {
|
||||
format!("{}", item)
|
||||
} else if current.ends_with(" ") {
|
||||
@@ -110,7 +110,7 @@ impl RenderOnce for EmojiPicker {
|
||||
} else {
|
||||
format!("{} {}", current, item)
|
||||
};
|
||||
this.set_text(new_text, window, cx);
|
||||
this.set_value(new_text, window, cx);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,7 +68,6 @@ pub enum IconName {
|
||||
ToggleFill,
|
||||
ThumbsDown,
|
||||
ThumbsUp,
|
||||
TriangleAlert,
|
||||
Upload,
|
||||
UsersThreeFill,
|
||||
WindowClose,
|
||||
@@ -139,7 +138,6 @@ impl IconName {
|
||||
Self::ToggleFill => "icons/toggle-fill.svg",
|
||||
Self::ThumbsDown => "icons/thumbs-down.svg",
|
||||
Self::ThumbsUp => "icons/thumbs-up.svg",
|
||||
Self::TriangleAlert => "icons/triangle-alert.svg",
|
||||
Self::Upload => "icons/upload.svg",
|
||||
Self::UsersThreeFill => "icons/users-three-fill.svg",
|
||||
Self::WindowClose => "icons/window-close.svg",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
use gpui::{Context, Timer};
|
||||
use std::time::Duration;
|
||||
|
||||
use gpui::{Context, Timer};
|
||||
|
||||
static INTERVAL: Duration = Duration::from_millis(500);
|
||||
static PAUSE_DELAY: Duration = Duration::from_millis(300);
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ use std::{fmt::Debug, ops::Range};
|
||||
|
||||
use crate::history::HistoryItem;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
#[derive(Debug, PartialEq, Clone)]
|
||||
pub struct Change {
|
||||
pub(crate) old_range: Range<usize>,
|
||||
pub(crate) old_text: String,
|
||||
|
||||
16
crates/ui/src/input/clear_button.rs
Normal file
16
crates/ui/src/input/clear_button.rs
Normal file
@@ -0,0 +1,16 @@
|
||||
use gpui::{App, Styled};
|
||||
use theme::ActiveTheme;
|
||||
|
||||
use crate::{
|
||||
button::{Button, ButtonVariants as _},
|
||||
Icon, IconName, Sizable as _,
|
||||
};
|
||||
|
||||
#[inline]
|
||||
pub(crate) fn clear_button(cx: &App) -> Button {
|
||||
Button::new("clean")
|
||||
.icon(Icon::new(IconName::CloseCircle))
|
||||
.ghost()
|
||||
.xsmall()
|
||||
.text_color(cx.theme().text_muted)
|
||||
}
|
||||
@@ -1,24 +1,35 @@
|
||||
use gpui::{
|
||||
fill, point, px, relative, size, App, Bounds, Corners, Element, ElementId, ElementInputHandler,
|
||||
Entity, GlobalElementId, IntoElement, LayoutId, MouseButton, MouseMoveEvent, PaintQuad, Path,
|
||||
Pixels, Point, Style, TextRun, UnderlineStyle, Window, WrappedLine,
|
||||
Pixels, Point, SharedString, Style, TextAlign, TextRun, UnderlineStyle, Window, WrappedLine,
|
||||
};
|
||||
use smallvec::SmallVec;
|
||||
use theme::ActiveTheme;
|
||||
|
||||
use super::TextInput;
|
||||
use super::InputState;
|
||||
use crate::Root;
|
||||
|
||||
const RIGHT_MARGIN: Pixels = px(5.);
|
||||
const BOTTOM_MARGIN: Pixels = px(20.);
|
||||
const CURSOR_THICKNESS: Pixels = px(2.);
|
||||
|
||||
pub(super) struct TextElement {
|
||||
input: Entity<TextInput>,
|
||||
input: Entity<InputState>,
|
||||
placeholder: SharedString,
|
||||
}
|
||||
|
||||
impl TextElement {
|
||||
pub(super) fn new(input: Entity<TextInput>) -> Self {
|
||||
Self { input }
|
||||
pub(super) fn new(input: Entity<InputState>) -> Self {
|
||||
Self {
|
||||
input,
|
||||
placeholder: SharedString::default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Set the placeholder text of the input field.
|
||||
pub fn placeholder(mut self, placeholder: impl Into<SharedString>) -> Self {
|
||||
self.placeholder = placeholder.into();
|
||||
self
|
||||
}
|
||||
|
||||
fn paint_mouse_listeners(&mut self, window: &mut Window, _: &mut App) {
|
||||
@@ -142,7 +153,6 @@ impl TextElement {
|
||||
// cursor blink
|
||||
let cursor_height =
|
||||
window.text_style().font_size.to_pixels(window.rem_size()) + px(4.);
|
||||
|
||||
cursor = Some(fill(
|
||||
Bounds::new(
|
||||
point(
|
||||
@@ -301,6 +311,31 @@ impl IntoElement for TextElement {
|
||||
}
|
||||
}
|
||||
|
||||
/// A debug function to print points as SVG path.
|
||||
#[allow(unused)]
|
||||
fn print_points_as_svg_path(line_corners: &Vec<Corners<Point<Pixels>>>, points: &[Point<Pixels>]) {
|
||||
for corners in line_corners {
|
||||
println!(
|
||||
"tl: ({}, {}), tr: ({}, {}), bl: ({}, {}), br: ({}, {})",
|
||||
corners.top_left.x.0 as i32,
|
||||
corners.top_left.y.0 as i32,
|
||||
corners.top_right.x.0 as i32,
|
||||
corners.top_right.y.0 as i32,
|
||||
corners.bottom_left.x.0 as i32,
|
||||
corners.bottom_left.y.0 as i32,
|
||||
corners.bottom_right.x.0 as i32,
|
||||
corners.bottom_right.y.0 as i32,
|
||||
);
|
||||
}
|
||||
|
||||
if !points.is_empty() {
|
||||
println!("M{},{}", points[0].x.0 as i32, points[0].y.0 as i32);
|
||||
for p in points.iter().skip(1) {
|
||||
println!("L{},{}", p.x.0 as i32, p.y.0 as i32);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Element for TextElement {
|
||||
type RequestLayoutState = ();
|
||||
type PrepaintState = PrepaintState;
|
||||
@@ -319,11 +354,19 @@ impl Element for TextElement {
|
||||
let mut style = Style::default();
|
||||
style.size.width = relative(1.).into();
|
||||
if self.input.read(cx).is_multi_line() {
|
||||
style.flex_grow = 1.0;
|
||||
if let Some(h) = input.height {
|
||||
style.size.height = h.into();
|
||||
style.min_size.height = window.line_height().into();
|
||||
} else {
|
||||
style.size.height = relative(1.).into();
|
||||
style.min_size.height = (input.rows.max(1) as f32 * window.line_height()).into();
|
||||
}
|
||||
} else {
|
||||
// For single-line inputs, the minimum height should be the line height
|
||||
style.size.height = window.line_height().into();
|
||||
};
|
||||
|
||||
(window.request_layout(style, [], cx), ())
|
||||
}
|
||||
|
||||
@@ -339,7 +382,7 @@ impl Element for TextElement {
|
||||
let line_height = window.line_height();
|
||||
let input = self.input.read(cx);
|
||||
let text = input.text.clone();
|
||||
let placeholder = input.placeholder.clone();
|
||||
let placeholder = self.placeholder.clone();
|
||||
let style = window.text_style();
|
||||
let mut bounds = bounds;
|
||||
|
||||
@@ -388,7 +431,6 @@ impl Element for TextElement {
|
||||
};
|
||||
|
||||
let font_size = style.font_size.to_pixels(window.rem_size());
|
||||
|
||||
let wrap_width = if multi_line {
|
||||
Some(bounds.size.width - RIGHT_MARGIN)
|
||||
} else {
|
||||
@@ -465,6 +507,32 @@ impl Element for TextElement {
|
||||
cx,
|
||||
);
|
||||
|
||||
// Set Root focused_input when self is focused
|
||||
if focused {
|
||||
let state = self.input.clone();
|
||||
|
||||
if Root::read(window, cx).focused_input.as_ref() != Some(&state) {
|
||||
Root::update(window, cx, |root, _, cx| {
|
||||
root.focused_input = Some(state);
|
||||
cx.notify();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// And reset focused_input when next_frame start
|
||||
window.on_next_frame({
|
||||
let state = self.input.clone();
|
||||
|
||||
move |window, cx| {
|
||||
if !focused && Root::read(window, cx).focused_input.as_ref() == Some(&state) {
|
||||
Root::update(window, cx, |root, _, cx| {
|
||||
root.focused_input = None;
|
||||
cx.notify();
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Paint selections
|
||||
if let Some(path) = prepaint.selection_path.take() {
|
||||
window.paint_path(path, cx.theme().element_disabled);
|
||||
@@ -475,7 +543,6 @@ impl Element for TextElement {
|
||||
let origin = bounds.origin;
|
||||
|
||||
let mut offset_y = px(0.);
|
||||
|
||||
if self.input.read(cx).masked {
|
||||
// Move down offset for vertical centering the *****
|
||||
if cfg!(target_os = "macos") {
|
||||
@@ -484,10 +551,9 @@ impl Element for TextElement {
|
||||
offset_y = px(2.5);
|
||||
}
|
||||
}
|
||||
|
||||
for line in prepaint.lines.iter() {
|
||||
let p = point(origin.x, origin.y + offset_y);
|
||||
_ = line.paint(p, line_height, gpui::TextAlign::Left, None, window, cx);
|
||||
_ = line.paint(p, line_height, TextAlign::Left, None, window, cx);
|
||||
offset_y += line.size(line_height).height;
|
||||
}
|
||||
|
||||
|
||||
380
crates/ui/src/input/mask_pattern.rs
Normal file
380
crates/ui/src/input/mask_pattern.rs
Normal file
@@ -0,0 +1,380 @@
|
||||
use gpui::SharedString;
|
||||
|
||||
#[derive(Clone, PartialEq, Debug)]
|
||||
pub enum MaskToken {
|
||||
/// 0 Digit, equivalent to `[0]`
|
||||
// Digit0,
|
||||
/// Digit, equivalent to `[0-9]`
|
||||
Digit,
|
||||
/// Letter, equivalent to `[a-zA-Z]`
|
||||
Letter,
|
||||
/// Letter or digit, equivalent to `[a-zA-Z0-9]`
|
||||
LetterOrDigit,
|
||||
/// Separator
|
||||
Sep(char),
|
||||
/// Any character
|
||||
Any,
|
||||
}
|
||||
|
||||
#[allow(unused)]
|
||||
impl MaskToken {
|
||||
/// Check if the token is any character.
|
||||
pub fn is_any(&self) -> bool {
|
||||
matches!(self, MaskToken::Any)
|
||||
}
|
||||
|
||||
/// Check if the token is a match for the given character.
|
||||
///
|
||||
/// The separator is always a match any input character.
|
||||
fn is_match(&self, ch: char) -> bool {
|
||||
match self {
|
||||
MaskToken::Digit => ch.is_ascii_digit(),
|
||||
MaskToken::Letter => ch.is_ascii_alphabetic(),
|
||||
MaskToken::LetterOrDigit => ch.is_ascii_alphanumeric(),
|
||||
MaskToken::Any => true,
|
||||
MaskToken::Sep(c) => *c == ch,
|
||||
}
|
||||
}
|
||||
|
||||
/// Is the token a separator (Can be ignored)
|
||||
fn is_sep(&self) -> bool {
|
||||
matches!(self, MaskToken::Sep(_))
|
||||
}
|
||||
|
||||
/// Check if the token is a number.
|
||||
pub fn is_number(&self) -> bool {
|
||||
matches!(self, MaskToken::Digit)
|
||||
}
|
||||
|
||||
pub fn placeholder(&self) -> char {
|
||||
match self {
|
||||
MaskToken::Sep(c) => *c,
|
||||
_ => '_',
|
||||
}
|
||||
}
|
||||
|
||||
fn mask_char(&self, ch: char) -> char {
|
||||
match self {
|
||||
MaskToken::Digit | MaskToken::LetterOrDigit | MaskToken::Letter => ch,
|
||||
MaskToken::Sep(c) => *c,
|
||||
MaskToken::Any => ch,
|
||||
}
|
||||
}
|
||||
|
||||
fn unmask_char(&self, ch: char) -> Option<char> {
|
||||
match self {
|
||||
MaskToken::Digit => Some(ch),
|
||||
MaskToken::Letter => Some(ch),
|
||||
MaskToken::LetterOrDigit => Some(ch),
|
||||
MaskToken::Any => Some(ch),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
pub enum MaskPattern {
|
||||
#[default]
|
||||
None,
|
||||
Pattern {
|
||||
pattern: SharedString,
|
||||
tokens: Vec<MaskToken>,
|
||||
},
|
||||
Number {
|
||||
/// Group separator, e.g. "," or " "
|
||||
separator: Option<char>,
|
||||
/// Number of fraction digits, e.g. 2 for 123.45
|
||||
fraction: Option<usize>,
|
||||
},
|
||||
}
|
||||
|
||||
impl From<&str> for MaskPattern {
|
||||
fn from(pattern: &str) -> Self {
|
||||
Self::new(pattern)
|
||||
}
|
||||
}
|
||||
|
||||
impl MaskPattern {
|
||||
/// Create a new mask pattern
|
||||
///
|
||||
/// - `9` - Digit
|
||||
/// - `A` - Letter
|
||||
/// - `#` - Letter or Digit
|
||||
/// - `*` - Any character
|
||||
/// - other characters - Separator
|
||||
///
|
||||
/// For example:
|
||||
///
|
||||
/// - `(999)999-9999` - US phone number: (123)456-7890
|
||||
/// - `99999-9999` - ZIP code: 12345-6789
|
||||
/// - `AAAA-99-####` - Custom pattern: ABCD-12-3AB4
|
||||
/// - `*999*` - Custom pattern: (123) or [123]
|
||||
pub fn new(pattern: &str) -> Self {
|
||||
let tokens = pattern
|
||||
.chars()
|
||||
.map(|ch| match ch {
|
||||
// '0' => MaskToken::Digit0,
|
||||
'9' => MaskToken::Digit,
|
||||
'A' => MaskToken::Letter,
|
||||
'#' => MaskToken::LetterOrDigit,
|
||||
'*' => MaskToken::Any,
|
||||
_ => MaskToken::Sep(ch),
|
||||
})
|
||||
.collect();
|
||||
|
||||
Self::Pattern {
|
||||
pattern: pattern.to_owned().into(),
|
||||
tokens,
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(unused)]
|
||||
fn tokens(&self) -> Option<&Vec<MaskToken>> {
|
||||
match self {
|
||||
Self::Pattern { tokens, .. } => Some(tokens),
|
||||
Self::Number { .. } => None,
|
||||
Self::None => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a new mask pattern with group separator, e.g. "," or " "
|
||||
pub fn number(sep: Option<char>) -> Self {
|
||||
Self::Number {
|
||||
separator: sep,
|
||||
fraction: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn placeholder(&self) -> Option<String> {
|
||||
match self {
|
||||
Self::Pattern { tokens, .. } => {
|
||||
Some(tokens.iter().map(|token| token.placeholder()).collect())
|
||||
}
|
||||
Self::Number { .. } => None,
|
||||
Self::None => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Return true if the mask pattern is None or no any pattern.
|
||||
pub fn is_none(&self) -> bool {
|
||||
match self {
|
||||
Self::Pattern { tokens, .. } => tokens.is_empty(),
|
||||
Self::Number { .. } => false,
|
||||
Self::None => true,
|
||||
}
|
||||
}
|
||||
|
||||
/// Check is the mask text is valid.
|
||||
///
|
||||
/// If the mask pattern is None, always return true.
|
||||
pub fn is_valid(&self, mask_text: &str) -> bool {
|
||||
if self.is_none() {
|
||||
return true;
|
||||
}
|
||||
|
||||
let mut text_index = 0;
|
||||
let mask_text_chars: Vec<char> = mask_text.chars().collect();
|
||||
match self {
|
||||
Self::Pattern { tokens, .. } => {
|
||||
for token in tokens {
|
||||
if text_index >= mask_text_chars.len() {
|
||||
break;
|
||||
}
|
||||
|
||||
let ch = mask_text_chars[text_index];
|
||||
if token.is_match(ch) {
|
||||
text_index += 1;
|
||||
}
|
||||
}
|
||||
text_index == mask_text.len()
|
||||
}
|
||||
Self::Number { separator, .. } => {
|
||||
if mask_text.is_empty() {
|
||||
return true;
|
||||
}
|
||||
|
||||
// check if the text is valid number
|
||||
let mut parts = mask_text.split('.');
|
||||
let int_part = parts.next().unwrap_or("");
|
||||
let frac_part = parts.next();
|
||||
|
||||
if int_part.is_empty() {
|
||||
return false;
|
||||
}
|
||||
|
||||
// check if the integer part is valid
|
||||
if !int_part
|
||||
.chars()
|
||||
.all(|ch| ch.is_ascii_digit() || Some(ch) == *separator)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// check if the fraction part is valid
|
||||
if let Some(frac) = frac_part {
|
||||
if !frac
|
||||
.chars()
|
||||
.all(|ch| ch.is_ascii_digit() || Some(ch) == *separator)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
true
|
||||
}
|
||||
Self::None => true,
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if valid input char at the given position.
|
||||
pub fn is_valid_at(&self, ch: char, pos: usize) -> bool {
|
||||
if self.is_none() {
|
||||
return true;
|
||||
}
|
||||
|
||||
match self {
|
||||
Self::Pattern { tokens, .. } => {
|
||||
if let Some(token) = tokens.get(pos) {
|
||||
if token.is_match(ch) {
|
||||
return true;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
Self::Number { .. } => true,
|
||||
Self::None => true,
|
||||
}
|
||||
}
|
||||
|
||||
/// Format the text according to the mask pattern
|
||||
///
|
||||
/// For example:
|
||||
///
|
||||
/// - pattern: (999)999-999
|
||||
/// - text: 123456789
|
||||
/// - mask_text: (123)456-789
|
||||
pub fn mask(&self, text: &str) -> SharedString {
|
||||
if self.is_none() {
|
||||
return text.to_owned().into();
|
||||
}
|
||||
|
||||
match self {
|
||||
Self::Number {
|
||||
separator,
|
||||
fraction,
|
||||
} => {
|
||||
if let Some(sep) = *separator {
|
||||
// Remove the existing group separator
|
||||
let text = text.replace(sep, "");
|
||||
|
||||
let mut parts = text.split('.');
|
||||
let int_part = parts.next().unwrap_or("");
|
||||
|
||||
// Limit the fraction part to the given range, if not enough, pad with 0
|
||||
let frac_part = parts.next().map(|part| {
|
||||
part.chars()
|
||||
.take(fraction.unwrap_or(usize::MAX))
|
||||
.collect::<String>()
|
||||
});
|
||||
|
||||
// Reverse the integer part for easier grouping
|
||||
let chars: Vec<char> = int_part.chars().rev().collect();
|
||||
let mut result = String::new();
|
||||
for (i, ch) in chars.iter().enumerate() {
|
||||
if i > 0 && i % 3 == 0 {
|
||||
result.push(sep);
|
||||
}
|
||||
result.push(*ch);
|
||||
}
|
||||
let int_with_sep: String = result.chars().rev().collect();
|
||||
|
||||
let final_str = if let Some(frac) = frac_part {
|
||||
if fraction == &Some(0) {
|
||||
int_with_sep
|
||||
} else {
|
||||
format!("{}.{}", int_with_sep, frac)
|
||||
}
|
||||
} else {
|
||||
int_with_sep
|
||||
};
|
||||
return final_str.into();
|
||||
}
|
||||
|
||||
text.to_owned().into()
|
||||
}
|
||||
Self::Pattern { tokens, .. } => {
|
||||
let mut result = String::new();
|
||||
let mut text_index = 0;
|
||||
let text_chars: Vec<char> = text.chars().collect();
|
||||
for (pos, token) in tokens.iter().enumerate() {
|
||||
if text_index >= text_chars.len() {
|
||||
break;
|
||||
}
|
||||
let ch = text_chars[text_index];
|
||||
// Break if expected char is not match
|
||||
if !token.is_sep() && !self.is_valid_at(ch, pos) {
|
||||
break;
|
||||
}
|
||||
let mask_ch = token.mask_char(ch);
|
||||
result.push(mask_ch);
|
||||
if ch == mask_ch {
|
||||
text_index += 1;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
result.into()
|
||||
}
|
||||
Self::None => text.to_owned().into(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract original text from masked text
|
||||
pub fn unmask(&self, mask_text: &str) -> String {
|
||||
match self {
|
||||
Self::Number { separator, .. } => {
|
||||
if let Some(sep) = *separator {
|
||||
let mut result = String::new();
|
||||
for ch in mask_text.chars() {
|
||||
if ch == sep {
|
||||
continue;
|
||||
}
|
||||
result.push(ch);
|
||||
}
|
||||
|
||||
if result.contains('.') {
|
||||
result = result.trim_end_matches('0').to_string();
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
mask_text.to_owned()
|
||||
}
|
||||
Self::Pattern { tokens, .. } => {
|
||||
let mut result = String::new();
|
||||
let mask_text_chars: Vec<char> = mask_text.chars().collect();
|
||||
for (text_index, token) in tokens.iter().enumerate() {
|
||||
if text_index >= mask_text_chars.len() {
|
||||
break;
|
||||
}
|
||||
let ch = mask_text_chars[text_index];
|
||||
let unmask_ch = token.unmask_char(ch);
|
||||
if let Some(ch) = unmask_ch {
|
||||
result.push(ch);
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
Self::None => mask_text.to_owned(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,12 @@
|
||||
mod blink_cursor;
|
||||
mod change;
|
||||
mod element;
|
||||
#[allow(clippy::module_inception)]
|
||||
mod input;
|
||||
mod mask_pattern;
|
||||
mod state;
|
||||
mod text_input;
|
||||
|
||||
pub use input::*;
|
||||
pub(crate) mod clear_button;
|
||||
|
||||
#[allow(ambiguous_glob_reexports)]
|
||||
pub use state::*;
|
||||
pub use text_input::*;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
302
crates/ui/src/input/text_input.rs
Normal file
302
crates/ui/src/input/text_input.rs
Normal file
@@ -0,0 +1,302 @@
|
||||
use gpui::{
|
||||
div, prelude::FluentBuilder as _, px, relative, AnyElement, App, DefiniteLength, Entity,
|
||||
InteractiveElement as _, IntoElement, MouseButton, ParentElement as _, Rems, RenderOnce,
|
||||
Styled, Window,
|
||||
};
|
||||
use theme::ActiveTheme;
|
||||
|
||||
use super::InputState;
|
||||
use crate::{
|
||||
button::{Button, ButtonVariants as _},
|
||||
h_flex,
|
||||
indicator::Indicator,
|
||||
input::clear_button::clear_button,
|
||||
scroll::{Scrollbar, ScrollbarAxis},
|
||||
IconName, Sizable, Size, StyleSized,
|
||||
};
|
||||
|
||||
#[derive(IntoElement)]
|
||||
pub struct TextInput {
|
||||
state: Entity<InputState>,
|
||||
size: Size,
|
||||
no_gap: bool,
|
||||
prefix: Option<AnyElement>,
|
||||
suffix: Option<AnyElement>,
|
||||
height: Option<DefiniteLength>,
|
||||
appearance: bool,
|
||||
cleanable: bool,
|
||||
mask_toggle: bool,
|
||||
disabled: bool,
|
||||
}
|
||||
|
||||
impl Sizable for TextInput {
|
||||
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`].
|
||||
pub fn new(state: &Entity<InputState>) -> Self {
|
||||
Self {
|
||||
state: state.clone(),
|
||||
size: Size::default(),
|
||||
no_gap: false,
|
||||
prefix: None,
|
||||
suffix: None,
|
||||
height: None,
|
||||
appearance: true,
|
||||
cleanable: false,
|
||||
mask_toggle: false,
|
||||
disabled: false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn prefix(mut self, prefix: impl IntoElement) -> Self {
|
||||
self.prefix = Some(prefix.into_any_element());
|
||||
self
|
||||
}
|
||||
|
||||
pub fn suffix(mut self, suffix: impl IntoElement) -> Self {
|
||||
self.suffix = Some(suffix.into_any_element());
|
||||
self
|
||||
}
|
||||
|
||||
/// Set full height of the input (Multi-line only).
|
||||
pub fn h_full(mut self) -> Self {
|
||||
self.height = Some(relative(1.));
|
||||
self
|
||||
}
|
||||
|
||||
/// Set height of the input (Multi-line only).
|
||||
pub fn h(mut self, height: impl Into<DefiniteLength>) -> Self {
|
||||
self.height = Some(height.into());
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the appearance of the input field.
|
||||
pub fn appearance(mut self, appearance: bool) -> Self {
|
||||
self.appearance = appearance;
|
||||
self
|
||||
}
|
||||
|
||||
/// Set true to show the clear button when the input field is not empty.
|
||||
pub fn cleanable(mut self) -> Self {
|
||||
self.cleanable = true;
|
||||
self
|
||||
}
|
||||
|
||||
/// Set to enable toggle button for password mask state.
|
||||
pub fn mask_toggle(mut self) -> Self {
|
||||
self.mask_toggle = true;
|
||||
self
|
||||
}
|
||||
|
||||
/// Set to disable the input field.
|
||||
pub fn disabled(mut self, disabled: bool) -> Self {
|
||||
self.disabled = disabled;
|
||||
self
|
||||
}
|
||||
|
||||
/// Set true to not use gap between input and prefix, suffix, and clear button.
|
||||
///
|
||||
/// Default: false
|
||||
#[allow(dead_code)]
|
||||
pub(super) fn no_gap(mut self) -> Self {
|
||||
self.no_gap = true;
|
||||
self
|
||||
}
|
||||
|
||||
fn render_toggle_mask_button(state: Entity<InputState>) -> impl IntoElement {
|
||||
Button::new("toggle-mask")
|
||||
.icon(IconName::Eye)
|
||||
.xsmall()
|
||||
.ghost()
|
||||
.on_mouse_down(MouseButton::Left, {
|
||||
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);
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for TextInput {
|
||||
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
const LINE_HEIGHT: Rems = Rems(1.25);
|
||||
|
||||
self.state.update(cx, |state, _| {
|
||||
state.height = self.height;
|
||||
state.disabled = self.disabled;
|
||||
});
|
||||
|
||||
let state = self.state.read(cx);
|
||||
let focused = state.focus_handle.is_focused(window);
|
||||
|
||||
let mut gap_x = match self.size {
|
||||
Size::Small => px(4.),
|
||||
Size::Large => px(8.),
|
||||
_ => px(4.),
|
||||
};
|
||||
|
||||
if self.no_gap {
|
||||
gap_x = px(0.);
|
||||
}
|
||||
|
||||
let prefix = self.prefix;
|
||||
let suffix = self.suffix;
|
||||
|
||||
let show_clear_button =
|
||||
self.cleanable && !state.loading && !state.text.is_empty() && state.is_single_line();
|
||||
|
||||
let bg = if state.disabled {
|
||||
cx.theme().surface_background
|
||||
} else {
|
||||
cx.theme().elevated_surface_background
|
||||
};
|
||||
|
||||
div()
|
||||
.id(("input", self.state.entity_id()))
|
||||
.flex()
|
||||
.key_context(crate::input::CONTEXT)
|
||||
.track_focus(&state.focus_handle)
|
||||
.when(!state.disabled, |this| {
|
||||
this.on_action(window.listener_for(&self.state, InputState::backspace))
|
||||
.on_action(window.listener_for(&self.state, InputState::delete))
|
||||
.on_action(
|
||||
window.listener_for(&self.state, InputState::delete_to_beginning_of_line),
|
||||
)
|
||||
.on_action(window.listener_for(&self.state, InputState::delete_to_end_of_line))
|
||||
.on_action(window.listener_for(&self.state, InputState::delete_previous_word))
|
||||
.on_action(window.listener_for(&self.state, InputState::delete_next_word))
|
||||
.on_action(window.listener_for(&self.state, InputState::enter))
|
||||
.on_action(window.listener_for(&self.state, InputState::escape))
|
||||
})
|
||||
.on_action(window.listener_for(&self.state, InputState::left))
|
||||
.on_action(window.listener_for(&self.state, InputState::right))
|
||||
.on_action(window.listener_for(&self.state, InputState::select_left))
|
||||
.on_action(window.listener_for(&self.state, InputState::select_right))
|
||||
.when(state.multi_line, |this| {
|
||||
this.on_action(window.listener_for(&self.state, InputState::up))
|
||||
.on_action(window.listener_for(&self.state, InputState::down))
|
||||
.on_action(window.listener_for(&self.state, InputState::select_up))
|
||||
.on_action(window.listener_for(&self.state, InputState::select_down))
|
||||
.on_action(window.listener_for(&self.state, InputState::shift_to_new_line))
|
||||
})
|
||||
.on_action(window.listener_for(&self.state, InputState::select_all))
|
||||
.on_action(window.listener_for(&self.state, InputState::select_to_start_of_line))
|
||||
.on_action(window.listener_for(&self.state, InputState::select_to_end_of_line))
|
||||
.on_action(window.listener_for(&self.state, InputState::select_to_previous_word))
|
||||
.on_action(window.listener_for(&self.state, InputState::select_to_next_word))
|
||||
.on_action(window.listener_for(&self.state, InputState::home))
|
||||
.on_action(window.listener_for(&self.state, InputState::end))
|
||||
.on_action(window.listener_for(&self.state, InputState::move_to_start))
|
||||
.on_action(window.listener_for(&self.state, InputState::move_to_end))
|
||||
.on_action(window.listener_for(&self.state, InputState::move_to_previous_word))
|
||||
.on_action(window.listener_for(&self.state, InputState::move_to_next_word))
|
||||
.on_action(window.listener_for(&self.state, InputState::select_to_start))
|
||||
.on_action(window.listener_for(&self.state, InputState::select_to_end))
|
||||
.on_action(window.listener_for(&self.state, InputState::show_character_palette))
|
||||
.on_action(window.listener_for(&self.state, InputState::copy))
|
||||
.on_action(window.listener_for(&self.state, InputState::paste))
|
||||
.on_action(window.listener_for(&self.state, InputState::cut))
|
||||
.on_action(window.listener_for(&self.state, InputState::undo))
|
||||
.on_action(window.listener_for(&self.state, InputState::redo))
|
||||
.on_key_down(window.listener_for(&self.state, InputState::on_key_down))
|
||||
.on_mouse_down(
|
||||
MouseButton::Left,
|
||||
window.listener_for(&self.state, InputState::on_mouse_down),
|
||||
)
|
||||
.on_mouse_up(
|
||||
MouseButton::Left,
|
||||
window.listener_for(&self.state, InputState::on_mouse_up),
|
||||
)
|
||||
.on_scroll_wheel(window.listener_for(&self.state, InputState::on_scroll_wheel))
|
||||
.size_full()
|
||||
.line_height(LINE_HEIGHT)
|
||||
.cursor_text()
|
||||
.input_py(self.size)
|
||||
.input_h(self.size)
|
||||
.when(state.multi_line, |this| {
|
||||
this.h_auto()
|
||||
.when_some(self.height, |this, height| this.h(height))
|
||||
})
|
||||
.when(self.appearance, |this| {
|
||||
this.bg(bg)
|
||||
.rounded(cx.theme().radius)
|
||||
.when(focused, |this| this.border_color(cx.theme().ring))
|
||||
})
|
||||
.when(prefix.is_none(), |this| this.input_pl(self.size))
|
||||
.input_pr(self.size)
|
||||
.items_center()
|
||||
.gap(gap_x)
|
||||
.children(prefix)
|
||||
// TODO: Define height here, and use it in the input element
|
||||
.child(self.state.clone())
|
||||
.child(
|
||||
h_flex()
|
||||
.id("suffix")
|
||||
.absolute()
|
||||
.gap(gap_x)
|
||||
.when(self.appearance, |this| this.bg(bg))
|
||||
.items_center()
|
||||
.when(suffix.is_none(), |this| this.pr_1())
|
||||
.right_0()
|
||||
.when(state.loading, |this| {
|
||||
this.child(Indicator::new().color(cx.theme().text_muted))
|
||||
})
|
||||
.when(self.mask_toggle, |this| {
|
||||
this.child(Self::render_toggle_mask_button(self.state.clone()))
|
||||
})
|
||||
.when(show_clear_button, |this| {
|
||||
this.child(clear_button(cx).on_click({
|
||||
let state = self.state.clone();
|
||||
move |_, window, cx| {
|
||||
state.update(cx, |state, cx| {
|
||||
state.clean(window, cx);
|
||||
})
|
||||
}
|
||||
}))
|
||||
})
|
||||
.children(suffix),
|
||||
)
|
||||
.when(state.is_multi_line(), |this| {
|
||||
let entity_id = self.state.entity_id();
|
||||
|
||||
if state.last_layout.is_some() {
|
||||
let scroll_size = state.scroll_size;
|
||||
|
||||
this.relative().child(
|
||||
div()
|
||||
.absolute()
|
||||
.top_0()
|
||||
.left_0()
|
||||
.right(px(1.))
|
||||
.bottom_0()
|
||||
.child(
|
||||
Scrollbar::vertical(
|
||||
entity_id,
|
||||
state.scrollbar_state.clone(),
|
||||
state.scroll_handle.clone(),
|
||||
scroll_size,
|
||||
)
|
||||
.axis(ScrollbarAxis::Vertical),
|
||||
),
|
||||
)
|
||||
} else {
|
||||
this
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,7 @@ mod styled;
|
||||
mod title_bar;
|
||||
mod window_border;
|
||||
|
||||
pub(crate) mod actions;
|
||||
pub mod animation;
|
||||
pub mod button;
|
||||
pub mod checkbox;
|
||||
|
||||
@@ -1,33 +1,43 @@
|
||||
use std::{cell::Cell, rc::Rc, time::Duration};
|
||||
|
||||
use gpui::{
|
||||
actions, div, prelude::FluentBuilder, px, uniform_list, AnyElement, App, AppContext, Context,
|
||||
Entity, FocusHandle, Focusable, InteractiveElement, IntoElement, KeyBinding, Length,
|
||||
ListSizingBehavior, MouseButton, ParentElement, Render, ScrollStrategy, SharedString, Styled,
|
||||
Subscription, Task, UniformListScrollHandle, Window,
|
||||
div, prelude::FluentBuilder, uniform_list, AnyElement, AppContext, Entity, FocusHandle,
|
||||
Focusable, InteractiveElement, IntoElement, KeyBinding, Length, ListSizingBehavior,
|
||||
MouseButton, ParentElement, Render, Styled, Task, UniformListScrollHandle, Window,
|
||||
};
|
||||
use gpui::{px, App, Context, EventEmitter, MouseDownEvent, ScrollStrategy, Subscription};
|
||||
use smol::Timer;
|
||||
use theme::ActiveTheme;
|
||||
|
||||
use super::loading::Loading;
|
||||
use crate::{
|
||||
input::{InputEvent, TextInput},
|
||||
actions::{Cancel, Confirm, SelectNext, SelectPrev},
|
||||
input::{InputEvent, InputState, TextInput},
|
||||
scroll::{Scrollbar, ScrollbarState},
|
||||
v_flex, Icon, IconName, Size,
|
||||
v_flex, Icon, IconName, Sizable as _, Size,
|
||||
};
|
||||
|
||||
actions!(list, [Cancel, Confirm, SelectPrev, SelectNext]);
|
||||
|
||||
pub fn init(cx: &mut App) {
|
||||
let context: Option<&str> = Some("List");
|
||||
|
||||
cx.bind_keys([
|
||||
KeyBinding::new("escape", Cancel, context),
|
||||
KeyBinding::new("enter", Confirm, context),
|
||||
KeyBinding::new("enter", Confirm { secondary: false }, context),
|
||||
KeyBinding::new("secondary-enter", Confirm { secondary: true }, context),
|
||||
KeyBinding::new("up", SelectPrev, context),
|
||||
KeyBinding::new("down", SelectNext, context),
|
||||
]);
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub enum ListEvent {
|
||||
/// Move to select item.
|
||||
Select(usize),
|
||||
/// Click on item or pressed Enter.
|
||||
Confirm(usize),
|
||||
/// Pressed ESC to deselect the item.
|
||||
Cancel,
|
||||
}
|
||||
|
||||
/// A delegate for the List.
|
||||
#[allow(unused)]
|
||||
pub trait ListDelegate: Sized + 'static {
|
||||
@@ -77,9 +87,18 @@ pub trait ListDelegate: Sized + 'static {
|
||||
None
|
||||
}
|
||||
|
||||
/// Return the confirmed index of the selected item.
|
||||
fn confirmed_index(&self, cx: &App) -> Option<usize> {
|
||||
None
|
||||
/// Returns the loading state to show the loading view.
|
||||
fn loading(&self, cx: &App) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
/// Returns a Element to show when loading, default is built-in Skeleton loading view.
|
||||
fn render_loading(
|
||||
&self,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<List<Self>>,
|
||||
) -> impl IntoElement {
|
||||
Loading
|
||||
}
|
||||
|
||||
/// Set the selected index, just store the ix, don't confirm.
|
||||
@@ -91,29 +110,56 @@ pub trait ListDelegate: Sized + 'static {
|
||||
);
|
||||
|
||||
/// Set the confirm and give the selected index, this is means user have clicked the item or pressed Enter.
|
||||
fn confirm(&mut self, ix: Option<usize>, window: &mut Window, cx: &mut Context<List<Self>>) {}
|
||||
///
|
||||
/// This will always to `set_selected_index` before confirm.
|
||||
fn confirm(&mut self, secondary: bool, window: &mut Window, cx: &mut Context<List<Self>>) {}
|
||||
|
||||
/// Cancel the selection, e.g.: Pressed ESC.
|
||||
fn cancel(&mut self, window: &mut Window, cx: &mut Context<List<Self>>) {}
|
||||
|
||||
/// Return true to enable load more data when scrolling to the bottom.
|
||||
///
|
||||
/// Default: true
|
||||
fn can_load_more(&self, cx: &App) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
/// Returns a threshold value (n rows), of course, when scrolling to the bottom,
|
||||
/// the remaining number of rows triggers `load_more`.
|
||||
/// This should smaller than the total number of first load rows.
|
||||
///
|
||||
/// Default: 20 rows
|
||||
fn load_more_threshold(&self) -> usize {
|
||||
20
|
||||
}
|
||||
|
||||
/// Load more data when the table is scrolled to the bottom.
|
||||
///
|
||||
/// This will performed in a background task.
|
||||
///
|
||||
/// This is always called when the table is near the bottom,
|
||||
/// so you must check if there is more data to load or lock the loading state.
|
||||
fn load_more(&mut self, window: &mut Window, cx: &mut Context<List<Self>>) {}
|
||||
}
|
||||
|
||||
pub struct List<D: ListDelegate> {
|
||||
focus_handle: FocusHandle,
|
||||
delegate: D,
|
||||
max_height: Option<Length>,
|
||||
query_input: Option<Entity<TextInput>>,
|
||||
query_input: Option<Entity<InputState>>,
|
||||
last_query: Option<String>,
|
||||
loading: bool,
|
||||
|
||||
enable_scrollbar: bool,
|
||||
selectable: bool,
|
||||
querying: bool,
|
||||
scrollbar_visible: bool,
|
||||
vertical_scroll_handle: UniformListScrollHandle,
|
||||
scrollbar_state: Rc<Cell<ScrollbarState>>,
|
||||
|
||||
pub(crate) size: Size,
|
||||
selected_index: Option<usize>,
|
||||
right_clicked_index: Option<usize>,
|
||||
reset_on_cancel: bool,
|
||||
_search_task: Task<()>,
|
||||
query_input_subscription: Subscription,
|
||||
_load_more_task: Task<()>,
|
||||
_query_input_subscription: Subscription,
|
||||
}
|
||||
|
||||
impl<D> List<D>
|
||||
@@ -121,15 +167,8 @@ where
|
||||
D: ListDelegate,
|
||||
{
|
||||
pub fn new(delegate: D, window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||
let query_input = cx.new(|cx| {
|
||||
TextInput::new(window, cx)
|
||||
.appearance(false)
|
||||
.prefix(|_window, cx| Icon::new(IconName::Search).text_color(cx.theme().text_muted))
|
||||
.placeholder("Search...")
|
||||
.cleanable()
|
||||
});
|
||||
|
||||
let query_input_subscription =
|
||||
let query_input = cx.new(|cx| InputState::new(window, cx).placeholder("Search..."));
|
||||
let _query_input_subscription =
|
||||
cx.subscribe_in(&query_input, window, Self::on_query_input_event);
|
||||
|
||||
Self {
|
||||
@@ -142,21 +181,19 @@ where
|
||||
vertical_scroll_handle: UniformListScrollHandle::new(),
|
||||
scrollbar_state: Rc::new(Cell::new(ScrollbarState::new())),
|
||||
max_height: None,
|
||||
enable_scrollbar: true,
|
||||
loading: false,
|
||||
scrollbar_visible: true,
|
||||
selectable: true,
|
||||
querying: false,
|
||||
size: Size::default(),
|
||||
reset_on_cancel: true,
|
||||
_search_task: Task::ready(()),
|
||||
query_input_subscription,
|
||||
_load_more_task: Task::ready(()),
|
||||
_query_input_subscription,
|
||||
}
|
||||
}
|
||||
|
||||
/// Set the size
|
||||
pub fn set_size(&mut self, size: Size, window: &mut Window, cx: &mut Context<Self>) {
|
||||
if let Some(input) = &self.query_input {
|
||||
input.update(cx, |input, cx| {
|
||||
input.set_size(size, window, cx);
|
||||
})
|
||||
}
|
||||
pub fn set_size(&mut self, size: Size, _: &mut Window, _: &mut Context<Self>) {
|
||||
self.size = size;
|
||||
}
|
||||
|
||||
@@ -165,8 +202,9 @@ where
|
||||
self
|
||||
}
|
||||
|
||||
pub fn no_scrollbar(mut self) -> Self {
|
||||
self.enable_scrollbar = false;
|
||||
/// Set the visibility of the scrollbar, default is true.
|
||||
pub fn scrollbar_visible(mut self, visible: bool) -> Self {
|
||||
self.scrollbar_visible = visible;
|
||||
self
|
||||
}
|
||||
|
||||
@@ -175,17 +213,28 @@ where
|
||||
self
|
||||
}
|
||||
|
||||
/// Sets whether the list is selectable, default is true.
|
||||
pub fn selectable(mut self, selectable: bool) -> Self {
|
||||
self.selectable = selectable;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn set_query_input(
|
||||
&mut self,
|
||||
query_input: Entity<TextInput>,
|
||||
query_input: Entity<InputState>,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
self.query_input_subscription =
|
||||
self._query_input_subscription =
|
||||
cx.subscribe_in(&query_input, window, Self::on_query_input_event);
|
||||
self.query_input = Some(query_input);
|
||||
}
|
||||
|
||||
/// Get the query input entity.
|
||||
pub fn query_input(&self) -> Option<&Entity<InputState>> {
|
||||
self.query_input.as_ref()
|
||||
}
|
||||
|
||||
pub fn delegate(&self) -> &D {
|
||||
&self.delegate
|
||||
}
|
||||
@@ -198,6 +247,7 @@ where
|
||||
self.focus_handle(cx).focus(window);
|
||||
}
|
||||
|
||||
/// Set the selected index of the list, this will also scroll to the selected item.
|
||||
pub fn set_selected_index(
|
||||
&mut self,
|
||||
ix: Option<usize>,
|
||||
@@ -206,31 +256,15 @@ where
|
||||
) {
|
||||
self.selected_index = ix;
|
||||
self.delegate.set_selected_index(ix, window, cx);
|
||||
self.scroll_to_selected_item(window, cx);
|
||||
}
|
||||
|
||||
pub fn selected_index(&self) -> Option<usize> {
|
||||
self.selected_index
|
||||
}
|
||||
|
||||
/// Set the query_input text
|
||||
pub fn set_query(&mut self, query: &str, window: &mut Window, cx: &mut Context<Self>) {
|
||||
if let Some(query_input) = &self.query_input {
|
||||
let query = query.to_owned();
|
||||
query_input.update(cx, |input, cx| input.set_text(query, window, cx))
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the query_input text
|
||||
pub fn query(&self, _window: &mut Window, cx: &mut Context<Self>) -> Option<SharedString> {
|
||||
self.query_input.as_ref().map(|input| input.read(cx).text())
|
||||
}
|
||||
|
||||
fn render_scrollbar(
|
||||
&self,
|
||||
_window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Option<impl IntoElement> {
|
||||
if !self.enable_scrollbar {
|
||||
fn render_scrollbar(&self, _: &mut Window, cx: &mut Context<Self>) -> Option<impl IntoElement> {
|
||||
if !self.scrollbar_visible {
|
||||
return None;
|
||||
}
|
||||
|
||||
@@ -241,6 +275,18 @@ where
|
||||
))
|
||||
}
|
||||
|
||||
/// Scroll to the item at the given index.
|
||||
pub fn scroll_to_item(&mut self, ix: usize, _: &mut Window, cx: &mut Context<Self>) {
|
||||
self.vertical_scroll_handle
|
||||
.scroll_to_item(ix, ScrollStrategy::Top);
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// Get scroll handle
|
||||
pub fn scroll_handle(&self) -> &UniformListScrollHandle {
|
||||
&self.vertical_scroll_handle
|
||||
}
|
||||
|
||||
fn scroll_to_selected_item(&mut self, _window: &mut Window, _cx: &mut Context<Self>) {
|
||||
if let Some(ix) = self.selected_index {
|
||||
self.vertical_scroll_handle
|
||||
@@ -250,7 +296,7 @@ where
|
||||
|
||||
fn on_query_input_event(
|
||||
&mut self,
|
||||
_: &Entity<TextInput>,
|
||||
_: &Entity<InputState>,
|
||||
event: &InputEvent,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
@@ -262,9 +308,15 @@ where
|
||||
return;
|
||||
}
|
||||
|
||||
self.set_loading(true, window, cx);
|
||||
self.set_querying(true, window, cx);
|
||||
let search = self.delegate.perform_search(&text, window, cx);
|
||||
|
||||
if self.delegate.items_count(cx) > 0 {
|
||||
self.set_selected_index(Some(0), window, cx);
|
||||
} else {
|
||||
self.set_selected_index(None, window, cx);
|
||||
}
|
||||
|
||||
self._search_task = cx.spawn_in(window, async move |this, window| {
|
||||
search.await;
|
||||
|
||||
@@ -277,35 +329,97 @@ where
|
||||
// Always wait 100ms to avoid flicker
|
||||
Timer::after(Duration::from_millis(100)).await;
|
||||
_ = this.update_in(window, |this, window, cx| {
|
||||
this.set_loading(false, window, cx);
|
||||
this.set_querying(false, window, cx);
|
||||
});
|
||||
});
|
||||
}
|
||||
InputEvent::PressEnter => self.on_action_confirm(&Confirm, window, cx),
|
||||
InputEvent::PressEnter { secondary } => self.on_action_confirm(
|
||||
&Confirm {
|
||||
secondary: *secondary,
|
||||
},
|
||||
window,
|
||||
cx,
|
||||
),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn set_loading(&mut self, loading: bool, _window: &mut Window, cx: &mut Context<Self>) {
|
||||
self.loading = loading;
|
||||
fn set_querying(&mut self, querying: bool, _: &mut Window, cx: &mut Context<Self>) {
|
||||
self.querying = querying;
|
||||
if let Some(input) = &self.query_input {
|
||||
input.update(cx, |input, cx| input.set_loading(loading, cx))
|
||||
input.update(cx, |input, cx| input.set_loading(querying, cx))
|
||||
}
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// Dispatch delegate's `load_more` method when the visible range is near the end.
|
||||
fn load_more_if_need(
|
||||
&mut self,
|
||||
items_count: usize,
|
||||
visible_end: usize,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let threshold = self.delegate.load_more_threshold();
|
||||
// Securely handle subtract logic to prevent attempt to subtract with overflow
|
||||
if visible_end >= items_count.saturating_sub(threshold) {
|
||||
if !self.delegate.can_load_more(cx) {
|
||||
return;
|
||||
}
|
||||
|
||||
self._load_more_task = cx.spawn_in(window, async move |view, cx| {
|
||||
_ = view.update_in(cx, |view, window, cx| {
|
||||
view.delegate.load_more(window, cx);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn reset_on_cancel(mut self, reset: bool) -> Self {
|
||||
self.reset_on_cancel = reset;
|
||||
self
|
||||
}
|
||||
|
||||
fn on_action_cancel(&mut self, _: &Cancel, window: &mut Window, cx: &mut Context<Self>) {
|
||||
if self.selected_index.is_none() {
|
||||
cx.propagate();
|
||||
}
|
||||
|
||||
if self.reset_on_cancel {
|
||||
self.set_selected_index(None, window, cx);
|
||||
}
|
||||
|
||||
self.delegate.cancel(window, cx);
|
||||
cx.emit(ListEvent::Cancel);
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn on_action_confirm(&mut self, _: &Confirm, window: &mut Window, cx: &mut Context<Self>) {
|
||||
fn on_action_confirm(
|
||||
&mut self,
|
||||
confirm: &Confirm,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
if self.delegate.items_count(cx) == 0 {
|
||||
return;
|
||||
}
|
||||
|
||||
self.delegate.confirm(self.selected_index, window, cx);
|
||||
let Some(ix) = self.selected_index else {
|
||||
return;
|
||||
};
|
||||
|
||||
self.delegate
|
||||
.set_selected_index(self.selected_index, window, cx);
|
||||
self.delegate.confirm(confirm.secondary, window, cx);
|
||||
cx.emit(ListEvent::Confirm(ix));
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn select_item(&mut self, ix: usize, window: &mut Window, cx: &mut Context<Self>) {
|
||||
self.selected_index = Some(ix);
|
||||
self.delegate.set_selected_index(Some(ix), window, cx);
|
||||
self.scroll_to_selected_item(window, cx);
|
||||
cx.emit(ListEvent::Select(ix));
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
@@ -315,21 +429,18 @@ where
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
if self.delegate.items_count(cx) == 0 {
|
||||
let items_count = self.delegate.items_count(cx);
|
||||
if items_count == 0 {
|
||||
return;
|
||||
}
|
||||
|
||||
let selected_index = self.selected_index.unwrap_or(0);
|
||||
let mut selected_index = self.selected_index.unwrap_or(0);
|
||||
if selected_index > 0 {
|
||||
self.selected_index = Some(selected_index - 1);
|
||||
selected_index -= 1;
|
||||
} else {
|
||||
self.selected_index = Some(self.delegate.items_count(cx) - 1);
|
||||
selected_index = items_count - 1;
|
||||
}
|
||||
|
||||
self.delegate
|
||||
.set_selected_index(self.selected_index, window, cx);
|
||||
self.scroll_to_selected_item(window, cx);
|
||||
cx.notify();
|
||||
self.select_item(selected_index, window, cx);
|
||||
}
|
||||
|
||||
fn on_action_select_next(
|
||||
@@ -338,24 +449,25 @@ where
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
if self.delegate.items_count(cx) == 0 {
|
||||
let items_count = self.delegate.items_count(cx);
|
||||
if items_count == 0 {
|
||||
return;
|
||||
}
|
||||
|
||||
if let Some(selected_index) = self.selected_index {
|
||||
if selected_index < self.delegate.items_count(cx) - 1 {
|
||||
self.selected_index = Some(selected_index + 1);
|
||||
let selected_index;
|
||||
if let Some(ix) = self.selected_index {
|
||||
if ix < items_count - 1 {
|
||||
selected_index = ix + 1;
|
||||
} else {
|
||||
self.selected_index = Some(0);
|
||||
// When the last item is selected, select the first item.
|
||||
selected_index = 0;
|
||||
}
|
||||
} else {
|
||||
self.selected_index = Some(0);
|
||||
// When no selected index, select the first item.
|
||||
selected_index = 0;
|
||||
}
|
||||
|
||||
self.delegate
|
||||
.set_selected_index(self.selected_index, window, cx);
|
||||
self.scroll_to_selected_item(window, cx);
|
||||
cx.notify();
|
||||
self.select_item(selected_index, window, cx);
|
||||
}
|
||||
|
||||
fn render_list_item(
|
||||
@@ -364,13 +476,16 @@ where
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) -> impl IntoElement {
|
||||
let selected = self.selected_index == Some(ix);
|
||||
let right_clicked = self.right_clicked_index == Some(ix);
|
||||
|
||||
div()
|
||||
.id("list-item")
|
||||
.w_full()
|
||||
.relative()
|
||||
.children(self.delegate.render_item(ix, window, cx))
|
||||
.when_some(self.selected_index, |this, selected_index| {
|
||||
this.when(ix == selected_index, |this| {
|
||||
.when(self.selectable, |this| {
|
||||
this.when(selected || right_clicked, |this| {
|
||||
this.child(
|
||||
div()
|
||||
.absolute()
|
||||
@@ -378,39 +493,33 @@ where
|
||||
.left(px(0.))
|
||||
.right(px(0.))
|
||||
.bottom(px(0.))
|
||||
.bg(cx.theme().element_background)
|
||||
.when(selected, |this| this.bg(cx.theme().element_background))
|
||||
.border_1()
|
||||
.border_color(cx.theme().border_selected),
|
||||
)
|
||||
})
|
||||
})
|
||||
.when(self.right_clicked_index == Some(ix), |this| {
|
||||
this.child(
|
||||
div()
|
||||
.absolute()
|
||||
.top(px(0.))
|
||||
.left(px(0.))
|
||||
.right(px(0.))
|
||||
.bottom(px(0.))
|
||||
.border_1()
|
||||
.border_color(cx.theme().element_active),
|
||||
)
|
||||
})
|
||||
.on_mouse_down(
|
||||
MouseButton::Left,
|
||||
cx.listener(move |this, _, window, cx| {
|
||||
cx.listener(move |this, ev: &MouseDownEvent, window, cx| {
|
||||
this.right_clicked_index = None;
|
||||
this.selected_index = Some(ix);
|
||||
this.on_action_confirm(&Confirm, window, cx);
|
||||
this.on_action_confirm(
|
||||
&Confirm {
|
||||
secondary: ev.modifiers.secondary(),
|
||||
},
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
}),
|
||||
)
|
||||
.on_mouse_down(
|
||||
MouseButton::Right,
|
||||
cx.listener(move |this, _, _window, cx| {
|
||||
cx.listener(move |this, _, _, cx| {
|
||||
this.right_clicked_index = Some(ix);
|
||||
cx.notify();
|
||||
}),
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -426,7 +535,7 @@ where
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<D> EventEmitter<ListEvent> for List<D> where D: ListDelegate {}
|
||||
impl<D> Render for List<D>
|
||||
where
|
||||
D: ListDelegate,
|
||||
@@ -435,6 +544,7 @@ where
|
||||
let view = cx.entity().clone();
|
||||
let vertical_scroll_handle = self.vertical_scroll_handle.clone();
|
||||
let items_count = self.delegate.items_count(cx);
|
||||
let loading = self.delegate.loading(cx);
|
||||
let sizing_behavior = if self.max_height.is_some() {
|
||||
ListSizingBehavior::Infer
|
||||
} else {
|
||||
@@ -442,7 +552,7 @@ where
|
||||
};
|
||||
|
||||
let initial_view = if let Some(input) = &self.query_input {
|
||||
if input.read(cx).text().is_empty() {
|
||||
if input.read(cx).value().is_empty() {
|
||||
self.delegate().render_initial(window, cx)
|
||||
} else {
|
||||
None
|
||||
@@ -458,10 +568,6 @@ where
|
||||
.size_full()
|
||||
.relative()
|
||||
.overflow_hidden()
|
||||
.on_action(cx.listener(Self::on_action_cancel))
|
||||
.on_action(cx.listener(Self::on_action_confirm))
|
||||
.on_action(cx.listener(Self::on_action_select_next))
|
||||
.on_action(cx.listener(Self::on_action_select_prev))
|
||||
.when_some(self.query_input.clone(), |this, input| {
|
||||
this.child(
|
||||
div()
|
||||
@@ -471,9 +577,25 @@ where
|
||||
})
|
||||
.border_b_1()
|
||||
.border_color(cx.theme().border)
|
||||
.child(input),
|
||||
.child(
|
||||
TextInput::new(&input)
|
||||
.with_size(self.size)
|
||||
.prefix(
|
||||
Icon::new(IconName::Search).text_color(cx.theme().text_muted),
|
||||
)
|
||||
.cleanable()
|
||||
.appearance(false),
|
||||
),
|
||||
)
|
||||
})
|
||||
.when(loading, |this| {
|
||||
this.child(self.delegate().render_loading(window, cx))
|
||||
})
|
||||
.when(!loading, |this| {
|
||||
this.on_action(cx.listener(Self::on_action_cancel))
|
||||
.on_action(cx.listener(Self::on_action_confirm))
|
||||
.on_action(cx.listener(Self::on_action_select_next))
|
||||
.on_action(cx.listener(Self::on_action_select_prev))
|
||||
.map(|this| {
|
||||
if let Some(view) = initial_view {
|
||||
this.child(view)
|
||||
@@ -491,8 +613,17 @@ where
|
||||
this.child(
|
||||
uniform_list(view, "uniform-list", items_count, {
|
||||
move |list, visible_range, window, cx| {
|
||||
list.load_more_if_need(
|
||||
items_count,
|
||||
visible_range.end,
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
|
||||
visible_range
|
||||
.map(|ix| list.render_list_item(ix, window, cx))
|
||||
.map(|ix| {
|
||||
list.render_list_item(ix, window, cx)
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
}
|
||||
})
|
||||
@@ -508,10 +639,11 @@ where
|
||||
})
|
||||
// Click out to cancel right clicked row
|
||||
.when(self.right_clicked_index.is_some(), |this| {
|
||||
this.on_mouse_down_out(cx.listener(|this, _, _window, cx| {
|
||||
this.on_mouse_down_out(cx.listener(|this, _, _, cx| {
|
||||
this.right_clicked_index = None;
|
||||
cx.notify();
|
||||
}))
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
33
crates/ui/src/list/loading.rs
Normal file
33
crates/ui/src/list/loading.rs
Normal file
@@ -0,0 +1,33 @@
|
||||
use gpui::{IntoElement, ParentElement as _, RenderOnce, Styled};
|
||||
|
||||
use super::ListItem;
|
||||
use crate::{skeleton::Skeleton, v_flex};
|
||||
|
||||
#[derive(IntoElement)]
|
||||
pub struct Loading;
|
||||
|
||||
#[derive(IntoElement)]
|
||||
struct LoadingItem;
|
||||
|
||||
impl RenderOnce for LoadingItem {
|
||||
fn render(self, _window: &mut gpui::Window, _cx: &mut gpui::App) -> impl IntoElement {
|
||||
ListItem::new("skeleton").disabled(true).child(
|
||||
v_flex()
|
||||
.gap_1p5()
|
||||
.overflow_hidden()
|
||||
.child(Skeleton::new().h_5().w_48().max_w_full())
|
||||
.child(Skeleton::new().secondary(true).h_3().w_64().max_w_full()),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for Loading {
|
||||
fn render(self, _window: &mut gpui::Window, _cx: &mut gpui::App) -> impl IntoElement {
|
||||
v_flex()
|
||||
.py_2p5()
|
||||
.gap_3()
|
||||
.child(LoadingItem)
|
||||
.child(LoadingItem)
|
||||
.child(LoadingItem)
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
#[allow(clippy::module_inception)]
|
||||
mod list;
|
||||
mod list_item;
|
||||
mod loading;
|
||||
|
||||
pub use list::*;
|
||||
pub use list_item::*;
|
||||
|
||||
@@ -216,11 +216,9 @@ impl Render for Notification {
|
||||
Some(icon) => icon,
|
||||
None => match self.kind {
|
||||
NotificationType::Info => Icon::new(IconName::Info).text_color(blue()),
|
||||
NotificationType::Warning => Icon::new(IconName::Info).text_color(yellow()),
|
||||
NotificationType::Error => Icon::new(IconName::CloseCircle).text_color(red()),
|
||||
NotificationType::Success => Icon::new(IconName::CheckCircle).text_color(green()),
|
||||
NotificationType::Warning => {
|
||||
Icon::new(IconName::TriangleAlert).text_color(yellow())
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ use gpui::{
|
||||
use theme::ActiveTheme;
|
||||
|
||||
use crate::{
|
||||
input::InputState,
|
||||
modal::Modal,
|
||||
notification::{Notification, NotificationList},
|
||||
window_border,
|
||||
@@ -36,6 +37,12 @@ pub trait ContextModal: Sized {
|
||||
|
||||
/// Clear all notifications
|
||||
fn clear_notifications(&mut self, cx: &mut App);
|
||||
|
||||
/// Return current focused Input entity.
|
||||
fn focused_input(&mut self, cx: &mut App) -> Option<Entity<InputState>>;
|
||||
|
||||
/// Returns true if there is a focused Input entity.
|
||||
fn has_focused_input(&mut self, cx: &mut App) -> bool;
|
||||
}
|
||||
|
||||
impl ContextModal for Window {
|
||||
@@ -110,12 +117,20 @@ impl ContextModal for Window {
|
||||
let entity = Root::read(self, cx).notification.clone();
|
||||
Rc::new(entity.read(cx).notifications())
|
||||
}
|
||||
|
||||
fn has_focused_input(&mut self, cx: &mut App) -> bool {
|
||||
Root::read(self, cx).focused_input.is_some()
|
||||
}
|
||||
|
||||
fn focused_input(&mut self, cx: &mut App) -> Option<Entity<InputState>> {
|
||||
Root::read(self, cx).focused_input.clone()
|
||||
}
|
||||
}
|
||||
|
||||
type Builder = Rc<dyn Fn(Modal, &mut Window, &mut App) -> Modal + 'static>;
|
||||
|
||||
#[derive(Clone)]
|
||||
struct ActiveModal {
|
||||
pub struct ActiveModal {
|
||||
focus_handle: FocusHandle,
|
||||
builder: Builder,
|
||||
}
|
||||
@@ -124,11 +139,13 @@ struct ActiveModal {
|
||||
///
|
||||
/// It is used to manage the Modal, and Notification.
|
||||
pub struct Root {
|
||||
pub active_modals: Vec<ActiveModal>,
|
||||
pub notification: Entity<NotificationList>,
|
||||
pub focused_input: Option<Entity<InputState>>,
|
||||
/// Used to store the focus handle of the previous view.
|
||||
///
|
||||
/// When the Modal closes, we will focus back to the previous view.
|
||||
previous_focus_handle: Option<FocusHandle>,
|
||||
active_modals: Vec<ActiveModal>,
|
||||
pub notification: Entity<NotificationList>,
|
||||
view: AnyView,
|
||||
}
|
||||
|
||||
@@ -136,6 +153,7 @@ impl Root {
|
||||
pub fn new(view: AnyView, window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||
Self {
|
||||
previous_focus_handle: None,
|
||||
focused_input: None,
|
||||
active_modals: Vec::new(),
|
||||
notification: cx.new(|cx| NotificationList::new(window, cx)),
|
||||
view,
|
||||
|
||||
@@ -9,14 +9,21 @@ use theme::ActiveTheme;
|
||||
#[derive(IntoElement)]
|
||||
pub struct Skeleton {
|
||||
base: Div,
|
||||
secondary: bool,
|
||||
}
|
||||
|
||||
impl Skeleton {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
base: div().w_full().h_4().rounded_md(),
|
||||
secondary: false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn secondary(mut self, secondary: bool) -> Self {
|
||||
self.secondary = secondary;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for Skeleton {
|
||||
@@ -33,10 +40,14 @@ impl Styled for Skeleton {
|
||||
|
||||
impl RenderOnce for Skeleton {
|
||||
fn render(self, _window: &mut gpui::Window, cx: &mut gpui::App) -> impl IntoElement {
|
||||
let color = if self.secondary {
|
||||
cx.theme().ghost_element_active.opacity(0.5)
|
||||
} else {
|
||||
cx.theme().ghost_element_active
|
||||
};
|
||||
|
||||
div().child(
|
||||
self.base
|
||||
.bg(cx.theme().ghost_element_active)
|
||||
.with_animation(
|
||||
self.base.bg(color).with_animation(
|
||||
"skeleton",
|
||||
Animation::new(Duration::from_secs(2))
|
||||
.repeat()
|
||||
|
||||
@@ -135,7 +135,7 @@ pub trait Sizable: Sized {
|
||||
|
||||
#[allow(unused)]
|
||||
pub trait StyleSized<T: Styled> {
|
||||
fn input_text_size(self, size: Size) -> Self;
|
||||
fn input_font_size(self, size: Size) -> Self;
|
||||
fn input_size(self, size: Size) -> Self;
|
||||
fn input_pl(self, size: Size) -> Self;
|
||||
fn input_pr(self, size: Size) -> Self;
|
||||
@@ -150,7 +150,7 @@ pub trait StyleSized<T: Styled> {
|
||||
}
|
||||
|
||||
impl<T: Styled> StyleSized<T> for T {
|
||||
fn input_text_size(self, size: Size) -> Self {
|
||||
fn input_font_size(self, size: Size) -> Self {
|
||||
match size {
|
||||
Size::XSmall => self.text_xs(),
|
||||
Size::Small => self.text_sm(),
|
||||
@@ -203,11 +203,11 @@ impl<T: Styled> StyleSized<T> for T {
|
||||
Size::Large => self.h_12(),
|
||||
_ => self.h(px(24.)),
|
||||
}
|
||||
.input_text_size(size)
|
||||
.input_font_size(size)
|
||||
}
|
||||
|
||||
fn list_size(self, size: Size) -> Self {
|
||||
self.list_px(size).list_py(size).input_text_size(size)
|
||||
self.list_px(size).list_py(size).input_font_size(size)
|
||||
}
|
||||
|
||||
fn list_px(self, size: Size) -> Self {
|
||||
|
||||
Reference in New Issue
Block a user