Detailed changes
@@ -2301,6 +2301,7 @@ dependencies = [
"collections",
"db",
"editor",
+ "emojis",
"extensions_ui",
"feedback",
"futures 0.3.28",
@@ -3281,6 +3282,7 @@ dependencies = [
"copilot",
"ctor",
"db",
+ "emojis",
"env_logger",
"futures 0.3.28",
"fuzzy",
@@ -3349,6 +3351,15 @@ dependencies = [
"zeroize",
]
+[[package]]
+name = "emojis"
+version = "0.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4ee61eb945bff65ee7d19d157d39c67c33290ff0742907413fd5eefd29edc979"
+dependencies = [
+ "phf",
+]
+
[[package]]
name = "encode_unicode"
version = "0.3.6"
@@ -207,6 +207,7 @@ ctor = "0.2.6"
core-foundation = { version = "0.9.3" }
core-foundation-sys = "0.8.6"
derive_more = "0.99.17"
+emojis = "0.6.1"
env_logger = "0.9"
futures = "0.3"
git2 = { version = "0.15", default-features = false }
@@ -211,6 +211,11 @@
// Default width of the channels panel.
"default_width": 240
},
+ "message_editor": {
+ // Whether to automatically replace emoji shortcodes with emoji characters.
+ // For example: typing `:wave:` gets replaced with `👋`.
+ "auto_replace_emoji_shortcode": true
+ },
"notification_panel": {
// Whether to show the collaboration panel button in the status bar.
"button": true,
@@ -37,6 +37,7 @@ clock.workspace = true
collections.workspace = true
db.workspace = true
editor.workspace = true
+emojis.workspace = true
extensions_ui.workspace = true
feedback.workspace = true
futures.workspace = true
@@ -3,7 +3,7 @@ use channel::{ChannelMembership, ChannelStore, MessageParams};
use client::{ChannelId, UserId};
use collections::{HashMap, HashSet};
use editor::{AnchorRangeExt, CompletionProvider, Editor, EditorElement, EditorStyle};
-use fuzzy::StringMatchCandidate;
+use fuzzy::{StringMatch, StringMatchCandidate};
use gpui::{
AsyncWindowContext, FocusableView, FontStyle, FontWeight, HighlightStyle, IntoElement, Model,
Render, SharedString, Task, TextStyle, View, ViewContext, WeakView, WhiteSpace,
@@ -16,10 +16,12 @@ use lazy_static::lazy_static;
use parking_lot::RwLock;
use project::search::SearchQuery;
use settings::Settings;
-use std::{sync::Arc, time::Duration};
+use std::{ops::Range, sync::Arc, time::Duration};
use theme::ThemeSettings;
use ui::{prelude::*, UiTextSize};
+use crate::panel_settings::MessageEditorSettings;
+
const MENTIONS_DEBOUNCE_INTERVAL: Duration = Duration::from_millis(50);
lazy_static! {
@@ -86,6 +88,11 @@ impl MessageEditor {
editor.set_soft_wrap_mode(SoftWrap::EditorWidth, cx);
editor.set_use_autoclose(false);
editor.set_completion_provider(Box::new(MessageEditorCompletionProvider(this)));
+ editor.set_auto_replace_emoji_shortcode(
+ MessageEditorSettings::get_global(cx)
+ .auto_replace_emoji_shortcode
+ .unwrap_or_default(),
+ );
});
let buffer = editor
@@ -96,6 +103,16 @@ impl MessageEditor {
.expect("message editor must be singleton");
cx.subscribe(&buffer, Self::on_buffer_event).detach();
+ cx.observe_global::<settings::SettingsStore>(|view, cx| {
+ view.editor.update(cx, |editor, cx| {
+ editor.set_auto_replace_emoji_shortcode(
+ MessageEditorSettings::get_global(cx)
+ .auto_replace_emoji_shortcode
+ .unwrap_or_default(),
+ )
+ })
+ })
+ .detach();
let markdown = language_registry.language_for_name("Markdown");
cx.spawn(|_, mut cx| async move {
@@ -219,6 +236,101 @@ impl MessageEditor {
end_anchor: Anchor,
cx: &mut ViewContext<Self>,
) -> Task<Result<Vec<Completion>>> {
+ if let Some((start_anchor, query, candidates)) =
+ self.collect_mention_candidates(buffer, end_anchor, cx)
+ {
+ if !candidates.is_empty() {
+ return cx.spawn(|_, cx| async move {
+ Ok(Self::resolve_completions_for_candidates(
+ &cx,
+ query.as_str(),
+ &candidates,
+ start_anchor..end_anchor,
+ Self::completion_for_mention,
+ )
+ .await)
+ });
+ }
+ }
+
+ if let Some((start_anchor, query, candidates)) =
+ self.collect_emoji_candidates(buffer, end_anchor, cx)
+ {
+ if !candidates.is_empty() {
+ return cx.spawn(|_, cx| async move {
+ Ok(Self::resolve_completions_for_candidates(
+ &cx,
+ query.as_str(),
+ candidates,
+ start_anchor..end_anchor,
+ Self::completion_for_emoji,
+ )
+ .await)
+ });
+ }
+ }
+
+ Task::ready(Ok(vec![]))
+ }
+
+ async fn resolve_completions_for_candidates(
+ cx: &AsyncWindowContext,
+ query: &str,
+ candidates: &[StringMatchCandidate],
+ range: Range<Anchor>,
+ completion_fn: impl Fn(&StringMatch) -> (String, CodeLabel),
+ ) -> Vec<Completion> {
+ let matches = fuzzy::match_strings(
+ &candidates,
+ &query,
+ true,
+ 10,
+ &Default::default(),
+ cx.background_executor().clone(),
+ )
+ .await;
+
+ matches
+ .into_iter()
+ .map(|mat| {
+ let (new_text, label) = completion_fn(&mat);
+ Completion {
+ old_range: range.clone(),
+ new_text,
+ label,
+ documentation: None,
+ server_id: LanguageServerId(0), // TODO: Make this optional or something?
+ lsp_completion: Default::default(), // TODO: Make this optional or something?
+ }
+ })
+ .collect()
+ }
+
+ fn completion_for_mention(mat: &StringMatch) -> (String, CodeLabel) {
+ let label = CodeLabel {
+ filter_range: 1..mat.string.len() + 1,
+ text: format!("@{}", mat.string),
+ runs: Vec::new(),
+ };
+ (mat.string.clone(), label)
+ }
+
+ fn completion_for_emoji(mat: &StringMatch) -> (String, CodeLabel) {
+ let emoji = emojis::get_by_shortcode(&mat.string).unwrap();
+ let label = CodeLabel {
+ filter_range: 1..mat.string.len() + 1,
+ text: format!(":{}: {}", mat.string, emoji),
+ runs: Vec::new(),
+ };
+ (emoji.to_string(), label)
+ }
+
+ fn collect_mention_candidates(
+ &mut self,
+ buffer: &Model<Buffer>,
+ end_anchor: Anchor,
+ cx: &mut ViewContext<Self>,
+ ) -> Option<(Anchor, String, Vec<StringMatchCandidate>)> {
let end_offset = end_anchor.to_offset(buffer.read(cx));
let Some(query) = buffer.update(cx, |buffer, _| {
@@ -232,9 +344,9 @@ impl MessageEditor {
}
query.push(ch);
}
- return None;
+ None
}) else {
- return Task::ready(Ok(vec![]));
+ return None;
};
let start_offset = end_offset - query.len();
@@ -258,33 +370,59 @@ impl MessageEditor {
char_bag: user.chars().collect(),
})
.collect::<Vec<_>>();
- cx.spawn(|_, cx| async move {
- let matches = fuzzy::match_strings(
- &candidates,
- &query,
- true,
- 10,
- &Default::default(),
- cx.background_executor().clone(),
- )
- .await;
- Ok(matches
- .into_iter()
- .map(|mat| Completion {
- old_range: start_anchor..end_anchor,
- new_text: mat.string.clone(),
- label: CodeLabel {
- filter_range: 1..mat.string.len() + 1,
- text: format!("@{}", mat.string),
- runs: Vec::new(),
- },
- documentation: None,
- server_id: LanguageServerId(0), // TODO: Make this optional or something?
- lsp_completion: Default::default(), // TODO: Make this optional or something?
- })
- .collect())
- })
+ Some((start_anchor, query, candidates))
+ }
+
+ fn collect_emoji_candidates(
+ &mut self,
+ buffer: &Model<Buffer>,
+ end_anchor: Anchor,
+ cx: &mut ViewContext<Self>,
+ ) -> Option<(Anchor, String, &'static [StringMatchCandidate])> {
+ lazy_static! {
+ static ref EMOJI_FUZZY_MATCH_CANDIDATES: Vec<StringMatchCandidate> = {
+ let emojis = emojis::iter()
+ .flat_map(|s| s.shortcodes())
+ .map(|emoji| StringMatchCandidate {
+ id: 0,
+ string: emoji.to_string(),
+ char_bag: emoji.chars().collect(),
+ })
+ .collect::<Vec<_>>();
+ emojis
+ };
+ }
+
+ let end_offset = end_anchor.to_offset(buffer.read(cx));
+
+ let Some(query) = buffer.update(cx, |buffer, _| {
+ let mut query = String::new();
+ for ch in buffer.reversed_chars_at(end_offset).take(100) {
+ if ch == ':' {
+ let next_char = buffer
+ .reversed_chars_at(end_offset - query.len() - 1)
+ .next();
+ // Ensure we are at the start of the message or that the previous character is a whitespace
+ if next_char.is_none() || next_char.unwrap().is_whitespace() {
+ return Some(query.chars().rev().collect::<String>());
+ }
+ break;
+ }
+ if ch.is_whitespace() || !ch.is_ascii() {
+ break;
+ }
+ query.push(ch);
+ }
+ None
+ }) else {
+ return None;
+ };
+
+ let start_offset = end_offset - query.len() - 1;
+ let start_anchor = buffer.read(cx).anchor_before(start_offset);
+
+ Some((start_anchor, query, &EMOJI_FUZZY_MATCH_CANDIDATES))
}
async fn find_mentions(
@@ -465,6 +603,8 @@ mod tests {
editor::init(cx);
client::init(&client, cx);
channel::init(&client, user_store, cx);
+
+ MessageEditorSettings::register(cx);
});
let language_registry = Arc::new(LanguageRegistry::test());
@@ -16,6 +16,7 @@ use gpui::{
actions, point, AppContext, GlobalPixels, Pixels, PlatformDisplay, Size, Task, WindowBounds,
WindowContext, WindowKind, WindowOptions,
};
+use panel_settings::MessageEditorSettings;
pub use panel_settings::{
ChatPanelSettings, CollaborationPanelSettings, NotificationPanelSettings,
};
@@ -31,6 +32,7 @@ pub fn init(app_state: &Arc<AppState>, cx: &mut AppContext) {
CollaborationPanelSettings::register(cx);
ChatPanelSettings::register(cx);
NotificationPanelSettings::register(cx);
+ MessageEditorSettings::register(cx);
vcs_menu::init(cx);
collab_titlebar_item::init(cx);
@@ -42,6 +42,15 @@ pub struct PanelSettingsContent {
pub default_width: Option<f32>,
}
+#[derive(Clone, Default, Serialize, Deserialize, JsonSchema, Debug)]
+pub struct MessageEditorSettings {
+ /// Whether to automatically replace emoji shortcodes with emoji characters.
+ /// For example: typing `:wave:` gets replaced with `👋`.
+ ///
+ /// Default: false
+ pub auto_replace_emoji_shortcode: Option<bool>,
+}
+
impl Settings for CollaborationPanelSettings {
const KEY: Option<&'static str> = Some("collaboration_panel");
type FileContent = PanelSettingsContent;
@@ -77,3 +86,15 @@ impl Settings for NotificationPanelSettings {
Self::load_via_json_merge(default_value, user_values)
}
}
+
+impl Settings for MessageEditorSettings {
+ const KEY: Option<&'static str> = Some("message_editor");
+ type FileContent = MessageEditorSettings;
+ fn load(
+ default_value: &Self::FileContent,
+ user_values: &[&Self::FileContent],
+ _: &mut gpui::AppContext,
+ ) -> anyhow::Result<Self> {
+ Self::load_via_json_merge(default_value, user_values)
+ }
+}
@@ -36,6 +36,7 @@ collections.workspace = true
convert_case = "0.6.0"
copilot.workspace = true
db.workspace = true
+emojis.workspace = true
futures.workspace = true
fuzzy.workspace = true
git.workspace = true
@@ -425,6 +425,7 @@ pub struct Editor {
editor_actions: Vec<Box<dyn Fn(&mut ViewContext<Self>)>>,
show_copilot_suggestions: bool,
use_autoclose: bool,
+ auto_replace_emoji_shortcode: bool,
custom_context_menu: Option<
Box<
dyn 'static
@@ -1539,6 +1540,7 @@ impl Editor {
use_modal_editing: mode == EditorMode::Full,
read_only: false,
use_autoclose: true,
+ auto_replace_emoji_shortcode: false,
leader_peer_id: None,
remote_id: None,
hover_state: Default::default(),
@@ -1829,6 +1831,10 @@ impl Editor {
self.use_autoclose = autoclose;
}
+ pub fn set_auto_replace_emoji_shortcode(&mut self, auto_replace: bool) {
+ self.auto_replace_emoji_shortcode = auto_replace;
+ }
+
pub fn set_show_copilot_suggestions(&mut self, show_copilot_suggestions: bool) {
self.show_copilot_suggestions = show_copilot_suggestions;
}
@@ -2505,6 +2511,47 @@ impl Editor {
}
}
+ if self.auto_replace_emoji_shortcode
+ && selection.is_empty()
+ && text.as_ref().ends_with(':')
+ {
+ if let Some(possible_emoji_short_code) =
+ Self::find_possible_emoji_shortcode_at_position(&snapshot, selection.start)
+ {
+ if !possible_emoji_short_code.is_empty() {
+ if let Some(emoji) = emojis::get_by_shortcode(&possible_emoji_short_code) {
+ let emoji_shortcode_start = Point::new(
+ selection.start.row,
+ selection.start.column - possible_emoji_short_code.len() as u32 - 1,
+ );
+
+ // Remove shortcode from buffer
+ edits.push((
+ emoji_shortcode_start..selection.start,
+ "".to_string().into(),
+ ));
+ new_selections.push((
+ Selection {
+ id: selection.id,
+ start: snapshot.anchor_after(emoji_shortcode_start),
+ end: snapshot.anchor_before(selection.start),
+ reversed: selection.reversed,
+ goal: selection.goal,
+ },
+ 0,
+ ));
+
+ // Insert emoji
+ let selection_start_anchor = snapshot.anchor_after(selection.start);
+ new_selections.push((selection.map(|_| selection_start_anchor), 0));
+ edits.push((selection.start..selection.end, emoji.to_string().into()));
+
+ continue;
+ }
+ }
+ }
+ }
+
// If not handling any auto-close operation, then just replace the selected
// text with the given input and move the selection to the end of the
// newly inserted text.
@@ -2588,6 +2635,32 @@ impl Editor {
});
}
+ fn find_possible_emoji_shortcode_at_position(
+ snapshot: &MultiBufferSnapshot,
+ position: Point,
+ ) -> Option<String> {
+ let mut chars = Vec::new();
+ let mut found_colon = false;
+ for char in snapshot.reversed_chars_at(position).take(100) {
+ // Found a possible emoji shortcode in the middle of the buffer
+ if found_colon && char.is_whitespace() {
+ chars.reverse();
+ return Some(chars.iter().collect());
+ }
+ if char.is_whitespace() || !char.is_ascii() {
+ return None;
+ }
+ if char == ':' {
+ found_colon = true;
+ } else {
+ chars.push(char);
+ }
+ }
+ // Found a possible emoji shortcode at the beginning of the buffer
+ chars.reverse();
+ Some(chars.iter().collect())
+ }
+
pub fn newline(&mut self, _: &Newline, cx: &mut ViewContext<Self>) {
self.transact(cx, |this, cx| {
let (edits, selection_fixup_info): (Vec<_>, Vec<_>) = {
@@ -5171,6 +5171,59 @@ async fn test_delete_autoclose_pair(cx: &mut gpui::TestAppContext) {
});
}
+#[gpui::test]
+async fn test_auto_replace_emoji_shortcode(cx: &mut gpui::TestAppContext) {
+ init_test(cx, |_| {});
+
+ let language = Arc::new(Language::new(
+ LanguageConfig::default(),
+ Some(tree_sitter_rust::language()),
+ ));
+
+ let buffer = cx.new_model(|cx| {
+ Buffer::new(0, BufferId::new(cx.entity_id().as_u64()).unwrap(), "")
+ .with_language(language, cx)
+ });
+ let buffer = cx.new_model(|cx| MultiBuffer::singleton(buffer, cx));
+ let (editor, cx) = cx.add_window_view(|cx| build_editor(buffer, cx));
+ editor
+ .condition::<crate::EditorEvent>(cx, |view, cx| !view.buffer.read(cx).is_parsing(cx))
+ .await;
+
+ _ = editor.update(cx, |editor, cx| {
+ editor.set_auto_replace_emoji_shortcode(true);
+
+ editor.handle_input("Hello ", cx);
+ editor.handle_input(":wave", cx);
+ assert_eq!(editor.text(cx), "Hello :wave".unindent());
+
+ editor.handle_input(":", cx);
+ assert_eq!(editor.text(cx), "Hello 👋".unindent());
+
+ editor.handle_input(" :smile", cx);
+ assert_eq!(editor.text(cx), "Hello 👋 :smile".unindent());
+
+ editor.handle_input(":", cx);
+ assert_eq!(editor.text(cx), "Hello 👋 😄".unindent());
+
+ editor.handle_input(":1:", cx);
+ assert_eq!(editor.text(cx), "Hello 👋 😄:1:".unindent());
+
+ // Ensure shortcode does not get replaced when it is part of a word
+ editor.handle_input(" Test:wave:", cx);
+ assert_eq!(editor.text(cx), "Hello 👋 😄:1: Test:wave:".unindent());
+
+ editor.set_auto_replace_emoji_shortcode(false);
+
+ // Ensure shortcode does not get replaced when auto replace is off
+ editor.handle_input(" :wave:", cx);
+ assert_eq!(
+ editor.text(cx),
+ "Hello 👋 😄:1: Test:wave: :wave:".unindent()
+ );
+ });
+}
+
#[gpui::test]
async fn test_snippets(cx: &mut gpui::TestAppContext) {
init_test(cx, |_| {});