feat: redesign the sidebar and workspace (#53)

Reviewed-on: #53
This commit was merged in pull request #53.
This commit is contained in:
2026-09-25 02:26:31 +00:00
parent 1032ac3751
commit e4e13b0889
38 changed files with 2378 additions and 2484 deletions
Generated
+276 -165
View File
File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 669 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 598 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

+3
View File
@@ -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

+3
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -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

+6 -1
View File
@@ -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(_))
+5 -30
View File
@@ -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));
});
} }
} }
-3
View File
@@ -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"
+48 -60
View File
@@ -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,20 +1206,10 @@ 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()
.py_1()
.px_3()
.child(
div()
.flex()
.gap_3()
.when(!hide_avatar, |this| {
if show_author {
this.child(
Avatar::new(author.avatar()) Avatar::new(author.avatar())
.seed(author.avatar_seed()) .seed(author.avatar_seed())
.flex_shrink_0() .flex_shrink_0()
@@ -1213,28 +1221,10 @@ impl ChatPanel {
.menu("View on njump.me", Box::new(Command::Njump(pk))) .menu("View on njump.me", Box::new(Command::Njump(pk)))
}), }),
) )
} else { .author(author.name())
this.child(div().flex_shrink_0().w(px(32.))) .timestamp(message.created_at.to_human_time())
}
})
.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| { .when(has_reports, |this| {
this.child(self.render_sent_reports(&id, cx)) this.header_extra(self.render_sent_reports(&id, cx))
}),
)
}) })
.when(has_replies, |this| { .when(has_replies, |this| {
this.children(self.render_message_replies(replies, cx)) this.children(self.render_message_replies(replies, cx))
@@ -1246,10 +1236,8 @@ impl ChatPanel {
}) })
.when(has_reactions, |this| { .when(has_reactions, |this| {
this.child(self.render_reactions(&id, cx)) this.child(self.render_reactions(&id, cx))
}), })
), .overlay(
)
.child(
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> {
+17 -477
View File
@@ -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)]
pub enum Highlight {
Code,
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, content: &str,
mentions: &[Mention], mentions: &[Mention],
persons: &Entity<PersonRegistry>, persons: &Entity<PersonRegistry>,
markdown: bool, markdown: bool,
cx: &App, cx: &App,
) -> Self { ) -> RenderedText {
Self::render(content, mentions, markdown, |mention| { let replacements = mentions
format!("@{}", persons.read(cx).get(&mention.public_key, cx).name()) .iter()
.map(|mention| {
InlineReplacement::new(
mention.range.clone(),
format!("@{}", persons.read(cx).get(&mention.public_key, cx).name()),
)
}) })
} .collect::<Vec<_>>();
fn render( RenderedText::new(content, &replacements, markdown)
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,
resolve_mention: impl Fn(&Mention) -> String,
) {
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;
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(" ");
}
} }
+6 -7
View File
@@ -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 {
+1 -22
View File
@@ -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);
+90 -44
View File
@@ -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| {
id, this.list_state.set_follow_mode(FollowMode::Tail);
focus_handle: cx.focus_handle(), this.list_state.set_scroll_handler(cx.listener(
community: community.downgrade(),
channel,
rows: Vec::new(),
has_more: false,
loading: false,
list_state: ListState::new(0, ListAlignment::Bottom, px(1024.)),
input,
tasks: smallvec![],
_subscriptions: subscriptions,
};
panel.list_state.set_follow_mode(FollowMode::Tail);
panel.list_state.set_scroll_handler(cx.listener(
|this, event: &ListScrollEvent, window, cx| { |this, event: &ListScrollEvent, window, cx| {
if event.visible_range.start <= LOAD_OLDER_THRESHOLD { if event.visible_range.start <= LOAD_OLDER_THRESHOLD {
this.load_older(window, cx); this.load_older(window, cx);
} }
}, },
)); ));
cx.defer_in(window, |this, window, cx| {
this.load(window, cx); this.load(window, cx);
}); });
panel Self {
id,
focus_handle: cx.focus_handle(),
community: community.downgrade(),
channel,
rows: Vec::new(),
rendered_texts_by_id: BTreeMap::new(),
has_more: false,
loading: false,
list_state: ListState::new(0, ListAlignment::Bottom, px(1024.)),
input,
tasks: smallvec![],
_subscriptions: subscriptions,
}
} }
/// 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> {
+22 -52
View File
@@ -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()
.hover(|this| this.bg(cx.theme().surface_background))
.child(
h_flex()
.items_start()
.gap_3()
.when(!hide_avatar, |this| {
if show_author {
this.child(
Avatar::new(author.avatar()) Avatar::new(author.avatar())
.seed(author.avatar_seed()) .seed(author.avatar_seed())
.flex_shrink_0(), .flex_shrink_0(),
) )
} else { .author(author.name())
this.child(div().flex_shrink_0().w(px(32.))) .timestamp(Timestamp::from_secs(message.at_ms / 1000).to_human_time())
}
})
.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| { .when(message.edited_at.is_some(), |this| {
this.child(div().child("(edited)")) this.header_extra(div().child("(edited)"))
}),
)
}) })
.child(content(message, cx)) .child(content)
.when(!message.reactions.is_empty(), |this| { .when(!message.reactions.is_empty(), |this| {
this.child(reactions(message, cx)) 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() div()
.text_color(cx.theme().text_danger) .text_color(cx.theme().text_danger)
.child("Message deleted") .child("Message deleted")
.into_any_element();
}
div()
.child(SharedString::from(&message.content))
.into_any_element() .into_any_element()
} }
+4
View File
@@ -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
+1 -1
View File
@@ -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(),
} }
} }
+38 -200
View File
@@ -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(
-3
View File
@@ -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
+4
View File
@@ -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",
+3
View File
@@ -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;
+491
View File
@@ -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(" ");
}
}
+325
View File
@@ -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)
}
}
+123
View File
@@ -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),
)
}
}
+85 -48
View File
@@ -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()
.flex_wrap()
.gap_1()
.items_center()
.flex_shrink_0()
.h(TABBAR_HEIGHT)
.relative()
.overflow_hidden()
.text_color(fg)
.text_sm()
.when_some(self.prefix, |this, prefix| this.child(prefix))
.child(
h_flex()
.flex_1() .flex_1()
.h(px(30.)) .h_6()
.line_height(relative(1.))
.whitespace_nowrap() .whitespace_nowrap()
.items_center() .items_center()
.justify_center()
.overflow_hidden() .overflow_hidden()
.flex_shrink_0() .when(segmented, |this| this.justify_center().px_1())
.px_3() .when(!segmented, |this| this.justify_start())
.map(|this| match self.icon { .map(|this| match icon {
Some(icon) => this.w(px(38.)).child(icon.size_4()), Some(icon) => this.w(px(38.)).child(icon.size_4()),
None => this None => this
.map(|this| match self.label { .map(|this| match label {
Some(label) => this.child(label), Some(label) => this.child(label),
None => this, None => this,
}) })
.children(self.children), .children(children),
}), });
base.id(ix)
.group("tab")
.flex()
.items_center()
.text_color(foreground)
.when(segmented, |this| {
this.text_xs()
.flex_1()
.h_6()
.rounded(cx.theme().radius)
.when(selected && !disabled, |this| {
this.bg(cx.theme().tab_active_background)
.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()
.when(selected && !disabled, |this| {
this.bg(cx.theme().tab_background)
})
.when(!selected && !disabled, |this| {
this.hover(|this| {
this.bg(cx.theme().tab_hover_background)
.text_color(cx.theme().tab_active_foreground)
})
})
})
.when_some(prefix, |this, prefix| this.child(prefix))
.child(content)
.when_some(suffix, |this, suffix| {
this.child(
div()
.flex_shrink_0()
.when(!selected, |this| {
this.invisible().group_hover("tab", |this| this.visible())
})
.child(suffix),
) )
.when_some(self.suffix, |this, suffix| {
this.child(div().pr_2().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)
})
}),
)
} }
} }
+24 -3
View File
@@ -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)
}) })
+1 -1
View File
@@ -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())
}); });
} }
+22 -7
View File
@@ -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(&current_user) .organize(&current_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);
} }
+16 -266
View File
@@ -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));
this.dock.update(cx, |this, cx| {
let left = DockLayout::tabs().panel_view(Arc::new(sidebar), cx);
let center = DockLayout::v_split() let center = DockLayout::v_split()
.child(DockLayout::tabs().panel_view(Arc::new(greeter), cx), None); .child(DockLayout::tabs().panel_view(Arc::new(greeter), cx), None);
this.dock.update(cx, |area, cx| { this.set_dock(DockPlacement::Left, left, window, cx);
area.set_center(center, 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))
+4 -1
View File
@@ -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()
} }
} }
+47 -22
View File
@@ -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()
File diff suppressed because it is too large Load Diff
@@ -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)),
),
),
)
}
+10 -93
View File
@@ -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);
}
})
})),
)
}
} }
-235
View File
@@ -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))
})
}
}
+58
View File
@@ -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()
}