|
| 1 | +//! The reaction picker: every emoji by group, or the ones a search finds, |
| 2 | +//! in the user's skin tone, as zbugs' emoji picker. |
| 3 | +
|
| 4 | +use gpui_kit::component::input::{Input, InputEvent, InputState}; |
| 5 | +use gpui_kit::component::tooltip::Tooltip; |
| 6 | +use gpui_kit::component::*; |
| 7 | +use gpui_kit::*; |
| 8 | +use std::rc::Rc; |
| 9 | + |
| 10 | +const COLUMNS: usize = 9; |
| 11 | +const CELL: f32 = 36.; |
| 12 | + |
| 13 | +/// What the picker does with the emoji the user picks, and its name. |
| 14 | +pub type OnPick = Rc<dyn Fn(&str, &str, &mut Window, &mut App)>; |
| 15 | + |
| 16 | +enum Row { |
| 17 | + Group(&'static str), |
| 18 | + Emoji(Vec<&'static emojis::Emoji>), |
| 19 | +} |
| 20 | + |
| 21 | +pub struct EmojiPicker { |
| 22 | + pub query: Entity<InputState>, |
| 23 | + tone: Option<emojis::SkinTone>, |
| 24 | + rows: Rc<Vec<Row>>, |
| 25 | + on_pick: OnPick, |
| 26 | + scroll: UniformListScrollHandle, |
| 27 | + _search: Subscription, |
| 28 | +} |
| 29 | + |
| 30 | +impl EmojiPicker { |
| 31 | + /// `tone` as the `emojiSkinTone` pref keeps it: 0 for none, 1 to 5. |
| 32 | + pub fn new(tone: u32, on_pick: OnPick, window: &mut Window, cx: &mut Context<Self>) -> Self { |
| 33 | + use emojis::SkinTone::*; |
| 34 | + let tone = [Light, MediumLight, Medium, MediumDark, Dark] |
| 35 | + .get((tone as usize).wrapping_sub(1)) |
| 36 | + .copied(); |
| 37 | + let query = cx.new(|cx| InputState::new(window, cx).placeholder("Search emoji")); |
| 38 | + let search = cx.subscribe_in(&query, window, |this, query, event, window, cx| { |
| 39 | + match event { |
| 40 | + InputEvent::Change => { |
| 41 | + this.rows = Rc::new(rows(&query.read(cx).value())); |
| 42 | + this.scroll.scroll_to_item(0, ScrollStrategy::Top); |
| 43 | + cx.notify(); |
| 44 | + } |
| 45 | + // Enter picks the first emoji the search found. |
| 46 | + InputEvent::PressEnter { .. } => { |
| 47 | + let first = this.rows.iter().find_map(|row| match row { |
| 48 | + Row::Emoji(row) => row.first().copied(), |
| 49 | + Row::Group(_) => None, |
| 50 | + }); |
| 51 | + if let Some(emoji) = first { |
| 52 | + this.pick(emoji, window, cx); |
| 53 | + } |
| 54 | + } |
| 55 | + _ => {} |
| 56 | + } |
| 57 | + }); |
| 58 | + Self { |
| 59 | + query, |
| 60 | + tone, |
| 61 | + rows: Rc::new(rows("")), |
| 62 | + on_pick, |
| 63 | + scroll: UniformListScrollHandle::new(), |
| 64 | + _search: search, |
| 65 | + } |
| 66 | + } |
| 67 | + |
| 68 | + fn toned(&self, emoji: &'static emojis::Emoji) -> &'static emojis::Emoji { |
| 69 | + self.tone |
| 70 | + .and_then(|tone| emoji.with_skin_tone(tone)) |
| 71 | + .unwrap_or(emoji) |
| 72 | + } |
| 73 | + |
| 74 | + fn pick(&self, emoji: &'static emojis::Emoji, window: &mut Window, cx: &mut App) { |
| 75 | + (self.on_pick)(self.toned(emoji).as_str(), emoji.name(), window, cx); |
| 76 | + } |
| 77 | +} |
| 78 | + |
| 79 | +/// The groups of every emoji, or, for a search, the emoji with a word in |
| 80 | +/// their name or shortcodes that starts with each word of `query`. |
| 81 | +fn rows(query: &str) -> Vec<Row> { |
| 82 | + // The emoji macOS 14 draws; Apple's font lags newer Unicode releases. |
| 83 | + let drawn = |e: &&emojis::Emoji| e.unicode_version() <= emojis::UnicodeVersion::new(15, 0); |
| 84 | + let query = query.trim().to_lowercase(); |
| 85 | + let chunks = |found: Vec<&'static emojis::Emoji>| { |
| 86 | + found |
| 87 | + .chunks(COLUMNS) |
| 88 | + .map(|row| Row::Emoji(row.to_vec())) |
| 89 | + .collect::<Vec<_>>() |
| 90 | + }; |
| 91 | + if query.is_empty() { |
| 92 | + return emojis::Group::iter() |
| 93 | + .flat_map(|group| { |
| 94 | + let found = group.emojis().filter(drawn).collect(); |
| 95 | + std::iter::once(Row::Group(group_name(group))).chain(chunks(found)) |
| 96 | + }) |
| 97 | + .collect(); |
| 98 | + } |
| 99 | + let found = emojis::iter() |
| 100 | + .filter(drawn) |
| 101 | + .filter(|e| { |
| 102 | + let words = e |
| 103 | + .name() |
| 104 | + .split([' ', '-', ':', ',']) |
| 105 | + .chain(e.shortcodes().flat_map(|s| s.split(['_', '-']))) |
| 106 | + .map(str::to_lowercase) |
| 107 | + .collect::<Vec<_>>(); |
| 108 | + query |
| 109 | + .split_whitespace() |
| 110 | + .all(|q| words.iter().any(|w| w.starts_with(q))) |
| 111 | + }) |
| 112 | + .collect(); |
| 113 | + chunks(found) |
| 114 | +} |
| 115 | + |
| 116 | +fn group_name(group: emojis::Group) -> &'static str { |
| 117 | + use emojis::Group::*; |
| 118 | + match group { |
| 119 | + SmileysAndEmotion => "Smileys and emotion", |
| 120 | + PeopleAndBody => "People and body", |
| 121 | + AnimalsAndNature => "Animals and nature", |
| 122 | + FoodAndDrink => "Food and drink", |
| 123 | + TravelAndPlaces => "Travel and places", |
| 124 | + Activities => "Activities", |
| 125 | + Objects => "Objects", |
| 126 | + Symbols => "Symbols", |
| 127 | + Flags => "Flags", |
| 128 | + } |
| 129 | +} |
| 130 | + |
| 131 | +impl Render for EmojiPicker { |
| 132 | + fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement { |
| 133 | + let rows = self.rows.clone(); |
| 134 | + let list = if rows.is_empty() { |
| 135 | + div() |
| 136 | + .h(px(CELL * 8.)) |
| 137 | + .p_2() |
| 138 | + .text_color(cx.theme().muted_foreground) |
| 139 | + .child("No emoji found") |
| 140 | + .into_any_element() |
| 141 | + } else { |
| 142 | + uniform_list( |
| 143 | + "emoji", |
| 144 | + rows.len(), |
| 145 | + cx.processor(move |this, range: std::ops::Range<usize>, _, cx| { |
| 146 | + range |
| 147 | + .map(|ix| match &rows[ix] { |
| 148 | + Row::Group(name) => div() |
| 149 | + .h(px(CELL)) |
| 150 | + .flex() |
| 151 | + .items_end() |
| 152 | + .pb_1() |
| 153 | + .text_xs() |
| 154 | + .text_color(cx.theme().muted_foreground) |
| 155 | + .child(*name) |
| 156 | + .into_any_element(), |
| 157 | + Row::Emoji(row) => h_flex() |
| 158 | + .h(px(CELL)) |
| 159 | + .children(row.iter().map(|&emoji| { |
| 160 | + let shown = this.toned(emoji); |
| 161 | + let name = SharedString::from(emoji.name()); |
| 162 | + div() |
| 163 | + .id(SharedString::from(format!("emoji-{}", emoji.as_str()))) |
| 164 | + .size(px(CELL)) |
| 165 | + .flex() |
| 166 | + .items_center() |
| 167 | + .justify_center() |
| 168 | + .rounded_md() |
| 169 | + .text_xl() |
| 170 | + .cursor_pointer() |
| 171 | + .hover(|s| s.bg(cx.theme().accent)) |
| 172 | + .child(shown.as_str()) |
| 173 | + .tooltip(move |window, cx| { |
| 174 | + Tooltip::new(name.clone()).build(window, cx) |
| 175 | + }) |
| 176 | + .on_click(cx.listener(move |this, _, window, cx| { |
| 177 | + this.pick(emoji, window, cx) |
| 178 | + })) |
| 179 | + })) |
| 180 | + .into_any_element(), |
| 181 | + }) |
| 182 | + .collect::<Vec<_>>() |
| 183 | + }), |
| 184 | + ) |
| 185 | + .track_scroll(&self.scroll) |
| 186 | + .h(px(CELL * 8.)) |
| 187 | + .into_any_element() |
| 188 | + }; |
| 189 | + v_flex() |
| 190 | + .gap_2() |
| 191 | + .w(px(CELL * COLUMNS as f32)) |
| 192 | + .child(Input::new(&self.query)) |
| 193 | + .child(list) |
| 194 | + } |
| 195 | +} |
0 commit comments