use ratatui::Frame;
use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyEventKind};
use ratatui::layout::{Constraint, Flex, Layout, Rect};
use ratatui::style::Style;
use ratatui::text::{Line, Span};
use ratatui::widgets::{Clear, Paragraph};
use crate::shared::theme::Palette;
const EMOJIS: &[&str] = &[
"๐", "๐", "๐", "๐", "๐", "๐
", "๐", "๐คฃ", "๐", "๐", "๐", "๐", "๐ค", "๐", "๐", "๐ฅณ", "๐ด", "๐ญ", "๐ข", "๐ก", "๐คฏ", "๐", "๐", "๐", "๐", "๐", "๐", "๐ช", "๐ค", "๐", "๐คท", "๐", "๐ค", "๐", "๐ฅ", "โจ", "๐", "๐ฏ", "โ
", "โ", "โญ", "๐", "๐", "๐ฏ", ];
const COLS: usize = 11;
const CELL_W: u16 = 4;
#[derive(Debug, Clone, PartialEq)]
pub enum EmojiPickerAction {
None,
Cancel,
Pick(String),
}
pub struct EmojiPickerState {
selected: usize,
}
impl Default for EmojiPickerState {
fn default() -> Self {
Self::new()
}
}
impl EmojiPickerState {
pub fn new() -> Self {
Self { selected: 0 }
}
pub fn with_selected(idx: usize) -> Self {
Self {
selected: idx.min(EMOJIS.len() - 1),
}
}
pub fn selected(&self) -> usize {
self.selected
}
fn selected_emoji(&self) -> String {
EMOJIS[self.selected.min(EMOJIS.len() - 1)].to_string()
}
pub fn on_key(&mut self, key: KeyEvent) -> EmojiPickerAction {
if key.kind != KeyEventKind::Press {
return EmojiPickerAction::None;
}
match key.code {
KeyCode::Esc => EmojiPickerAction::Cancel,
KeyCode::Enter => EmojiPickerAction::Pick(self.selected_emoji()),
KeyCode::Left => {
self.selected = self.selected.saturating_sub(1);
EmojiPickerAction::None
}
KeyCode::Right => {
self.selected = (self.selected + 1).min(EMOJIS.len() - 1);
EmojiPickerAction::None
}
KeyCode::Up => {
if self.selected >= COLS {
self.selected -= COLS;
}
EmojiPickerAction::None
}
KeyCode::Down => {
if self.selected + COLS < EMOJIS.len() {
self.selected += COLS;
}
EmojiPickerAction::None
}
_ => EmojiPickerAction::None,
}
}
pub fn render(
&self,
frame: &mut Frame,
area: Rect,
palette: &Palette,
loc: &'static crate::shared::i18n::Locale,
) {
let rows = EMOJIS.len().div_ceil(COLS) as u16;
let width = COLS as u16 * CELL_W + 2; let popup = centered_rect(width, rows + 2, area);
frame.render_widget(Clear, popup);
let block = palette
.panel(format!("โบ {}", loc.t("ui.emoji.title")), true)
.title_bottom(Line::from(Span::styled(
loc.t("ui.emoji.footer"),
palette.muted_style(),
)));
let inner = block.inner(popup);
frame.render_widget(block, popup);
let selected = self.selected.min(EMOJIS.len() - 1);
let lines: Vec<Line> = EMOJIS
.chunks(COLS)
.enumerate()
.map(|(r, row)| {
let spans: Vec<Span> = row
.iter()
.enumerate()
.map(|(c, emoji)| {
let idx = r * COLS + c;
let cell = format!(" {emoji} ");
if idx == selected {
Span::styled(cell, Style::new().fg(palette.text).bg(palette.keycap_bg))
} else {
Span::styled(cell, Style::new().fg(palette.text))
}
})
.collect();
Line::from(spans)
})
.collect();
frame.render_widget(Paragraph::new(lines), inner);
}
}
fn centered_rect(width: u16, height: u16, area: Rect) -> Rect {
let [h] = Layout::horizontal([Constraint::Length(width.min(area.width))])
.flex(Flex::Center)
.areas(area);
let [v] = Layout::vertical([Constraint::Length(height.min(area.height))])
.flex(Flex::Center)
.areas(h);
v
}
#[cfg(test)]
mod tests {
use super::*;
use ratatui::crossterm::event::KeyModifiers;
fn ru() -> &'static crate::shared::i18n::Locale {
crate::shared::i18n::locale(crate::shared::i18n::Lang::Ru)
}
fn key(code: KeyCode) -> KeyEvent {
KeyEvent::new(code, KeyModifiers::NONE)
}
#[test]
fn enter_picks_first_by_default() {
let mut s = EmojiPickerState::new();
assert_eq!(
s.on_key(key(KeyCode::Enter)),
EmojiPickerAction::Pick(EMOJIS[0].to_string())
);
}
#[test]
fn arrows_navigate_grid() {
let mut s = EmojiPickerState::new();
assert_eq!(s.on_key(key(KeyCode::Right)), EmojiPickerAction::None);
assert_eq!(s.selected, 1);
s.on_key(key(KeyCode::Down));
assert_eq!(s.selected, 1 + COLS);
s.on_key(key(KeyCode::Up));
assert_eq!(s.selected, 1);
s.on_key(key(KeyCode::Left));
assert_eq!(s.selected, 0);
}
#[test]
fn navigation_clamps_at_bounds() {
let mut s = EmojiPickerState::new();
s.on_key(key(KeyCode::Left));
s.on_key(key(KeyCode::Up));
assert_eq!(s.selected, 0);
for _ in 0..EMOJIS.len() + 5 {
s.on_key(key(KeyCode::Right));
}
assert_eq!(s.selected, EMOJIS.len() - 1);
let last = s.selected;
s.on_key(key(KeyCode::Down));
assert_eq!(s.selected, last);
}
#[test]
fn with_selected_restores_and_clamps() {
assert_eq!(EmojiPickerState::with_selected(5).selected(), 5);
assert_eq!(
EmojiPickerState::with_selected(9999).selected(),
EMOJIS.len() - 1
);
}
#[test]
fn esc_cancels() {
let mut s = EmojiPickerState::new();
assert_eq!(s.on_key(key(KeyCode::Esc)), EmojiPickerAction::Cancel);
}
#[test]
fn emoji_list_is_width2_without_vs16() {
use unicode_width::UnicodeWidthStr;
for (i, emoji) in EMOJIS.iter().enumerate() {
assert!(
!emoji.chars().any(|c| c == '\u{FE0F}'),
"EMOJIS[{i}] = {emoji:?} โ a VS16 cluster: use a supplementary-plane \
variant instead (e.g. โค๏ธโ๐, โ๏ธโ๐ค)"
);
assert_eq!(
emoji.width(),
CELL_W as usize - 2,
"EMOJIS[{i}] = {emoji:?} โ width isn't 2 columns, the grid will drift"
);
}
}
#[test]
fn grid_is_full_rows() {
assert_eq!(EMOJIS.len() % COLS, 0);
}
#[test]
fn sentinel_repaint_never_writes_into_second_half_of_wide_glyph() {
use ratatui::Terminal;
use ratatui::backend::TestBackend;
use unicode_width::UnicodeWidthStr;
let picker = EmojiPickerState::new();
let mut term = Terminal::new(TestBackend::new(60, 10)).unwrap();
term.draw(|f| picker.render(f, f.area(), &Palette::default(), ru()))
.unwrap();
let painted = term.backend().buffer().clone();
let mut sentinel = painted.clone();
crate::shared::ui::prime_full_redraw(&mut sentinel);
for (x, y, _) in sentinel.diff(&painted) {
if x == 0 {
continue;
}
let left = painted[(x - 1, y)].symbol();
assert!(
left.width() < 2,
"the update at ({x},{y}) targets the second half of a wide glyph \
{left:?} โ it would print without MoveTo and shift the rest of the row"
);
}
}
#[test]
fn upstream_repaints_styled_tail_on_close_but_not_on_selection_move() {
use ratatui::Terminal;
use ratatui::backend::TestBackend;
use ratatui::buffer::Buffer;
use ratatui::style::Color;
let render = |state: &EmojiPickerState| {
let mut term = Terminal::new(TestBackend::new(60, 10)).unwrap();
term.draw(|f| state.render(f, f.area(), &Palette::default(), ru()))
.unwrap();
term.backend().buffer().clone()
};
let painted = render(&EmojiPickerState::new());
let area = painted.area;
let (x, y) = (area.top()..area.bottom())
.flat_map(|y| (area.left()..area.right()).map(move |x| (x, y)))
.find(|&(x, y)| painted[(x, y)].symbol() == EMOJIS[0])
.expect("emoji not found in the rendered grid");
assert_ne!(
painted[(x, y)].bg,
Color::Reset,
"the selection carries a background"
);
assert_eq!(
painted[(x + 1, y)].symbol(),
" ",
"the wide glyph's trailing cell is reset to default"
);
let closed = Buffer::empty(area);
assert!(
painted
.diff(&closed)
.iter()
.any(|&(ux, uy, _)| (ux, uy) == (x + 1, y)),
"ratatui โฅ 0.1.2 must send the tail of a disappeared styled glyph"
);
let moved = render(&EmojiPickerState::with_selected(1));
assert_eq!(
moved[(x, y)].bg,
Color::Reset,
"the cell lost the selection background"
);
assert!(
!painted
.diff(&moved)
.iter()
.any(|&(ux, uy, _)| (ux, uy) == (x + 1, y)),
"upstream closed the selection-shift case too โ the full redraw can be removed"
);
let tail_after_full_redraw = |next: &Buffer| {
let mut sentinel = painted.clone();
crate::shared::ui::prime_full_redraw(&mut sentinel);
sentinel
.diff(next)
.iter()
.any(|&(ux, uy, _)| (ux, uy) == (x + 1, y))
};
assert!(
tail_after_full_redraw(&closed),
"a full redraw must rewrite the trailing cell on close"
);
assert!(
!tail_after_full_redraw(&moved),
"the sentinel doesn't write into the second half of a wide glyph (ratatui#2651)"
);
}
#[test]
fn render_does_not_panic() {
use ratatui::Terminal;
use ratatui::backend::TestBackend;
let s = EmojiPickerState::new();
for (w, h) in [(80u16, 24u16), (20, 6)] {
let mut term = Terminal::new(TestBackend::new(w, h)).unwrap();
term.draw(|f| s.render(f, f.area(), &Palette::default(), ru()))
.unwrap();
}
}
}