chore: restructure and refine the ui (#199)

* update deps

* clean up

* add account crate

* add person crate

* add chat and chat ui crates

* .

* clean up the ui crate

* .

* .
This commit is contained in:
reya
2025-11-01 09:16:02 +07:00
committed by GitHub
parent a1bd4954eb
commit 7091fa1cab
42 changed files with 980 additions and 794 deletions

View File

@@ -7,11 +7,7 @@ publish.workspace = true
[dependencies]
common = { path = "../common" }
theme = { path = "../theme" }
registry = { path = "../registry" }
rust-i18n.workspace = true
i18n.workspace = true
nostr-sdk.workspace = true
gpui.workspace = true
smol.workspace = true
serde.workspace = true
@@ -20,14 +16,11 @@ smallvec.workspace = true
anyhow.workspace = true
itertools.workspace = true
log.workspace = true
emojis.workspace = true
regex = "1"
unicode-segmentation = "1.12.0"
uuid = "1.10"
once_cell = "1.19.0"
regex = "1"
image = "0.25.1"
linkify = "0.10.0"
lsp-types = "0.97.0"
rope = { git = "https://github.com/zed-industries/zed" }
sum_tree = { git = "https://github.com/zed-industries/zed" }

View File

@@ -1,17 +1,6 @@
use gpui::{actions, Action};
use nostr_sdk::prelude::PublicKey;
use serde::Deserialize;
/// Define a open public key action
#[derive(Action, Clone, PartialEq, Eq, Deserialize, Debug)]
#[action(namespace = pubkey, no_json)]
pub struct OpenPublicKey(pub PublicKey);
/// Define a copy inline public key action
#[derive(Action, Clone, PartialEq, Eq, Deserialize, Debug)]
#[action(namespace = pubkey, no_json)]
pub struct CopyPublicKey(pub PublicKey);
/// Define a custom confirm action
#[derive(Clone, Action, PartialEq, Eq, Deserialize)]
#[action(namespace = list, no_json)]

View File

@@ -359,6 +359,7 @@ impl RenderOnce for Button {
.justify_center()
.cursor_default()
.overflow_hidden()
.refine_style(&self.style)
.map(|this| match self.rounded {
false => this.rounded(cx.theme().radius),
true => this.rounded_full(),
@@ -436,29 +437,6 @@ impl RenderOnce for Button {
}
}
})
.text_color(normal_style.fg)
.when(!self.disabled && !self.selected, |this| {
this.bg(normal_style.bg)
.hover(|this| {
let hover_style = style.hovered(cx);
this.bg(hover_style.bg).text_color(hover_style.fg)
})
.active(|this| {
let active_style = style.active(cx);
this.bg(active_style.bg).text_color(active_style.fg)
})
})
.when(self.selected, |this| {
let selected_style = style.selected(cx);
this.bg(selected_style.bg).text_color(selected_style.fg)
})
.when(self.disabled, |this| {
let disabled_style = style.disabled(cx);
this.cursor_not_allowed()
.bg(disabled_style.bg)
.text_color(disabled_style.fg)
})
.refine_style(&self.style)
.on_mouse_down(gpui::MouseButton::Left, |_, window, _| {
// Avoid focus on mouse down.
window.prevent_default();
@@ -505,6 +483,28 @@ impl RenderOnce for Button {
})
.children(self.children)
})
.text_color(normal_style.fg)
.when(!self.disabled && !self.selected, |this| {
this.bg(normal_style.bg)
.hover(|this| {
let hover_style = style.hovered(cx);
this.bg(hover_style.bg).text_color(hover_style.fg)
})
.active(|this| {
let active_style = style.active(cx);
this.bg(active_style.bg).text_color(active_style.fg)
})
})
.when(self.selected, |this| {
let selected_style = style.selected(cx);
this.bg(selected_style.bg).text_color(selected_style.fg)
})
.when(self.disabled, |this| {
let disabled_style = style.disabled(cx);
this.cursor_not_allowed()
.bg(disabled_style.bg)
.text_color(disabled_style.fg)
})
.when(self.loading && !self.disabled, |this| {
this.bg(normal_style.bg.opacity(0.8))
.text_color(normal_style.fg.opacity(0.8))

View File

@@ -1,134 +0,0 @@
use std::sync::OnceLock;
use gpui::prelude::FluentBuilder;
use gpui::{
div, px, App, AppContext, Corner, Element, InteractiveElement, IntoElement, ParentElement,
RenderOnce, SharedString, StatefulInteractiveElement, Styled, WeakEntity, Window,
};
use theme::ActiveTheme;
use crate::button::{Button, ButtonVariants};
use crate::input::InputState;
use crate::popover::{Popover, PopoverContent};
use crate::{Icon, Sizable, Size};
static EMOJIS: OnceLock<Vec<SharedString>> = OnceLock::new();
fn get_emojis() -> &'static Vec<SharedString> {
EMOJIS.get_or_init(|| {
let mut emojis: Vec<SharedString> = vec![];
emojis.extend(
emojis::Group::SmileysAndEmotion
.emojis()
.map(|e| SharedString::from(e.as_str()))
.collect::<Vec<SharedString>>(),
);
emojis
})
}
#[derive(IntoElement)]
pub struct EmojiPicker {
icon: Option<Icon>,
size: Size,
anchor: Option<Corner>,
target_input: WeakEntity<InputState>,
}
impl EmojiPicker {
pub fn new(target_input: WeakEntity<InputState>) -> Self {
Self {
target_input,
size: Size::default(),
anchor: None,
icon: None,
}
}
pub fn icon(mut self, icon: impl Into<Icon>) -> Self {
self.icon = Some(icon.into());
self
}
pub fn anchor(mut self, corner: Corner) -> Self {
self.anchor = Some(corner);
self
}
}
impl Sizable for EmojiPicker {
fn with_size(mut self, size: impl Into<Size>) -> Self {
self.size = size.into();
self
}
}
impl RenderOnce for EmojiPicker {
fn render(self, _window: &mut Window, _cx: &mut App) -> impl IntoElement {
Popover::new("emoji-picker")
.map(|this| {
if let Some(corner) = self.anchor {
this.anchor(corner)
} else {
this.anchor(gpui::Corner::BottomLeft)
}
})
.trigger(
Button::new("emoji-trigger")
.when_some(self.icon, |this, icon| this.icon(icon))
.ghost()
.with_size(self.size),
)
.content(move |window, cx| {
let input = self.target_input.clone();
cx.new(|cx| {
PopoverContent::new(window, cx, move |_window, cx| {
div()
.flex()
.flex_wrap()
.items_center()
.gap_2()
.children(get_emojis().iter().map(|e| {
div()
.id(e.clone())
.flex_auto()
.size_10()
.flex()
.items_center()
.justify_center()
.rounded(cx.theme().radius)
.child(e.clone())
.hover(|this| this.bg(cx.theme().ghost_element_hover))
.on_click({
let item = e.clone();
let input = input.upgrade();
move |_, window, cx| {
if let Some(input) = input.as_ref() {
input.update(cx, |this, cx| {
let current = this.value();
let new_text = if current.is_empty() {
format!("{item}")
} else if current.ends_with(" ") {
format!("{current}{item}")
} else {
format!("{current} {item}")
};
this.set_value(new_text, window, cx);
});
}
}
})
}))
.into_any()
})
.scrollable()
.max_h(px(300.))
.max_w(px(300.))
})
})
}
}

View File

@@ -541,9 +541,15 @@ impl Element for TextElement {
let mut bounds = bounds;
let (display_text, text_color) = if is_empty {
(Rope::from(placeholder.as_str()), cx.theme().text_muted)
(
Rope::from_str_small(placeholder.as_str()),
cx.theme().text_muted,
)
} else if state.masked {
(Rope::from("*".repeat(text.chars_count())), cx.theme().text)
(
Rope::from_str_small("*".repeat(text.chars_count()).as_str()),
cx.theme().text,
)
} else {
(text.clone(), cx.theme().text)
};

View File

@@ -328,7 +328,7 @@ impl InputState {
Self {
focus_handle: focus_handle.clone(),
text: "".into(),
text: Rope::default(),
text_wrapper: TextWrapper::new(
text_style.font(),
text_style.font_size.to_pixels(window.rem_size()),
@@ -718,7 +718,7 @@ impl InputState {
/// Set the default value of the input field.
pub fn default_value(mut self, value: impl Into<SharedString>) -> Self {
let text: SharedString = value.into();
self.text = Rope::from(text.as_str());
self.text = Rope::from_str_small(text.as_str());
self.text_wrapper.set_default_text(&self.text);
self
}
@@ -2099,7 +2099,9 @@ impl EntityInputHandler for InputState {
.unwrap_or(self.selected_range.into());
let old_text = self.text.clone();
self.text.replace(range.clone(), new_text);
let executor = cx.background_executor();
self.text.replace(range.clone(), new_text, executor);
let mut new_offset = (range.start + new_text.len()).min(self.text.len());
@@ -2113,7 +2115,7 @@ impl EntityInputHandler for InputState {
if !self.mask_pattern.is_none() {
let mask_text = self.mask_pattern.mask(&pending_text);
self.text = Rope::from(mask_text.as_str());
self.text = Rope::from_str_small(mask_text.as_str());
let new_text_len =
(new_text.len() + mask_text.len()).saturating_sub(pending_text.len());
new_offset = (range.start + new_text_len).min(mask_text.len());
@@ -2121,8 +2123,13 @@ impl EntityInputHandler for InputState {
}
self.push_history(&old_text, &range, new_text);
self.text_wrapper
.update(&self.text, &range, &Rope::from(new_text), false, cx);
self.text_wrapper.update(
&self.text,
&range,
&Rope::from_str_small(new_text),
false,
cx,
);
self.selected_range = (new_offset..new_offset).into();
self.ime_marked_range.take();
self.update_preferred_column();
@@ -2154,7 +2161,9 @@ impl EntityInputHandler for InputState {
.unwrap_or(self.selected_range.into());
let old_text = self.text.clone();
self.text.replace(range.clone(), new_text);
let executor = cx.background_executor();
self.text.replace(range.clone(), new_text, executor);
if self.mode.is_single_line() {
let pending_text = self.text.to_string();
@@ -2165,8 +2174,13 @@ impl EntityInputHandler for InputState {
}
self.push_history(&old_text, &range, new_text);
self.text_wrapper
.update(&self.text, &range, &Rope::from(new_text), false, cx);
self.text_wrapper.update(
&self.text,
&range,
&Rope::from_str_small(new_text),
false,
cx,
);
if new_text.is_empty() {
// Cancel selection, when cancel IME input.
self.selected_range = (range.start..range.start).into();

View File

@@ -17,7 +17,6 @@ pub mod checkbox;
pub mod divider;
pub mod dock_area;
pub mod dropdown;
pub mod emoji_picker;
pub mod history;
pub mod indicator;
pub mod input;
@@ -31,7 +30,6 @@ pub mod scroll;
pub mod skeleton;
pub mod switch;
pub mod tab;
pub mod text;
pub mod tooltip;
mod event;
@@ -42,8 +40,6 @@ mod root;
mod styled;
mod window_border;
i18n::init!();
/// Initialize the UI module.
///
/// This must be called before using any of the UI components.

View File

@@ -14,13 +14,7 @@ use crate::{Selectable, StyledExt as _};
const CONTEXT: &str = "Popover";
actions!(
popover,
[
/// Action when user presses escape button
Escape
]
);
actions!(popover, [Escape]);
pub fn init(cx: &mut App) {
cx.bind_keys([KeyBinding::new("escape", Escape, Some(CONTEXT))])

View File

@@ -1,420 +0,0 @@
use std::ops::Range;
use std::sync::Arc;
use common::display::RenderedProfile;
use gpui::{
AnyElement, AnyView, App, ElementId, HighlightStyle, InteractiveText, IntoElement,
SharedString, StyledText, UnderlineStyle, Window,
};
use linkify::{LinkFinder, LinkKind};
use nostr_sdk::prelude::*;
use once_cell::sync::Lazy;
use regex::Regex;
use registry::Registry;
use theme::ActiveTheme;
use crate::actions::OpenPublicKey;
static URL_REGEX: Lazy<Regex> = Lazy::new(|| {
Regex::new(r"^(?:[a-zA-Z]+://)?([a-zA-Z0-9-]+\.)+[a-zA-Z]{2,}(:\d+)?(/.*)?$").unwrap()
});
static NOSTR_URI_REGEX: Lazy<Regex> =
Lazy::new(|| Regex::new(r"nostr:(npub|note|nprofile|nevent|naddr)[a-zA-Z0-9]+").unwrap());
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Highlight {
Link(HighlightStyle),
Nostr,
}
impl Highlight {
fn link() -> Self {
Self::Link(HighlightStyle {
underline: Some(UnderlineStyle {
thickness: 1.0.into(),
..Default::default()
}),
..Default::default()
})
}
fn nostr() -> Self {
Self::Nostr
}
}
impl From<HighlightStyle> for Highlight {
fn from(style: HighlightStyle) -> Self {
Self::Link(style)
}
}
type CustomRangeTooltipFn =
Option<Arc<dyn Fn(usize, Range<usize>, &mut Window, &mut App) -> Option<AnyView>>>;
#[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]>,
pub custom_ranges: Vec<Range<usize>>,
custom_ranges_tooltip_fn: CustomRangeTooltipFn,
}
impl RenderedText {
pub fn new(content: &str, cx: &App) -> Self {
let mut text = String::new();
let mut highlights = Vec::new();
let mut link_ranges = Vec::new();
let mut link_urls = Vec::new();
render_plain_text_mut(
content,
&mut text,
&mut highlights,
&mut link_ranges,
&mut link_urls,
cx,
);
text.truncate(text.trim_end().len());
RenderedText {
text: SharedString::from(text),
link_urls: link_urls.into(),
link_ranges,
highlights,
custom_ranges: Vec::new(),
custom_ranges_tooltip_fn: None,
}
}
pub fn set_tooltip_builder_for_custom_ranges<F>(&mut self, f: F)
where
F: Fn(usize, Range<usize>, &mut Window, &mut App) -> Option<AnyView> + 'static,
{
self.custom_ranges_tooltip_fn = Some(Arc::new(f));
}
pub fn element(&self, id: ElementId, window: &Window, cx: &App) -> AnyElement {
let link_color = cx.theme().text_accent;
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::Link(highlight) => {
// Check if this is a link highlight by seeing if it has an underline
if highlight.underline.is_some() {
// It's a link, so apply the link color
let mut link_style = *highlight;
link_style.color = Some(link_color);
link_style
} else {
*highlight
}
}
Highlight::Nostr => HighlightStyle {
color: Some(link_color),
..Default::default()
},
},
)
}),
),
)
.on_click(self.link_ranges.clone(), {
let link_urls = self.link_urls.clone();
move |ix, window, cx| {
let token = link_urls[ix].as_str();
if token.starts_with("nostr:") {
let clean_url = token.replace("nostr:", "");
let Ok(public_key) = PublicKey::parse(&clean_url) else {
log::error!("Failed to parse public key from: {clean_url}");
return;
};
window.dispatch_action(Box::new(OpenPublicKey(public_key)), cx);
} else if is_url(token) {
if !token.starts_with("http") {
cx.open_url(&format!("https://{token}"));
} else {
cx.open_url(token);
}
} else {
log::warn!("Unrecognized token {token}")
}
}
})
.tooltip({
let link_ranges = self.link_ranges.clone();
let link_urls = self.link_urls.clone();
let custom_tooltip_ranges = self.custom_ranges.clone();
let custom_tooltip_fn = self.custom_ranges_tooltip_fn.clone();
move |idx, window, cx| {
for (ix, range) in link_ranges.iter().enumerate() {
if range.contains(&idx) {
let url = &link_urls[ix];
if url.starts_with("http") {
// return Some(LinkPreview::new(url, cx));
}
// You can add custom tooltip handling for mentions here
}
}
for range in &custom_tooltip_ranges {
if range.contains(&idx) {
if let Some(f) = &custom_tooltip_fn {
return f(idx, range.clone(), window, cx);
}
}
}
None
}
})
.into_any_element()
}
}
fn render_plain_text_mut(
content: &str,
text: &mut String,
highlights: &mut Vec<(Range<usize>, Highlight)>,
link_ranges: &mut Vec<Range<usize>>,
link_urls: &mut Vec<String>,
cx: &App,
) {
// Copy the content directly
text.push_str(content);
// Initialize the link finder
let mut finder = LinkFinder::new();
finder.url_must_have_scheme(false);
finder.kinds(&[LinkKind::Url]);
// Collect all URLs
let mut url_matches: Vec<(Range<usize>, String)> = Vec::new();
for link in finder.links(content) {
let start = link.start();
let end = link.end();
let range = start..end;
let url = link.as_str().to_string();
url_matches.push((range, url));
}
// Collect all nostr entities with nostr: prefix
let mut nostr_matches: Vec<(Range<usize>, String)> = Vec::new();
for nostr_match in NOSTR_URI_REGEX.find_iter(content) {
let start = nostr_match.start();
let end = nostr_match.end();
let range = start..end;
let nostr_uri = nostr_match.as_str().to_string();
// Check if this nostr URI overlaps with any already processed URL
if !url_matches
.iter()
.any(|(url_range, _)| url_range.start < range.end && range.start < url_range.end)
{
nostr_matches.push((range, nostr_uri));
}
}
// Combine all matches for processing from end to start
let mut all_matches = Vec::new();
all_matches.extend(url_matches);
all_matches.extend(nostr_matches);
// Sort by position (end to start) to avoid changing positions when replacing text
all_matches.sort_by(|(range_a, _), (range_b, _)| range_b.start.cmp(&range_a.start));
// Process all matches
for (range, entity) in all_matches {
// Handle URL token
if is_url(&entity) {
// Add underline highlight
highlights.push((range.clone(), Highlight::link()));
// Make it clickable
link_ranges.push(range);
link_urls.push(entity);
continue;
};
if let Ok(nip21) = Nip21::parse(&entity) {
match nip21 {
Nip21::Pubkey(public_key) => {
render_pubkey(
public_key,
text,
&range,
highlights,
link_ranges,
link_urls,
cx,
);
}
Nip21::Profile(nip19_profile) => {
render_pubkey(
nip19_profile.public_key,
text,
&range,
highlights,
link_ranges,
link_urls,
cx,
);
}
Nip21::EventId(event_id) => {
render_bech32(
event_id.to_bech32().unwrap(),
text,
&range,
highlights,
link_ranges,
link_urls,
);
}
Nip21::Event(nip19_event) => {
render_bech32(
nip19_event.to_bech32().unwrap(),
text,
&range,
highlights,
link_ranges,
link_urls,
);
}
Nip21::Coordinate(nip19_coordinate) => {
render_bech32(
nip19_coordinate.to_bech32().unwrap(),
text,
&range,
highlights,
link_ranges,
link_urls,
);
}
}
}
}
fn render_pubkey(
public_key: PublicKey,
text: &mut String,
range: &Range<usize>,
highlights: &mut Vec<(Range<usize>, Highlight)>,
link_ranges: &mut Vec<Range<usize>>,
link_urls: &mut Vec<String>,
cx: &App,
) {
let registry = Registry::read_global(cx);
let profile = registry.get_person(&public_key, cx);
let display_name = format!("@{}", profile.display_name());
// Replace token with display name
text.replace_range(range.clone(), &display_name);
// Adjust ranges
let new_length = display_name.len();
let length_diff = new_length as isize - (range.end - range.start) as isize;
// New range for the replacement
let new_range = range.start..(range.start + new_length);
// Add highlight for the profile name
highlights.push((new_range.clone(), Highlight::nostr()));
// Make it clickable
link_ranges.push(new_range);
link_urls.push(format!("nostr:{}", profile.public_key().to_hex()));
// Adjust subsequent ranges if needed
if length_diff != 0 {
adjust_ranges(highlights, link_ranges, range.end, length_diff);
}
}
fn render_bech32(
bech32: String,
text: &mut String,
range: &Range<usize>,
highlights: &mut Vec<(Range<usize>, Highlight)>,
link_ranges: &mut Vec<Range<usize>>,
link_urls: &mut Vec<String>,
) {
let njump_url = format!("https://njump.me/{bech32}");
// Create a shortened display format for the URL
let shortened_entity = format_shortened_entity(&bech32);
let display_text = format!("https://njump.me/{shortened_entity}");
// Replace the original entity with the shortened display version
text.replace_range(range.clone(), &display_text);
// Adjust the ranges
let new_length = display_text.len();
let length_diff = new_length as isize - (range.end - range.start) as isize;
// New range for the replacement
let new_range = range.start..(range.start + new_length);
// Add underline highlight
highlights.push((new_range.clone(), Highlight::link()));
// Make it clickable
link_ranges.push(new_range);
link_urls.push(njump_url);
// Adjust subsequent ranges if needed
if length_diff != 0 {
adjust_ranges(highlights, link_ranges, range.end, length_diff);
}
}
}
/// Check if a string is a URL
fn is_url(s: &str) -> bool {
URL_REGEX.is_match(s)
}
/// Format a bech32 entity with ellipsis and last 4 characters
fn format_shortened_entity(entity: &str) -> String {
let prefix_end = entity.find('1').unwrap_or(0);
if prefix_end > 0 && entity.len() > prefix_end + 5 {
let prefix = &entity[0..=prefix_end]; // Include the '1'
let suffix = &entity[entity.len() - 4..]; // Last 4 chars
format!("{prefix}...{suffix}")
} else {
entity.to_string()
}
}
// Helper function to adjust ranges when text length changes
fn adjust_ranges(
highlights: &mut [(Range<usize>, Highlight)],
link_ranges: &mut [Range<usize>],
position: usize,
length_diff: isize,
) {
// Adjust highlight ranges
for (range, _) in highlights.iter_mut() {
if range.start > position {
range.start = (range.start as isize + length_diff) as usize;
range.end = (range.end as isize + length_diff) as usize;
}
}
// Adjust link ranges
for range in link_ranges.iter_mut() {
if range.start > position {
range.start = (range.start as isize + length_diff) as usize;
range.end = (range.end as isize + length_diff) as usize;
}
}
}