Skip to content

Commit 34e2313

Browse files
tmmampagent
andauthored
feat(zbugs-rune): emoji picker with search and skin tones (#155)
Reactions open a picker of every emoji up to Emoji 16.0, searchable by name and shortcode, in the skin tone kept in the emojiSkinTone user pref, as the web app's picker. Segmented controls fill their width with width(None), so the selected segment's highlight lines up. Amp-Thread-ID: https://ampcode.com/threads/T-01a1c85e-fd3c-72ba-98dd-f4767a00e73b Co-authored-by: Amp <amp@ampcode.com>
1 parent 0136f91 commit 34e2313

5 files changed

Lines changed: 172 additions & 40 deletions

File tree

‎apps/zbugs-rune/Cargo.lock‎

Lines changed: 32 additions & 4 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎apps/zbugs-rune/Cargo.toml‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@ tokio = { version = "1", default-features = false, features = [
2525
"sync",
2626
"time",
2727
] }
28+
# The emoji picker's emoji, names, shortcodes, and skin tones.
29+
emojis = "0.9"
2830
# Fetches avatars and the images in issues and comments, over rustls.
2931
ureq = { version = "3", default-features = false, features = ["rustls"] }
3032
# The login token goes in the Keychain, the Credential Manager, or the

‎apps/zbugs-rune/e2e.sh‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,8 @@ cat > "$out/script.json" <<EOF
4545
{"t": 12.2, "type": "type", "text": "$body"},
4646
{"t": 14.0, "type": "tap", "key": "comment-button"},
4747
{"t": 15.0, "type": "tap", "key": "add-reaction-$issue-trigger"},
48-
{"t": 15.5, "type": "tap", "key": "add-reaction-$issue-item6"},
48+
{"t": 15.3, "type": "type", "text": "rocket"},
49+
{"t": 15.8, "type": "tap", "key": "emoji-🚀"},
4950
{"t": 16.5, "type": "tap", "key": "labels-trigger"},
5051
{"t": 17.0, "type": "tap", "key": "labels-item2"},
5152
{"t": 17.5, "type": "key", "name": "Escape"},

‎apps/zbugs-rune/src/main.rs‎

Lines changed: 39 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -20,14 +20,14 @@ use zbugs_model::args::{
2020
EditCommentArgs, IdField, IssueDetailArgs, IssueKey, IssueLabelArgs, IssueListCountArgs,
2121
IssueListV2Args, LabelsOrderByNameArgs, ListContextParams, NotificationType,
2222
NotificationUpdateArgs, PageDirection, ProjectArgs, RemoveLabelArgs, SortDirection, SortField,
23-
UpdateIssueArgs, UsersFilter, UsersForProjectArgs, ViewStateArgs,
23+
UpdateIssueArgs, UserPrefArgs, UsersFilter, UsersForProjectArgs, ViewStateArgs,
2424
};
2525
use zbugs_model::mutators::{
2626
ADD_COMMENT, ADD_EMOJI_TO_COMMENT, ADD_EMOJI_TO_ISSUE, ADD_LABEL, CREATE_AND_ADD_LABEL,
2727
CREATE_ISSUE, DELETE_ISSUE, DELETE_ISSUES, EDIT_COMMENT, REMOVE_COMMENT, REMOVE_EMOJI,
28-
REMOVE_LABEL, SET_VIEW_STATE, UPDATE_ISSUE, UPDATE_NOTIFICATION,
28+
REMOVE_LABEL, SET_USER_PREF, SET_VIEW_STATE, UPDATE_ISSUE, UPDATE_NOTIFICATION,
2929
};
30-
use zbugs_model::schema::{Comment, Emoji, Issue, IssueVisibility, Label, Project, User};
30+
use zbugs_model::schema::{Comment, Emoji, Issue, IssueVisibility, Label, Project, User, UserPref};
3131
use zbugs_model::{AuthData, Context, MutateRequest, ZERO_PROJECT_NAME, make_id, queries};
3232
use zero_client::client::ConnectionState;
3333
use zero_client::ivm::ResultType;
@@ -81,19 +81,6 @@ pub struct Filter {
8181
pub labels: Vec<String>,
8282
}
8383

84-
/// The reactions the emoji menu offers, with their CLDR names, which the
85-
/// web app shows on hover.
86-
pub const REACTIONS: [(&str, &str); 8] = [
87-
("👍", "thumbs up"),
88-
("👎", "thumbs down"),
89-
("😄", "grinning face with smiling eyes"),
90-
("🎉", "party popper"),
91-
("😕", "confused face"),
92-
("❤️", "red heart"),
93-
("🚀", "rocket"),
94-
("👀", "eyes"),
95-
];
96-
9784
/// A dropdown on the issue page.
9885
#[derive(Clone, Debug, PartialEq, Eq)]
9986
pub enum Picker {
@@ -166,6 +153,10 @@ pub struct App {
166153
pub comments: Vec<Comment>,
167154
comments_limit: i64,
168155
pub comment: TextEdit,
156+
/// The emoji picker's search, and the skin tone it shows, as the web
157+
/// picker keeps it in the `emojiSkinTone` user pref (0 is the default).
158+
pub emoji_search: TextEdit,
159+
pub skin_tone: u8,
169160
/// The comment being edited in place, and its text.
170161
pub editing_comment: Option<String>,
171162
pub comment_edit: TextEdit,
@@ -191,6 +182,9 @@ pub struct App {
191182
pub github_login: Option<login::LoopbackLogin>,
192183
}
193184

185+
/// The user pref the web app keeps the emoji picker's skin tone in.
186+
const SKIN_TONE_PREF: &str = "emojiSkinTone";
187+
194188
/// The choices of the issue page's Visibility and Notifications menus.
195189
pub const VISIBILITY: [&str; 2] = ["Public", "Internal"];
196190
pub const NOTIFICATIONS: [&str; 2] = ["Subscribed", "Unsubscribed"];
@@ -277,6 +271,8 @@ impl App {
277271
comments: Vec::new(),
278272
comments_limit: COMMENTS_PAGE,
279273
comment: TextEdit::new(""),
274+
emoji_search: TextEdit::new(""),
275+
skin_tone: 0,
280276
editing_comment: None,
281277
comment_edit: TextEdit::new(""),
282278
confirm: None,
@@ -349,8 +345,17 @@ impl App {
349345
move |auth: &Context| queries::USER.call(&sub, auth.as_ref()),
350346
|s, me, _, _| s.me = me,
351347
);
348+
self.sync.watch(
349+
"skin-tone",
350+
|auth: &Context| queries::USER_PREF.call(&SKIN_TONE_PREF.to_owned(), auth.as_ref()),
351+
|s, pref: Option<UserPref>, _, _| {
352+
s.skin_tone = pref.and_then(|p| p.value.parse().ok()).unwrap_or(0);
353+
},
354+
);
352355
} else {
353356
self.sync.unwatch("me");
357+
self.sync.unwatch("skin-tone");
358+
self.skin_tone = 0;
354359
}
355360
self.watch_list();
356361
if let Some(id) = self.selected.clone() {
@@ -828,7 +833,22 @@ impl App {
828833
self.mutate(UPDATE_NOTIFICATION.with(&args));
829834
}
830835

836+
/// Shows emoji in this skin tone (0 to 5, light to dark after the
837+
/// default), and keeps it for the user, as the web picker does.
838+
pub fn set_skin_tone(&mut self, tone: u8) {
839+
self.skin_tone = tone;
840+
let args = UserPrefArgs {
841+
key: SKIN_TONE_PREF.to_owned(),
842+
value: tone.to_string(),
843+
};
844+
self.mutate(SET_USER_PREF.with(&args));
845+
}
846+
831847
pub fn toggle_picker(&mut self, picker: Picker) {
848+
if matches!(picker, Picker::Emoji(_)) {
849+
self.emoji_search = TextEdit::new("");
850+
self.emoji_search.focused = true;
851+
}
832852
self.picker = (self.picker.as_ref() != Some(&picker)).then_some(picker);
833853
}
834854

@@ -889,10 +909,9 @@ impl App {
889909
self.mutate(REMOVE_EMOJI.with(&id));
890910
return;
891911
}
892-
let annotation = REACTIONS
893-
.iter()
894-
.find(|(v, _)| *v == value)
895-
.map(|(_, a)| *a)
912+
// Its CLDR name, as the web picker sends it.
913+
let annotation = emojis::get(value)
914+
.map(emojis::Emoji::name)
896915
.or_else(|| {
897916
let e = self.reactions(subject_id).iter().find(|e| e.value == value);
898917
e.map(|e| e.annotation.as_str())

‎apps/zbugs-rune/src/view.rs‎

Lines changed: 97 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -6,16 +6,16 @@ use std::sync::OnceLock;
66

77
use rune::{Cx, El, GrowthSpec, ScrollSpec, col, el, row, text};
88
use rune_kit::{
9-
Button, ButtonVariant, Checkbox, Dialog, Kit, Markdown, Menu, MenuItem, SegmentedControl,
10-
Select, TextArea, TextInput,
9+
Button, ButtonVariant, Checkbox, Dialog, Kit, Markdown, Menu, MenuItem, Popover,
10+
SegmentedControl, Select, TextArea, TextInput,
1111
};
1212
use zbugs_model::args::{SortDirection, SortField};
1313
use zbugs_model::schema::{Comment, Issue, IssueVisibility, Label, User};
1414
use zero_client::client::ConnectionState;
1515
use zero_client::ivm::ResultType;
1616

1717
use crate::images;
18-
use crate::{App, Confirm, NOTIFICATIONS, Picker, REACTIONS, Sheet, Status, VISIBILITY};
18+
use crate::{App, Confirm, NOTIFICATIONS, Picker, Sheet, Status, VISIBILITY};
1919

2020
/// The rows' estimated height, for the virtual list.
2121
const ROW: f32 = 58.0;
@@ -93,8 +93,8 @@ fn sidebar(s: &App, k: &Kit, layer: &mut Vec<El>) -> El {
9393
s.filter.status = Status::ALL[i];
9494
s.list_changed();
9595
})
96+
.width(None)
9697
.build(k)
97-
.w_full()
9898
.key("status"),
9999
)
100100
.child(label_text(k, "Sort"))
@@ -111,8 +111,8 @@ fn sidebar(s: &App, k: &Kit, layer: &mut Vec<El>) -> El {
111111
};
112112
s.list_changed();
113113
})
114+
.width(None)
114115
.build(k)
115-
.w_full()
116116
.key("sort"),
117117
)
118118
.child(label_text(k, "Order"))
@@ -129,8 +129,8 @@ fn sidebar(s: &App, k: &Kit, layer: &mut Vec<El>) -> El {
129129
};
130130
s.list_changed();
131131
})
132+
.width(None)
132133
.build(k)
133-
.w_full()
134134
.key("order"),
135135
);
136136
// Shortcuts for the creator and assignee filters, by the user's login,
@@ -1012,26 +1012,108 @@ fn reactions(s: &App, subject_id: &str, k: &Kit, layer: &mut Vec<El>) -> El {
10121012
}
10131013
let picker = Picker::Emoji(subject_id.to_owned());
10141014
let open = s.picker.as_ref() == Some(&picker);
1015-
let mut menu = Menu::new(format!("add-reaction-{subject_id}"))
1015+
let mut popover = Popover::new(format!("add-reaction-{subject_id}"))
10161016
.trigger(
10171017
Button::new("+ 🙂")
10181018
.variant(ButtonVariant::Ghost)
1019-
.expanded(open)
10201019
.on_press(move |s: &mut App| s.toggle_picker(picker.clone()))
10211020
.build(k)
10221021
.key("add-reaction"),
10231022
)
1024-
.min_width(280.0)
1023+
.label("Emoji")
10251024
.open(open)
10261025
.on_close(|s: &mut App| s.picker = None);
1027-
for (value, name) in REACTIONS {
1028-
let subject = subject_id.to_owned();
1029-
menu = menu.item(
1030-
MenuItem::new(format!("{value} {name}"))
1031-
.on_select(move |s: &mut App| s.toggle_reaction(&subject, value)),
1026+
if open {
1027+
popover = popover.child(emoji_picker(s, subject_id, k));
1028+
}
1029+
r.child(popover.build(k, layer))
1030+
}
1031+
1032+
/// The skin tones the picker offers, as the web picker's: the default,
1033+
/// then light to dark.
1034+
const SKIN_TONES: [emojis::SkinTone; 6] = [
1035+
emojis::SkinTone::Default,
1036+
emojis::SkinTone::Light,
1037+
emojis::SkinTone::MediumLight,
1038+
emojis::SkinTone::Medium,
1039+
emojis::SkinTone::MediumDark,
1040+
emojis::SkinTone::Dark,
1041+
];
1042+
1043+
/// The most a picker shows at once: the search narrows the rest.
1044+
const EMOJI_SHOWN: usize = 96;
1045+
1046+
/// The newest emoji the system fonts draw (Apple Color Emoji and Noto Color
1047+
/// Emoji in 2026): later ones would show as empty boxes.
1048+
const EMOJI_NEWEST: emojis::EmojiVersion = emojis::EmojiVersion::new(16, 0);
1049+
1050+
/// Every emoji, searchable by name and GitHub shortcode, in the user's skin
1051+
/// tone, as the web app's emoji picker.
1052+
fn emoji_picker(s: &App, subject_id: &str, k: &Kit) -> El {
1053+
let query = s.emoji_search.text.trim().to_lowercase();
1054+
let tone = SKIN_TONES[usize::from(s.skin_tone).min(SKIN_TONES.len() - 1)];
1055+
let hands: Vec<String> = SKIN_TONES
1056+
.iter()
1057+
.map(|t| {
1058+
emojis::get("✋")
1059+
.and_then(|e| e.with_skin_tone(*t))
1060+
.map_or_else(String::new, |e| e.as_str().to_owned())
1061+
})
1062+
.collect();
1063+
let hands: Vec<&str> = hands.iter().map(String::as_str).collect();
1064+
let matches = emojis::iter()
1065+
.filter(|e| {
1066+
e.emoji_version() <= EMOJI_NEWEST
1067+
&& (query.is_empty()
1068+
|| e.name().contains(&query)
1069+
|| e.shortcodes().any(|c| c.contains(&query)))
1070+
})
1071+
.take(EMOJI_SHOWN);
1072+
let mut grid = row().key("emoji-grid").w_full().flex_wrap();
1073+
for e in matches {
1074+
let e = e.with_skin_tone(tone).unwrap_or(e);
1075+
let (subject, value) = (subject_id.to_owned(), e.as_str());
1076+
grid = grid.child(
1077+
text(value)
1078+
.key(format!("emoji-{value}"))
1079+
.role("button")
1080+
.label(e.name())
1081+
.help(e.name())
1082+
.font(k.ty.body)
1083+
.w(32.0)
1084+
.h(32.0)
1085+
.center()
1086+
.radius(6.0)
1087+
.on_press(move |s: &mut App| s.toggle_reaction(&subject, value)),
10321088
);
10331089
}
1034-
r.child(menu.build(k, layer))
1090+
col()
1091+
.w(288.0)
1092+
.gap(8.0)
1093+
.child(
1094+
TextInput::new(&s.emoji_search)
1095+
.placeholder("Search")
1096+
.on_edit(|s: &mut App| &mut s.emoji_search)
1097+
.build(k)
1098+
.key("emoji-search"),
1099+
)
1100+
.child(
1101+
SegmentedControl::new(&hands, Some(usize::from(s.skin_tone)))
1102+
.on_pick(|s: &mut App, i, _| s.set_skin_tone(i as u8))
1103+
.width(None)
1104+
.build(k)
1105+
.key("skin-tone"),
1106+
)
1107+
.child(
1108+
col()
1109+
.key("emoji-scroll")
1110+
.w_full()
1111+
.h(232.0)
1112+
.scroll(ScrollSpec {
1113+
stick_bottom: false,
1114+
})
1115+
.child(grid),
1116+
)
10351117
}
10361118

10371119
fn composer(s: &App, k: &Kit) -> El {

0 commit comments

Comments
 (0)