🔒 Repository is read-only – file editing is disabled.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187
//! `EmojiChooser` — wybór emoji z siatki (GTK4, w duchu libLuny).
//!
//! Prosta, samowystarczalna siatka glifów emoji: wybór jednego z nich. To nie jest pełny
//! `GtkEmojiChooser` (bez kategorii i wyszukiwania) — libLuna daje zwięzłą paletę znaków.
use libluna_core::{Point, Rect, Theme};
use crate::ctx::PaintCtx;
/// Siatka emoji.
#[derive(Debug, Clone, Default)]
pub struct EmojiChooser {
emojis: Vec<String>,
columns: usize,
selected: usize,
hovered: Option<usize>,
}
impl EmojiChooser {
/// Krótka, domyślna paleta emoji (do szybkiego wyboru).
pub fn default_palette() -> Vec<String> {
[
"😀", "😄", "😉", "😍", "🤔", "😎", "🥳", "😴", "👍", "👏", "🙏", "💪", "🔥", "✨",
"🎉", "❤️", "⭐", "✅", "⚠️", "💡", "📁", "📄", "🔍", "🔒",
]
.into_iter()
.map(String::from)
.collect()
}
/// Siatka z listy znaków (4 kolumny, wybrany pierwszy).
pub fn new(emojis: Vec<String>) -> Self {
Self {
emojis,
columns: 4,
selected: 0,
hovered: None,
}
}
pub fn emojis(&self) -> &[String] {
&self.emojis
}
pub fn columns(&self) -> usize {
self.columns
}
pub fn set_columns(&mut self, columns: usize) {
self.columns = columns.max(1);
}
pub fn selected(&self) -> usize {
self.selected
}
pub fn selected_emoji(&self) -> Option<&str> {
self.emojis.get(self.selected).map(String::as_str)
}
pub fn set_selected(&mut self, index: usize) {
if index < self.emojis.len() {
self.selected = index;
}
}
pub fn set_hovered(&mut self, hovered: Option<usize>) {
self.hovered = hovered.filter(|i| *i < self.emojis.len());
}
/// Prostokąty komórek (kwadraty wypełniające szerokość, wierszami).
pub fn cells(&self, rect: Rect, theme: &Theme) -> Vec<Rect> {
if self.emojis.is_empty() {
return Vec::new();
}
let cols = self.columns.max(1);
let gap = theme.spacing.sm;
let side = ((rect.width() - gap * (cols as f64 - 1.0)) / cols as f64).max(0.0);
self.emojis
.iter()
.enumerate()
.map(|(i, _)| {
let row = i / cols;
let col = i % cols;
Rect::from_xywh(
rect.x() + col as f64 * (side + gap),
rect.y() + row as f64 * (side + gap),
side,
side,
)
})
.collect()
}
pub fn index_at(&self, rect: Rect, theme: &Theme, p: Point) -> Option<usize> {
self.cells(rect, theme).iter().position(|c| c.contains(p))
}
/// Obsługuje klik: wybiera emoji. Zwraca `true`, gdy wybór się zmienił.
pub fn click_at(&mut self, rect: Rect, theme: &Theme, p: Point) -> bool {
match self.index_at(rect, theme, p) {
Some(i) if i != self.selected => {
self.selected = i;
true
}
_ => false,
}
}
/// Wysokość potrzebna na całą siatkę.
pub fn height(&self, width: f64, theme: &Theme) -> f64 {
if self.emojis.is_empty() {
return 0.0;
}
let cols = self.columns.max(1);
let gap = theme.spacing.sm;
let side = ((width - gap * (cols as f64 - 1.0)) / cols as f64).max(0.0);
let rows = self.emojis.len().div_ceil(cols) as f64;
rows * side + (rows - 1.0) * gap
}
/// Rysuje siatkę emoji; wybrana dostaje tło akcentu.
pub fn paint(&self, ctx: &mut PaintCtx, rect: Rect) {
let theme = *ctx.theme;
let size = theme.typography.title.size;
for (i, cell) in self.cells(rect, &theme).iter().enumerate() {
let inset = cell.deflate(libluna_core::Insets::splat(2.0));
if i == self.selected {
ctx.scene
.fill_rounded_rect(inset, theme.radius.small, theme.colors.accent);
} else if self.hovered == Some(i) {
ctx.scene
.fill_rounded_rect(inset, theme.radius.small, theme.colors.shade);
}
ctx.label_center(
cell.center().x,
cell.center().y,
&self.emojis[i],
size,
libluna_core::style::weight::NORMAL,
theme.colors.text,
);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn chooser() -> EmojiChooser {
EmojiChooser::new(EmojiChooser::default_palette())
}
#[test]
fn cells_grid_row_major() {
let theme = Theme::light();
let c = chooser();
let rect = Rect::from_xywh(0.0, 0.0, 200.0, 200.0);
let cells = c.cells(rect, &theme);
assert_eq!(cells.len(), 24);
assert_eq!(cells[4].x(), cells[0].x());
assert!(cells[4].y() > cells[0].y());
}
#[test]
fn click_selects_emoji() {
let theme = Theme::light();
let rect = Rect::from_xywh(0.0, 0.0, 200.0, 200.0);
let mut c = chooser();
let cells = c.cells(rect, &theme);
assert!(c.click_at(rect, &theme, cells[1].center()));
assert_eq!(c.selected_emoji(), Some("😄"));
assert!(!c.click_at(rect, &theme, cells[1].center()));
assert!(!c.click_at(rect, &theme, Point::new(999.0, 999.0)));
}
#[test]
fn height_matches_rows() {
let theme = Theme::light();
let c = chooser(); // 24 emoji, 4 kolumny → 6 wierszy
let w = 200.0;
let side = (w - 3.0 * theme.spacing.sm) / 4.0;
let expected = 6.0 * side + 5.0 * theme.spacing.sm;
assert!((c.height(w, &theme) - expected).abs() < 1e-9);
}
}