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:
reya
2025-05-18 15:35:33 +07:00
committed by GitHub
parent 4f066b7c00
commit 443dbc82a6
37 changed files with 3060 additions and 1979 deletions

780
Cargo.lock generated

File diff suppressed because it is too large Load Diff

BIN
assets/brand/group.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 KiB

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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
View 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]);

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

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

View File

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

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

View File

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

View File

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

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

View File

@@ -1,6 +1,7 @@
#[allow(clippy::module_inception)]
mod list;
mod list_item;
mod loading;
pub use list::*;
pub use list_item::*;

View File

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

View File

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

View File

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

View File

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