Files
Ely-GPUI-Components/src/forms/glyphs.rs
T
ZacharyZhang-NY 1700a72753 Messaging · Stickers and GIFs: a sticker picker and a GIF picker
StickerPicker shows stickers a pack at a time, chosen above with a
segmented control; a search spans every pack and keeps the stickers
whose names hold it. Arrows move, Enter or a press picks. It reuses the
glyph grid, now forms::picks, which takes its columns and cell size and
titles from the host, so emoji, icons and stickers share one searchable
grid; forms::glyphs keeps the emoji and icon pickers.

GifPicker packs host GIFs in two columns, each moving, under a search
it owns; the host hears the trimmed words only when they change, and
categories fill the search while it is empty. Waiting, it shows tiles
in GIF shapes; finding nothing, it says so. Each GIF is a Tab stop that
a press, Enter or Space picks.

The share toggle now shows its state, as the microphone and camera do.
The gallery ships line-drawn stickers and small GIFs made from its own
pictures.
2026-09-26 23:00:15 -04:00

217 lines
6.7 KiB
Rust

use std::rc::Rc;
use emojis::Group;
use gpui::{App, ElementId, IntoElement, RenderOnce, SharedString, Window};
use super::picks::{Grid, Pick, finder, grid, rows};
use crate::{
primitives::{Icon, IconName},
theme::{ActiveTheme, ControlSize, IconSize, TextSize},
typography::Emoji,
};
/// Glyphs across an emoji or icon grid.
const COLUMNS: usize = 8;
type OnIcon = Rc<dyn Fn(IconName, &mut Window, &mut App)>;
type OnEmoji = Rc<dyn Fn(&str, &mut Window, &mut App)>;
/// A searchable grid of the library's icons.
#[derive(IntoElement)]
pub struct IconPicker {
id: ElementId,
selected: Option<IconName>,
on_change: Option<OnIcon>,
}
impl IconPicker {
pub fn new(id: impl Into<ElementId>) -> Self {
Self {
id: id.into(),
selected: None,
on_change: None,
}
}
pub fn selected(mut self, icon: IconName) -> Self {
self.selected = Some(icon);
self
}
pub fn on_change(
mut self,
handler: impl Fn(IconName, &mut Window, &mut App) + 'static,
) -> Self {
self.on_change = Some(Rc::new(handler));
self
}
}
impl RenderOnce for IconPicker {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let finder = finder(&self.id, "Search icons", window, cx);
let query = finder.read(cx).query.clone();
let found: Rc<Vec<IconName>> = Rc::new(
IconName::ALL
.iter()
.copied()
.filter(|icon| icon.name().replace('-', " ").contains(&query))
.collect(),
);
let (painted, picked) = (found.clone(), found.clone());
let (id, on_change) = (self.id.clone(), self.on_change);
let pick: Pick = Rc::new(move |ix, window, cx| {
let icon = picked[ix];
log::info!("icon picker {id:?}: {}", icon.name());
if let Some(on_change) = &on_change {
on_change(icon, window, cx);
}
});
let fg = cx.theme().colors.fg;
grid(
Grid {
id: self.id,
names: Rc::new(found.iter().map(|icon| icon.name().into()).collect()),
rows: Rc::new(rows([(None, found.len())], COLUMNS)),
selected: self
.selected
.and_then(|icon| found.iter().position(|f| *f == icon)),
draw: Rc::new(move |ix, _| {
Icon::new(painted[ix])
.size(IconSize::Md)
.color(fg)
.into_any_element()
}),
pick,
none: "No icons match",
columns: COLUMNS,
cell: cx.theme().control_height(ControlSize::Lg),
},
&finder,
window,
cx,
)
}
}
/// Emoji whose name or shortcode holds `query`, a lowercase string; all for an empty one.
pub(crate) fn emoji_found(query: &str) -> Vec<&'static emojis::Emoji> {
emojis::iter()
.filter(|emoji| {
query.is_empty()
|| emoji.name().to_lowercase().contains(query)
|| emoji.shortcodes().any(|code| code.contains(query))
})
.collect()
}
fn title(group: Group) -> &'static str {
match group {
Group::SmileysAndEmotion => "Smileys & Emotion",
Group::PeopleAndBody => "People & Body",
Group::AnimalsAndNature => "Animals & Nature",
Group::FoodAndDrink => "Food & Drink",
Group::TravelAndPlaces => "Travel & Places",
Group::Activities => "Activities",
Group::Objects => "Objects",
Group::Symbols => "Symbols",
Group::Flags => "Flags",
}
}
/// A searchable grid of emoji, grouped as Unicode groups them.
#[derive(IntoElement)]
pub struct EmojiPicker {
id: ElementId,
selected: Option<SharedString>,
on_change: Option<OnEmoji>,
}
impl EmojiPicker {
pub fn new(id: impl Into<ElementId>) -> Self {
Self {
id: id.into(),
selected: None,
on_change: None,
}
}
pub fn selected(mut self, emoji: impl Into<SharedString>) -> Self {
self.selected = Some(emoji.into());
self
}
pub fn on_change(mut self, handler: impl Fn(&str, &mut Window, &mut App) + 'static) -> Self {
self.on_change = Some(Rc::new(handler));
self
}
}
impl RenderOnce for EmojiPicker {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let finder = finder(&self.id, "Search emoji", window, cx);
let query = finder.read(cx).query.clone();
let found = emoji_found(&query);
let layout = if query.is_empty() {
rows(
Group::iter().map(|group| (Some(title(group).into()), group.emojis().count())),
COLUMNS,
)
} else {
rows([(None, found.len())], COLUMNS)
};
let found = Rc::new(found);
let (painted, picked) = (found.clone(), found.clone());
let (id, on_change) = (self.id.clone(), self.on_change);
let pick: Pick = Rc::new(move |ix, window, cx| {
let emoji = picked[ix];
log::info!("emoji picker {id:?}: {}", emoji.name());
if let Some(on_change) = &on_change {
on_change(emoji.as_str(), window, cx);
}
});
grid(
Grid {
id: self.id,
names: Rc::new(found.iter().map(|emoji| emoji.name().into()).collect()),
rows: Rc::new(layout),
selected: self.selected.and_then(|glyph| {
found
.iter()
.position(|emoji| emoji.as_str() == glyph.as_ref())
}),
draw: Rc::new(move |ix, _| {
Emoji::new(painted[ix].as_str())
.size(TextSize::Lg)
.into_any_element()
}),
pick,
none: "No emoji match",
columns: COLUMNS,
cell: cx.theme().control_height(ControlSize::Lg),
},
&finder,
window,
cx,
)
}
}
#[cfg(test)]
mod tests {
use super::emoji_found;
#[test]
fn emoji_are_found_whatever_the_case_of_their_names() {
let glyphs = |query| {
emoji_found(query)
.into_iter()
.map(|emoji| emoji.as_str())
.collect::<Vec<_>>()
};
assert!(glyphs("united states").contains(&"🇺🇸"));
assert!(glyphs("sparkles").contains(&"✨"));
assert_eq!(glyphs("").len(), emojis::iter().count());
}
}