feat: redesign the sidebar and workspace (#53)
Reviewed-on: #53
|
After Width: | Height: | Size: 669 KiB |
|
After Width: | Height: | Size: 598 KiB |
|
After Width: | Height: | Size: 44 KiB |
@@ -0,0 +1,3 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
|
||||||
|
<path d="M2.75 12C2.75 17.1086 6.89137 21.25 12 21.25C17.1086 21.25 21.25 17.1086 21.25 12C21.25 6.89137 17.1086 2.75 12 2.75C6.89137 2.75 2.75 6.89137 2.75 12Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M15.25 13L12 16.25L8.75 13" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M12 7.75V15.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 571 B |
@@ -0,0 +1,3 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
|
||||||
|
<path d="M8.75 3.75L6.75 20.25M17.25 3.75L15.25 20.25M3.75 7.75H20.25M20.25 16.25H3.75" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 258 B |
@@ -1,3 +1,3 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
|
||||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M9 5.5V18.5H19.25C19.9404 18.5 20.5 17.9404 20.5 17.25V6.75C20.5 6.05964 19.9404 5.5 19.25 5.5H9ZM2 6.75C2 5.23122 3.23122 4 4.75 4H19.25C20.7688 4 22 5.23122 22 6.75V17.25C22 18.7688 20.7688 20 19.25 20H4.75C3.23122 20 2 18.7688 2 17.25V6.75Z" fill="currentColor"/>
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M4.75 4C3.23122 4 2 5.23122 2 6.75V17.25C2 18.7688 3.23122 20 4.75 20H19.25C20.7688 20 22 18.7688 22 17.25V6.75C22 5.23122 20.7688 4 19.25 4H4.75ZM3.5 6.75C3.5 6.05964 4.05964 5.5 4.75 5.5H10.5V18.5H4.75C4.05964 18.5 3.5 17.9404 3.5 17.25V6.75Z" fill="currentColor"/><path fill-rule="evenodd" clip-rule="evenodd" d="M7 9.5C6.44772 9.5 6 9.05228 6 8.5C6 7.94772 6.44772 7.5 7 7.5C7.55228 7.5 8 7.94772 8 8.5C8 9.05228 7.55228 9.5 7 9.5ZM7 13C6.44772 13 6 12.5523 6 12C6 11.4477 6.44772 11 7 11C7.55228 11 8 11.4477 8 12C8 12.5523 7.55228 13 7 13ZM7 16.5C6.44772 16.5 6 16.0523 6 15.5C6 14.9477 6.44772 14.5 7 14.5C7.55228 14.5 8 14.9477 8 15.5C8 16.0523 7.55228 16.5 7 16.5Z" fill="currentColor"/>
|
||||||
</svg>
|
</svg>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 396 B After Width: | Height: | Size: 826 B |
@@ -1,3 +1,3 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
|
||||||
<path d="M2.75 6.75C2.75 5.64543 3.64543 4.75 4.75 4.75H19.25C20.3546 4.75 21.25 5.64543 21.25 6.75V17.25C21.25 18.3546 20.3546 19.25 19.25 19.25H4.75C3.64543 19.25 2.75 18.3546 2.75 17.25V6.75Z" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round"/><path d="M8.25 5V12V19" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round"/>
|
<path d="M11.25 4.75H4.75C3.64543 4.75 2.75 5.64543 2.75 6.75V17.25C2.75 18.3546 3.64543 19.25 4.75 19.25H11.25M11.25 4.75H19.25C20.3546 4.75 21.25 5.64543 21.25 6.75V17.25C21.25 18.3546 20.3546 19.25 19.25 19.25H11.25M11.25 4.75V19.25" stroke="currentColor" stroke-width="1.5" stroke-linecap="square" stroke-linejoin="round"/><path d="M6.25 8.5C6.25 8.91421 6.58579 9.25 7 9.25C7.41421 9.25 7.75 8.91421 7.75 8.5C7.75 8.08579 7.41421 7.75 7 7.75C6.58579 7.75 6.25 8.08579 6.25 8.5ZM6.25 12C6.25 12.4142 6.58579 12.75 7 12.75C7.41421 12.75 7.75 12.4142 7.75 12C7.75 11.5858 7.41421 11.25 7 11.25C6.58579 11.25 6.25 11.5858 6.25 12ZM6.25 15.5C6.25 15.9142 6.58579 16.25 7 16.25C7.41421 16.25 7.75 15.9142 7.75 15.5C7.75 15.0858 7.41421 14.75 7 14.75C6.58579 14.75 6.25 15.0858 6.25 15.5Z" fill="currentColor" stroke="currentColor" stroke-width="0.5"/>
|
||||||
</svg>
|
</svg>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 433 B After Width: | Height: | Size: 931 B |
@@ -31,7 +31,7 @@ fn uses_managed_updates() -> bool {
|
|||||||
// Allow opting out of in-app updates via an explicit environment variable.
|
// Allow opting out of in-app updates via an explicit environment variable.
|
||||||
|| std::env::var(COOP_UPDATE_EXPLANATION).is_ok()
|
|| std::env::var(COOP_UPDATE_EXPLANATION).is_ok()
|
||||||
// The Snap package sets `COOP_BUNDLE_TYPE=snap` (see snapcraft.yaml.in).
|
// The Snap package sets `COOP_BUNDLE_TYPE=snap` (see snapcraft.yaml.in).
|
||||||
|| std::env::var(COOP_BUNDLE_TYPE).is_ok_and(|value| value == "snap")
|
|| std::env::var(COOP_BUNDLE_TYPE).is_ok_and(|value | value == "snap")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Initialize the auto-update system.
|
/// Initialize the auto-update system.
|
||||||
@@ -129,6 +129,11 @@ impl AutoUpdater {
|
|||||||
matches!(self.status, UpdateStatus::Idle)
|
matches!(self.status, UpdateStatus::Idle)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whether the running version is the newest release, so the status line can be hidden.
|
||||||
|
pub fn up_to_date(&self) -> bool {
|
||||||
|
matches!(self.status, UpdateStatus::Idle | UpdateStatus::UpToDate)
|
||||||
|
}
|
||||||
|
|
||||||
/// Whether a verified update is installed and waiting for a restart.
|
/// Whether a verified update is installed and waiting for a restart.
|
||||||
pub fn staged(&self) -> bool {
|
pub fn staged(&self) -> bool {
|
||||||
matches!(self.status, UpdateStatus::Staged(_))
|
matches!(self.status, UpdateStatus::Staged(_))
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ use fuzzy_matcher::FuzzyMatcher;
|
|||||||
use fuzzy_matcher::skim::SkimMatcherV2;
|
use fuzzy_matcher::skim::SkimMatcherV2;
|
||||||
use gpui::{
|
use gpui::{
|
||||||
App, AppContext, Context, Entity, EventEmitter, Global, SharedString, Subscription, Task,
|
App, AppContext, Context, Entity, EventEmitter, Global, SharedString, Subscription, Task,
|
||||||
WeakEntity, Window,
|
WeakEntity,
|
||||||
};
|
};
|
||||||
use instant::Duration;
|
use instant::Duration;
|
||||||
use nostr_sdk::prelude::*;
|
use nostr_sdk::prelude::*;
|
||||||
@@ -37,15 +37,8 @@ impl Global for GlobalChatRegistry {}
|
|||||||
/// Chat event.
|
/// Chat event.
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
|
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
|
||||||
pub enum ChatEvent {
|
pub enum ChatEvent {
|
||||||
/// An event to open a room by its ID
|
|
||||||
OpenRoom(u64),
|
|
||||||
/// An event to close a room by its ID
|
|
||||||
CloseRoom(u64),
|
|
||||||
/// An event to notify UI about a new chat request
|
|
||||||
Ping,
|
Ping,
|
||||||
/// No Inbox Relays found, the app is not ready to subscribe messages
|
|
||||||
InboxRelayNotFound,
|
InboxRelayNotFound,
|
||||||
/// An error occurred
|
|
||||||
Error(String),
|
Error(String),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -543,34 +536,16 @@ impl ChatRegistry {
|
|||||||
cx.notify();
|
cx.notify();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Emit an open room event.
|
/// Track a room so it is listed and can be looked up by id.
|
||||||
///
|
pub fn track_room(&mut self, room: &Entity<Room>, cx: &mut Context<Self>) {
|
||||||
/// If the room is new, add it to the registry.
|
|
||||||
pub fn emit_room(&mut self, room: &Entity<Room>, window: &mut Window, cx: &mut Context<Self>) {
|
|
||||||
// Get the room's ID.
|
|
||||||
let id = room.read(cx).id;
|
let id = room.read(cx).id;
|
||||||
|
|
||||||
// If the room is new, add it to the registry and index.
|
|
||||||
if let hash_map::Entry::Vacant(e) = self.room_index.entry(id) {
|
if let hash_map::Entry::Vacant(e) = self.room_index.entry(id) {
|
||||||
let entity = room.to_owned();
|
let entity = room.to_owned();
|
||||||
e.insert(entity.clone());
|
e.insert(entity.clone());
|
||||||
|
|
||||||
self.rooms.insert(0, entity);
|
self.rooms.insert(0, entity);
|
||||||
}
|
cx.notify();
|
||||||
|
|
||||||
// Emit the open room event deferred to avoid re-entrant reads
|
|
||||||
cx.defer_in(window, move |_this, _window, cx| {
|
|
||||||
cx.emit(ChatEvent::OpenRoom(id));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Close a room.
|
|
||||||
pub fn close_room(&mut self, id: u64, window: &mut Window, cx: &mut Context<Self>) {
|
|
||||||
if self.room_index.contains_key(&id) {
|
|
||||||
self.room_index.remove(&id);
|
|
||||||
self.rooms.retain(|r| r.read(cx).id != id);
|
|
||||||
cx.defer_in(window, move |_this, _window, cx| {
|
|
||||||
cx.emit(ChatEvent::CloseRoom(id));
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -23,7 +23,4 @@ futures.workspace = true
|
|||||||
log.workspace = true
|
log.workspace = true
|
||||||
serde.workspace = true
|
serde.workspace = true
|
||||||
|
|
||||||
linkify = "0.10.0"
|
|
||||||
pulldown-cmark = "0.13.1"
|
|
||||||
regex = "1"
|
regex = "1"
|
||||||
|
|
||||||
|
|||||||
@@ -31,13 +31,11 @@ use ui::button::{Button, ButtonVariants};
|
|||||||
use ui::dock::{Panel, PanelEvent};
|
use ui::dock::{Panel, PanelEvent};
|
||||||
use ui::input::{Input, InputEvent, InputState, Textarea, TextareaState};
|
use ui::input::{Input, InputEvent, InputState, Textarea, TextareaState};
|
||||||
use ui::menu::DropdownMenu;
|
use ui::menu::DropdownMenu;
|
||||||
|
use ui::message::{MessageRow, WelcomeMessage};
|
||||||
use ui::notification::Notification;
|
use ui::notification::Notification;
|
||||||
use ui::scroll::Scrollbar;
|
use ui::scroll::Scrollbar;
|
||||||
use ui::tooltip::Tooltip;
|
use ui::tooltip::Tooltip;
|
||||||
use ui::{
|
use ui::{Disableable, Icon, IconName, Sizable, StyledExt, WindowExtension, h_flex, v_flex};
|
||||||
Disableable, Icon, IconName, InteractiveElementExt, Sizable, StyledExt, WindowExtension,
|
|
||||||
h_flex, v_flex,
|
|
||||||
};
|
|
||||||
|
|
||||||
use crate::file::*;
|
use crate::file::*;
|
||||||
use crate::text::RenderedText;
|
use crate::text::RenderedText;
|
||||||
@@ -45,6 +43,10 @@ use crate::text::RenderedText;
|
|||||||
const REACTION_EMOJIS: &[&str] = &["👍", "👎", "😄", "🎉", "😕", "❤️", "🚀", "👀"];
|
const REACTION_EMOJIS: &[&str] = &["👍", "👎", "😄", "🎉", "😕", "❤️", "🚀", "👀"];
|
||||||
const COMPACT_REACTION_EMOJIS: &[&str] = &["👍", "❤️", "👀"];
|
const COMPACT_REACTION_EMOJIS: &[&str] = &["👍", "❤️", "👀"];
|
||||||
|
|
||||||
|
/// Notice shown when a conversation has no messages, and above the message list.
|
||||||
|
const PRIVATE_NOTICE: &str =
|
||||||
|
"This conversation is private. Only members can see each other's messages.";
|
||||||
|
|
||||||
/// Regex matching strings that consist entirely of emoji characters,
|
/// Regex matching strings that consist entirely of emoji characters,
|
||||||
/// zero-width joiners, variation selectors, and keycap combiners.
|
/// zero-width joiners, variation selectors, and keycap combiners.
|
||||||
static EMOJI_RE: LazyLock<Regex> =
|
static EMOJI_RE: LazyLock<Regex> =
|
||||||
@@ -126,9 +128,8 @@ impl ChatPanel {
|
|||||||
let replies_to = cx.new(|_| HashSet::new());
|
let replies_to = cx.new(|_| HashSet::new());
|
||||||
let reports_by_id = Arc::new(RwLock::new(BTreeMap::new()));
|
let reports_by_id = Arc::new(RwLock::new(BTreeMap::new()));
|
||||||
|
|
||||||
// Define list of messages
|
|
||||||
let messages = Vec::new();
|
let messages = Vec::new();
|
||||||
let list_state = ListState::new(messages.len(), ListAlignment::Bottom, px(1024.));
|
let list_state = ListState::new(messages.len() + 1, ListAlignment::Bottom, px(1024.));
|
||||||
|
|
||||||
// Get room id and name
|
// Get room id and name
|
||||||
let (id, name) = room
|
let (id, name) = room
|
||||||
@@ -577,7 +578,6 @@ impl ChatPanel {
|
|||||||
where
|
where
|
||||||
E: Into<Message>,
|
E: Into<Message>,
|
||||||
{
|
{
|
||||||
let old_len = self.messages.len();
|
|
||||||
let msg: Message = m.into();
|
let msg: Message = m.into();
|
||||||
|
|
||||||
if let Err(pos) = self.messages.binary_search(&msg) {
|
if let Err(pos) = self.messages.binary_search(&msg) {
|
||||||
@@ -586,7 +586,8 @@ impl ChatPanel {
|
|||||||
for (i, message) in self.messages.iter().enumerate().skip(pos) {
|
for (i, message) in self.messages.iter().enumerate().skip(pos) {
|
||||||
self.message_index.insert(message.id, i);
|
self.message_index.insert(message.id, i);
|
||||||
}
|
}
|
||||||
self.list_state.splice(old_len..old_len, 1);
|
let len = self.list_state.item_count();
|
||||||
|
self.list_state.splice(len..len, 1);
|
||||||
|
|
||||||
if scroll {
|
if scroll {
|
||||||
self.list_state.scroll_to(ListOffset {
|
self.list_state.scroll_to(ListOffset {
|
||||||
@@ -657,7 +658,8 @@ impl ChatPanel {
|
|||||||
/// Scroll to a message by its ID
|
/// Scroll to a message by its ID
|
||||||
fn scroll_to(&self, id: &EventId) {
|
fn scroll_to(&self, id: &EventId) {
|
||||||
if let Some(ix) = self.messages.iter().position(|msg| &msg.id == id) {
|
if let Some(ix) = self.messages.iter().position(|msg| &msg.id == id) {
|
||||||
self.list_state.scroll_to_reveal_item(ix);
|
// The welcome message occupies the first row, so message rows sit one ahead.
|
||||||
|
self.list_state.scroll_to_reveal_item(ix + 1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1051,10 +1053,19 @@ impl ChatPanel {
|
|||||||
cx.open_url(&content);
|
cx.open_url(&content);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn render_announcement(&self, cx: &Context<Self>) -> AnyElement {
|
fn render_welcome(&self, cx: &Context<Self>) -> AnyElement {
|
||||||
const MSG: &str =
|
WelcomeMessage::new("welcome")
|
||||||
"This conversation is private. Only members can see each other's messages.";
|
.icon(
|
||||||
|
svg()
|
||||||
|
.path("brand/coop.svg")
|
||||||
|
.size_12()
|
||||||
|
.text_color(cx.theme().ghost_element_background_alt),
|
||||||
|
)
|
||||||
|
.message(PRIVATE_NOTICE)
|
||||||
|
.into_any_element()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_announcement(&self, cx: &Context<Self>) -> AnyElement {
|
||||||
v_flex()
|
v_flex()
|
||||||
.h_40()
|
.h_40()
|
||||||
.w_full()
|
.w_full()
|
||||||
@@ -1072,7 +1083,7 @@ impl ChatPanel {
|
|||||||
.size_12()
|
.size_12()
|
||||||
.text_color(cx.theme().ghost_element_active),
|
.text_color(cx.theme().ghost_element_active),
|
||||||
)
|
)
|
||||||
.child(MSG)
|
.child(PRIVATE_NOTICE)
|
||||||
.into_any_element()
|
.into_any_element()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1141,7 +1152,14 @@ impl ChatPanel {
|
|||||||
window: &mut Window,
|
window: &mut Window,
|
||||||
cx: &mut Context<Self>,
|
cx: &mut Context<Self>,
|
||||||
) -> AnyElement {
|
) -> AnyElement {
|
||||||
let file = self.messages.get(ix).and_then(|message| {
|
if ix == 0 {
|
||||||
|
return self.render_welcome(cx);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The welcome message occupies the first row, so message rows sit one ahead.
|
||||||
|
let index = ix - 1;
|
||||||
|
|
||||||
|
let file = self.messages.get(index).and_then(|message| {
|
||||||
let file = message.file.clone()?;
|
let file = message.file.clone()?;
|
||||||
(!self.decrypted_files.contains_key(&message.id) && file.is_image())
|
(!self.decrypted_files.contains_key(&message.id) && file.is_image())
|
||||||
.then_some((message.id, file))
|
.then_some((message.id, file))
|
||||||
@@ -1151,14 +1169,14 @@ impl ChatPanel {
|
|||||||
self.load_file(id, file, cx);
|
self.load_file(id, file, cx);
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(message) = self.messages.get(ix) {
|
if let Some(message) = self.messages.get(index) {
|
||||||
let persons = PersonRegistry::global(cx);
|
let persons = PersonRegistry::global(cx);
|
||||||
let show_author = self.is_group_start(ix);
|
let show_author = self.is_group_start(index);
|
||||||
let text = self
|
let text = self
|
||||||
.rendered_texts_by_id
|
.rendered_texts_by_id
|
||||||
.entry(message.id)
|
.entry(message.id)
|
||||||
.or_insert_with(|| {
|
.or_insert_with(|| {
|
||||||
RenderedText::new(&message.content, &message.mentions, &persons, true, cx)
|
text::rendered_text(&message.content, &message.mentions, &persons, true, cx)
|
||||||
})
|
})
|
||||||
.element(ix.into(), window, cx);
|
.element(ix.into(), window, cx);
|
||||||
|
|
||||||
@@ -1188,68 +1206,38 @@ impl ChatPanel {
|
|||||||
// Hide avatar setting
|
// Hide avatar setting
|
||||||
let hide_avatar = AppSettings::get_hide_avatar(cx);
|
let hide_avatar = AppSettings::get_hide_avatar(cx);
|
||||||
|
|
||||||
div()
|
MessageRow::new(ix)
|
||||||
.id(ix)
|
.show_author(show_author)
|
||||||
.group("")
|
.hide_avatar(hide_avatar)
|
||||||
.relative()
|
.avatar(
|
||||||
.w_full()
|
Avatar::new(author.avatar())
|
||||||
.py_1()
|
.seed(author.avatar_seed())
|
||||||
.px_3()
|
.flex_shrink_0()
|
||||||
.child(
|
.relative()
|
||||||
div()
|
.dropdown_menu(move |this, _window, _cx| {
|
||||||
.flex()
|
this.menu("Public Key", Box::new(Command::Copy(pk)))
|
||||||
.gap_3()
|
.menu("View Relays", Box::new(Command::Relays(pk)))
|
||||||
.when(!hide_avatar, |this| {
|
.separator()
|
||||||
if show_author {
|
.menu("View on njump.me", Box::new(Command::Njump(pk)))
|
||||||
this.child(
|
}),
|
||||||
Avatar::new(author.avatar())
|
|
||||||
.seed(author.avatar_seed())
|
|
||||||
.flex_shrink_0()
|
|
||||||
.relative()
|
|
||||||
.dropdown_menu(move |this, _window, _cx| {
|
|
||||||
this.menu("Public Key", Box::new(Command::Copy(pk)))
|
|
||||||
.menu("View Relays", Box::new(Command::Relays(pk)))
|
|
||||||
.separator()
|
|
||||||
.menu("View on njump.me", Box::new(Command::Njump(pk)))
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
this.child(div().flex_shrink_0().w(px(32.)))
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.child(
|
|
||||||
v_flex()
|
|
||||||
.flex_1()
|
|
||||||
.w_full()
|
|
||||||
.flex_initial()
|
|
||||||
.overflow_hidden()
|
|
||||||
.when(show_author, |this| {
|
|
||||||
this.child(
|
|
||||||
h_flex()
|
|
||||||
.gap_2()
|
|
||||||
.text_sm()
|
|
||||||
.text_color(cx.theme().text_placeholder)
|
|
||||||
.child(div().font_semibold().child(author.name()))
|
|
||||||
.child(message.created_at.to_human_time())
|
|
||||||
.when(has_reports, |this| {
|
|
||||||
this.child(self.render_sent_reports(&id, cx))
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
.when(has_replies, |this| {
|
|
||||||
this.children(self.render_message_replies(replies, cx))
|
|
||||||
})
|
|
||||||
.when(message.file.is_none(), |this| this.child(rendered_text))
|
|
||||||
.child(self.render_media(&message.media, cx))
|
|
||||||
.when_some(message.file.as_ref(), |this, file| {
|
|
||||||
this.child(self.render_message_file(&id, file, cx))
|
|
||||||
})
|
|
||||||
.when(has_reactions, |this| {
|
|
||||||
this.child(self.render_reactions(&id, cx))
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
.child(
|
.author(author.name())
|
||||||
|
.timestamp(message.created_at.to_human_time())
|
||||||
|
.when(has_reports, |this| {
|
||||||
|
this.header_extra(self.render_sent_reports(&id, cx))
|
||||||
|
})
|
||||||
|
.when(has_replies, |this| {
|
||||||
|
this.children(self.render_message_replies(replies, cx))
|
||||||
|
})
|
||||||
|
.when(message.file.is_none(), |this| this.child(rendered_text))
|
||||||
|
.child(self.render_media(&message.media, cx))
|
||||||
|
.when_some(message.file.as_ref(), |this, file| {
|
||||||
|
this.child(self.render_message_file(&id, file, cx))
|
||||||
|
})
|
||||||
|
.when(has_reactions, |this| {
|
||||||
|
this.child(self.render_reactions(&id, cx))
|
||||||
|
})
|
||||||
|
.overlay(
|
||||||
div()
|
div()
|
||||||
.group_hover("", |this| this.bg(cx.theme().element_active))
|
.group_hover("", |this| this.bg(cx.theme().element_active))
|
||||||
.absolute()
|
.absolute()
|
||||||
@@ -1259,7 +1247,7 @@ impl ChatPanel {
|
|||||||
.h_full()
|
.h_full()
|
||||||
.bg(cx.theme().border_transparent),
|
.bg(cx.theme().border_transparent),
|
||||||
)
|
)
|
||||||
.child(self.render_actions(&id, &pk, cx))
|
.overlay(self.render_actions(&id, &pk, cx))
|
||||||
.on_mouse_down(
|
.on_mouse_down(
|
||||||
MouseButton::Middle,
|
MouseButton::Middle,
|
||||||
cx.listener(move |this, _, _window, cx| {
|
cx.listener(move |this, _, _window, cx| {
|
||||||
@@ -1269,7 +1257,6 @@ impl ChatPanel {
|
|||||||
.on_double_click(cx.listener(move |this, _, _window, cx| {
|
.on_double_click(cx.listener(move |this, _, _window, cx| {
|
||||||
this.reply_to(&id, cx);
|
this.reply_to(&id, cx);
|
||||||
}))
|
}))
|
||||||
.hover(|this| this.bg(cx.theme().surface_background))
|
|
||||||
.into_any_element()
|
.into_any_element()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1983,12 +1970,13 @@ impl Panel for ChatPanel {
|
|||||||
let seed = this.display_image_seed(cx);
|
let seed = this.display_image_seed(cx);
|
||||||
|
|
||||||
h_flex()
|
h_flex()
|
||||||
.gap_1p5()
|
.gap_1()
|
||||||
|
.text_xs()
|
||||||
.child(Avatar::new(picture).seed(seed).xsmall())
|
.child(Avatar::new(picture).seed(seed).xsmall())
|
||||||
.child(label)
|
.child(label)
|
||||||
.into_any_element()
|
.into_any_element()
|
||||||
})
|
})
|
||||||
.unwrap_or(div().child("Unknown").into_any_element())
|
.unwrap_or(div().text_xs().child("Unknown").into_any_element())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn toolbar_buttons(&self, _window: &Window, _cx: &App) -> Vec<Button> {
|
fn toolbar_buttons(&self, _window: &Window, _cx: &App) -> Vec<Button> {
|
||||||
|
|||||||
@@ -1,488 +1,28 @@
|
|||||||
use std::ops::Range;
|
|
||||||
use std::sync::{Arc, LazyLock};
|
|
||||||
|
|
||||||
use chat::Mention;
|
use chat::Mention;
|
||||||
use gpui::{
|
use gpui::{App, Entity};
|
||||||
AnyElement, App, ElementId, Entity, FontStyle, FontWeight, HighlightStyle, InteractiveText,
|
|
||||||
IntoElement, SharedString, StrikethroughStyle, StyledText, UnderlineStyle, Window,
|
|
||||||
};
|
|
||||||
use person::PersonRegistry;
|
use person::PersonRegistry;
|
||||||
use regex::Regex;
|
use ui::markdown::InlineReplacement;
|
||||||
use theme::ActiveTheme;
|
pub use ui::markdown::RenderedText;
|
||||||
|
|
||||||
/// Matches `http://` and `https://` URLs. Only these are treated as clickable links.
|
/// Render message `content` to text, replacing mentions with their display names.
|
||||||
static WEB_URL: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"(?i)^https?://").unwrap());
|
///
|
||||||
|
/// When `markdown` is set the content is parsed as markdown.
|
||||||
#[allow(clippy::enum_variant_names)]
|
pub fn rendered_text(
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
content: &str,
|
||||||
pub enum Highlight {
|
mentions: &[Mention],
|
||||||
Code,
|
persons: &Entity<PersonRegistry>,
|
||||||
InlineCode(bool),
|
|
||||||
Highlight(HighlightStyle),
|
|
||||||
Mention,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<HighlightStyle> for Highlight {
|
|
||||||
fn from(style: HighlightStyle) -> Self {
|
|
||||||
Self::Highlight(style)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Default)]
|
|
||||||
pub struct RenderedText {
|
|
||||||
pub text: SharedString,
|
|
||||||
pub highlights: Vec<(Range<usize>, Highlight)>,
|
|
||||||
pub link_ranges: Vec<Range<usize>>,
|
|
||||||
pub link_urls: Arc<[String]>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl RenderedText {
|
|
||||||
pub fn new(
|
|
||||||
content: &str,
|
|
||||||
mentions: &[Mention],
|
|
||||||
persons: &Entity<PersonRegistry>,
|
|
||||||
markdown: bool,
|
|
||||||
cx: &App,
|
|
||||||
) -> Self {
|
|
||||||
Self::render(content, mentions, markdown, |mention| {
|
|
||||||
format!("@{}", persons.read(cx).get(&mention.public_key, cx).name())
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn render(
|
|
||||||
content: &str,
|
|
||||||
mentions: &[Mention],
|
|
||||||
markdown: bool,
|
|
||||||
resolve_mention: impl Fn(&Mention) -> String,
|
|
||||||
) -> Self {
|
|
||||||
let mut text = String::new();
|
|
||||||
let mut highlights = Vec::new();
|
|
||||||
let mut link_ranges = Vec::new();
|
|
||||||
let mut link_urls = Vec::new();
|
|
||||||
|
|
||||||
render_text_mut(
|
|
||||||
content,
|
|
||||||
mentions,
|
|
||||||
&mut text,
|
|
||||||
&mut highlights,
|
|
||||||
&mut link_ranges,
|
|
||||||
&mut link_urls,
|
|
||||||
markdown,
|
|
||||||
resolve_mention,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Trim trailing whitespace and adjust highlight and link ranges.
|
|
||||||
let trimmed_len = text.trim_end().len();
|
|
||||||
|
|
||||||
// Retain highlights and link ranges that are within the trimmed text.
|
|
||||||
if trimmed_len < text.len() {
|
|
||||||
highlights.retain_mut(|(range, _)| {
|
|
||||||
range.end = range.end.min(trimmed_len);
|
|
||||||
range.start < range.end
|
|
||||||
});
|
|
||||||
|
|
||||||
let mut ix = 0;
|
|
||||||
|
|
||||||
while ix < link_ranges.len() {
|
|
||||||
let range = &mut link_ranges[ix];
|
|
||||||
range.end = range.end.min(trimmed_len);
|
|
||||||
if range.start < range.end {
|
|
||||||
ix += 1;
|
|
||||||
} else {
|
|
||||||
link_ranges.remove(ix);
|
|
||||||
link_urls.remove(ix);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
text.truncate(trimmed_len);
|
|
||||||
}
|
|
||||||
|
|
||||||
RenderedText {
|
|
||||||
text: SharedString::from(text),
|
|
||||||
link_urls: link_urls.into(),
|
|
||||||
link_ranges,
|
|
||||||
highlights,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn element(&self, id: ElementId, window: &Window, cx: &App) -> AnyElement {
|
|
||||||
let code_background = cx.theme().elevated_surface_background;
|
|
||||||
let color = cx.theme().text_accent;
|
|
||||||
let code_font = if cfg!(target_os = "macos") {
|
|
||||||
"Menlo"
|
|
||||||
} else if cfg!(target_os = "windows") {
|
|
||||||
"Consolas"
|
|
||||||
} else {
|
|
||||||
"monospace"
|
|
||||||
};
|
|
||||||
|
|
||||||
InteractiveText::new(
|
|
||||||
id,
|
|
||||||
StyledText::new(self.text.clone())
|
|
||||||
.with_default_highlights(
|
|
||||||
&window.text_style(),
|
|
||||||
self.highlights.iter().map(|(range, highlight)| {
|
|
||||||
(
|
|
||||||
range.clone(),
|
|
||||||
match highlight {
|
|
||||||
Highlight::Code => HighlightStyle {
|
|
||||||
background_color: Some(code_background),
|
|
||||||
..Default::default()
|
|
||||||
},
|
|
||||||
Highlight::InlineCode(link) => {
|
|
||||||
if *link {
|
|
||||||
HighlightStyle {
|
|
||||||
background_color: Some(code_background),
|
|
||||||
underline: Some(UnderlineStyle {
|
|
||||||
thickness: 1.0.into(),
|
|
||||||
..Default::default()
|
|
||||||
}),
|
|
||||||
..Default::default()
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
HighlightStyle {
|
|
||||||
background_color: Some(code_background),
|
|
||||||
..Default::default()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Highlight::Mention => HighlightStyle {
|
|
||||||
color: Some(color),
|
|
||||||
underline: Some(UnderlineStyle {
|
|
||||||
thickness: 1.0.into(),
|
|
||||||
..Default::default()
|
|
||||||
}),
|
|
||||||
..Default::default()
|
|
||||||
},
|
|
||||||
Highlight::Highlight(highlight) => *highlight,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.with_font_family_overrides(self.highlights.iter().filter_map(
|
|
||||||
|(range, highlight)| match highlight {
|
|
||||||
Highlight::Code | Highlight::InlineCode(_) => {
|
|
||||||
Some((range.clone(), code_font.into()))
|
|
||||||
}
|
|
||||||
_ => None,
|
|
||||||
},
|
|
||||||
)),
|
|
||||||
)
|
|
||||||
.on_click(self.link_ranges.clone(), {
|
|
||||||
let link_urls = self.link_urls.clone();
|
|
||||||
move |ix, _, cx| {
|
|
||||||
let url = &link_urls[ix];
|
|
||||||
if WEB_URL.is_match(url) {
|
|
||||||
cx.open_url(url);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.into_any_element()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[allow(clippy::too_many_arguments)]
|
|
||||||
fn render_text_mut(
|
|
||||||
block: &str,
|
|
||||||
mut mentions: &[Mention],
|
|
||||||
text: &mut String,
|
|
||||||
highlights: &mut Vec<(Range<usize>, Highlight)>,
|
|
||||||
link_ranges: &mut Vec<Range<usize>>,
|
|
||||||
link_urls: &mut Vec<String>,
|
|
||||||
markdown: bool,
|
markdown: bool,
|
||||||
resolve_mention: impl Fn(&Mention) -> String,
|
cx: &App,
|
||||||
) {
|
) -> RenderedText {
|
||||||
use pulldown_cmark::{Event, Options, Parser, Tag, TagEnd};
|
let replacements = mentions
|
||||||
|
.iter()
|
||||||
|
.map(|mention| {
|
||||||
|
InlineReplacement::new(
|
||||||
|
mention.range.clone(),
|
||||||
|
format!("@{}", persons.read(cx).get(&mention.public_key, cx).name()),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
let mut bold_depth = 0;
|
RenderedText::new(content, &replacements, markdown)
|
||||||
let mut italic_depth = 0;
|
|
||||||
let mut strikethrough_depth = 0;
|
|
||||||
let mut link_url = None;
|
|
||||||
let mut list_stack = Vec::new();
|
|
||||||
let mut code_block = false;
|
|
||||||
|
|
||||||
// Only enable the extensions that make sense for chat messages. Notably this leaves
|
|
||||||
// out smart punctuation, tables, math and footnotes: they rewrite or swallow text.
|
|
||||||
let events: Box<dyn Iterator<Item = (Event<'_>, Range<usize>)> + '_> = if markdown {
|
|
||||||
Box::new(Parser::new_ext(block, Options::ENABLE_STRIKETHROUGH).into_offset_iter())
|
|
||||||
} else {
|
|
||||||
Box::new(std::iter::once((Event::Text(block.into()), 0..block.len())))
|
|
||||||
};
|
|
||||||
|
|
||||||
for (event, source_range) in events {
|
|
||||||
let prev_len = text.len();
|
|
||||||
|
|
||||||
match event {
|
|
||||||
Event::Text(t) => {
|
|
||||||
if code_block {
|
|
||||||
text.push_str(t.as_ref());
|
|
||||||
highlights.push((prev_len..text.len(), Highlight::Code));
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
let t_str = t.as_ref();
|
|
||||||
let mut last_processed = 0;
|
|
||||||
|
|
||||||
while let Some(mention) = mentions.first() {
|
|
||||||
if mention.range.start >= source_range.end {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
mentions = &mentions[1..];
|
|
||||||
if mention.range.start < source_range.start
|
|
||||||
|| mention.range.end > source_range.end
|
|
||||||
{
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
let Some(token) = block.get(mention.range.clone()) else {
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
|
|
||||||
let Some(offset) = t_str[last_processed..].find(token) else {
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
|
|
||||||
let mention_start_in_text = last_processed + offset;
|
|
||||||
let mention_end_in_text = mention_start_in_text + token.len();
|
|
||||||
|
|
||||||
// Add text before this mention
|
|
||||||
if mention_start_in_text > last_processed {
|
|
||||||
let before_mention = &t_str[last_processed..mention_start_in_text];
|
|
||||||
process_text_segment(
|
|
||||||
before_mention,
|
|
||||||
bold_depth,
|
|
||||||
italic_depth,
|
|
||||||
strikethrough_depth,
|
|
||||||
link_url.clone(),
|
|
||||||
text,
|
|
||||||
highlights,
|
|
||||||
link_ranges,
|
|
||||||
link_urls,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Process the mention replacement
|
|
||||||
let replacement_text = resolve_mention(mention);
|
|
||||||
let replacement_start = text.len();
|
|
||||||
text.push_str(&replacement_text);
|
|
||||||
let replacement_end = text.len();
|
|
||||||
|
|
||||||
highlights.push((replacement_start..replacement_end, Highlight::Mention));
|
|
||||||
|
|
||||||
last_processed = mention_end_in_text;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add any remaining text after the last mention
|
|
||||||
if last_processed < t_str.len() {
|
|
||||||
let remaining_text = &t_str[last_processed..];
|
|
||||||
process_text_segment(
|
|
||||||
remaining_text,
|
|
||||||
bold_depth,
|
|
||||||
italic_depth,
|
|
||||||
strikethrough_depth,
|
|
||||||
link_url.clone(),
|
|
||||||
text,
|
|
||||||
highlights,
|
|
||||||
link_ranges,
|
|
||||||
link_urls,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Event::Code(t) => {
|
|
||||||
text.push_str(t.as_ref());
|
|
||||||
let is_link = link_url.is_some();
|
|
||||||
|
|
||||||
if let Some(link_url) = link_url.clone() {
|
|
||||||
link_ranges.push(prev_len..text.len());
|
|
||||||
link_urls.push(link_url);
|
|
||||||
}
|
|
||||||
|
|
||||||
highlights.push((prev_len..text.len(), Highlight::InlineCode(is_link)))
|
|
||||||
}
|
|
||||||
Event::Start(tag) => match tag {
|
|
||||||
Tag::Paragraph => new_paragraph(text, &mut list_stack),
|
|
||||||
Tag::Heading { .. } => {
|
|
||||||
new_paragraph(text, &mut list_stack);
|
|
||||||
bold_depth += 1;
|
|
||||||
}
|
|
||||||
Tag::CodeBlock(_kind) => {
|
|
||||||
new_paragraph(text, &mut list_stack);
|
|
||||||
code_block = true;
|
|
||||||
}
|
|
||||||
Tag::Emphasis => italic_depth += 1,
|
|
||||||
Tag::Strong => bold_depth += 1,
|
|
||||||
Tag::Strikethrough => strikethrough_depth += 1,
|
|
||||||
Tag::Link { dest_url, .. } => {
|
|
||||||
link_url = WEB_URL.is_match(&dest_url).then(|| dest_url.to_string());
|
|
||||||
}
|
|
||||||
Tag::List(number) => {
|
|
||||||
list_stack.push((number, false));
|
|
||||||
}
|
|
||||||
Tag::Item => {
|
|
||||||
let len = list_stack.len();
|
|
||||||
if let Some((list_number, has_content)) = list_stack.last_mut() {
|
|
||||||
*has_content = false;
|
|
||||||
if !text.is_empty() && !text.ends_with('\n') {
|
|
||||||
text.push('\n');
|
|
||||||
}
|
|
||||||
for _ in 0..len - 1 {
|
|
||||||
text.push_str(" ");
|
|
||||||
}
|
|
||||||
if let Some(number) = list_number {
|
|
||||||
text.push_str(&format!("{}. ", number));
|
|
||||||
*number += 1;
|
|
||||||
*has_content = false;
|
|
||||||
} else {
|
|
||||||
text.push_str("- ");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_ => {}
|
|
||||||
},
|
|
||||||
Event::End(tag) => match tag {
|
|
||||||
TagEnd::CodeBlock => code_block = false,
|
|
||||||
TagEnd::Heading(_) => bold_depth -= 1,
|
|
||||||
TagEnd::Emphasis => italic_depth -= 1,
|
|
||||||
TagEnd::Strong => bold_depth -= 1,
|
|
||||||
TagEnd::Strikethrough => strikethrough_depth -= 1,
|
|
||||||
TagEnd::Link => link_url = None,
|
|
||||||
TagEnd::List(_) => drop(list_stack.pop()),
|
|
||||||
_ => {}
|
|
||||||
},
|
|
||||||
Event::Html(t) | Event::InlineHtml(t) => text.push_str(t.as_ref()),
|
|
||||||
Event::Rule => {
|
|
||||||
new_paragraph(text, &mut list_stack);
|
|
||||||
text.push_str("────────\n");
|
|
||||||
}
|
|
||||||
Event::HardBreak => text.push('\n'),
|
|
||||||
Event::SoftBreak => text.push('\n'),
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[allow(clippy::too_many_arguments)]
|
|
||||||
fn process_text_segment(
|
|
||||||
segment: &str,
|
|
||||||
bold_depth: i32,
|
|
||||||
italic_depth: i32,
|
|
||||||
strikethrough_depth: i32,
|
|
||||||
link_url: Option<String>,
|
|
||||||
text: &mut String,
|
|
||||||
highlights: &mut Vec<(Range<usize>, Highlight)>,
|
|
||||||
link_ranges: &mut Vec<Range<usize>>,
|
|
||||||
link_urls: &mut Vec<String>,
|
|
||||||
) {
|
|
||||||
// Build the style for this segment
|
|
||||||
let mut style = HighlightStyle::default();
|
|
||||||
if bold_depth > 0 {
|
|
||||||
style.font_weight = Some(FontWeight::BOLD);
|
|
||||||
}
|
|
||||||
if italic_depth > 0 {
|
|
||||||
style.font_style = Some(FontStyle::Italic);
|
|
||||||
}
|
|
||||||
if strikethrough_depth > 0 {
|
|
||||||
style.strikethrough = Some(StrikethroughStyle {
|
|
||||||
thickness: 1.0.into(),
|
|
||||||
..Default::default()
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ranges always refer to the rendered text, including replaced mentions.
|
|
||||||
let segment_start = text.len();
|
|
||||||
text.push_str(segment);
|
|
||||||
let text_end = text.len();
|
|
||||||
|
|
||||||
if let Some(link_url) = link_url {
|
|
||||||
// Handle as a markdown link
|
|
||||||
link_ranges.push(segment_start..text_end);
|
|
||||||
link_urls.push(link_url);
|
|
||||||
style.underline = Some(UnderlineStyle {
|
|
||||||
thickness: 1.0.into(),
|
|
||||||
..Default::default()
|
|
||||||
});
|
|
||||||
|
|
||||||
// Add highlight for the entire linked segment
|
|
||||||
if style != HighlightStyle::default() {
|
|
||||||
highlights.push((segment_start..text_end, Highlight::Highlight(style)));
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Handle link detection within the segment
|
|
||||||
let mut finder = linkify::LinkFinder::new();
|
|
||||||
finder.kinds(&[linkify::LinkKind::Url]);
|
|
||||||
let mut last_link_pos = 0;
|
|
||||||
|
|
||||||
for link in finder
|
|
||||||
.links(segment)
|
|
||||||
.filter(|link| WEB_URL.is_match(link.as_str()))
|
|
||||||
{
|
|
||||||
let start = link.start();
|
|
||||||
let end = link.end();
|
|
||||||
|
|
||||||
// Add non-link text before this link
|
|
||||||
if start > last_link_pos {
|
|
||||||
let non_link_start = segment_start + last_link_pos;
|
|
||||||
let non_link_end = segment_start + start;
|
|
||||||
|
|
||||||
if style != HighlightStyle::default() {
|
|
||||||
highlights.push((non_link_start..non_link_end, Highlight::Highlight(style)));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add the link
|
|
||||||
let range = (segment_start + start)..(segment_start + end);
|
|
||||||
link_ranges.push(range.clone());
|
|
||||||
link_urls.push(link.as_str().to_string());
|
|
||||||
|
|
||||||
// Apply link styling (underline + existing style)
|
|
||||||
let mut link_style = style;
|
|
||||||
link_style.underline = Some(UnderlineStyle {
|
|
||||||
thickness: 1.0.into(),
|
|
||||||
..Default::default()
|
|
||||||
});
|
|
||||||
|
|
||||||
highlights.push((range, Highlight::Highlight(link_style)));
|
|
||||||
|
|
||||||
last_link_pos = end;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add any remaining text after the last link
|
|
||||||
if last_link_pos < segment.len() {
|
|
||||||
let remaining_start = segment_start + last_link_pos;
|
|
||||||
let remaining_end = segment_start + segment.len();
|
|
||||||
|
|
||||||
if style != HighlightStyle::default() {
|
|
||||||
highlights.push((remaining_start..remaining_end, Highlight::Highlight(style)));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn new_paragraph(text: &mut String, list_stack: &mut [(Option<u64>, bool)]) {
|
|
||||||
let mut is_subsequent_paragraph_of_list = false;
|
|
||||||
|
|
||||||
if let Some((_, has_content)) = list_stack.last_mut() {
|
|
||||||
if *has_content {
|
|
||||||
is_subsequent_paragraph_of_list = true;
|
|
||||||
} else {
|
|
||||||
*has_content = true;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if !text.is_empty() {
|
|
||||||
if !text.ends_with('\n') {
|
|
||||||
text.push('\n');
|
|
||||||
}
|
|
||||||
text.push('\n');
|
|
||||||
}
|
|
||||||
|
|
||||||
for _ in 0..list_stack.len().saturating_sub(1) {
|
|
||||||
text.push_str(" ");
|
|
||||||
}
|
|
||||||
|
|
||||||
if is_subsequent_paragraph_of_list {
|
|
||||||
text.push_str(" ");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ use concord::cord06::RekeyScope;
|
|||||||
use concord::derive::{channel_group_key, grant_locator};
|
use concord::derive::{channel_group_key, grant_locator};
|
||||||
use concord::state::{ChannelCursor, ChannelKeyRef, CommunityState, HeldKey, HeldRoot};
|
use concord::state::{ChannelCursor, ChannelKeyRef, CommunityState, HeldKey, HeldRoot};
|
||||||
use concord::{ChannelId, CommunityId, Epoch};
|
use concord::{ChannelId, CommunityId, Epoch};
|
||||||
use gpui::{App, AppContext, Context, EventEmitter, Task};
|
use gpui::{App, AppContext, Context, EventEmitter, SharedString, Task};
|
||||||
use nostr_sdk::prelude::*;
|
use nostr_sdk::prelude::*;
|
||||||
use smallvec::{SmallVec, smallvec};
|
use smallvec::{SmallVec, smallvec};
|
||||||
use state::NostrRegistry;
|
use state::NostrRegistry;
|
||||||
@@ -107,8 +107,6 @@ impl SubscriptionKey {
|
|||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub enum CommunityEvent {
|
pub enum CommunityEvent {
|
||||||
Updated(CommunityId),
|
Updated(CommunityId),
|
||||||
Open(CommunityId),
|
|
||||||
Close(CommunityId),
|
|
||||||
Channel(CommunityId, ChannelId),
|
Channel(CommunityId, ChannelId),
|
||||||
/// History exists here that no held key can open.
|
/// History exists here that no held key can open.
|
||||||
Unreadable(CommunityId),
|
Unreadable(CommunityId),
|
||||||
@@ -180,15 +178,16 @@ impl Community {
|
|||||||
&self.state
|
&self.state
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn name(&self) -> String {
|
pub fn name(&self) -> SharedString {
|
||||||
if let Some(metadata) = &self.control.community {
|
if let Some(metadata) = &self.control.community {
|
||||||
return metadata.name.clone();
|
return SharedString::from(&metadata.name);
|
||||||
}
|
}
|
||||||
|
|
||||||
self.state
|
self.state
|
||||||
.name
|
.name
|
||||||
.clone()
|
.as_ref()
|
||||||
.unwrap_or_else(|| self.state.id.to_hex())
|
.map(SharedString::from)
|
||||||
|
.unwrap_or_else(|| SharedString::from("Unknown"))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn control(&self) -> &ControlFold {
|
pub fn control(&self) -> &ControlFold {
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ pub use concord::cord03::{ChatMessage, ReplyRef};
|
|||||||
use concord::state::CommunityState;
|
use concord::state::CommunityState;
|
||||||
pub use concord::{ChannelId, CommunityId, Epoch};
|
pub use concord::{ChannelId, CommunityId, Epoch};
|
||||||
use futures::future::{Either, select};
|
use futures::future::{Either, select};
|
||||||
use gpui::{App, AppContext, Context, Entity, EventEmitter, Global, Subscription, Task, Window};
|
use gpui::{App, AppContext, Context, Entity, EventEmitter, Global, Subscription, Task};
|
||||||
use nostr_sdk::prelude::*;
|
use nostr_sdk::prelude::*;
|
||||||
use smallvec::{SmallVec, smallvec};
|
use smallvec::{SmallVec, smallvec};
|
||||||
use state::NostrRegistry;
|
use state::NostrRegistry;
|
||||||
@@ -225,27 +225,6 @@ impl CommunityRegistry {
|
|||||||
self.index.get(id).cloned()
|
self.index.get(id).cloned()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Ask the workspace to open a community's panel.
|
|
||||||
pub fn emit_community(
|
|
||||||
&mut self,
|
|
||||||
community: &Entity<Community>,
|
|
||||||
window: &mut Window,
|
|
||||||
cx: &mut Context<Self>,
|
|
||||||
) {
|
|
||||||
let id = community.read(cx).id();
|
|
||||||
|
|
||||||
cx.defer_in(window, move |_this, _window, cx| {
|
|
||||||
cx.emit(CommunityEvent::Open(id));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Ask the workspace to close a community's panel.
|
|
||||||
pub fn emit_close(&mut self, id: CommunityId, window: &mut Window, cx: &mut Context<Self>) {
|
|
||||||
cx.defer_in(window, move |_this, _window, cx| {
|
|
||||||
cx.emit(CommunityEvent::Close(id));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Create a community owned by the current account and begin tracking it.
|
/// Create a community owned by the current account and begin tracking it.
|
||||||
pub fn create(&mut self, metadata: CommunityMetadata, cx: &mut Context<Self>) {
|
pub fn create(&mut self, metadata: CommunityMetadata, cx: &mut Context<Self>) {
|
||||||
let nostr = NostrRegistry::global(cx);
|
let nostr = NostrRegistry::global(cx);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
use std::collections::HashMap;
|
use std::collections::{BTreeMap, HashMap};
|
||||||
use std::fmt;
|
use std::fmt;
|
||||||
|
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
@@ -19,6 +19,8 @@ use ui::avatar::Avatar;
|
|||||||
use ui::button::{Button, ButtonVariants};
|
use ui::button::{Button, ButtonVariants};
|
||||||
use ui::dock::{Panel, PanelEvent};
|
use ui::dock::{Panel, PanelEvent};
|
||||||
use ui::input::{InputEvent, Textarea, TextareaState};
|
use ui::input::{InputEvent, Textarea, TextareaState};
|
||||||
|
use ui::markdown::RenderedText;
|
||||||
|
use ui::message::WelcomeMessage;
|
||||||
use ui::notification::Notification;
|
use ui::notification::Notification;
|
||||||
use ui::scroll::Scrollbar;
|
use ui::scroll::Scrollbar;
|
||||||
use ui::{Disableable, IconName, Sizable, WindowExtension, h_flex, v_flex};
|
use ui::{Disableable, IconName, Sizable, WindowExtension, h_flex, v_flex};
|
||||||
@@ -97,6 +99,8 @@ pub struct CommunityPanel {
|
|||||||
channel: Option<ChannelId>,
|
channel: Option<ChannelId>,
|
||||||
/// The selected channel's timeline (oldest first)
|
/// The selected channel's timeline (oldest first)
|
||||||
rows: Vec<ChatMessage>,
|
rows: Vec<ChatMessage>,
|
||||||
|
/// Rendered markdown content, keyed by message id and dropped when a row changes
|
||||||
|
rendered_texts_by_id: BTreeMap<EventId, RenderedText>,
|
||||||
/// Whether the store holds rows older than `rows`
|
/// Whether the store holds rows older than `rows`
|
||||||
has_more: bool,
|
has_more: bool,
|
||||||
/// A round or a page read is in flight
|
/// A round or a page read is in flight
|
||||||
@@ -145,53 +149,49 @@ impl CommunityPanel {
|
|||||||
window,
|
window,
|
||||||
|_this, _community, event, window, cx| {
|
|_this, _community, event, window, cx| {
|
||||||
match event {
|
match event {
|
||||||
CommunityEvent::Updated(_)
|
|
||||||
| CommunityEvent::Unreadable(_)
|
|
||||||
| CommunityEvent::Failed(_) => {
|
|
||||||
cx.defer_in(window, |this, window, cx| this.reload(window, cx));
|
|
||||||
}
|
|
||||||
CommunityEvent::Channel(..) => {
|
CommunityEvent::Channel(..) => {
|
||||||
cx.defer_in(window, |this, window, cx| this.load(window, cx));
|
cx.defer_in(window, |this, window, cx| {
|
||||||
|
this.load(window, cx);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
CommunityEvent::Error(error) => {
|
CommunityEvent::Error(error) => {
|
||||||
window.push_notification(
|
window.push_notification(Notification::error(error.clone()), cx);
|
||||||
Notification::error(error.clone()).autohide(false),
|
}
|
||||||
cx,
|
_ => {
|
||||||
);
|
cx.defer_in(window, |this, window, cx| {
|
||||||
|
this.reload(window, cx);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
CommunityEvent::Open(_) | CommunityEvent::Close(_) => {}
|
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
));
|
));
|
||||||
|
|
||||||
let panel = Self {
|
cx.defer_in(window, |this, window, cx| {
|
||||||
|
this.list_state.set_follow_mode(FollowMode::Tail);
|
||||||
|
this.list_state.set_scroll_handler(cx.listener(
|
||||||
|
|this, event: &ListScrollEvent, window, cx| {
|
||||||
|
if event.visible_range.start <= LOAD_OLDER_THRESHOLD {
|
||||||
|
this.load_older(window, cx);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
));
|
||||||
|
this.load(window, cx);
|
||||||
|
});
|
||||||
|
|
||||||
|
Self {
|
||||||
id,
|
id,
|
||||||
focus_handle: cx.focus_handle(),
|
focus_handle: cx.focus_handle(),
|
||||||
community: community.downgrade(),
|
community: community.downgrade(),
|
||||||
channel,
|
channel,
|
||||||
rows: Vec::new(),
|
rows: Vec::new(),
|
||||||
|
rendered_texts_by_id: BTreeMap::new(),
|
||||||
has_more: false,
|
has_more: false,
|
||||||
loading: false,
|
loading: false,
|
||||||
list_state: ListState::new(0, ListAlignment::Bottom, px(1024.)),
|
list_state: ListState::new(0, ListAlignment::Bottom, px(1024.)),
|
||||||
input,
|
input,
|
||||||
tasks: smallvec![],
|
tasks: smallvec![],
|
||||||
_subscriptions: subscriptions,
|
_subscriptions: subscriptions,
|
||||||
};
|
}
|
||||||
|
|
||||||
panel.list_state.set_follow_mode(FollowMode::Tail);
|
|
||||||
panel.list_state.set_scroll_handler(cx.listener(
|
|
||||||
|this, event: &ListScrollEvent, window, cx| {
|
|
||||||
if event.visible_range.start <= LOAD_OLDER_THRESHOLD {
|
|
||||||
this.load_older(window, cx);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
));
|
|
||||||
|
|
||||||
cx.defer_in(window, |this, window, cx| {
|
|
||||||
this.load(window, cx);
|
|
||||||
});
|
|
||||||
|
|
||||||
panel
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The channel to show, following the community's selection.
|
/// The channel to show, following the community's selection.
|
||||||
@@ -205,17 +205,18 @@ impl CommunityPanel {
|
|||||||
if channel != self.channel {
|
if channel != self.channel {
|
||||||
self.channel = channel;
|
self.channel = channel;
|
||||||
self.rows.clear();
|
self.rows.clear();
|
||||||
|
self.rendered_texts_by_id.clear();
|
||||||
self.has_more = false;
|
self.has_more = false;
|
||||||
self.loading = false;
|
self.loading = false;
|
||||||
self.list_state.reset(1);
|
self.list_state.reset(2);
|
||||||
}
|
}
|
||||||
|
|
||||||
channel
|
channel
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The list's item count: the load-older row, then every message row.
|
/// The list's item count: the welcome row, the load-older row, then every message row.
|
||||||
fn item_count(&self) -> usize {
|
fn item_count(&self) -> usize {
|
||||||
self.rows.len() + 1
|
self.rows.len() + 2
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A timeline read, `before_ms` exclusive, or `None` for the newest rows.
|
/// A timeline read, `before_ms` exclusive, or `None` for the newest rows.
|
||||||
@@ -494,6 +495,7 @@ impl CommunityPanel {
|
|||||||
|
|
||||||
if !connected {
|
if !connected {
|
||||||
self.rows = messages;
|
self.rows = messages;
|
||||||
|
self.rendered_texts_by_id.clear();
|
||||||
self.has_more = has_more;
|
self.has_more = has_more;
|
||||||
self.list_state.reset(self.item_count());
|
self.list_state.reset(self.item_count());
|
||||||
cx.notify();
|
cx.notify();
|
||||||
@@ -528,7 +530,13 @@ impl CommunityPanel {
|
|||||||
|
|
||||||
for message in messages {
|
for message in messages {
|
||||||
match shown.get(&message.id).copied() {
|
match shown.get(&message.id).copied() {
|
||||||
Some(ix) => self.rows[ix] = message,
|
Some(ix) => {
|
||||||
|
// Drop the cached render when the text changes, so edits re-parse.
|
||||||
|
if self.rows[ix].content != message.content {
|
||||||
|
self.rendered_texts_by_id.remove(&message.id);
|
||||||
|
}
|
||||||
|
self.rows[ix] = message;
|
||||||
|
}
|
||||||
None => fresh.push(message),
|
None => fresh.push(message),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -544,7 +552,8 @@ impl CommunityPanel {
|
|||||||
|
|
||||||
shown.insert(message.id, at);
|
shown.insert(message.id, at);
|
||||||
self.rows.insert(at, message);
|
self.rows.insert(at, message);
|
||||||
self.list_state.splice(at + 1..at + 1, 1);
|
// The welcome and load-older rows sit above the messages.
|
||||||
|
self.list_state.splice(at + 2..at + 2, 1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -626,7 +635,29 @@ impl CommunityPanel {
|
|||||||
.into_any_element()
|
.into_any_element()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The row at index 0: the affordance that pages older history in.
|
/// The row at index 0: the welcome message for the channel.
|
||||||
|
fn render_welcome(&self, cx: &Context<Self>) -> AnyElement {
|
||||||
|
let (name, avatar) = self
|
||||||
|
.community
|
||||||
|
.read_with(cx, |community, _cx| {
|
||||||
|
let seed = community.id().to_hex();
|
||||||
|
let avatar = match community.icon() {
|
||||||
|
Some(path) => Avatar::from_source(path).seed(seed).large(),
|
||||||
|
None => Avatar::new(None).seed(seed).large(),
|
||||||
|
};
|
||||||
|
|
||||||
|
(community.name(), avatar)
|
||||||
|
})
|
||||||
|
.unwrap_or_else(|_| (SharedString::from("this community"), Avatar::new(None)));
|
||||||
|
|
||||||
|
WelcomeMessage::new("welcome")
|
||||||
|
.icon(avatar)
|
||||||
|
.title(format!("Welcome to {}", name))
|
||||||
|
.message(format!("This is the start of the {} channel.", name))
|
||||||
|
.into_any_element()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The row at index 1: the affordance that pages older history in.
|
||||||
fn render_older(&self, cx: &mut Context<Self>) -> AnyElement {
|
fn render_older(&self, cx: &mut Context<Self>) -> AnyElement {
|
||||||
if !self.has_more {
|
if !self.has_more {
|
||||||
return div().into_any_element();
|
return div().into_any_element();
|
||||||
@@ -668,20 +699,34 @@ impl CommunityPanel {
|
|||||||
fn render_message(
|
fn render_message(
|
||||||
&mut self,
|
&mut self,
|
||||||
ix: usize,
|
ix: usize,
|
||||||
_window: &mut Window,
|
window: &mut Window,
|
||||||
cx: &mut Context<Self>,
|
cx: &mut Context<Self>,
|
||||||
) -> AnyElement {
|
) -> AnyElement {
|
||||||
if ix == 0 {
|
if ix == 0 {
|
||||||
|
return self.render_welcome(cx);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ix == 1 {
|
||||||
return self.render_older(cx);
|
return self.render_older(cx);
|
||||||
}
|
}
|
||||||
|
|
||||||
let Some(message) = self.rows.get(ix - 1) else {
|
// The welcome and load-older rows sit above the messages.
|
||||||
|
let Some(message) = self.rows.get(ix - 2) else {
|
||||||
return div().into_any_element();
|
return div().into_any_element();
|
||||||
};
|
};
|
||||||
|
|
||||||
let show_author = self.opens_run(ix - 1);
|
let show_author = self.opens_run(ix - 2);
|
||||||
|
|
||||||
message::render(ix, message, show_author, cx)
|
let content = if message.deleted {
|
||||||
|
message::deleted(cx)
|
||||||
|
} else {
|
||||||
|
self.rendered_texts_by_id
|
||||||
|
.entry(message.id)
|
||||||
|
.or_insert_with(|| RenderedText::new(&message.content, &[], true))
|
||||||
|
.element(ix.into(), window, cx)
|
||||||
|
};
|
||||||
|
|
||||||
|
message::render(ix, message, content, show_author, cx)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn render_composer(&self, cx: &mut Context<Self>) -> impl IntoElement {
|
fn render_composer(&self, cx: &mut Context<Self>) -> impl IntoElement {
|
||||||
@@ -723,12 +768,13 @@ impl Panel for CommunityPanel {
|
|||||||
};
|
};
|
||||||
|
|
||||||
h_flex()
|
h_flex()
|
||||||
.gap_1p5()
|
.gap_1()
|
||||||
|
.text_xs()
|
||||||
.child(avatar)
|
.child(avatar)
|
||||||
.child(SharedString::from(community.name()))
|
.child(community.name())
|
||||||
.into_any_element()
|
.into_any_element()
|
||||||
})
|
})
|
||||||
.unwrap_or_else(|_| div().child("Unknown").into_any_element())
|
.unwrap_or_else(|_| div().text_xs().child("Unknown").into_any_element())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn toolbar_buttons(&self, _window: &Window, _cx: &App) -> Vec<Button> {
|
fn toolbar_buttons(&self, _window: &Window, _cx: &App) -> Vec<Button> {
|
||||||
|
|||||||
@@ -3,81 +3,51 @@ use std::collections::BTreeMap;
|
|||||||
use common::TimestampExt;
|
use common::TimestampExt;
|
||||||
use community::ChatMessage;
|
use community::ChatMessage;
|
||||||
use gpui::prelude::FluentBuilder;
|
use gpui::prelude::FluentBuilder;
|
||||||
use gpui::{
|
use gpui::{AnyElement, App, IntoElement, ParentElement, SharedString, Styled, div};
|
||||||
AnyElement, App, InteractiveElement, IntoElement, ParentElement, SharedString, Styled, div, px,
|
|
||||||
};
|
|
||||||
use nostr_sdk::prelude::*;
|
use nostr_sdk::prelude::*;
|
||||||
use person::PersonRegistry;
|
use person::PersonRegistry;
|
||||||
use settings::AppSettings;
|
use settings::AppSettings;
|
||||||
use theme::ActiveTheme;
|
use theme::ActiveTheme;
|
||||||
use ui::avatar::Avatar;
|
use ui::avatar::Avatar;
|
||||||
use ui::{StyledExt, h_flex, v_flex};
|
use ui::h_flex;
|
||||||
|
use ui::message::MessageRow;
|
||||||
|
|
||||||
pub(crate) fn render(ix: usize, message: &ChatMessage, show_author: bool, cx: &App) -> AnyElement {
|
pub(crate) fn render(
|
||||||
|
ix: usize,
|
||||||
|
message: &ChatMessage,
|
||||||
|
content: AnyElement,
|
||||||
|
show_author: bool,
|
||||||
|
cx: &App,
|
||||||
|
) -> AnyElement {
|
||||||
let persons = PersonRegistry::global(cx);
|
let persons = PersonRegistry::global(cx);
|
||||||
let author = persons.read(cx).get(&message.author, cx);
|
let author = persons.read(cx).get(&message.author, cx);
|
||||||
let hide_avatar = AppSettings::get_hide_avatar(cx);
|
let hide_avatar = AppSettings::get_hide_avatar(cx);
|
||||||
|
|
||||||
div()
|
MessageRow::new(ix)
|
||||||
.id(ix)
|
.show_author(show_author)
|
||||||
.w_full()
|
.hide_avatar(hide_avatar)
|
||||||
.py_1()
|
.avatar(
|
||||||
.px_3()
|
Avatar::new(author.avatar())
|
||||||
.hover(|this| this.bg(cx.theme().surface_background))
|
.seed(author.avatar_seed())
|
||||||
.child(
|
.flex_shrink_0(),
|
||||||
h_flex()
|
|
||||||
.items_start()
|
|
||||||
.gap_3()
|
|
||||||
.when(!hide_avatar, |this| {
|
|
||||||
if show_author {
|
|
||||||
this.child(
|
|
||||||
Avatar::new(author.avatar())
|
|
||||||
.seed(author.avatar_seed())
|
|
||||||
.flex_shrink_0(),
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
this.child(div().flex_shrink_0().w(px(32.)))
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.child(
|
|
||||||
v_flex()
|
|
||||||
.flex_1()
|
|
||||||
.min_w_0()
|
|
||||||
.gap_0p5()
|
|
||||||
.when(show_author, |this| {
|
|
||||||
this.child(
|
|
||||||
h_flex()
|
|
||||||
.gap_2()
|
|
||||||
.text_sm()
|
|
||||||
.text_color(cx.theme().text_placeholder)
|
|
||||||
.child(div().font_semibold().child(author.name()))
|
|
||||||
.child(
|
|
||||||
Timestamp::from_secs(message.at_ms / 1000).to_human_time(),
|
|
||||||
)
|
|
||||||
.when(message.edited_at.is_some(), |this| {
|
|
||||||
this.child(div().child("(edited)"))
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
.child(content(message, cx))
|
|
||||||
.when(!message.reactions.is_empty(), |this| {
|
|
||||||
this.child(reactions(message, cx))
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
|
.author(author.name())
|
||||||
|
.timestamp(Timestamp::from_secs(message.at_ms / 1000).to_human_time())
|
||||||
|
.when(message.edited_at.is_some(), |this| {
|
||||||
|
this.header_extra(div().child("(edited)"))
|
||||||
|
})
|
||||||
|
.child(content)
|
||||||
|
.when(!message.reactions.is_empty(), |this| {
|
||||||
|
this.child(reactions(message, cx))
|
||||||
|
})
|
||||||
.into_any_element()
|
.into_any_element()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn content(message: &ChatMessage, cx: &App) -> AnyElement {
|
/// The placeholder shown in place of the body of a deleted message.
|
||||||
if message.deleted {
|
pub(crate) fn deleted(cx: &App) -> AnyElement {
|
||||||
return div()
|
|
||||||
.text_color(cx.theme().text_danger)
|
|
||||||
.child("Message deleted")
|
|
||||||
.into_any_element();
|
|
||||||
}
|
|
||||||
|
|
||||||
div()
|
div()
|
||||||
.child(SharedString::from(&message.content))
|
.text_color(cx.theme().text_danger)
|
||||||
|
.child("Message deleted")
|
||||||
.into_any_element()
|
.into_any_element()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -19,5 +19,9 @@ log.workspace = true
|
|||||||
|
|
||||||
uuid = "1.10"
|
uuid = "1.10"
|
||||||
|
|
||||||
|
linkify = "0.10.0"
|
||||||
|
pulldown-cmark = "0.13.1"
|
||||||
|
regex = "1"
|
||||||
|
|
||||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||||
smol.workspace = true
|
smol.workspace = true
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ pub(super) fn avatar_size(size: Size) -> AbsoluteLength {
|
|||||||
Size::Large => px(64.).into(),
|
Size::Large => px(64.).into(),
|
||||||
Size::Medium => px(32.).into(),
|
Size::Medium => px(32.).into(),
|
||||||
Size::Small => px(24.).into(),
|
Size::Small => px(24.).into(),
|
||||||
Size::XSmall => px(20.).into(),
|
Size::XSmall => px(18.).into(),
|
||||||
Size::Size(size) => size.into(),
|
Size::Size(size) => size.into(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,22 +5,21 @@ use std::sync::Arc;
|
|||||||
|
|
||||||
use gpui::prelude::FluentBuilder as _;
|
use gpui::prelude::FluentBuilder as _;
|
||||||
use gpui::{
|
use gpui::{
|
||||||
Anchor, AnyElement, AnyView, App, AppContext as _, Bounds, Context, Div, Empty, Entity,
|
AnyElement, AnyView, App, AppContext as _, Bounds, Context, Div, Empty, Entity,
|
||||||
GlobalElementId, InspectorElementId, InteractiveElement as _, IntoElement, LayoutId,
|
GlobalElementId, InspectorElementId, InteractiveElement as _, IntoElement, LayoutId,
|
||||||
MouseButton, MouseMoveEvent, MouseUpEvent, ParentElement as _, Pixels, Render, ScrollHandle,
|
MouseButton, MouseMoveEvent, MouseUpEvent, ParentElement as _, Pixels, Render, ScrollHandle,
|
||||||
SharedString, Stateful, StatefulInteractiveElement as _, Style, StyleRefinement, Styled as _,
|
SharedString, Stateful, StatefulInteractiveElement as _, Style, StyleRefinement, Styled as _,
|
||||||
WeakEntity, Window, actions, div, px, rems,
|
WeakEntity, Window, actions, div, px,
|
||||||
};
|
};
|
||||||
pub use gpui_base::dock::{DockArea, DockLayout, DockPlacement};
|
pub use gpui_base::dock::{DockArea, DockLayout, DockPlacement};
|
||||||
use gpui_base::dock::{
|
use gpui_base::dock::{
|
||||||
DockAreaRenderer, DockContext, DragPanel, DropIndicator, InsertTarget, NodeId, PaneNode,
|
DockAreaRenderer, DockContext, DragPanel, DropIndicator, InsertTarget, NodeId, PaneNode,
|
||||||
PaneRef, PanelId, TabGroupContext, TabGroupRenderer, TileContext, TilesRenderer,
|
PaneRef, PanelId, TabGroupContext, TabGroupRenderer,
|
||||||
};
|
};
|
||||||
use gpui_base::{Placement, ResizeHandleContext, Side};
|
use gpui_base::{Placement, ResizeHandleContext, Side};
|
||||||
use theme::{ActiveTheme, TABBAR_HEIGHT};
|
use theme::{ActiveTheme, TABBAR_HEIGHT};
|
||||||
|
|
||||||
use crate::button::{Button, ButtonVariants as _};
|
use crate::button::{Button, ButtonVariants as _};
|
||||||
use crate::menu::DropdownMenu as _;
|
|
||||||
use crate::resizable::{resize_handle, resize_handle_appearance};
|
use crate::resizable::{resize_handle, resize_handle_appearance};
|
||||||
use crate::tab::Tab;
|
use crate::tab::Tab;
|
||||||
use crate::tab::tab_bar::TabBar;
|
use crate::tab::tab_bar::TabBar;
|
||||||
@@ -95,6 +94,19 @@ pub fn add_panel(
|
|||||||
area.add_panel_view(Arc::new(panel), placement, None, window, cx);
|
area.add_panel_view(Arc::new(panel), placement, None, window, cx);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Add a panel to a dock reached through a weak handle.
|
||||||
|
pub fn add_panel_to(
|
||||||
|
dock: &WeakEntity<DockArea>,
|
||||||
|
panel: PanelHandle,
|
||||||
|
placement: DockPlacement,
|
||||||
|
window: &mut Window,
|
||||||
|
cx: &mut App,
|
||||||
|
) {
|
||||||
|
let _ = dock.update(cx, |area, cx| {
|
||||||
|
add_panel(area, panel, placement, window, cx);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/// The panel in any region of `area` whose logical id is `key`.
|
/// The panel in any region of `area` whose logical id is `key`.
|
||||||
fn find_panel(area: &DockArea, key: &SharedString, cx: &App) -> Option<(PanelId, NodeId, usize)> {
|
fn find_panel(area: &DockArea, key: &SharedString, cx: &App) -> Option<(PanelId, NodeId, usize)> {
|
||||||
let placements = [
|
let placements = [
|
||||||
@@ -167,7 +179,6 @@ fn left_top_group(node: &PaneNode) -> Option<NodeId> {
|
|||||||
match node.kind() {
|
match node.kind() {
|
||||||
PaneRef::Tabs { .. } => Some(node.id()),
|
PaneRef::Tabs { .. } => Some(node.id()),
|
||||||
PaneRef::Split { children, .. } => children.first().and_then(left_top_group),
|
PaneRef::Split { children, .. } => children.first().and_then(left_top_group),
|
||||||
PaneRef::Tiles { .. } => None,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -182,7 +193,6 @@ fn right_top_group(node: &PaneNode) -> Option<NodeId> {
|
|||||||
};
|
};
|
||||||
child.and_then(right_top_group)
|
child.and_then(right_top_group)
|
||||||
}
|
}
|
||||||
PaneRef::Tiles { .. } => None,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -263,18 +273,6 @@ impl DockAreaRenderer for DockSkin {
|
|||||||
fn tab_group_renderer(&self) -> Rc<dyn TabGroupRenderer> {
|
fn tab_group_renderer(&self) -> Rc<dyn TabGroupRenderer> {
|
||||||
Rc::new(TabGroupSkin::new(self.shared.clone()))
|
Rc::new(TabGroupSkin::new(self.shared.clone()))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn tiles_renderer(&self) -> Rc<dyn TilesRenderer> {
|
|
||||||
Rc::new(NoTiles)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct NoTiles;
|
|
||||||
|
|
||||||
impl TilesRenderer for NoTiles {
|
|
||||||
fn render_drag_bar(&self, _: &TileContext, _: &mut Window, _: &mut App) -> AnyElement {
|
|
||||||
Empty.into_any_element()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The payload a dock's resize handle drags; the handle is the affordance.
|
/// The payload a dock's resize handle drags; the handle is the affordance.
|
||||||
@@ -430,9 +428,6 @@ impl TabGroupSkin {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Whether this group is the left dock's root with a single panel.
|
/// Whether this group is the left dock's root with a single panel.
|
||||||
///
|
|
||||||
/// Such a group draws no tab bar, so its panel owns the window's top-left
|
|
||||||
/// corner — including the space the macOS traffic lights overlay.
|
|
||||||
fn is_plain_left_group(&self, group: &TabGroupContext, cx: &App) -> bool {
|
fn is_plain_left_group(&self, group: &TabGroupContext, cx: &App) -> bool {
|
||||||
let Some(area) = self.shared.area() else {
|
let Some(area) = self.shared.area() else {
|
||||||
return false;
|
return false;
|
||||||
@@ -445,9 +440,6 @@ impl TabGroupSkin {
|
|||||||
&& group.panels().len() == 1
|
&& group.panels().len() == 1
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Whether this group is the topmost-left group on screen, which sits under
|
|
||||||
/// the native macOS traffic lights. The left dock's group is leftmost while
|
|
||||||
/// it is open and holds a panel; the center's is leftmost otherwise.
|
|
||||||
fn is_leftmost_top_group(&self, group: &TabGroupContext, cx: &App) -> bool {
|
fn is_leftmost_top_group(&self, group: &TabGroupContext, cx: &App) -> bool {
|
||||||
let Some(area) = self.shared.area() else {
|
let Some(area) = self.shared.area() else {
|
||||||
return false;
|
return false;
|
||||||
@@ -465,172 +457,6 @@ impl TabGroupSkin {
|
|||||||
tree.and_then(|tree| left_top_group(tree.root())) == Some(group.node())
|
tree.and_then(|tree| left_top_group(tree.root())) == Some(group.node())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn render_toolbar(
|
|
||||||
&self,
|
|
||||||
group: &TabGroupContext,
|
|
||||||
window: &mut Window,
|
|
||||||
cx: &mut App,
|
|
||||||
) -> impl IntoElement {
|
|
||||||
let zoomed = group.is_zoomed();
|
|
||||||
let closable = group.is_closable();
|
|
||||||
let zoomable = group.active_panel().is_some_and(|panel| panel.zoomable(cx));
|
|
||||||
|
|
||||||
let zoom_label = if zoomed { "Zoom Out" } else { "Zoom In" };
|
|
||||||
|
|
||||||
let buttons = group
|
|
||||||
.active_panel()
|
|
||||||
.and_then(PanelHandle::of)
|
|
||||||
.map(|handle| handle.panel().toolbar_buttons(window, cx))
|
|
||||||
.unwrap_or_default();
|
|
||||||
|
|
||||||
let menu_panel = group
|
|
||||||
.active_panel()
|
|
||||||
.and_then(PanelHandle::of)
|
|
||||||
.map(|handle| handle.panel().clone());
|
|
||||||
|
|
||||||
h_flex()
|
|
||||||
.p_0p5()
|
|
||||||
.gap_1p5()
|
|
||||||
.occlude()
|
|
||||||
.rounded_full()
|
|
||||||
.children(buttons.into_iter().map(|button| button.small().ghost()))
|
|
||||||
.when(zoomed, |this| {
|
|
||||||
this.child(
|
|
||||||
Button::new("zoom")
|
|
||||||
.icon(IconName::Zoom)
|
|
||||||
.small()
|
|
||||||
.ghost()
|
|
||||||
.tooltip("Zoom Out")
|
|
||||||
.on_click({
|
|
||||||
let group = TabGroupContext::clone(group);
|
|
||||||
move |_, window, cx| group.toggle_zoom(window, cx)
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
.child(
|
|
||||||
Button::new("menu")
|
|
||||||
.icon(IconName::Ellipsis)
|
|
||||||
.small()
|
|
||||||
.ghost()
|
|
||||||
.dropdown_menu({
|
|
||||||
move |menu, _, cx| {
|
|
||||||
let menu = match menu_panel.clone() {
|
|
||||||
Some(panel) => panel.popup_menu(menu, cx),
|
|
||||||
None => menu,
|
|
||||||
};
|
|
||||||
|
|
||||||
menu.when(zoomable, |this| {
|
|
||||||
this.separator().menu(zoom_label, Box::new(ToggleZoom))
|
|
||||||
})
|
|
||||||
.when(closable, |this| {
|
|
||||||
this.separator().menu("Close", Box::new(ClosePanel))
|
|
||||||
})
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.anchor(Anchor::TopRight),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn render_title(
|
|
||||||
&self,
|
|
||||||
group: &TabGroupContext,
|
|
||||||
ix: usize,
|
|
||||||
window: &mut Window,
|
|
||||||
cx: &mut App,
|
|
||||||
) -> AnyElement {
|
|
||||||
let panel = group.panels()[ix].clone();
|
|
||||||
let left_button = self.dock_toggle_button(DockPlacement::Left, group, cx);
|
|
||||||
let bottom_button = self.dock_toggle_button(DockPlacement::Bottom, group, cx);
|
|
||||||
let right_button = self.dock_toggle_button(DockPlacement::Right, group, cx);
|
|
||||||
let has_leading = left_button.is_some() || bottom_button.is_some();
|
|
||||||
let drag = tab_drag(group, ix, cx);
|
|
||||||
let is_title_bar = self.is_title_bar_group(group, cx);
|
|
||||||
let needs_traffic_light_padding =
|
|
||||||
cfg!(target_os = "macos") && self.is_leftmost_top_group(group, cx);
|
|
||||||
let trailing_chrome = is_title_bar
|
|
||||||
.then(|| self.shared.chrome.trailing(window, cx))
|
|
||||||
.flatten();
|
|
||||||
|
|
||||||
let bar = h_flex()
|
|
||||||
.id("tab-title-bar")
|
|
||||||
.justify_between()
|
|
||||||
.items_center()
|
|
||||||
.line_height(rems(1.0))
|
|
||||||
.h(TABBAR_HEIGHT)
|
|
||||||
.bg(cx.theme().panel_background)
|
|
||||||
.when(left_button.is_some(), |this| this.pl_2())
|
|
||||||
.when(right_button.is_some(), |this| this.pr_2())
|
|
||||||
.when(has_leading, |this| {
|
|
||||||
this.child(
|
|
||||||
h_flex()
|
|
||||||
.flex_shrink_0()
|
|
||||||
.mr_1()
|
|
||||||
.gap_1()
|
|
||||||
.children(left_button)
|
|
||||||
.children(bottom_button),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
.when(needs_traffic_light_padding, |this| {
|
|
||||||
this.pl(px(TRAFFIC_LIGHT_PADDING))
|
|
||||||
})
|
|
||||||
.child(
|
|
||||||
div()
|
|
||||||
.id("tab")
|
|
||||||
.flex_initial()
|
|
||||||
.min_w_0()
|
|
||||||
.px_2()
|
|
||||||
.overflow_hidden()
|
|
||||||
.whitespace_nowrap()
|
|
||||||
.child(
|
|
||||||
div()
|
|
||||||
.w_full()
|
|
||||||
.text_ellipsis()
|
|
||||||
.text_sm()
|
|
||||||
.child(panel_title(&panel, cx)),
|
|
||||||
)
|
|
||||||
.when_some(drag, |this, drag| {
|
|
||||||
this.on_drag(drag, {
|
|
||||||
let panel = panel.clone();
|
|
||||||
move |drag, offset, _, cx| {
|
|
||||||
cx.stop_propagation();
|
|
||||||
drag.set_drag_offset(offset);
|
|
||||||
cx.new(|_| DragPreview {
|
|
||||||
panel: panel.clone(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.child({
|
|
||||||
let space = div().id("tab-title-space").flex_1().h_full();
|
|
||||||
if is_title_bar {
|
|
||||||
title_bar_drag_handlers(space, window, cx).into_any_element()
|
|
||||||
} else {
|
|
||||||
space.into_any_element()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.child(
|
|
||||||
h_flex()
|
|
||||||
.flex_shrink_0()
|
|
||||||
.ml_1()
|
|
||||||
.gap_1()
|
|
||||||
.child(self.render_toolbar(group, window, cx))
|
|
||||||
.children(right_button),
|
|
||||||
)
|
|
||||||
.when_some(trailing_chrome, |this, chrome| this.child(chrome));
|
|
||||||
|
|
||||||
if is_title_bar {
|
|
||||||
h_flex()
|
|
||||||
.h(TABBAR_HEIGHT)
|
|
||||||
.bg(cx.theme().panel_background)
|
|
||||||
.child(bar.flex_1())
|
|
||||||
.child(window_controls())
|
|
||||||
.into_any_element()
|
|
||||||
} else {
|
|
||||||
bar.into_any_element()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn render_tabs(
|
fn render_tabs(
|
||||||
&self,
|
&self,
|
||||||
group: &TabGroupContext,
|
group: &TabGroupContext,
|
||||||
@@ -642,9 +468,12 @@ impl TabGroupSkin {
|
|||||||
let bottom_button = self.dock_toggle_button(DockPlacement::Bottom, group, cx);
|
let bottom_button = self.dock_toggle_button(DockPlacement::Bottom, group, cx);
|
||||||
let right_button = self.dock_toggle_button(DockPlacement::Right, group, cx);
|
let right_button = self.dock_toggle_button(DockPlacement::Right, group, cx);
|
||||||
let has_leading = left_button.is_some() || bottom_button.is_some();
|
let has_leading = left_button.is_some() || bottom_button.is_some();
|
||||||
|
|
||||||
let collapsed = group.is_collapsed();
|
let collapsed = group.is_collapsed();
|
||||||
let droppable = group.is_droppable();
|
let droppable = group.is_droppable();
|
||||||
let tabs_count = group.panels().len();
|
let tabs_count = group.panels().len();
|
||||||
|
let is_title_bar = self.is_title_bar_group(group, cx);
|
||||||
|
|
||||||
let displayed = group.active_panel().map(|panel| panel.panel_id(cx));
|
let displayed = group.active_panel().map(|panel| panel.panel_id(cx));
|
||||||
let displayed_ix = displayed.and_then(|displayed| {
|
let displayed_ix = displayed.and_then(|displayed| {
|
||||||
group
|
group
|
||||||
@@ -652,12 +481,14 @@ impl TabGroupSkin {
|
|||||||
.iter()
|
.iter()
|
||||||
.position(|panel| panel.panel_id(cx) == displayed)
|
.position(|panel| panel.panel_id(cx) == displayed)
|
||||||
});
|
});
|
||||||
let is_title_bar = self.is_title_bar_group(group, cx);
|
|
||||||
let needs_traffic_light_padding =
|
let needs_traffic_light_padding =
|
||||||
cfg!(target_os = "macos") && self.is_leftmost_top_group(group, cx);
|
cfg!(target_os = "macos") && self.is_leftmost_top_group(group, cx);
|
||||||
|
|
||||||
let trailing_chrome = is_title_bar
|
let trailing_chrome = is_title_bar
|
||||||
.then(|| self.shared.chrome.trailing(window, cx))
|
.then(|| self.shared.chrome.trailing(window, cx))
|
||||||
.flatten();
|
.flatten();
|
||||||
|
|
||||||
let empty_space = div()
|
let empty_space = div()
|
||||||
.id("tab-bar-empty-space")
|
.id("tab-bar-empty-space")
|
||||||
.h_full()
|
.h_full()
|
||||||
@@ -673,6 +504,7 @@ impl TabGroupSkin {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
let empty_space = if is_title_bar {
|
let empty_space = if is_title_bar {
|
||||||
title_bar_drag_handlers(empty_space, window, cx).into_any_element()
|
title_bar_drag_handlers(empty_space, window, cx).into_any_element()
|
||||||
} else {
|
} else {
|
||||||
@@ -689,7 +521,9 @@ impl TabGroupSkin {
|
|||||||
.when(is_title_bar || has_leading, |this| {
|
.when(is_title_bar || has_leading, |this| {
|
||||||
this.prefix(
|
this.prefix(
|
||||||
h_flex()
|
h_flex()
|
||||||
.items_center()
|
.size(TABBAR_HEIGHT)
|
||||||
|
.flex_shrink_0()
|
||||||
|
.justify_center()
|
||||||
.top_0()
|
.top_0()
|
||||||
.right(-px(1.))
|
.right(-px(1.))
|
||||||
.pl_0p5()
|
.pl_0p5()
|
||||||
@@ -767,13 +601,13 @@ impl TabGroupSkin {
|
|||||||
.when(!collapsed, |this| {
|
.when(!collapsed, |this| {
|
||||||
this.suffix(
|
this.suffix(
|
||||||
h_flex()
|
h_flex()
|
||||||
|
.flex_shrink_0()
|
||||||
.items_center()
|
.items_center()
|
||||||
.top_0()
|
.top_0()
|
||||||
.right_0()
|
.right_0()
|
||||||
.h_full()
|
.h_full()
|
||||||
.px_0p5()
|
.px_0p5()
|
||||||
.gap_1()
|
.gap_1()
|
||||||
.child(self.render_toolbar(group, window, cx))
|
|
||||||
.children(right_button)
|
.children(right_button)
|
||||||
.children(trailing_chrome),
|
.children(trailing_chrome),
|
||||||
)
|
)
|
||||||
@@ -846,7 +680,13 @@ impl TabGroupSkin {
|
|||||||
.small()
|
.small()
|
||||||
.ghost()
|
.ghost()
|
||||||
.tab_stop(false)
|
.tab_stop(false)
|
||||||
.tooltip(if is_open { "Collapse" } else { "Expand" })
|
.map(|this| {
|
||||||
|
if is_open {
|
||||||
|
this.tooltip("Collapse")
|
||||||
|
} else {
|
||||||
|
this.tooltip("Expand")
|
||||||
|
}
|
||||||
|
})
|
||||||
.on_click(move |_, window, cx| {
|
.on_click(move |_, window, cx| {
|
||||||
area.update(cx, |area, cx| area.toggle_dock(placement, window, cx));
|
area.update(cx, |area, cx| area.toggle_dock(placement, window, cx));
|
||||||
}),
|
}),
|
||||||
@@ -901,13 +741,11 @@ impl TabGroupRenderer for TabGroupSkin {
|
|||||||
self.scroll_handle.scroll_to_item(visible_ix);
|
self.scroll_handle.scroll_to_item(visible_ix);
|
||||||
}
|
}
|
||||||
|
|
||||||
match visible.as_slice() {
|
if visible.is_empty() {
|
||||||
[] => Empty.into_any_element(),
|
return Empty.into_any_element();
|
||||||
// One panel in a group that is not asking for tabs gets the title
|
|
||||||
// instead of a tab bar.
|
|
||||||
[ix] => self.render_title(group, *ix, window, cx),
|
|
||||||
_ => self.render_tabs(group, visible.as_slice(), window, cx),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
self.render_tabs(group, visible.as_slice(), window, cx)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn render_active_panel(
|
fn render_active_panel(
|
||||||
|
|||||||
@@ -19,9 +19,6 @@ pub enum PanelEvent {
|
|||||||
|
|
||||||
pub trait Panel: EventEmitter<PanelEvent> + Render + Focusable {
|
pub trait Panel: EventEmitter<PanelEvent> + Render + Focusable {
|
||||||
/// The name of the panel used to serialize, deserialize and identify the panel.
|
/// The name of the panel used to serialize, deserialize and identify the panel.
|
||||||
///
|
|
||||||
/// This is used to identify the panel when deserializing the panel.
|
|
||||||
/// Once you have defined a panel id, this must not be changed.
|
|
||||||
fn panel_id(&self) -> SharedString;
|
fn panel_id(&self) -> SharedString;
|
||||||
|
|
||||||
/// The title of the panel
|
/// The title of the panel
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ impl<T: IconNamed> From<T> for Icon {
|
|||||||
pub enum IconName {
|
pub enum IconName {
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
ArrowRight,
|
ArrowRight,
|
||||||
|
ArrowDownCircle,
|
||||||
Boom,
|
Boom,
|
||||||
Book,
|
Book,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
@@ -66,6 +67,7 @@ pub enum IconName {
|
|||||||
Ship,
|
Ship,
|
||||||
Shield,
|
Shield,
|
||||||
Group,
|
Group,
|
||||||
|
Hashtag,
|
||||||
History,
|
History,
|
||||||
UserKey,
|
UserKey,
|
||||||
Upload,
|
Upload,
|
||||||
@@ -99,6 +101,7 @@ impl IconNamed for IconName {
|
|||||||
match self {
|
match self {
|
||||||
Self::ArrowLeft => "icons/arrow-left.svg",
|
Self::ArrowLeft => "icons/arrow-left.svg",
|
||||||
Self::ArrowRight => "icons/arrow-right.svg",
|
Self::ArrowRight => "icons/arrow-right.svg",
|
||||||
|
Self::ArrowDownCircle => "icons/arrow-down-circle.svg",
|
||||||
Self::Boom => "icons/boom.svg",
|
Self::Boom => "icons/boom.svg",
|
||||||
Self::Book => "icons/book.svg",
|
Self::Book => "icons/book.svg",
|
||||||
Self::ChevronDown => "icons/chevron-down.svg",
|
Self::ChevronDown => "icons/chevron-down.svg",
|
||||||
@@ -146,6 +149,7 @@ impl IconNamed for IconName {
|
|||||||
Self::Upload => "icons/upload.svg",
|
Self::Upload => "icons/upload.svg",
|
||||||
Self::Usb => "icons/usb.svg",
|
Self::Usb => "icons/usb.svg",
|
||||||
Self::Group => "icons/group.svg",
|
Self::Group => "icons/group.svg",
|
||||||
|
Self::Hashtag => "icons/hashtag.svg",
|
||||||
Self::History => "icons/history.svg",
|
Self::History => "icons/history.svg",
|
||||||
Self::PanelLeft => "icons/panel-left.svg",
|
Self::PanelLeft => "icons/panel-left.svg",
|
||||||
Self::PanelLeftOpen => "icons/panel-left-open.svg",
|
Self::PanelLeftOpen => "icons/panel-left-open.svg",
|
||||||
|
|||||||
@@ -16,8 +16,11 @@ pub mod dock;
|
|||||||
pub mod group_box;
|
pub mod group_box;
|
||||||
pub mod indicator;
|
pub mod indicator;
|
||||||
pub mod input;
|
pub mod input;
|
||||||
|
pub mod markdown;
|
||||||
pub mod menu;
|
pub mod menu;
|
||||||
|
pub mod message;
|
||||||
pub mod modal;
|
pub mod modal;
|
||||||
|
pub mod nav;
|
||||||
pub mod nav_item;
|
pub mod nav_item;
|
||||||
pub mod notification;
|
pub mod notification;
|
||||||
pub mod popover;
|
pub mod popover;
|
||||||
|
|||||||
@@ -0,0 +1,491 @@
|
|||||||
|
use std::ops::Range;
|
||||||
|
use std::sync::{Arc, LazyLock};
|
||||||
|
|
||||||
|
use gpui::{
|
||||||
|
AnyElement, App, ElementId, FontStyle, FontWeight, HighlightStyle, InteractiveText,
|
||||||
|
IntoElement, SharedString, StrikethroughStyle, StyledText, UnderlineStyle, Window,
|
||||||
|
};
|
||||||
|
use regex::Regex;
|
||||||
|
use theme::ActiveTheme;
|
||||||
|
|
||||||
|
/// Matches `http://` and `https://` URLs. Only these are treated as clickable links.
|
||||||
|
static WEB_URL: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"(?i)^https?://").unwrap());
|
||||||
|
|
||||||
|
/// A span of the source content, by byte range, that renders as `text` instead.
|
||||||
|
///
|
||||||
|
/// Replacements are resolved before rendering and styled as an accent-colored
|
||||||
|
/// link. Mentions are one use: callers resolve the public key to a display name
|
||||||
|
/// and hand the renderer the span to substitute. They must be sorted by
|
||||||
|
/// `range.start` and must not overlap.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct InlineReplacement {
|
||||||
|
pub range: Range<usize>,
|
||||||
|
pub text: SharedString,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl InlineReplacement {
|
||||||
|
pub fn new(range: Range<usize>, text: impl Into<SharedString>) -> Self {
|
||||||
|
Self {
|
||||||
|
range,
|
||||||
|
text: text.into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(clippy::enum_variant_names)]
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub enum Highlight {
|
||||||
|
Code,
|
||||||
|
InlineCode(bool),
|
||||||
|
Replacement,
|
||||||
|
Style(HighlightStyle),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<HighlightStyle> for Highlight {
|
||||||
|
fn from(style: HighlightStyle) -> Self {
|
||||||
|
Self::Style(style)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Message content flattened into a single styled string.
|
||||||
|
///
|
||||||
|
/// Markdown structure (headings, lists, code blocks, emphasis, links) is turned
|
||||||
|
/// into plain text plus highlight ranges, ready to hand to an `InteractiveText`.
|
||||||
|
#[derive(Default)]
|
||||||
|
pub struct RenderedText {
|
||||||
|
pub text: SharedString,
|
||||||
|
pub highlights: Vec<(Range<usize>, Highlight)>,
|
||||||
|
pub link_ranges: Vec<Range<usize>>,
|
||||||
|
pub link_urls: Arc<[String]>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RenderedText {
|
||||||
|
/// Parse `content`, optionally as markdown, replacing `replacements` inline.
|
||||||
|
pub fn new(content: &str, replacements: &[InlineReplacement], markdown: bool) -> Self {
|
||||||
|
let mut text = String::new();
|
||||||
|
let mut highlights = Vec::new();
|
||||||
|
let mut link_ranges = Vec::new();
|
||||||
|
let mut link_urls = Vec::new();
|
||||||
|
|
||||||
|
render_text_mut(
|
||||||
|
content,
|
||||||
|
replacements,
|
||||||
|
&mut text,
|
||||||
|
&mut highlights,
|
||||||
|
&mut link_ranges,
|
||||||
|
&mut link_urls,
|
||||||
|
markdown,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Trim trailing whitespace and adjust highlight and link ranges.
|
||||||
|
let trimmed_len = text.trim_end().len();
|
||||||
|
|
||||||
|
// Retain highlights and link ranges that are within the trimmed text.
|
||||||
|
if trimmed_len < text.len() {
|
||||||
|
highlights.retain_mut(|(range, _)| {
|
||||||
|
range.end = range.end.min(trimmed_len);
|
||||||
|
range.start < range.end
|
||||||
|
});
|
||||||
|
|
||||||
|
let mut ix = 0;
|
||||||
|
|
||||||
|
while ix < link_ranges.len() {
|
||||||
|
let range = &mut link_ranges[ix];
|
||||||
|
range.end = range.end.min(trimmed_len);
|
||||||
|
if range.start < range.end {
|
||||||
|
ix += 1;
|
||||||
|
} else {
|
||||||
|
link_ranges.remove(ix);
|
||||||
|
link_urls.remove(ix);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
text.truncate(trimmed_len);
|
||||||
|
}
|
||||||
|
|
||||||
|
RenderedText {
|
||||||
|
text: SharedString::from(text),
|
||||||
|
link_urls: link_urls.into(),
|
||||||
|
link_ranges,
|
||||||
|
highlights,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn element(&self, id: ElementId, window: &Window, cx: &App) -> AnyElement {
|
||||||
|
let code_background = cx.theme().elevated_surface_background;
|
||||||
|
let color = cx.theme().text_accent;
|
||||||
|
let code_font = if cfg!(target_os = "macos") {
|
||||||
|
"Menlo"
|
||||||
|
} else if cfg!(target_os = "windows") {
|
||||||
|
"Consolas"
|
||||||
|
} else {
|
||||||
|
"monospace"
|
||||||
|
};
|
||||||
|
|
||||||
|
InteractiveText::new(
|
||||||
|
id,
|
||||||
|
StyledText::new(self.text.clone())
|
||||||
|
.with_default_highlights(
|
||||||
|
&window.text_style(),
|
||||||
|
self.highlights.iter().map(|(range, highlight)| {
|
||||||
|
(
|
||||||
|
range.clone(),
|
||||||
|
match highlight {
|
||||||
|
Highlight::Code => HighlightStyle {
|
||||||
|
background_color: Some(code_background),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
Highlight::InlineCode(link) => {
|
||||||
|
if *link {
|
||||||
|
HighlightStyle {
|
||||||
|
background_color: Some(code_background),
|
||||||
|
underline: Some(UnderlineStyle {
|
||||||
|
thickness: 1.0.into(),
|
||||||
|
..Default::default()
|
||||||
|
}),
|
||||||
|
..Default::default()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
HighlightStyle {
|
||||||
|
background_color: Some(code_background),
|
||||||
|
..Default::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Highlight::Replacement => HighlightStyle {
|
||||||
|
color: Some(color),
|
||||||
|
underline: Some(UnderlineStyle {
|
||||||
|
thickness: 1.0.into(),
|
||||||
|
..Default::default()
|
||||||
|
}),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
Highlight::Style(highlight) => *highlight,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.with_font_family_overrides(self.highlights.iter().filter_map(
|
||||||
|
|(range, highlight)| match highlight {
|
||||||
|
Highlight::Code | Highlight::InlineCode(_) => {
|
||||||
|
Some((range.clone(), code_font.into()))
|
||||||
|
}
|
||||||
|
_ => None,
|
||||||
|
},
|
||||||
|
)),
|
||||||
|
)
|
||||||
|
.on_click(self.link_ranges.clone(), {
|
||||||
|
let link_urls = self.link_urls.clone();
|
||||||
|
move |ix, _, cx| {
|
||||||
|
let url = &link_urls[ix];
|
||||||
|
if WEB_URL.is_match(url) {
|
||||||
|
cx.open_url(url);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.into_any_element()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
fn render_text_mut(
|
||||||
|
block: &str,
|
||||||
|
replacements: &[InlineReplacement],
|
||||||
|
text: &mut String,
|
||||||
|
highlights: &mut Vec<(Range<usize>, Highlight)>,
|
||||||
|
link_ranges: &mut Vec<Range<usize>>,
|
||||||
|
link_urls: &mut Vec<String>,
|
||||||
|
markdown: bool,
|
||||||
|
) {
|
||||||
|
use pulldown_cmark::{Event, Options, Parser, Tag, TagEnd};
|
||||||
|
|
||||||
|
let mut bold_depth = 0;
|
||||||
|
let mut italic_depth = 0;
|
||||||
|
let mut strikethrough_depth = 0;
|
||||||
|
let mut link_url = None;
|
||||||
|
let mut list_stack = Vec::new();
|
||||||
|
let mut code_block = false;
|
||||||
|
|
||||||
|
// Only enable the extensions that make sense for chat messages. Notably this leaves
|
||||||
|
// out smart punctuation, tables, math and footnotes: they rewrite or swallow text.
|
||||||
|
let events: Box<dyn Iterator<Item = (Event<'_>, Range<usize>)> + '_> = if markdown {
|
||||||
|
Box::new(Parser::new_ext(block, Options::ENABLE_STRIKETHROUGH).into_offset_iter())
|
||||||
|
} else {
|
||||||
|
Box::new(std::iter::once((Event::Text(block.into()), 0..block.len())))
|
||||||
|
};
|
||||||
|
|
||||||
|
for (event, source_range) in events {
|
||||||
|
let prev_len = text.len();
|
||||||
|
|
||||||
|
match event {
|
||||||
|
Event::Text(t) => {
|
||||||
|
if code_block {
|
||||||
|
text.push_str(t.as_ref());
|
||||||
|
highlights.push((prev_len..text.len(), Highlight::Code));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let t_str = t.as_ref();
|
||||||
|
let mut last_processed = 0;
|
||||||
|
|
||||||
|
for replacement in replacements {
|
||||||
|
if replacement.range.start >= source_range.end {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if replacement.range.start < source_range.start
|
||||||
|
|| replacement.range.end > source_range.end
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let Some(token) = block.get(replacement.range.clone()) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
|
let Some(offset) = t_str[last_processed..].find(token) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
|
let replacement_start_in_text = last_processed + offset;
|
||||||
|
let replacement_end_in_text = replacement_start_in_text + token.len();
|
||||||
|
|
||||||
|
// Add text before this replacement
|
||||||
|
if replacement_start_in_text > last_processed {
|
||||||
|
let before_replacement = &t_str[last_processed..replacement_start_in_text];
|
||||||
|
process_text_segment(
|
||||||
|
before_replacement,
|
||||||
|
bold_depth,
|
||||||
|
italic_depth,
|
||||||
|
strikethrough_depth,
|
||||||
|
link_url.clone(),
|
||||||
|
text,
|
||||||
|
highlights,
|
||||||
|
link_ranges,
|
||||||
|
link_urls,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process the replacement
|
||||||
|
let replacement_start = text.len();
|
||||||
|
text.push_str(&replacement.text);
|
||||||
|
let replacement_end = text.len();
|
||||||
|
|
||||||
|
highlights.push((replacement_start..replacement_end, Highlight::Replacement));
|
||||||
|
|
||||||
|
last_processed = replacement_end_in_text;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add any remaining text after the last replacement
|
||||||
|
if last_processed < t_str.len() {
|
||||||
|
let remaining_text = &t_str[last_processed..];
|
||||||
|
process_text_segment(
|
||||||
|
remaining_text,
|
||||||
|
bold_depth,
|
||||||
|
italic_depth,
|
||||||
|
strikethrough_depth,
|
||||||
|
link_url.clone(),
|
||||||
|
text,
|
||||||
|
highlights,
|
||||||
|
link_ranges,
|
||||||
|
link_urls,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Event::Code(t) => {
|
||||||
|
text.push_str(t.as_ref());
|
||||||
|
let is_link = link_url.is_some();
|
||||||
|
|
||||||
|
if let Some(link_url) = link_url.clone() {
|
||||||
|
link_ranges.push(prev_len..text.len());
|
||||||
|
link_urls.push(link_url);
|
||||||
|
}
|
||||||
|
|
||||||
|
highlights.push((prev_len..text.len(), Highlight::InlineCode(is_link)))
|
||||||
|
}
|
||||||
|
Event::Start(tag) => match tag {
|
||||||
|
Tag::Paragraph => new_paragraph(text, &mut list_stack),
|
||||||
|
Tag::Heading { .. } => {
|
||||||
|
new_paragraph(text, &mut list_stack);
|
||||||
|
bold_depth += 1;
|
||||||
|
}
|
||||||
|
Tag::CodeBlock(_kind) => {
|
||||||
|
new_paragraph(text, &mut list_stack);
|
||||||
|
code_block = true;
|
||||||
|
}
|
||||||
|
Tag::Emphasis => italic_depth += 1,
|
||||||
|
Tag::Strong => bold_depth += 1,
|
||||||
|
Tag::Strikethrough => strikethrough_depth += 1,
|
||||||
|
Tag::Link { dest_url, .. } => {
|
||||||
|
link_url = WEB_URL.is_match(&dest_url).then(|| dest_url.to_string());
|
||||||
|
}
|
||||||
|
Tag::List(number) => {
|
||||||
|
list_stack.push((number, false));
|
||||||
|
}
|
||||||
|
Tag::Item => {
|
||||||
|
let len = list_stack.len();
|
||||||
|
if let Some((list_number, has_content)) = list_stack.last_mut() {
|
||||||
|
*has_content = false;
|
||||||
|
if !text.is_empty() && !text.ends_with('\n') {
|
||||||
|
text.push('\n');
|
||||||
|
}
|
||||||
|
for _ in 0..len - 1 {
|
||||||
|
text.push_str(" ");
|
||||||
|
}
|
||||||
|
if let Some(number) = list_number {
|
||||||
|
text.push_str(&format!("{}. ", number));
|
||||||
|
*number += 1;
|
||||||
|
*has_content = false;
|
||||||
|
} else {
|
||||||
|
text.push_str("- ");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
},
|
||||||
|
Event::End(tag) => match tag {
|
||||||
|
TagEnd::CodeBlock => code_block = false,
|
||||||
|
TagEnd::Heading(_) => bold_depth -= 1,
|
||||||
|
TagEnd::Emphasis => italic_depth -= 1,
|
||||||
|
TagEnd::Strong => bold_depth -= 1,
|
||||||
|
TagEnd::Strikethrough => strikethrough_depth -= 1,
|
||||||
|
TagEnd::Link => link_url = None,
|
||||||
|
TagEnd::List(_) => drop(list_stack.pop()),
|
||||||
|
_ => {}
|
||||||
|
},
|
||||||
|
Event::Html(t) | Event::InlineHtml(t) => text.push_str(t.as_ref()),
|
||||||
|
Event::Rule => {
|
||||||
|
new_paragraph(text, &mut list_stack);
|
||||||
|
text.push_str("────────\n");
|
||||||
|
}
|
||||||
|
Event::HardBreak => text.push('\n'),
|
||||||
|
Event::SoftBreak => text.push('\n'),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
fn process_text_segment(
|
||||||
|
segment: &str,
|
||||||
|
bold_depth: i32,
|
||||||
|
italic_depth: i32,
|
||||||
|
strikethrough_depth: i32,
|
||||||
|
link_url: Option<String>,
|
||||||
|
text: &mut String,
|
||||||
|
highlights: &mut Vec<(Range<usize>, Highlight)>,
|
||||||
|
link_ranges: &mut Vec<Range<usize>>,
|
||||||
|
link_urls: &mut Vec<String>,
|
||||||
|
) {
|
||||||
|
// Build the style for this segment
|
||||||
|
let mut style = HighlightStyle::default();
|
||||||
|
if bold_depth > 0 {
|
||||||
|
style.font_weight = Some(FontWeight::BOLD);
|
||||||
|
}
|
||||||
|
if italic_depth > 0 {
|
||||||
|
style.font_style = Some(FontStyle::Italic);
|
||||||
|
}
|
||||||
|
if strikethrough_depth > 0 {
|
||||||
|
style.strikethrough = Some(StrikethroughStyle {
|
||||||
|
thickness: 1.0.into(),
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ranges always refer to the rendered text, including replaced spans.
|
||||||
|
let segment_start = text.len();
|
||||||
|
text.push_str(segment);
|
||||||
|
let text_end = text.len();
|
||||||
|
|
||||||
|
if let Some(link_url) = link_url {
|
||||||
|
// Handle as a markdown link
|
||||||
|
link_ranges.push(segment_start..text_end);
|
||||||
|
link_urls.push(link_url);
|
||||||
|
style.underline = Some(UnderlineStyle {
|
||||||
|
thickness: 1.0.into(),
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add highlight for the entire linked segment
|
||||||
|
if style != HighlightStyle::default() {
|
||||||
|
highlights.push((segment_start..text_end, Highlight::Style(style)));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Handle link detection within the segment
|
||||||
|
let mut finder = linkify::LinkFinder::new();
|
||||||
|
finder.kinds(&[linkify::LinkKind::Url]);
|
||||||
|
let mut last_link_pos = 0;
|
||||||
|
|
||||||
|
for link in finder
|
||||||
|
.links(segment)
|
||||||
|
.filter(|link| WEB_URL.is_match(link.as_str()))
|
||||||
|
{
|
||||||
|
let start = link.start();
|
||||||
|
let end = link.end();
|
||||||
|
|
||||||
|
// Add non-link text before this link
|
||||||
|
if start > last_link_pos {
|
||||||
|
let non_link_start = segment_start + last_link_pos;
|
||||||
|
let non_link_end = segment_start + start;
|
||||||
|
|
||||||
|
if style != HighlightStyle::default() {
|
||||||
|
highlights.push((non_link_start..non_link_end, Highlight::Style(style)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add the link
|
||||||
|
let range = (segment_start + start)..(segment_start + end);
|
||||||
|
link_ranges.push(range.clone());
|
||||||
|
link_urls.push(link.as_str().to_string());
|
||||||
|
|
||||||
|
// Apply link styling (underline + existing style)
|
||||||
|
let mut link_style = style;
|
||||||
|
link_style.underline = Some(UnderlineStyle {
|
||||||
|
thickness: 1.0.into(),
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
|
||||||
|
highlights.push((range, Highlight::Style(link_style)));
|
||||||
|
|
||||||
|
last_link_pos = end;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add any remaining text after the last link
|
||||||
|
if last_link_pos < segment.len() {
|
||||||
|
let remaining_start = segment_start + last_link_pos;
|
||||||
|
let remaining_end = segment_start + segment.len();
|
||||||
|
|
||||||
|
if style != HighlightStyle::default() {
|
||||||
|
highlights.push((remaining_start..remaining_end, Highlight::Style(style)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn new_paragraph(text: &mut String, list_stack: &mut [(Option<u64>, bool)]) {
|
||||||
|
let mut is_subsequent_paragraph_of_list = false;
|
||||||
|
|
||||||
|
if let Some((_, has_content)) = list_stack.last_mut() {
|
||||||
|
if *has_content {
|
||||||
|
is_subsequent_paragraph_of_list = true;
|
||||||
|
} else {
|
||||||
|
*has_content = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !text.is_empty() {
|
||||||
|
if !text.ends_with('\n') {
|
||||||
|
text.push('\n');
|
||||||
|
}
|
||||||
|
text.push('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
for _ in 0..list_stack.len().saturating_sub(1) {
|
||||||
|
text.push_str(" ");
|
||||||
|
}
|
||||||
|
|
||||||
|
if is_subsequent_paragraph_of_list {
|
||||||
|
text.push_str(" ");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,325 @@
|
|||||||
|
use gpui::prelude::FluentBuilder as _;
|
||||||
|
use gpui::{
|
||||||
|
AnyElement, App, ClickEvent, ElementId, InteractiveElement as _, IntoElement, MouseButton,
|
||||||
|
MouseDownEvent, ParentElement as _, RenderOnce, SharedString, StyleRefinement, Styled, Window,
|
||||||
|
div, px,
|
||||||
|
};
|
||||||
|
use smallvec::SmallVec;
|
||||||
|
use theme::ActiveTheme;
|
||||||
|
|
||||||
|
use crate::{InteractiveElementExt as _, StyledExt as _, h_flex, v_flex};
|
||||||
|
|
||||||
|
type MouseDownListener = Box<dyn Fn(&MouseDownEvent, &mut Window, &mut App) + 'static>;
|
||||||
|
type DoubleClickListener = Box<dyn Fn(&ClickEvent, &mut Window, &mut App) + 'static>;
|
||||||
|
|
||||||
|
/// A single message row in a chat-like list.
|
||||||
|
#[derive(IntoElement)]
|
||||||
|
pub struct MessageRow {
|
||||||
|
id: ElementId,
|
||||||
|
style: StyleRefinement,
|
||||||
|
show_author: bool,
|
||||||
|
hide_avatar: bool,
|
||||||
|
avatar: Option<AnyElement>,
|
||||||
|
author: Option<SharedString>,
|
||||||
|
timestamp: Option<SharedString>,
|
||||||
|
header_extras: SmallVec<[AnyElement; 1]>,
|
||||||
|
body: SmallVec<[AnyElement; 4]>,
|
||||||
|
overlays: SmallVec<[AnyElement; 2]>,
|
||||||
|
on_mouse_down: Option<(MouseButton, MouseDownListener)>,
|
||||||
|
on_double_click: Option<DoubleClickListener>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MessageRow {
|
||||||
|
/// Create a message row with the given element id.
|
||||||
|
pub fn new(id: impl Into<ElementId>) -> Self {
|
||||||
|
Self {
|
||||||
|
id: id.into(),
|
||||||
|
style: StyleRefinement::default(),
|
||||||
|
show_author: false,
|
||||||
|
hide_avatar: false,
|
||||||
|
avatar: None,
|
||||||
|
author: None,
|
||||||
|
timestamp: None,
|
||||||
|
header_extras: SmallVec::new(),
|
||||||
|
body: SmallVec::new(),
|
||||||
|
overlays: SmallVec::new(),
|
||||||
|
on_mouse_down: None,
|
||||||
|
on_double_click: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether this row opens a run of messages from one author.
|
||||||
|
#[must_use]
|
||||||
|
pub fn show_author(mut self, show_author: bool) -> Self {
|
||||||
|
self.show_author = show_author;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Hide the avatar column entirely.
|
||||||
|
#[must_use]
|
||||||
|
pub fn hide_avatar(mut self, hide_avatar: bool) -> Self {
|
||||||
|
self.hide_avatar = hide_avatar;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The avatar shown when this row opens a run of messages.
|
||||||
|
#[must_use]
|
||||||
|
pub fn avatar(mut self, avatar: impl IntoElement) -> Self {
|
||||||
|
self.avatar = Some(avatar.into_any_element());
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The author's display name, shown next to the timestamp.
|
||||||
|
#[must_use]
|
||||||
|
pub fn author(mut self, author: impl Into<SharedString>) -> Self {
|
||||||
|
self.author = Some(author.into());
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The time the message was sent.
|
||||||
|
#[must_use]
|
||||||
|
pub fn timestamp(mut self, timestamp: impl Into<SharedString>) -> Self {
|
||||||
|
self.timestamp = Some(timestamp.into());
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Append an element to the header row.
|
||||||
|
#[must_use]
|
||||||
|
pub fn header_extra(mut self, extra: impl IntoElement) -> Self {
|
||||||
|
self.header_extras.push(extra.into_any_element());
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Append an element to the message body, in order.
|
||||||
|
#[must_use]
|
||||||
|
pub fn child(mut self, child: impl IntoElement) -> Self {
|
||||||
|
self.body.push(child.into_any_element());
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Append several elements to the message body, in order.
|
||||||
|
#[must_use]
|
||||||
|
pub fn children(mut self, children: impl IntoIterator<Item = impl IntoElement>) -> Self {
|
||||||
|
self.body
|
||||||
|
.extend(children.into_iter().map(|child| child.into_any_element()));
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Append an element positioned over the row.
|
||||||
|
#[must_use]
|
||||||
|
pub fn overlay(mut self, overlay: impl IntoElement) -> Self {
|
||||||
|
self.overlays.push(overlay.into_any_element());
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handle a mouse button press anywhere on the row.
|
||||||
|
#[must_use]
|
||||||
|
pub fn on_mouse_down(
|
||||||
|
mut self,
|
||||||
|
button: MouseButton,
|
||||||
|
listener: impl Fn(&MouseDownEvent, &mut Window, &mut App) + 'static,
|
||||||
|
) -> Self {
|
||||||
|
self.on_mouse_down = Some((button, Box::new(listener)));
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handle a double click anywhere on the row.
|
||||||
|
#[must_use]
|
||||||
|
pub fn on_double_click(
|
||||||
|
mut self,
|
||||||
|
listener: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
|
||||||
|
) -> Self {
|
||||||
|
self.on_double_click = Some(Box::new(listener));
|
||||||
|
self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Styled for MessageRow {
|
||||||
|
fn style(&mut self) -> &mut StyleRefinement {
|
||||||
|
&mut self.style
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RenderOnce for MessageRow {
|
||||||
|
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||||
|
let Self {
|
||||||
|
id,
|
||||||
|
style,
|
||||||
|
show_author,
|
||||||
|
hide_avatar,
|
||||||
|
avatar,
|
||||||
|
author,
|
||||||
|
timestamp,
|
||||||
|
header_extras,
|
||||||
|
body,
|
||||||
|
overlays,
|
||||||
|
on_mouse_down,
|
||||||
|
on_double_click,
|
||||||
|
} = self;
|
||||||
|
|
||||||
|
div()
|
||||||
|
.id(id)
|
||||||
|
.group("")
|
||||||
|
.relative()
|
||||||
|
.w_full()
|
||||||
|
.py_1()
|
||||||
|
.when(show_author, |this| this.pt_2())
|
||||||
|
.px_3()
|
||||||
|
.refine_style(&style)
|
||||||
|
.child(
|
||||||
|
h_flex()
|
||||||
|
.items_start()
|
||||||
|
.gap_3()
|
||||||
|
.when(!hide_avatar, |this| {
|
||||||
|
if show_author {
|
||||||
|
match avatar {
|
||||||
|
Some(avatar) => this.child(avatar),
|
||||||
|
None => this.child(div().flex_shrink_0().w(px(32.))),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
this.child(div().flex_shrink_0().w(px(32.)))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.child(
|
||||||
|
v_flex()
|
||||||
|
.flex_1()
|
||||||
|
.w_full()
|
||||||
|
.min_w_0()
|
||||||
|
.flex_initial()
|
||||||
|
.overflow_hidden()
|
||||||
|
.when(show_author, |this| {
|
||||||
|
this.child(
|
||||||
|
h_flex()
|
||||||
|
.gap_2()
|
||||||
|
.text_sm()
|
||||||
|
.text_color(cx.theme().text_placeholder)
|
||||||
|
.when_some(author, |this, author| {
|
||||||
|
this.child(div().font_semibold().child(author))
|
||||||
|
})
|
||||||
|
.when_some(timestamp, |this, timestamp| {
|
||||||
|
this.child(timestamp)
|
||||||
|
})
|
||||||
|
.children(header_extras),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.children(body),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.children(overlays)
|
||||||
|
.when_some(on_mouse_down, |this, (button, listener)| {
|
||||||
|
this.on_mouse_down(button, listener)
|
||||||
|
})
|
||||||
|
.when_some(on_double_click, |this, listener| {
|
||||||
|
this.on_double_click(listener)
|
||||||
|
})
|
||||||
|
.hover(|this| this.bg(cx.theme().surface_background))
|
||||||
|
.into_any_element()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A welcome message shown at the top of a message list.
|
||||||
|
#[derive(IntoElement)]
|
||||||
|
pub struct WelcomeMessage {
|
||||||
|
id: ElementId,
|
||||||
|
style: StyleRefinement,
|
||||||
|
icon: Option<AnyElement>,
|
||||||
|
title: Option<SharedString>,
|
||||||
|
message: Option<SharedString>,
|
||||||
|
children: SmallVec<[AnyElement; 2]>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl WelcomeMessage {
|
||||||
|
/// Create a welcome message with the given element id.
|
||||||
|
pub fn new(id: impl Into<ElementId>) -> Self {
|
||||||
|
Self {
|
||||||
|
id: id.into(),
|
||||||
|
style: StyleRefinement::default(),
|
||||||
|
icon: None,
|
||||||
|
title: None,
|
||||||
|
message: None,
|
||||||
|
children: SmallVec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The icon shown above the title.
|
||||||
|
#[must_use]
|
||||||
|
pub fn icon(mut self, icon: impl IntoElement) -> Self {
|
||||||
|
self.icon = Some(icon.into_any_element());
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The welcome title.
|
||||||
|
#[must_use]
|
||||||
|
pub fn title(mut self, title: impl Into<SharedString>) -> Self {
|
||||||
|
self.title = Some(title.into());
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The welcome body text.
|
||||||
|
#[must_use]
|
||||||
|
pub fn message(mut self, message: impl Into<SharedString>) -> Self {
|
||||||
|
self.message = Some(message.into());
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Append an element below the body text.
|
||||||
|
#[must_use]
|
||||||
|
pub fn child(mut self, child: impl IntoElement) -> Self {
|
||||||
|
self.children.push(child.into_any_element());
|
||||||
|
self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Styled for WelcomeMessage {
|
||||||
|
fn style(&mut self) -> &mut StyleRefinement {
|
||||||
|
&mut self.style
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RenderOnce for WelcomeMessage {
|
||||||
|
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||||
|
let Self {
|
||||||
|
id,
|
||||||
|
style,
|
||||||
|
icon,
|
||||||
|
title,
|
||||||
|
message,
|
||||||
|
children,
|
||||||
|
} = self;
|
||||||
|
|
||||||
|
v_flex()
|
||||||
|
.id(id)
|
||||||
|
.w_full()
|
||||||
|
.gap_2()
|
||||||
|
.p_3()
|
||||||
|
.items_center()
|
||||||
|
.justify_center()
|
||||||
|
.text_center()
|
||||||
|
.refine_style(&style)
|
||||||
|
.when_some(icon, |this, icon| this.child(icon))
|
||||||
|
.child(
|
||||||
|
v_flex()
|
||||||
|
.items_center()
|
||||||
|
.justify_center()
|
||||||
|
.text_center()
|
||||||
|
.when_some(title, |this, title| {
|
||||||
|
this.child(
|
||||||
|
div()
|
||||||
|
.text_sm()
|
||||||
|
.font_semibold()
|
||||||
|
.text_color(cx.theme().text)
|
||||||
|
.child(title),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.when_some(message, |this, message| {
|
||||||
|
this.child(
|
||||||
|
div()
|
||||||
|
.text_xs()
|
||||||
|
.text_color(cx.theme().text_placeholder)
|
||||||
|
.child(message),
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.children(children)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
use std::rc::Rc;
|
||||||
|
|
||||||
|
use gpui::prelude::FluentBuilder as _;
|
||||||
|
use gpui::{
|
||||||
|
AnyElement, App, ClickEvent, ElementId, InteractiveElement, IntoElement, ParentElement,
|
||||||
|
RenderOnce, SharedString, StatefulInteractiveElement as _, StyleRefinement, Styled, Window,
|
||||||
|
div,
|
||||||
|
};
|
||||||
|
use theme::ActiveTheme;
|
||||||
|
|
||||||
|
use crate::{Selectable, StyledExt, h_flex, v_flex};
|
||||||
|
|
||||||
|
/// A single navigation row, such as an entry in a sidebar list.
|
||||||
|
#[derive(IntoElement)]
|
||||||
|
pub struct Nav {
|
||||||
|
id: ElementId,
|
||||||
|
style: StyleRefinement,
|
||||||
|
prefix: Option<AnyElement>,
|
||||||
|
label: SharedString,
|
||||||
|
suffix: Option<AnyElement>,
|
||||||
|
selected: bool,
|
||||||
|
#[allow(clippy::type_complexity)]
|
||||||
|
on_click: Option<Rc<dyn Fn(&ClickEvent, &mut Window, &mut App)>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Nav {
|
||||||
|
pub fn new(id: impl Into<ElementId>) -> Self {
|
||||||
|
Self {
|
||||||
|
id: id.into(),
|
||||||
|
style: StyleRefinement::default(),
|
||||||
|
prefix: None,
|
||||||
|
label: SharedString::default(),
|
||||||
|
suffix: None,
|
||||||
|
selected: false,
|
||||||
|
on_click: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sets the element shown before the label, such as an avatar or icon.
|
||||||
|
pub fn prefix(mut self, prefix: impl IntoElement) -> Self {
|
||||||
|
self.prefix = Some(prefix.into_any_element());
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sets the row's label.
|
||||||
|
pub fn label(mut self, label: impl Into<SharedString>) -> Self {
|
||||||
|
self.label = label.into();
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sets the element shown after the label, such as a timestamp or badge.
|
||||||
|
pub fn suffix(mut self, suffix: impl IntoElement) -> Self {
|
||||||
|
self.suffix = Some(suffix.into_any_element());
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn on_click(
|
||||||
|
mut self,
|
||||||
|
handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
|
||||||
|
) -> Self {
|
||||||
|
self.on_click = Some(Rc::new(handler));
|
||||||
|
self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Selectable for Nav {
|
||||||
|
fn selected(mut self, selected: bool) -> Self {
|
||||||
|
self.selected = selected;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_selected(&self) -> bool {
|
||||||
|
self.selected
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Styled for Nav {
|
||||||
|
fn style(&mut self) -> &mut StyleRefinement {
|
||||||
|
&mut self.style
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RenderOnce for Nav {
|
||||||
|
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||||
|
let clickable = self.on_click.is_some();
|
||||||
|
|
||||||
|
v_flex()
|
||||||
|
.id(format!("nav-{}", self.id))
|
||||||
|
.w_full()
|
||||||
|
.h_10()
|
||||||
|
.child(
|
||||||
|
h_flex()
|
||||||
|
.id(self.id)
|
||||||
|
.h_9()
|
||||||
|
.w_full()
|
||||||
|
.px_1p5()
|
||||||
|
.gap_1p5()
|
||||||
|
.rounded(cx.theme().radius_lg)
|
||||||
|
.when_some(self.prefix, |this, prefix| this.child(prefix))
|
||||||
|
.child(
|
||||||
|
h_flex()
|
||||||
|
.gap_1()
|
||||||
|
.flex_1()
|
||||||
|
.child(div().truncate().min_w_0().child(self.label))
|
||||||
|
.child(div().flex_1())
|
||||||
|
.when_some(self.suffix, |this, suffix| {
|
||||||
|
this.child(div().flex_shrink_0().child(suffix))
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.when(clickable, |this| {
|
||||||
|
this.cursor_pointer()
|
||||||
|
.hover(|this| this.bg(cx.theme().ghost_element_hover))
|
||||||
|
.when(self.selected, |this| {
|
||||||
|
this.bg(cx.theme().ghost_element_active)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.when_some(self.on_click, |this, handler| {
|
||||||
|
this.on_click(move |event, window, cx| handler(event, window, cx))
|
||||||
|
})
|
||||||
|
.refine_style(&self.style),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,9 +3,9 @@ use std::rc::Rc;
|
|||||||
use gpui::prelude::FluentBuilder as _;
|
use gpui::prelude::FluentBuilder as _;
|
||||||
use gpui::{
|
use gpui::{
|
||||||
AnyElement, App, ClickEvent, Div, InteractiveElement, IntoElement, MouseButton, ParentElement,
|
AnyElement, App, ClickEvent, Div, InteractiveElement, IntoElement, MouseButton, ParentElement,
|
||||||
RenderOnce, SharedString, StatefulInteractiveElement, Styled, Window, div, px, relative,
|
RenderOnce, SharedString, StatefulInteractiveElement, Styled, Window, div, px,
|
||||||
};
|
};
|
||||||
use theme::{ActiveTheme, TABBAR_HEIGHT};
|
use theme::ActiveTheme;
|
||||||
|
|
||||||
use crate::{Icon, IconName, Selectable, h_flex};
|
use crate::{Icon, IconName, Selectable, h_flex};
|
||||||
|
|
||||||
@@ -25,6 +25,7 @@ pub struct Tab {
|
|||||||
children: Vec<AnyElement>,
|
children: Vec<AnyElement>,
|
||||||
pub(super) disabled: bool,
|
pub(super) disabled: bool,
|
||||||
pub(super) selected: bool,
|
pub(super) selected: bool,
|
||||||
|
pub(super) segmented: bool,
|
||||||
on_click: Option<Rc<dyn Fn(&ClickEvent, &mut Window, &mut App) + 'static>>,
|
on_click: Option<Rc<dyn Fn(&ClickEvent, &mut Window, &mut App) + 'static>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -69,6 +70,7 @@ impl Default for Tab {
|
|||||||
children: Vec::new(),
|
children: Vec::new(),
|
||||||
disabled: false,
|
disabled: false,
|
||||||
selected: false,
|
selected: false,
|
||||||
|
segmented: false,
|
||||||
prefix: None,
|
prefix: None,
|
||||||
suffix: None,
|
suffix: None,
|
||||||
on_click: None,
|
on_click: None,
|
||||||
@@ -132,6 +134,12 @@ impl Tab {
|
|||||||
self.tab_bar_prefix = Some(tab_bar_prefix);
|
self.tab_bar_prefix = Some(tab_bar_prefix);
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Render the tab as a segment inside a segmented control.
|
||||||
|
pub(crate) fn segmented(mut self, segmented: bool) -> Self {
|
||||||
|
self.segmented = segmented;
|
||||||
|
self
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ParentElement for Tab {
|
impl ParentElement for Tab {
|
||||||
@@ -167,74 +175,103 @@ impl Styled for Tab {
|
|||||||
|
|
||||||
impl RenderOnce for Tab {
|
impl RenderOnce for Tab {
|
||||||
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
|
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||||
let fg = if self.disabled {
|
let Self {
|
||||||
|
ix,
|
||||||
|
base,
|
||||||
|
label,
|
||||||
|
icon,
|
||||||
|
prefix,
|
||||||
|
suffix,
|
||||||
|
children,
|
||||||
|
disabled,
|
||||||
|
selected,
|
||||||
|
segmented,
|
||||||
|
on_click,
|
||||||
|
..
|
||||||
|
} = self;
|
||||||
|
|
||||||
|
let foreground = if disabled {
|
||||||
cx.theme().text_muted
|
cx.theme().text_muted
|
||||||
} else if self.selected {
|
} else if selected {
|
||||||
cx.theme().tab_active_foreground
|
cx.theme().tab_active_foreground
|
||||||
} else {
|
} else {
|
||||||
cx.theme().tab_foreground
|
cx.theme().tab_foreground
|
||||||
};
|
};
|
||||||
|
|
||||||
self.base
|
let content = h_flex()
|
||||||
.id(self.ix)
|
.flex_1()
|
||||||
.flex()
|
.h_6()
|
||||||
.flex_wrap()
|
.whitespace_nowrap()
|
||||||
.gap_1()
|
|
||||||
.items_center()
|
.items_center()
|
||||||
.flex_shrink_0()
|
|
||||||
.h(TABBAR_HEIGHT)
|
|
||||||
.relative()
|
|
||||||
.overflow_hidden()
|
.overflow_hidden()
|
||||||
.text_color(fg)
|
.when(segmented, |this| this.justify_center().px_1())
|
||||||
.text_sm()
|
.when(!segmented, |this| this.justify_start())
|
||||||
.when_some(self.prefix, |this, prefix| this.child(prefix))
|
.map(|this| match icon {
|
||||||
.child(
|
Some(icon) => this.w(px(38.)).child(icon.size_4()),
|
||||||
h_flex()
|
None => this
|
||||||
|
.map(|this| match label {
|
||||||
|
Some(label) => this.child(label),
|
||||||
|
None => this,
|
||||||
|
})
|
||||||
|
.children(children),
|
||||||
|
});
|
||||||
|
|
||||||
|
base.id(ix)
|
||||||
|
.group("tab")
|
||||||
|
.flex()
|
||||||
|
.items_center()
|
||||||
|
.text_color(foreground)
|
||||||
|
.when(segmented, |this| {
|
||||||
|
this.text_xs()
|
||||||
.flex_1()
|
.flex_1()
|
||||||
.h(px(30.))
|
.h_6()
|
||||||
.line_height(relative(1.))
|
.rounded(cx.theme().radius)
|
||||||
.whitespace_nowrap()
|
.when(selected && !disabled, |this| {
|
||||||
.items_center()
|
this.bg(cx.theme().tab_active_background)
|
||||||
.justify_center()
|
.when(cx.theme().shadow, |this| this.shadow_sm())
|
||||||
|
})
|
||||||
|
.when(!selected && !disabled, |this| {
|
||||||
|
this.hover(|this| this.bg(cx.theme().tab_hover_background))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.when(!segmented, |this| {
|
||||||
|
this.flex_shrink_0()
|
||||||
|
.min_w_32()
|
||||||
|
.h_7()
|
||||||
|
.gap_1()
|
||||||
|
.px_1p5()
|
||||||
|
.text_sm()
|
||||||
|
.rounded(cx.theme().radius)
|
||||||
.overflow_hidden()
|
.overflow_hidden()
|
||||||
.flex_shrink_0()
|
.when(selected && !disabled, |this| {
|
||||||
.px_3()
|
this.bg(cx.theme().tab_background)
|
||||||
.map(|this| match self.icon {
|
})
|
||||||
Some(icon) => this.w(px(38.)).child(icon.size_4()),
|
.when(!selected && !disabled, |this| {
|
||||||
None => this
|
this.hover(|this| {
|
||||||
.map(|this| match self.label {
|
this.bg(cx.theme().tab_hover_background)
|
||||||
Some(label) => this.child(label),
|
.text_color(cx.theme().tab_active_foreground)
|
||||||
None => this,
|
})
|
||||||
})
|
})
|
||||||
.children(self.children),
|
})
|
||||||
}),
|
.when_some(prefix, |this, prefix| this.child(prefix))
|
||||||
)
|
.child(content)
|
||||||
.when_some(self.suffix, |this, suffix| {
|
.when_some(suffix, |this, suffix| {
|
||||||
this.child(div().pr_2().child(suffix))
|
this.child(
|
||||||
|
div()
|
||||||
|
.flex_shrink_0()
|
||||||
|
.when(!selected, |this| {
|
||||||
|
this.invisible().group_hover("tab", |this| this.visible())
|
||||||
|
})
|
||||||
|
.child(suffix),
|
||||||
|
)
|
||||||
})
|
})
|
||||||
.on_mouse_down(MouseButton::Left, |_ev, _window, cx| {
|
.on_mouse_down(MouseButton::Left, |_ev, _window, cx| {
|
||||||
cx.stop_propagation();
|
cx.stop_propagation();
|
||||||
})
|
})
|
||||||
.when(!self.disabled, |this| {
|
.when(!disabled, |this| {
|
||||||
this.when_some(self.on_click.clone(), |this, on_click| {
|
this.when_some(on_click, |this, on_click| {
|
||||||
this.on_click(move |event, window, cx| on_click(event, window, cx))
|
this.on_click(move |event, window, cx| on_click(event, window, cx))
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
.child(
|
|
||||||
div()
|
|
||||||
.absolute()
|
|
||||||
.bottom_0()
|
|
||||||
.left_0()
|
|
||||||
.right_0()
|
|
||||||
.h_0p5()
|
|
||||||
.when(self.selected && !self.disabled, |this| {
|
|
||||||
this.bg(cx.theme().element_active)
|
|
||||||
})
|
|
||||||
.when(!self.selected && !self.disabled, |this| {
|
|
||||||
this.invisible().group_hover("", |this| {
|
|
||||||
this.visible().bg(cx.theme().secondary_background)
|
|
||||||
})
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ use gpui::{
|
|||||||
Window, div, px,
|
Window, div, px,
|
||||||
};
|
};
|
||||||
use smallvec::SmallVec;
|
use smallvec::SmallVec;
|
||||||
|
use theme::ActiveTheme;
|
||||||
|
|
||||||
use super::Tab;
|
use super::Tab;
|
||||||
use crate::button::{Button, ButtonVariants as _};
|
use crate::button::{Button, ButtonVariants as _};
|
||||||
@@ -25,6 +26,7 @@ pub struct TabBar {
|
|||||||
last_empty_space: AnyElement,
|
last_empty_space: AnyElement,
|
||||||
selected_index: Option<usize>,
|
selected_index: Option<usize>,
|
||||||
menu: bool,
|
menu: bool,
|
||||||
|
segmented: bool,
|
||||||
#[allow(clippy::type_complexity)]
|
#[allow(clippy::type_complexity)]
|
||||||
on_click: Option<Rc<dyn Fn(&usize, &mut Window, &mut App) + 'static>>,
|
on_click: Option<Rc<dyn Fn(&usize, &mut Window, &mut App) + 'static>>,
|
||||||
}
|
}
|
||||||
@@ -43,9 +45,16 @@ impl TabBar {
|
|||||||
selected_index: None,
|
selected_index: None,
|
||||||
on_click: None,
|
on_click: None,
|
||||||
menu: false,
|
menu: false,
|
||||||
|
segmented: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Render the tabs as a segmented control inside a pill-shaped track.
|
||||||
|
pub fn segmented(mut self, segmented: bool) -> Self {
|
||||||
|
self.segmented = segmented;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
/// Set whether to show the menu button when tabs overflow, default is false.
|
/// Set whether to show the menu button when tabs overflow, default is false.
|
||||||
pub fn menu(mut self, menu: bool) -> Self {
|
pub fn menu(mut self, menu: bool) -> Self {
|
||||||
self.menu = menu;
|
self.menu = menu;
|
||||||
@@ -113,33 +122,45 @@ impl Styled for TabBar {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl RenderOnce for TabBar {
|
impl RenderOnce for TabBar {
|
||||||
fn render(self, _: &mut Window, _cx: &mut App) -> impl IntoElement {
|
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||||
let mut item_labels = Vec::new();
|
let mut item_labels = Vec::new();
|
||||||
let selected_index = self.selected_index;
|
let selected_index = self.selected_index;
|
||||||
let on_click = self.on_click.clone();
|
let on_click = self.on_click.clone();
|
||||||
|
let segmented = self.segmented;
|
||||||
|
let has_prefix = self.prefix.is_some();
|
||||||
|
|
||||||
self.base
|
self.base
|
||||||
.group("tab-bar")
|
.group("tab-bar")
|
||||||
.relative()
|
.relative()
|
||||||
.flex()
|
.flex()
|
||||||
.items_center()
|
.items_center()
|
||||||
|
.min_w_0()
|
||||||
.refine_style(&self.style)
|
.refine_style(&self.style)
|
||||||
|
.when(segmented, |this| {
|
||||||
|
this.bg(cx.theme().tab_background)
|
||||||
|
.p_0p5()
|
||||||
|
.rounded(cx.theme().radius)
|
||||||
|
})
|
||||||
.when_some(self.prefix, |this, prefix| this.child(prefix))
|
.when_some(self.prefix, |this, prefix| this.child(prefix))
|
||||||
.child(
|
.child(
|
||||||
h_flex()
|
h_flex()
|
||||||
.id("tabs")
|
.id("tabs")
|
||||||
.flex_1()
|
.flex_1()
|
||||||
.overflow_x_scroll()
|
.when(!segmented, |this| {
|
||||||
|
this.overflow_x_scroll()
|
||||||
|
.when(!has_prefix, |this| this.pl_2())
|
||||||
|
})
|
||||||
.when_some(self.scroll_handle, |this, scroll_handle| {
|
.when_some(self.scroll_handle, |this, scroll_handle| {
|
||||||
this.track_scroll(&scroll_handle)
|
this.track_scroll(&scroll_handle)
|
||||||
})
|
})
|
||||||
.gap(px(0.))
|
.gap_1()
|
||||||
.children(self.children.into_iter().enumerate().map(|(ix, child)| {
|
.children(self.children.into_iter().enumerate().map(|(ix, child)| {
|
||||||
item_labels.push((child.label.clone(), child.disabled));
|
item_labels.push((child.label.clone(), child.disabled));
|
||||||
let tab_bar_prefix = child.tab_bar_prefix.unwrap_or(true);
|
let tab_bar_prefix = child.tab_bar_prefix.unwrap_or(true);
|
||||||
child
|
child
|
||||||
.ix(ix)
|
.ix(ix)
|
||||||
.tab_bar_prefix(tab_bar_prefix)
|
.tab_bar_prefix(tab_bar_prefix)
|
||||||
|
.segmented(segmented)
|
||||||
.when_some(self.selected_index, |this, selected_ix| {
|
.when_some(self.selected_index, |this, selected_ix| {
|
||||||
this.selected(selected_ix == ix)
|
this.selected(selected_ix == ix)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ pub fn open(window: &mut Window, cx: &mut App) {
|
|||||||
.show_close(false)
|
.show_close(false)
|
||||||
.overlay_closable(false)
|
.overlay_closable(false)
|
||||||
.keyboard(false)
|
.keyboard(false)
|
||||||
.title("Onboarding")
|
.title("Get Started")
|
||||||
.child(import.clone())
|
.child(import.clone())
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,17 +2,18 @@ use chat::{ChatRegistry, Room, RoomKind};
|
|||||||
use gpui::prelude::FluentBuilder;
|
use gpui::prelude::FluentBuilder;
|
||||||
use gpui::{
|
use gpui::{
|
||||||
App, AppContext, Context, Entity, IntoElement, ParentElement, Render, SharedString, Styled,
|
App, AppContext, Context, Entity, IntoElement, ParentElement, Render, SharedString, Styled,
|
||||||
Subscription, Window, div, px,
|
Subscription, WeakEntity, Window, div, px,
|
||||||
};
|
};
|
||||||
use nostr_sdk::prelude::*;
|
use nostr_sdk::prelude::*;
|
||||||
use state::NostrRegistry;
|
use state::NostrRegistry;
|
||||||
use theme::ActiveTheme;
|
use theme::ActiveTheme;
|
||||||
use ui::button::{Button, ButtonVariants};
|
use ui::button::{Button, ButtonVariants};
|
||||||
|
use ui::dock::{DockArea, DockPlacement, PanelHandle};
|
||||||
use ui::input::{Input, InputEvent, InputState};
|
use ui::input::{Input, InputEvent, InputState};
|
||||||
use ui::{StyledExt, WindowExtension, v_flex};
|
use ui::{StyledExt, WindowExtension, v_flex};
|
||||||
|
|
||||||
pub fn open(window: &mut Window, cx: &mut App) {
|
pub fn open(dock: WeakEntity<DockArea>, window: &mut Window, cx: &mut App) {
|
||||||
let view = cx.new(|cx| NewChat::new(window, cx));
|
let view = cx.new(|cx| NewChat::new(dock, window, cx));
|
||||||
|
|
||||||
window.open_modal(cx, move |this, _window, _cx| {
|
window.open_modal(cx, move |this, _window, _cx| {
|
||||||
this.width(px(420.)).title("New chat").child(view.clone())
|
this.width(px(420.)).title("New chat").child(view.clone())
|
||||||
@@ -20,6 +21,9 @@ pub fn open(window: &mut Window, cx: &mut App) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub struct NewChat {
|
pub struct NewChat {
|
||||||
|
/// The dock a started chat opens in
|
||||||
|
dock: WeakEntity<DockArea>,
|
||||||
|
|
||||||
/// Public key input
|
/// Public key input
|
||||||
input: Entity<InputState>,
|
input: Entity<InputState>,
|
||||||
|
|
||||||
@@ -31,7 +35,7 @@ pub struct NewChat {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl NewChat {
|
impl NewChat {
|
||||||
fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
|
fn new(dock: WeakEntity<DockArea>, window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||||
let input = cx.new(|cx| InputState::new(window, cx).placeholder("npub"));
|
let input = cx.new(|cx| InputState::new(window, cx).placeholder("npub"));
|
||||||
|
|
||||||
let subscription = cx.subscribe_in(&input, window, |this, _input, event, window, cx| {
|
let subscription = cx.subscribe_in(&input, window, |this, _input, event, window, cx| {
|
||||||
@@ -41,6 +45,7 @@ impl NewChat {
|
|||||||
});
|
});
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
|
dock,
|
||||||
input,
|
input,
|
||||||
error: None,
|
error: None,
|
||||||
_subscription: Some(subscription),
|
_subscription: Some(subscription),
|
||||||
@@ -56,6 +61,8 @@ impl NewChat {
|
|||||||
};
|
};
|
||||||
|
|
||||||
let nostr = NostrRegistry::global(cx);
|
let nostr = NostrRegistry::global(cx);
|
||||||
|
let chat = ChatRegistry::global(cx);
|
||||||
|
|
||||||
let Some(current_user) = nostr.read(cx).current_user() else {
|
let Some(current_user) = nostr.read(cx).current_user() else {
|
||||||
self.set_error("You are not signed in", cx);
|
self.set_error("You are not signed in", cx);
|
||||||
return;
|
return;
|
||||||
@@ -70,12 +77,20 @@ impl NewChat {
|
|||||||
.organize(¤t_user)
|
.organize(¤t_user)
|
||||||
.kind(RoomKind::Ongoing);
|
.kind(RoomKind::Ongoing);
|
||||||
|
|
||||||
let chat = ChatRegistry::global(cx);
|
let room = chat.update(cx, |chat, cx| {
|
||||||
chat.update(cx, |chat, cx| {
|
|
||||||
let room = cx.new(|_| room);
|
let room = cx.new(|_| room);
|
||||||
chat.emit_room(&room, window, cx);
|
chat.track_room(&room, cx);
|
||||||
|
room
|
||||||
});
|
});
|
||||||
|
|
||||||
|
ui::dock::add_panel_to(
|
||||||
|
&self.dock,
|
||||||
|
PanelHandle::new(chat_ui::init(room.downgrade(), window, cx)),
|
||||||
|
DockPlacement::Center,
|
||||||
|
window,
|
||||||
|
cx,
|
||||||
|
);
|
||||||
|
|
||||||
window.close_modal(cx);
|
window.close_modal(cx);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
use std::rc::Rc;
|
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use ::settings::AppSettings;
|
use ::settings::AppSettings;
|
||||||
@@ -6,25 +5,20 @@ use anyhow::Error;
|
|||||||
use auto_update::AutoUpdater;
|
use auto_update::AutoUpdater;
|
||||||
use chat::{ChatEvent, ChatRegistry};
|
use chat::{ChatEvent, ChatRegistry};
|
||||||
use common::download_dir;
|
use common::download_dir;
|
||||||
use community::{CommunityEvent, CommunityRegistry};
|
|
||||||
use community_ui::CommunityPanel;
|
|
||||||
use device::{DeviceEvent, DeviceRegistry};
|
use device::{DeviceEvent, DeviceRegistry};
|
||||||
use gpui::prelude::FluentBuilder;
|
|
||||||
use gpui::{
|
use gpui::{
|
||||||
Action, AnyElement, App, AppContext, Context, Entity, InteractiveElement, IntoElement,
|
Action, App, AppContext, Context, Entity, InteractiveElement, IntoElement, ParentElement,
|
||||||
ParentElement, Render, SharedString, Styled, Subscription, Task, WeakEntity, Window, div, px,
|
Render, SharedString, Styled, Subscription, Task, Window, div, px,
|
||||||
};
|
};
|
||||||
use nostr_sdk::prelude::*;
|
use nostr_sdk::prelude::*;
|
||||||
use person::{PersonRegistry, shorten_pubkey};
|
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use smallvec::{SmallVec, smallvec};
|
use smallvec::{SmallVec, smallvec};
|
||||||
use state::{NostrRegistry, StateEvent};
|
use state::{NostrRegistry, StateEvent};
|
||||||
use theme::{ActiveTheme, SIDEBAR_WIDTH, Theme, ThemeRegistry};
|
use theme::{ActiveTheme, SIDEBAR_WIDTH, Theme, ThemeRegistry};
|
||||||
use ui::button::{Button, ButtonVariants};
|
use ui::button::{Button, ButtonVariants};
|
||||||
use ui::dock::{self, ClosePanel, DockArea, DockLayout, DockPlacement, Panel, PanelHandle};
|
use ui::dock::{self, DockArea, DockLayout, DockPlacement, Panel, PanelHandle};
|
||||||
use ui::menu::{DropdownMenu, PopupMenuItem};
|
|
||||||
use ui::notification::{Notification, NotificationKind};
|
use ui::notification::{Notification, NotificationKind};
|
||||||
use ui::{Icon, IconName, Root, Sizable, WindowExtension, h_flex, v_flex};
|
use ui::{IconName, Root, Sizable, WindowExtension, h_flex, v_flex};
|
||||||
|
|
||||||
use crate::dialogs::restore::RestoreEncryption;
|
use crate::dialogs::restore::RestoreEncryption;
|
||||||
use crate::dialogs::{new_chat, new_community, settings};
|
use crate::dialogs::{new_chat, new_community, settings};
|
||||||
@@ -71,9 +65,6 @@ enum Command {
|
|||||||
|
|
||||||
pub struct Workspace {
|
pub struct Workspace {
|
||||||
dock: Entity<DockArea>,
|
dock: Entity<DockArea>,
|
||||||
title_bar_chrome: Rc<dock::TitleBarChrome>,
|
|
||||||
/// The community panel currently docked, if any
|
|
||||||
community_panel: Option<WeakEntity<CommunityPanel>>,
|
|
||||||
/// Async tasks
|
/// Async tasks
|
||||||
tasks: Vec<Task<Result<(), Error>>>,
|
tasks: Vec<Task<Result<(), Error>>>,
|
||||||
/// Event subscriptions
|
/// Event subscriptions
|
||||||
@@ -83,12 +74,11 @@ pub struct Workspace {
|
|||||||
impl Workspace {
|
impl Workspace {
|
||||||
fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
|
fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||||
let chat = ChatRegistry::global(cx);
|
let chat = ChatRegistry::global(cx);
|
||||||
let communities = CommunityRegistry::global(cx);
|
|
||||||
let device = DeviceRegistry::global(cx);
|
let device = DeviceRegistry::global(cx);
|
||||||
let nostr = NostrRegistry::global(cx);
|
let nostr = NostrRegistry::global(cx);
|
||||||
|
|
||||||
let sidebar = cx.new(|cx| Sidebar::new(window, cx));
|
let (dock, _) = dock::dock_area("coop", window, cx);
|
||||||
let (dock, title_bar_chrome) = dock::dock_area("coop", window, cx);
|
let sidebar = cx.new(|cx| Sidebar::new(window, dock.downgrade(), cx));
|
||||||
|
|
||||||
let mut subscriptions = smallvec![];
|
let mut subscriptions = smallvec![];
|
||||||
|
|
||||||
@@ -164,7 +154,7 @@ impl Workspace {
|
|||||||
|
|
||||||
subscriptions.push(
|
subscriptions.push(
|
||||||
// Observe all events emitted by the chat registry
|
// Observe all events emitted by the chat registry
|
||||||
cx.subscribe_in(&chat, window, move |this, chat, ev, window, cx| {
|
cx.subscribe_in(&chat, window, move |_this, _chat, ev, window, cx| {
|
||||||
match ev {
|
match ev {
|
||||||
ChatEvent::InboxRelayNotFound => {
|
ChatEvent::InboxRelayNotFound => {
|
||||||
const MSG: &str = "Messaging Relays not found. Cannot receive messages.";
|
const MSG: &str = "Messaging Relays not found. Cannot receive messages.";
|
||||||
@@ -187,28 +177,6 @@ impl Workspace {
|
|||||||
cx,
|
cx,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
ChatEvent::OpenRoom(id) => {
|
|
||||||
if let Some(room) = chat.read(cx).room(id, cx) {
|
|
||||||
this.add_panel_to_dock(
|
|
||||||
chat_ui::init(room, window, cx),
|
|
||||||
DockPlacement::Center,
|
|
||||||
window,
|
|
||||||
cx,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
ChatEvent::CloseRoom(..) => {
|
|
||||||
this.dock.update(cx, |area, cx| {
|
|
||||||
// Force focus to the tab panel
|
|
||||||
ui::dock::focus_tab_panel(area, window, cx);
|
|
||||||
|
|
||||||
// Dispatch the close panel action
|
|
||||||
cx.defer_in(window, |_, window, cx| {
|
|
||||||
window.dispatch_action(Box::new(ClosePanel), cx);
|
|
||||||
window.close_all_modals(cx);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
ChatEvent::Error(error) => {
|
ChatEvent::Error(error) => {
|
||||||
window.push_notification(Notification::error(error).autohide(false), cx);
|
window.push_notification(Notification::error(error).autohide(false), cx);
|
||||||
}
|
}
|
||||||
@@ -217,71 +185,23 @@ impl Workspace {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
subscriptions.push(
|
|
||||||
// Observe all events emitted by the community registry
|
|
||||||
cx.subscribe_in(
|
|
||||||
&communities,
|
|
||||||
window,
|
|
||||||
move |this, communities, event, window, cx| match event {
|
|
||||||
CommunityEvent::Open(id) => {
|
|
||||||
if let Some(community) = communities.read(cx).community(id) {
|
|
||||||
let panel = community_ui::init(community, window, cx);
|
|
||||||
|
|
||||||
this.community_panel = Some(panel.downgrade());
|
|
||||||
this.add_panel_to_dock(panel, DockPlacement::Center, window, cx);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
CommunityEvent::Close(_) => {
|
|
||||||
let Some(panel) = this
|
|
||||||
.community_panel
|
|
||||||
.take()
|
|
||||||
.and_then(|panel| panel.upgrade())
|
|
||||||
else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
|
|
||||||
this.dock.update(cx, |area, cx| {
|
|
||||||
ui::dock::add_panel(
|
|
||||||
area,
|
|
||||||
PanelHandle::new(panel),
|
|
||||||
DockPlacement::Center,
|
|
||||||
window,
|
|
||||||
cx,
|
|
||||||
);
|
|
||||||
ui::dock::focus_tab_panel(area, window, cx);
|
|
||||||
|
|
||||||
cx.defer_in(window, |_, window, cx| {
|
|
||||||
window.dispatch_action(Box::new(ClosePanel), cx);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
_ => {}
|
|
||||||
},
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
cx.defer_in(window, move |this, window, cx| {
|
cx.defer_in(window, move |this, window, cx| {
|
||||||
let sidebar = PanelHandle::new(sidebar);
|
let sidebar = PanelHandle::new(sidebar);
|
||||||
|
|
||||||
this.dock.update(cx, |area, cx| {
|
|
||||||
let left = DockLayout::tabs().panel_view(Arc::new(sidebar), cx);
|
|
||||||
area.set_dock(DockPlacement::Left, left, window, cx);
|
|
||||||
area.set_dock_size(DockPlacement::Left, SIDEBAR_WIDTH, window, cx);
|
|
||||||
});
|
|
||||||
|
|
||||||
let greeter = PanelHandle::new(greeter::init(window, cx));
|
let greeter = PanelHandle::new(greeter::init(window, cx));
|
||||||
let center = DockLayout::v_split()
|
|
||||||
.child(DockLayout::tabs().panel_view(Arc::new(greeter), cx), None);
|
|
||||||
|
|
||||||
this.dock.update(cx, |area, cx| {
|
this.dock.update(cx, |this, cx| {
|
||||||
area.set_center(center, window, cx);
|
let left = DockLayout::tabs().panel_view(Arc::new(sidebar), cx);
|
||||||
|
let center = DockLayout::v_split()
|
||||||
|
.child(DockLayout::tabs().panel_view(Arc::new(greeter), cx), None);
|
||||||
|
|
||||||
|
this.set_dock(DockPlacement::Left, left, window, cx);
|
||||||
|
this.set_dock_size(DockPlacement::Left, SIDEBAR_WIDTH, window, cx);
|
||||||
|
this.set_center(center, window, cx);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
dock,
|
dock,
|
||||||
title_bar_chrome,
|
|
||||||
community_panel: None,
|
|
||||||
tasks: vec![],
|
tasks: vec![],
|
||||||
_subscriptions: subscriptions,
|
_subscriptions: subscriptions,
|
||||||
}
|
}
|
||||||
@@ -365,10 +285,11 @@ impl Workspace {
|
|||||||
self.add_panel_to_dock(browse::init(window, cx), DockPlacement::Center, window, cx);
|
self.add_panel_to_dock(browse::init(window, cx), DockPlacement::Center, window, cx);
|
||||||
}
|
}
|
||||||
Command::ShowSearch => {
|
Command::ShowSearch => {
|
||||||
self.add_panel_to_dock(search::init(window, cx), DockPlacement::Center, window, cx);
|
let panel = search::init(self.dock.downgrade(), window, cx);
|
||||||
|
self.add_panel_to_dock(panel, DockPlacement::Center, window, cx);
|
||||||
}
|
}
|
||||||
Command::NewChat => {
|
Command::NewChat => {
|
||||||
new_chat::open(window, cx);
|
new_chat::open(self.dock.downgrade(), window, cx);
|
||||||
}
|
}
|
||||||
Command::NewCommunity => {
|
Command::NewCommunity => {
|
||||||
new_community::open(window, cx);
|
new_community::open(window, cx);
|
||||||
@@ -583,174 +504,6 @@ impl Workspace {
|
|||||||
}))
|
}))
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
fn titlebar_right(_window: &mut Window, cx: &mut App) -> AnyElement {
|
|
||||||
let auto_updater = AutoUpdater::try_global(cx);
|
|
||||||
let chat = ChatRegistry::global(cx);
|
|
||||||
let nip4e_enabled = AppSettings::get_nip4e(cx);
|
|
||||||
let nostr = NostrRegistry::global(cx);
|
|
||||||
|
|
||||||
let Some(public_key) = nostr.read(cx).current_user() else {
|
|
||||||
return div().into_any_element();
|
|
||||||
};
|
|
||||||
|
|
||||||
let persons = PersonRegistry::global(cx);
|
|
||||||
let profile = persons.read(cx).get(&public_key, cx);
|
|
||||||
let announcement = profile.announcement();
|
|
||||||
|
|
||||||
let updater_status = auto_updater.as_ref().and_then(|updater| {
|
|
||||||
let updater = updater.read(cx);
|
|
||||||
(!updater.idle()).then(|| updater.status())
|
|
||||||
});
|
|
||||||
|
|
||||||
let staged_update = auto_updater
|
|
||||||
.as_ref()
|
|
||||||
.is_some_and(|updater| updater.read(cx).staged());
|
|
||||||
|
|
||||||
h_flex()
|
|
||||||
.when(!cx.theme().platform.is_mac(), |this| this.pr_2())
|
|
||||||
.gap_2()
|
|
||||||
.when_some(updater_status, |this, status| {
|
|
||||||
this.child(div().text_xs().italic().child(status))
|
|
||||||
})
|
|
||||||
.when(staged_update, |this| {
|
|
||||||
this.child(
|
|
||||||
Button::new("restart-to-update")
|
|
||||||
.label("Restart to Update")
|
|
||||||
.tooltip("Quit and relaunch into the installed update")
|
|
||||||
.small()
|
|
||||||
.ghost()
|
|
||||||
.on_click(|_event, _window, cx| {
|
|
||||||
if let Some(auto_updater) = AutoUpdater::try_global(cx) {
|
|
||||||
auto_updater.update(cx, |this, cx| this.restart(cx));
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
.when(nip4e_enabled, |this| {
|
|
||||||
this.child(
|
|
||||||
Button::new("key")
|
|
||||||
.icon(IconName::UserKey)
|
|
||||||
.tooltip("Decoupled encryption key")
|
|
||||||
.small()
|
|
||||||
.ghost()
|
|
||||||
.dropdown_menu(move |this, _window, _cx| {
|
|
||||||
this.min_w(px(260.))
|
|
||||||
.label("Encryption Key")
|
|
||||||
.when_some(announcement.as_ref(), |this, announcement| {
|
|
||||||
let name = announcement.client_name();
|
|
||||||
let pkey = shorten_pubkey(announcement.public_key(), 8);
|
|
||||||
|
|
||||||
this.item(PopupMenuItem::element(move |_window, cx| {
|
|
||||||
h_flex()
|
|
||||||
.gap_1()
|
|
||||||
.text_sm()
|
|
||||||
.child(
|
|
||||||
Icon::new(IconName::Device)
|
|
||||||
.small()
|
|
||||||
.text_color(cx.theme().icon_muted),
|
|
||||||
)
|
|
||||||
.child(name.clone())
|
|
||||||
}))
|
|
||||||
.item(
|
|
||||||
PopupMenuItem::element(move |_window, cx| {
|
|
||||||
h_flex()
|
|
||||||
.gap_1()
|
|
||||||
.text_sm()
|
|
||||||
.child(
|
|
||||||
Icon::new(IconName::UserKey)
|
|
||||||
.small()
|
|
||||||
.text_color(cx.theme().icon_muted),
|
|
||||||
)
|
|
||||||
.child(SharedString::from(pkey.clone()))
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
.separator()
|
|
||||||
.menu_with_icon(
|
|
||||||
"Backup",
|
|
||||||
IconName::Shield,
|
|
||||||
Box::new(Command::BackupEncryption),
|
|
||||||
)
|
|
||||||
.menu_with_icon(
|
|
||||||
"Restore from secret key",
|
|
||||||
IconName::Usb,
|
|
||||||
Box::new(Command::ImportEncryption),
|
|
||||||
)
|
|
||||||
.separator()
|
|
||||||
.menu_with_icon(
|
|
||||||
"Reload",
|
|
||||||
IconName::Refresh,
|
|
||||||
Box::new(Command::RefreshEncryption),
|
|
||||||
)
|
|
||||||
.menu_with_icon(
|
|
||||||
"Reset",
|
|
||||||
IconName::Warning,
|
|
||||||
Box::new(Command::ResetEncryption),
|
|
||||||
)
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
.child(
|
|
||||||
Button::new("inbox")
|
|
||||||
.icon(IconName::Inbox)
|
|
||||||
.small()
|
|
||||||
.ghost()
|
|
||||||
.dropdown_menu(move |this, _window, cx| {
|
|
||||||
let urls: Vec<(SharedString, SharedString)> = profile
|
|
||||||
.messaging_relays()
|
|
||||||
.iter()
|
|
||||||
.map(|url| {
|
|
||||||
(
|
|
||||||
SharedString::from(url.to_string()),
|
|
||||||
chat.read(cx).count_messages(url).to_string().into(),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
// Header
|
|
||||||
let menu = this.min_w(px(260.)).label("Messaging Relays");
|
|
||||||
|
|
||||||
// Content
|
|
||||||
let menu = urls.into_iter().fold(menu, |this, (url, count)| {
|
|
||||||
this.item(PopupMenuItem::element(move |_window, cx| {
|
|
||||||
h_flex()
|
|
||||||
.px_1()
|
|
||||||
.w_full()
|
|
||||||
.text_sm()
|
|
||||||
.justify_between()
|
|
||||||
.child(url.clone())
|
|
||||||
.child(
|
|
||||||
div()
|
|
||||||
.text_xs()
|
|
||||||
.text_color(cx.theme().text_muted)
|
|
||||||
.child(count.clone()),
|
|
||||||
)
|
|
||||||
}))
|
|
||||||
});
|
|
||||||
|
|
||||||
// Footer
|
|
||||||
menu.separator()
|
|
||||||
.menu_with_icon(
|
|
||||||
"Manage gossip relays",
|
|
||||||
IconName::Relay,
|
|
||||||
Box::new(Command::ShowRelayList),
|
|
||||||
)
|
|
||||||
.menu_with_icon(
|
|
||||||
"Manage messaging relays",
|
|
||||||
IconName::Relay,
|
|
||||||
Box::new(Command::ShowMessaging),
|
|
||||||
)
|
|
||||||
.separator()
|
|
||||||
.menu_with_icon(
|
|
||||||
"Reload",
|
|
||||||
IconName::Refresh,
|
|
||||||
Box::new(Command::RefreshMessagingRelays),
|
|
||||||
)
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.into_any_element()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Render for Workspace {
|
impl Render for Workspace {
|
||||||
@@ -758,9 +511,6 @@ impl Render for Workspace {
|
|||||||
let modal_layer = Root::render_modal_layer(window, cx);
|
let modal_layer = Root::render_modal_layer(window, cx);
|
||||||
let notification_layer = Root::render_notification_layer(window, cx);
|
let notification_layer = Root::render_notification_layer(window, cx);
|
||||||
|
|
||||||
// Render the title bar chrome
|
|
||||||
self.title_bar_chrome.set_trailing(Self::titlebar_right);
|
|
||||||
|
|
||||||
div()
|
div()
|
||||||
.id("workspace")
|
.id("workspace")
|
||||||
.on_action(cx.listener(Self::on_command))
|
.on_action(cx.listener(Self::on_command))
|
||||||
|
|||||||
@@ -58,13 +58,16 @@ impl Panel for GreeterPanel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn title(&self, cx: &App) -> AnyElement {
|
fn title(&self, cx: &App) -> AnyElement {
|
||||||
div()
|
h_flex()
|
||||||
|
.gap_1()
|
||||||
|
.text_xs()
|
||||||
.child(
|
.child(
|
||||||
svg()
|
svg()
|
||||||
.path("brand/coop.svg")
|
.path("brand/coop.svg")
|
||||||
.size_4()
|
.size_4()
|
||||||
.text_color(cx.theme().text_muted),
|
.text_color(cx.theme().text_muted),
|
||||||
)
|
)
|
||||||
|
.child("Welcome")
|
||||||
.into_any_element()
|
.into_any_element()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,8 +7,8 @@ use common::DebouncedDelay;
|
|||||||
use gpui::prelude::FluentBuilder;
|
use gpui::prelude::FluentBuilder;
|
||||||
use gpui::{
|
use gpui::{
|
||||||
AnyElement, App, AppContext, Context, ElementId, Entity, EventEmitter, FocusHandle, Focusable,
|
AnyElement, App, AppContext, Context, ElementId, Entity, EventEmitter, FocusHandle, Focusable,
|
||||||
IntoElement, ParentElement, Render, SharedString, Styled, Subscription, Task, Window, div,
|
IntoElement, ParentElement, Render, SharedString, Styled, Subscription, Task, WeakEntity,
|
||||||
uniform_list,
|
Window, div, uniform_list,
|
||||||
};
|
};
|
||||||
use instant::Duration;
|
use instant::Duration;
|
||||||
use nostr_sdk::prelude::*;
|
use nostr_sdk::prelude::*;
|
||||||
@@ -17,22 +17,25 @@ use smallvec::{SmallVec, smallvec};
|
|||||||
use state::{FIND_DELAY, NostrRegistry};
|
use state::{FIND_DELAY, NostrRegistry};
|
||||||
use theme::ActiveTheme;
|
use theme::ActiveTheme;
|
||||||
use ui::button::{Button, ButtonVariants};
|
use ui::button::{Button, ButtonVariants};
|
||||||
use ui::dock::{Panel, PanelEvent};
|
use ui::dock::{DockArea, DockPlacement, Panel, PanelEvent, PanelHandle};
|
||||||
use ui::input::{Input, InputEvent, InputState};
|
use ui::input::{Input, InputEvent, InputState};
|
||||||
|
use ui::nav::Nav;
|
||||||
use ui::notification::Notification;
|
use ui::notification::Notification;
|
||||||
use ui::{Icon, IconName, Selectable, Sizable, StyledExt, WindowExtension, h_flex, v_flex};
|
use ui::{Icon, IconName, Selectable, Sizable, StyledExt, WindowExtension, h_flex, v_flex};
|
||||||
|
|
||||||
use crate::sidebar::{TreeRow, TreeRowKind};
|
use crate::sidebar::nav_avatar;
|
||||||
|
|
||||||
const INPUT_PLACEHOLDER: &str = "Find or start a conversation";
|
const INPUT_PLACEHOLDER: &str = "Find or start a conversation";
|
||||||
|
|
||||||
pub fn init(window: &mut Window, cx: &mut App) -> Entity<SearchPanel> {
|
pub fn init(dock: WeakEntity<DockArea>, window: &mut Window, cx: &mut App) -> Entity<SearchPanel> {
|
||||||
cx.new(|cx| SearchPanel::new(window, cx))
|
cx.new(|cx| SearchPanel::new(dock, window, cx))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct SearchPanel {
|
pub struct SearchPanel {
|
||||||
name: SharedString,
|
name: SharedString,
|
||||||
focus_handle: FocusHandle,
|
focus_handle: FocusHandle,
|
||||||
|
/// The dock a started chat opens in
|
||||||
|
dock: WeakEntity<DockArea>,
|
||||||
|
|
||||||
/// Find input state
|
/// Find input state
|
||||||
find_input: Entity<InputState>,
|
find_input: Entity<InputState>,
|
||||||
@@ -63,7 +66,7 @@ pub struct SearchPanel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl SearchPanel {
|
impl SearchPanel {
|
||||||
fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
|
fn new(dock: WeakEntity<DockArea>, window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||||
let contact_list = cx.new(|_| None);
|
let contact_list = cx.new(|_| None);
|
||||||
let selected_pkeys = cx.new(|_| HashSet::new());
|
let selected_pkeys = cx.new(|_| HashSet::new());
|
||||||
let find_results = cx.new(|_| None);
|
let find_results = cx.new(|_| None);
|
||||||
@@ -107,6 +110,7 @@ impl SearchPanel {
|
|||||||
Self {
|
Self {
|
||||||
name: "Search".into(),
|
name: "Search".into(),
|
||||||
focus_handle: cx.focus_handle(),
|
focus_handle: cx.focus_handle(),
|
||||||
|
dock,
|
||||||
find_input,
|
find_input,
|
||||||
find_debouncer: DebouncedDelay::new(),
|
find_debouncer: DebouncedDelay::new(),
|
||||||
find_results,
|
find_results,
|
||||||
@@ -285,6 +289,7 @@ impl SearchPanel {
|
|||||||
fn create_room(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
fn create_room(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||||
let chat = ChatRegistry::global(cx);
|
let chat = ChatRegistry::global(cx);
|
||||||
let async_chat = chat.downgrade();
|
let async_chat = chat.downgrade();
|
||||||
|
let dock = self.dock.clone();
|
||||||
|
|
||||||
let nostr = NostrRegistry::global(cx);
|
let nostr = NostrRegistry::global(cx);
|
||||||
let Some(public_key) = nostr.read(cx).current_user() else {
|
let Some(public_key) = nostr.read(cx).current_user() else {
|
||||||
@@ -295,14 +300,26 @@ impl SearchPanel {
|
|||||||
let receivers = self.get_selected(cx);
|
let receivers = self.get_selected(cx);
|
||||||
|
|
||||||
self.tasks.push(cx.spawn_in(window, async move |this, cx| {
|
self.tasks.push(cx.spawn_in(window, async move |this, cx| {
|
||||||
// Create a new room and emit it
|
// Create a new room and register it
|
||||||
async_chat.update_in(cx, |this, _window, cx| {
|
let room = async_chat.update_in(cx, |chat, _window, cx| {
|
||||||
let room = cx.new(|_| {
|
let room = cx.new(|_| {
|
||||||
Room::new(public_key, receivers)
|
Room::new(public_key, receivers)
|
||||||
.organize(&public_key)
|
.organize(&public_key)
|
||||||
.kind(RoomKind::Ongoing)
|
.kind(RoomKind::Ongoing)
|
||||||
});
|
});
|
||||||
this.emit_room(&room, _window, cx);
|
chat.track_room(&room, cx);
|
||||||
|
room
|
||||||
|
})?;
|
||||||
|
|
||||||
|
// Open it in the dock
|
||||||
|
cx.update(|window, cx| {
|
||||||
|
ui::dock::add_panel_to(
|
||||||
|
&dock,
|
||||||
|
PanelHandle::new(chat_ui::init(room.downgrade(), window, cx)),
|
||||||
|
DockPlacement::Center,
|
||||||
|
window,
|
||||||
|
cx,
|
||||||
|
);
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
// Reset the find panel
|
// Reset the find panel
|
||||||
@@ -341,13 +358,17 @@ impl SearchPanel {
|
|||||||
this.select(&pkey_clone, cx);
|
this.select(&pkey_clone, cx);
|
||||||
});
|
});
|
||||||
|
|
||||||
TreeRow::new(
|
Nav::new(ElementId::NamedInteger(
|
||||||
ElementId::NamedInteger("search-result".into(), (range.start + ix) as u64),
|
"search-result".into(),
|
||||||
TreeRowKind::Room,
|
(range.start + ix) as u64,
|
||||||
profile.name(),
|
))
|
||||||
|
.label(profile.name())
|
||||||
|
.text_sm()
|
||||||
|
.font_medium()
|
||||||
|
.when_some(
|
||||||
|
nav_avatar(Some(profile.avatar_seed()), profile.avatar(), cx),
|
||||||
|
|this, avatar| this.prefix(avatar),
|
||||||
)
|
)
|
||||||
.avatar(profile.avatar_seed())
|
|
||||||
.picture(profile.avatar())
|
|
||||||
.on_click(handler)
|
.on_click(handler)
|
||||||
.selected(selected)
|
.selected(selected)
|
||||||
.into_any_element()
|
.into_any_element()
|
||||||
@@ -382,13 +403,17 @@ impl SearchPanel {
|
|||||||
this.select(&pkey_clone, cx);
|
this.select(&pkey_clone, cx);
|
||||||
});
|
});
|
||||||
|
|
||||||
TreeRow::new(
|
Nav::new(ElementId::NamedInteger(
|
||||||
ElementId::NamedInteger("contact".into(), (range.start + ix) as u64),
|
"contact".into(),
|
||||||
TreeRowKind::Room,
|
(range.start + ix) as u64,
|
||||||
profile.name().trim(),
|
))
|
||||||
|
.label(profile.name().trim())
|
||||||
|
.text_sm()
|
||||||
|
.font_medium()
|
||||||
|
.when_some(
|
||||||
|
nav_avatar(Some(profile.avatar_seed()), profile.avatar(), cx),
|
||||||
|
|this, avatar| this.prefix(avatar),
|
||||||
)
|
)
|
||||||
.avatar(profile.avatar_seed())
|
|
||||||
.picture(profile.avatar())
|
|
||||||
.on_click(handler)
|
.on_click(handler)
|
||||||
.selected(selected)
|
.selected(selected)
|
||||||
.into_any_element()
|
.into_any_element()
|
||||||
|
|||||||
@@ -1,74 +0,0 @@
|
|||||||
use gpui::{App, InteractiveElement, IntoElement, ParentElement, Styled, Window, div, svg};
|
|
||||||
use theme::{ActiveTheme, TABBAR_HEIGHT};
|
|
||||||
use ui::button::{Button, ButtonVariants};
|
|
||||||
use ui::{StyledExt, h_flex, title_bar_drag_handlers, v_flex};
|
|
||||||
|
|
||||||
use crate::dialogs::import;
|
|
||||||
|
|
||||||
const TITLE: &str = "Welcome to Coop!";
|
|
||||||
const DESCRIPTION: &str = "Chat Freely, Stay Private on Nostr.";
|
|
||||||
|
|
||||||
pub(super) fn render(window: &mut Window, cx: &mut App) -> impl IntoElement {
|
|
||||||
v_flex()
|
|
||||||
.size_full()
|
|
||||||
.relative()
|
|
||||||
.bg(cx.theme().surface_background)
|
|
||||||
.child(title_bar_drag_handlers(
|
|
||||||
div()
|
|
||||||
.id("onboarding-drag")
|
|
||||||
.absolute()
|
|
||||||
.top_0()
|
|
||||||
.left_0()
|
|
||||||
.h(TABBAR_HEIGHT)
|
|
||||||
.w_full(),
|
|
||||||
window,
|
|
||||||
cx,
|
|
||||||
))
|
|
||||||
.child(
|
|
||||||
v_flex()
|
|
||||||
.size_full()
|
|
||||||
.justify_end()
|
|
||||||
.gap_4()
|
|
||||||
.p_4()
|
|
||||||
.child(
|
|
||||||
h_flex()
|
|
||||||
.gap_2()
|
|
||||||
.child(
|
|
||||||
svg()
|
|
||||||
.path("brand/coop.svg")
|
|
||||||
.size_8()
|
|
||||||
.text_color(cx.theme().icon_muted),
|
|
||||||
)
|
|
||||||
.child(
|
|
||||||
v_flex().child(div().font_semibold().child(TITLE)).child(
|
|
||||||
div()
|
|
||||||
.text_xs()
|
|
||||||
.text_color(cx.theme().text_muted)
|
|
||||||
.child(DESCRIPTION),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.child(
|
|
||||||
v_flex()
|
|
||||||
.gap_2()
|
|
||||||
.w_full()
|
|
||||||
.child(
|
|
||||||
Button::new("join-now")
|
|
||||||
.label("Join now")
|
|
||||||
.primary()
|
|
||||||
.font_semibold()
|
|
||||||
.h_8()
|
|
||||||
.w_full(),
|
|
||||||
)
|
|
||||||
.child(
|
|
||||||
Button::new("import-identity")
|
|
||||||
.label("Import identity")
|
|
||||||
.secondary()
|
|
||||||
.font_semibold()
|
|
||||||
.h_8()
|
|
||||||
.w_full()
|
|
||||||
.on_click(|_event, window, cx| import::open(window, cx)),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,121 +1,38 @@
|
|||||||
use std::rc::Rc;
|
|
||||||
|
|
||||||
use gpui::prelude::FluentBuilder;
|
|
||||||
use gpui::{App, InteractiveElement, IntoElement, ParentElement, RenderOnce, Styled, Window, div};
|
|
||||||
use theme::ActiveTheme;
|
|
||||||
use ui::button::{Button, ButtonVariants};
|
|
||||||
use ui::{IconName, Selectable, h_flex};
|
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
pub enum SidebarTab {
|
pub enum SidebarTab {
|
||||||
Recents,
|
Inbox,
|
||||||
Chats,
|
|
||||||
Communities,
|
Communities,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl SidebarTab {
|
impl SidebarTab {
|
||||||
pub const ALL: [SidebarTab; 3] = [Self::Recents, Self::Chats, Self::Communities];
|
pub const ALL: [SidebarTab; 2] = [Self::Inbox, Self::Communities];
|
||||||
|
|
||||||
pub fn label(self) -> &'static str {
|
pub fn label(self) -> &'static str {
|
||||||
match self {
|
match self {
|
||||||
Self::Recents => "Recents",
|
Self::Inbox => "Inbox",
|
||||||
Self::Chats => "Chats",
|
|
||||||
Self::Communities => "Communities",
|
Self::Communities => "Communities",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn icon(self) -> IconName {
|
/// Heading for the tab's list of items.
|
||||||
|
pub fn list_title(self) -> &'static str {
|
||||||
match self {
|
match self {
|
||||||
Self::Recents => IconName::History,
|
Self::Inbox => "Direct Messages",
|
||||||
Self::Chats => IconName::Message,
|
Self::Communities => "Communities",
|
||||||
Self::Communities => IconName::Group,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn list_id(self) -> &'static str {
|
pub fn list_id(self) -> &'static str {
|
||||||
match self {
|
match self {
|
||||||
Self::Recents => "sidebar-recents",
|
Self::Inbox => "sidebar-inbox",
|
||||||
Self::Chats => "sidebar-chats",
|
|
||||||
Self::Communities => "sidebar-communities",
|
Self::Communities => "sidebar-communities",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn index(self) -> usize {
|
pub fn index(self) -> usize {
|
||||||
match self {
|
match self {
|
||||||
Self::Recents => 0,
|
Self::Inbox => 0,
|
||||||
Self::Chats => 1,
|
Self::Communities => 1,
|
||||||
Self::Communities => 2,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn chat(self) -> bool {
|
|
||||||
matches!(self, Self::Chats)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn community(self) -> bool {
|
|
||||||
matches!(self, Self::Communities)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(IntoElement)]
|
|
||||||
#[allow(clippy::type_complexity)]
|
|
||||||
pub struct TabBar {
|
|
||||||
active: SidebarTab,
|
|
||||||
on_select: Option<Rc<dyn Fn(SidebarTab, &mut Window, &mut App)>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl TabBar {
|
|
||||||
pub fn new(active: SidebarTab) -> Self {
|
|
||||||
Self {
|
|
||||||
active,
|
|
||||||
on_select: None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn on_select(
|
|
||||||
mut self,
|
|
||||||
handler: impl Fn(SidebarTab, &mut Window, &mut App) + 'static,
|
|
||||||
) -> Self {
|
|
||||||
self.on_select = Some(Rc::new(handler));
|
|
||||||
self
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl RenderOnce for TabBar {
|
|
||||||
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
|
|
||||||
let Self { active, on_select } = self;
|
|
||||||
|
|
||||||
div()
|
|
||||||
.id("sidebar-tabs")
|
|
||||||
.absolute()
|
|
||||||
.bottom_3()
|
|
||||||
.left_0()
|
|
||||||
.w_full()
|
|
||||||
.px_4()
|
|
||||||
.child(
|
|
||||||
h_flex()
|
|
||||||
.w_full()
|
|
||||||
.p_1()
|
|
||||||
.gap_1()
|
|
||||||
.rounded_full()
|
|
||||||
.bg(cx.theme().background)
|
|
||||||
.when(cx.theme().shadow, |this| this.shadow_md())
|
|
||||||
.children(SidebarTab::ALL.into_iter().map(|tab| {
|
|
||||||
let on_select = on_select.clone();
|
|
||||||
|
|
||||||
Button::new(format!("tab-{}", tab.list_id()))
|
|
||||||
.icon(tab.icon())
|
|
||||||
.ghost()
|
|
||||||
.flex_1()
|
|
||||||
.rounded()
|
|
||||||
.selected(tab == active)
|
|
||||||
.tooltip(tab.label())
|
|
||||||
.on_click(move |_event, window, cx| {
|
|
||||||
if let Some(on_select) = on_select.as_ref() {
|
|
||||||
on_select(tab, window, cx);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,235 +0,0 @@
|
|||||||
use std::rc::Rc;
|
|
||||||
|
|
||||||
use chat::Room;
|
|
||||||
use community::Community;
|
|
||||||
use gpui::prelude::FluentBuilder;
|
|
||||||
use gpui::{
|
|
||||||
App, ClickEvent, ElementId, Entity, ImageSource, InteractiveElement, IntoElement,
|
|
||||||
ParentElement, RenderOnce, SharedString, StatefulInteractiveElement, Styled, Window, div, px,
|
|
||||||
};
|
|
||||||
use settings::AppSettings;
|
|
||||||
use theme::ActiveTheme;
|
|
||||||
use ui::avatar::{Avatar, PixelAvatar};
|
|
||||||
use ui::{Icon, IconName, Selectable, Sizable, StyledExt, h_flex};
|
|
||||||
|
|
||||||
use super::tab::SidebarTab;
|
|
||||||
|
|
||||||
pub enum SidebarRow {
|
|
||||||
Section {
|
|
||||||
label: SharedString,
|
|
||||||
count: usize,
|
|
||||||
},
|
|
||||||
Room {
|
|
||||||
room: Entity<Room>,
|
|
||||||
},
|
|
||||||
Community {
|
|
||||||
community: Entity<Community>,
|
|
||||||
},
|
|
||||||
Action {
|
|
||||||
label: SharedString,
|
|
||||||
tab: SidebarTab,
|
|
||||||
},
|
|
||||||
Hint {
|
|
||||||
text: SharedString,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
|
||||||
pub enum TreeRowKind {
|
|
||||||
Section,
|
|
||||||
Room,
|
|
||||||
Community,
|
|
||||||
Action,
|
|
||||||
Hint,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(IntoElement)]
|
|
||||||
pub struct TreeRow {
|
|
||||||
id: ElementId,
|
|
||||||
kind: TreeRowKind,
|
|
||||||
label: SharedString,
|
|
||||||
avatar: Option<SharedString>,
|
|
||||||
picture: Option<ImageSource>,
|
|
||||||
icon: Option<IconName>,
|
|
||||||
count: Option<usize>,
|
|
||||||
created_at: Option<SharedString>,
|
|
||||||
selected: bool,
|
|
||||||
#[allow(clippy::type_complexity)]
|
|
||||||
on_click: Option<Rc<dyn Fn(&ClickEvent, &mut Window, &mut App)>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl TreeRow {
|
|
||||||
pub fn new(
|
|
||||||
id: impl Into<ElementId>,
|
|
||||||
kind: TreeRowKind,
|
|
||||||
label: impl Into<SharedString>,
|
|
||||||
) -> Self {
|
|
||||||
Self {
|
|
||||||
id: id.into(),
|
|
||||||
kind,
|
|
||||||
label: label.into(),
|
|
||||||
avatar: None,
|
|
||||||
picture: None,
|
|
||||||
icon: None,
|
|
||||||
count: None,
|
|
||||||
created_at: None,
|
|
||||||
selected: false,
|
|
||||||
on_click: None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Sets the seed for the row's generated avatar.
|
|
||||||
pub fn avatar(mut self, seed: impl Into<SharedString>) -> Self {
|
|
||||||
self.avatar = Some(seed.into());
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Shows `picture` instead of the generated avatar.
|
|
||||||
pub fn picture(mut self, picture: Option<impl Into<ImageSource>>) -> Self {
|
|
||||||
self.picture = picture.map(Into::into);
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Shows `icon` in the avatar slot when the row has no avatar or picture.
|
|
||||||
pub fn icon(mut self, icon: IconName) -> Self {
|
|
||||||
self.icon = Some(icon);
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn count(mut self, count: usize) -> Self {
|
|
||||||
self.count = Some(count);
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn created_at(mut self, created_at: impl Into<SharedString>) -> Self {
|
|
||||||
self.created_at = Some(created_at.into());
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn on_click(
|
|
||||||
mut self,
|
|
||||||
handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
|
|
||||||
) -> Self {
|
|
||||||
self.on_click = Some(Rc::new(handler));
|
|
||||||
self
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Selectable for TreeRow {
|
|
||||||
fn selected(mut self, selected: bool) -> Self {
|
|
||||||
self.selected = selected;
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
fn is_selected(&self) -> bool {
|
|
||||||
self.selected
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl RenderOnce for TreeRow {
|
|
||||||
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
|
|
||||||
let hide_avatar = AppSettings::get_hide_avatar(cx);
|
|
||||||
|
|
||||||
let is_section = self.kind == TreeRowKind::Section;
|
|
||||||
let is_room = self.kind == TreeRowKind::Room;
|
|
||||||
let is_community = self.kind == TreeRowKind::Community;
|
|
||||||
let is_action = self.kind == TreeRowKind::Action;
|
|
||||||
let is_hint = self.kind == TreeRowKind::Hint;
|
|
||||||
let is_selected = self.selected;
|
|
||||||
|
|
||||||
let avatar = if hide_avatar {
|
|
||||||
None
|
|
||||||
} else {
|
|
||||||
match (self.avatar, self.picture) {
|
|
||||||
(None, None) => None,
|
|
||||||
(seed, Some(picture)) => Some(
|
|
||||||
Avatar::from_source(picture)
|
|
||||||
.when_some(seed, |avatar, seed| avatar.seed(seed))
|
|
||||||
.xsmall()
|
|
||||||
.flex_shrink_0()
|
|
||||||
.into_any_element(),
|
|
||||||
),
|
|
||||||
(Some(seed), None) => Some(
|
|
||||||
PixelAvatar::new(seed)
|
|
||||||
.xsmall()
|
|
||||||
.flex_shrink_0()
|
|
||||||
.into_any_element(),
|
|
||||||
),
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let avatar = avatar.or_else(|| {
|
|
||||||
self.icon.map(|icon| {
|
|
||||||
h_flex()
|
|
||||||
.flex_shrink_0()
|
|
||||||
.w(px(20.))
|
|
||||||
.justify_center()
|
|
||||||
.text_color(cx.theme().icon_muted)
|
|
||||||
.child(Icon::new(icon).small())
|
|
||||||
.into_any_element()
|
|
||||||
})
|
|
||||||
});
|
|
||||||
|
|
||||||
h_flex()
|
|
||||||
.id(self.id)
|
|
||||||
.h_8()
|
|
||||||
.w_full()
|
|
||||||
.px_2()
|
|
||||||
.gap_2()
|
|
||||||
.rounded(cx.theme().radius)
|
|
||||||
.when(is_section, |this| {
|
|
||||||
this.text_xs()
|
|
||||||
.text_color(cx.theme().text_placeholder)
|
|
||||||
.font_semibold()
|
|
||||||
})
|
|
||||||
.when(is_room || is_community, |this| this.text_sm())
|
|
||||||
.when(is_action, |this| {
|
|
||||||
this.text_sm().text_color(cx.theme().text_muted)
|
|
||||||
})
|
|
||||||
.when(is_hint, |this| {
|
|
||||||
this.text_xs()
|
|
||||||
.font_normal()
|
|
||||||
.text_color(cx.theme().text_placeholder)
|
|
||||||
})
|
|
||||||
.when_some(avatar, |this, avatar| this.child(avatar))
|
|
||||||
.child(
|
|
||||||
h_flex()
|
|
||||||
.gap_1()
|
|
||||||
.flex_1()
|
|
||||||
.child(
|
|
||||||
div()
|
|
||||||
.truncate()
|
|
||||||
.min_w_0()
|
|
||||||
.when(is_room, |this| this.font_medium())
|
|
||||||
.child(self.label),
|
|
||||||
)
|
|
||||||
.when(is_selected, |this| {
|
|
||||||
this.child(
|
|
||||||
Icon::new(IconName::CheckCircle)
|
|
||||||
.small()
|
|
||||||
.flex_shrink_0()
|
|
||||||
.text_color(cx.theme().icon_accent),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
.when_some(self.count, |this, count| {
|
|
||||||
this.child(div().flex_shrink_0().font_normal().child(count.to_string()))
|
|
||||||
})
|
|
||||||
.when_some(self.created_at, |this, created_at| {
|
|
||||||
this.child(div().flex_1()).child(
|
|
||||||
div()
|
|
||||||
.flex_shrink_0()
|
|
||||||
.text_color(cx.theme().text_placeholder)
|
|
||||||
.text_xs()
|
|
||||||
.child(created_at),
|
|
||||||
)
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.when_some(self.on_click, |this, handler| {
|
|
||||||
this.cursor_pointer()
|
|
||||||
.when(!is_section, |this| {
|
|
||||||
this.hover(|this| this.bg(cx.theme().ghost_element_hover))
|
|
||||||
})
|
|
||||||
.on_click(move |event, window, cx| handler(event, window, cx))
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
|
use gpui::prelude::FluentBuilder as _;
|
||||||
|
use gpui::{AnyElement, App, ImageSource, IntoElement, ParentElement, SharedString, Styled, px};
|
||||||
|
use settings::AppSettings;
|
||||||
|
use theme::ActiveTheme;
|
||||||
|
use ui::avatar::{Avatar, PixelAvatar};
|
||||||
|
use ui::{Icon, IconName, Sizable, h_flex};
|
||||||
|
|
||||||
|
pub(crate) fn nav_avatar(
|
||||||
|
seed: Option<impl Into<SharedString>>,
|
||||||
|
picture: Option<impl Into<ImageSource>>,
|
||||||
|
cx: &App,
|
||||||
|
) -> Option<AnyElement> {
|
||||||
|
if AppSettings::get_hide_avatar(cx) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
match (seed.map(Into::into), picture.map(Into::into)) {
|
||||||
|
(None, None) => None,
|
||||||
|
(seed, Some(picture)) => Some(
|
||||||
|
Avatar::from_source(picture)
|
||||||
|
.when_some(seed, |avatar, seed| avatar.seed(seed))
|
||||||
|
.small()
|
||||||
|
.flex_shrink_0()
|
||||||
|
.into_any_element(),
|
||||||
|
),
|
||||||
|
(Some(seed), None) => Some(
|
||||||
|
PixelAvatar::new(seed)
|
||||||
|
.small()
|
||||||
|
.flex_shrink_0()
|
||||||
|
.into_any_element(),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn nav_icon(icon: IconName, cx: &App) -> AnyElement {
|
||||||
|
h_flex()
|
||||||
|
.flex_shrink_0()
|
||||||
|
.w(px(20.))
|
||||||
|
.justify_center()
|
||||||
|
.text_color(cx.theme().icon_muted)
|
||||||
|
.child(Icon::new(icon).small())
|
||||||
|
.into_any_element()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Brand backgrounds shown behind the signed-out screen; one is picked at random.
|
||||||
|
const SIGNED_OUT_BANNERS: [&str; 2] = ["brand/bg1.jpg", "brand/bg2.jpg"];
|
||||||
|
|
||||||
|
/// Pick one of the signed-out backgrounds at random.
|
||||||
|
pub(crate) fn pick_banner() -> SharedString {
|
||||||
|
let nanos = SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.map(|elapsed| elapsed.subsec_nanos())
|
||||||
|
.unwrap_or_default();
|
||||||
|
let index = nanos as usize % SIGNED_OUT_BANNERS.len();
|
||||||
|
SIGNED_OUT_BANNERS[index].into()
|
||||||
|
}
|
||||||