diff --git a/AGENTS.md b/AGENTS.md index 3bbbed0..e66753f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -107,7 +107,7 @@ Ely GPUI Component. A component library for GPUI, in light and dark. - The test platform never runs next-frame callbacks, and its text metrics are simplified: each character takes a fixed advance. Tests refresh the window in place of the display link, and click where layout does not hang on text, such as padding and slots. - An absolute element with no insets sits where it would flow: after the content in a block, inside the padding in a flex box. A canvas that measures its parent pins itself with `.top_0().left_0()`. - Keyed state lives while its element renders in consecutive frames. An overlay rendered only while open starts fresh each time. -- Long grids scroll in a `uniform_list`. gpui has no nearest scroll, so a key move up scrolls with `Top` and down with `Bottom` (`forms::glyphs`). +- Long grids scroll in a `uniform_list`. gpui has no nearest scroll, so a key move up scrolls with `Top` and down with `Bottom` (`forms::picks`). A searchable grid of picks, emoji, icons or stickers, goes through `forms::picks`'s `Grid`; `uniform_list` gives every row one height, so a grid of large cells shows one group at a time instead of titles (`messaging::StickerPicker`). - Masks reshape edits inside `TextInput::set_fit`, which sees the replaced range and the typed text. A diff after the fact cannot tell typed characters from kept ones. - Ids inside a reusable component carry its owner's id or `EntityId`, animation ids too. Twin ids share focus, click and animation state. A child keyed by its place inherits the state of what stood there before; key it by what it shows (`files::FileExplorer` keys a column by its folder). - Text wraps at the width its box had when measured. A cross-axis `max_w` comes too late, and a flex column counts wrapped text as one line. Put prose in a plain block inside a flex row with `flex_1().max_w(..)` (`feedback::states`). @@ -159,7 +159,7 @@ Ely GPUI Component. A component library for GPUI, in light and dark. - Every header, row and strip works at 280px: a name keeps its minimum width, the rest wraps below or scrolls, and nothing draws past its box. - gpui takes a scroll box's reach from its direct children's bounds, and a block child stretches to the box. Content that scrolls sideways sets its least width on that child (`tables::body::least`). - gpui turns a wheel along the other axis onto a box that scrolls one way, and the page around it scrolls too. A box inside a scrolling page keeps its wheel to its axis with `layout::on_axis`, and so does the page. -- The focus color belongs to the focus ring. A chosen or shown item takes accent, as `forms::glyphs` and `lists::ListItem` do. +- The focus color belongs to the focus ring. A chosen or shown item takes accent, as `forms::picks` and `lists::ListItem` do. - A box that shows a host's picture takes the picture's shape from the host (`ratio`), so nothing is cropped and a point given as a share lands on the picture. A focusable one keeps its focus border on an outer box, so the picture keeps that shape exactly (`media::ImageCropper`). - A default only the component can work out, such as a crop fitted to a picture it decodes, reaches the owner once through `window.defer` after render (`media::ImageUpload`). diff --git a/TASKS.md b/TASKS.md index 2e19045..94fa4fd 100644 --- a/TASKS.md +++ b/TASKS.md @@ -101,7 +101,7 @@ Per-component lines live in `tasks/`. Tags there: - [x] T28a Messaging · Channels - [x] T28b Messaging · Messages - [x] T28c Messaging · People -- [ ] T28d Messaging · Calls +- [x] T28d Messaging · Calls - [ ] T28e Messaging · Stickers and GIFs - [ ] T29 Mail - [ ] T30 Calendar @@ -336,3 +336,4 @@ Per-component lines live in `tasks/`. Tags there: | T28a | 1 | PASS | | | T28b | 1 | PASS | | | T28c | 1 | PASS | | +| T28d | 1 | PASS | | diff --git a/examples/gallery/assets/gifs/atrium-fade.gif b/examples/gallery/assets/gifs/atrium-fade.gif new file mode 100644 index 0000000..a632ecc Binary files /dev/null and b/examples/gallery/assets/gifs/atrium-fade.gif differ diff --git a/examples/gallery/assets/gifs/dunes-drift.gif b/examples/gallery/assets/gifs/dunes-drift.gif new file mode 100644 index 0000000..41a4a24 Binary files /dev/null and b/examples/gallery/assets/gifs/dunes-drift.gif differ diff --git a/examples/gallery/assets/gifs/olive-breath.gif b/examples/gallery/assets/gifs/olive-breath.gif new file mode 100644 index 0000000..3826aee Binary files /dev/null and b/examples/gallery/assets/gifs/olive-breath.gif differ diff --git a/examples/gallery/assets/gifs/stair-light.gif b/examples/gallery/assets/gifs/stair-light.gif new file mode 100644 index 0000000..88c7fad Binary files /dev/null and b/examples/gallery/assets/gifs/stair-light.gif differ diff --git a/examples/gallery/assets/stickers/arch.png b/examples/gallery/assets/stickers/arch.png new file mode 100644 index 0000000..c661d32 Binary files /dev/null and b/examples/gallery/assets/stickers/arch.png differ diff --git a/examples/gallery/assets/stickers/column.png b/examples/gallery/assets/stickers/column.png new file mode 100644 index 0000000..e3af9a2 Binary files /dev/null and b/examples/gallery/assets/stickers/column.png differ diff --git a/examples/gallery/assets/stickers/olive.png b/examples/gallery/assets/stickers/olive.png new file mode 100644 index 0000000..6a4dfa6 Binary files /dev/null and b/examples/gallery/assets/stickers/olive.png differ diff --git a/examples/gallery/assets/stickers/sprout.png b/examples/gallery/assets/stickers/sprout.png new file mode 100644 index 0000000..d388bfa Binary files /dev/null and b/examples/gallery/assets/stickers/sprout.png differ diff --git a/examples/gallery/assets/stickers/stair.png b/examples/gallery/assets/stickers/stair.png new file mode 100644 index 0000000..6c09af1 Binary files /dev/null and b/examples/gallery/assets/stickers/stair.png differ diff --git a/examples/gallery/assets/stickers/stone.png b/examples/gallery/assets/stickers/stone.png new file mode 100644 index 0000000..e185417 Binary files /dev/null and b/examples/gallery/assets/stickers/stone.png differ diff --git a/examples/gallery/assets/stickers/sun.png b/examples/gallery/assets/stickers/sun.png new file mode 100644 index 0000000..98c1aac Binary files /dev/null and b/examples/gallery/assets/stickers/sun.png differ diff --git a/examples/gallery/assets/stickers/window.png b/examples/gallery/assets/stickers/window.png new file mode 100644 index 0000000..294d4ac Binary files /dev/null and b/examples/gallery/assets/stickers/window.png differ diff --git a/examples/gallery/pages/messaging/mod.rs b/examples/gallery/pages/messaging/mod.rs index c25d542..39b237c 100644 --- a/examples/gallery/pages/messaging/mod.rs +++ b/examples/gallery/pages/messaging/mod.rs @@ -10,6 +10,7 @@ use jiff::{SignedDuration, Timestamp}; mod calls; mod messages; mod people; +mod pickers; use super::Page; use crate::{ @@ -61,6 +62,19 @@ const SCRIPT: &[Step] = &[ Step::UpAt("messaging-incoming", 24.0, 14.0), Step::Wait(300), Step::Shot("incoming-call"), + Step::Key("escape"), + Step::Wait(200), + Step::DownAt("messaging-stickers", 96.0, 105.0), + Step::UpAt("messaging-stickers", 96.0, 105.0), + Step::Wait(200), + Step::Shot("sticker-sent"), + Step::DownAt("messaging-gifs", 127.0, 48.0), + Step::UpAt("messaging-gifs", 127.0, 48.0), + Step::Wait(300), + Step::DownAt("messaging-gifs", 78.0, 120.0), + Step::UpAt("messaging-gifs", 78.0, 120.0), + Step::Wait(300), + Step::Shot("gif-sent"), ]; /// The demo's chats: which is open and what waits unread in each. @@ -252,5 +266,7 @@ fn render(window: &mut Window, cx: &mut App) -> AnyElement { .child(calls::grid(window, cx)) .child(calls::share(window, cx)) .child(calls::incoming(window, cx)) + .child(pickers::stickers(window, cx)) + .child(pickers::gifs(window, cx)) .into_any_element() } diff --git a/examples/gallery/pages/messaging/pickers.rs b/examples/gallery/pages/messaging/pickers.rs new file mode 100644 index 0000000..d942ab5 --- /dev/null +++ b/examples/gallery/pages/messaging/pickers.rs @@ -0,0 +1,211 @@ +use std::path::Path; + +use ely_gpui_component::{ + messaging::{ChatMessage, Gif, GifPicker, Gifs, Sticker, StickerPack, StickerPicker}, + primitives::Image, + theme::{ActiveTheme, Radius, TextSize}, +}; +use gpui::{ + App, ElementId, IntoElement, ObjectFit, ParentElement, SharedString, Styled, Window, div, px, +}; +use jiff::{civil::date, tz::TimeZone}; + +use crate::{ + probe::probe, + ui::{keep, section, set, specimen, specimens}, +}; + +const ATRIUM: [(&str, &str, &str); 4] = [ + ("arch", "Arch", asset!("stickers/arch.png")), + ("stair", "Stair", asset!("stickers/stair.png")), + ("column", "Column", asset!("stickers/column.png")), + ("window", "Window", asset!("stickers/window.png")), +]; + +const GARDEN: [(&str, &str, &str); 4] = [ + ("olive", "Olive", asset!("stickers/olive.png")), + ("sun", "Sun", asset!("stickers/sun.png")), + ("stone", "Stone", asset!("stickers/stone.png")), + ("sprout", "Sprout", asset!("stickers/sprout.png")), +]; + +/// The demo's GIFs: key, title, file, and width over height. +const GIFS: [(&str, &str, &str, f32); 4] = [ + ( + "stair-light", + "Light on the stair", + asset!("gifs/stair-light.gif"), + 1.5, + ), + ( + "olive-breath", + "The olive tree", + asset!("gifs/olive-breath.gif"), + 1.5, + ), + ( + "dunes-drift", + "Dunes at dusk", + asset!("gifs/dunes-drift.gif"), + 240.0 / 129.0, + ), + ( + "atrium-fade", + "The atrium, before and after", + asset!("gifs/atrium-fade.gif"), + 200.0 / 133.0, + ), +]; + +fn pack(name: &str, stickers: [(&'static str, &'static str, &'static str); 4]) -> StickerPack { + StickerPack::new( + name.to_string(), + stickers.map(|(key, title, file)| Sticker::new(key, title, Path::new(file))), + ) +} + +/// What you sent, as a message at a story time. +fn sent(id: &'static str, body: impl IntoElement) -> ChatMessage { + let at = date(2026, 9, 26) + .at(10, 20, 0, 0) + .to_zoned(TimeZone::UTC) + .expect("a UTC time") + .timestamp(); + ChatMessage::new(id, "You", at) + .zone(TimeZone::UTC) + .child(body) +} + +fn hint(text: &'static str, cx: &App) -> impl IntoElement + use<> { + let theme = cx.theme(); + div() + .text_size(theme.text_size(TextSize::Sm)) + .text_color(theme.colors.fg_muted) + .child(text) +} + +pub fn stickers(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> { + let chosen = keep("messaging-sticker", || None::<&'static str>, window, cx); + let shown = *chosen.read(cx); + let message = match shown.and_then(|key| { + ATRIUM + .iter() + .chain(GARDEN.iter()) + .find(|(each, _, _)| *each == key) + }) { + Some((key, _, file)) => sent( + "messaging-sticker-sent", + div().size(px(96.)).child( + Image::new( + ( + ElementId::from("messaging-sticker-picture"), + key.to_string(), + ), + Path::new(*file), + ) + .fit(ObjectFit::Contain) + .size_full(), + ), + ) + .into_any_element(), + None => hint("Pick a sticker to send it.", cx).into_any_element(), + }; + section( + "StickerPicker", + "Stickers a pack at a time, chosen above; a search spans every pack and keeps the stickers whose names hold it. Arrows move, and Enter or a press sends one.", + cx, + ) + .child(probe( + "messaging-stickers", + div() + .flex() + .flex_wrap() + .items_start() + .gap_6() + .child( + StickerPicker::new( + "messaging-stickers", + [pack("Atrium", ATRIUM), pack("Garden", GARDEN)], + ) + .on_pick(move |key, _, cx| { + let key = ATRIUM + .iter() + .chain(GARDEN.iter()) + .map(|(each, _, _)| *each) + .find(|each| *each == key.as_ref()) + .expect("a picked sticker is listed"); + set(&chosen, Some(key), cx) + }), + ) + .child(div().w(px(260.)).child(message)), + )) +} + +/// The GIFs whose titles hold `words`, as the host would answer. +fn found(words: &str) -> Vec { + let words = words.to_lowercase(); + GIFS.iter() + .filter(|(_, title, _, _)| title.to_lowercase().contains(&words)) + .map(|(key, title, file, ratio)| Gif::new(*key, *title, Path::new(*file), *ratio)) + .collect() +} + +pub fn gifs(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> { + let words = keep("messaging-gif-words", SharedString::default, window, cx); + let chosen = keep("messaging-gif", || None::<&'static str>, window, cx); + let results = found(&words.read(cx).clone()); + let shown = *chosen.read(cx); + let theme = cx.theme(); + let round = theme.radius(Radius::Md); + let message = match shown.and_then(|key| GIFS.iter().find(|(each, _, _, _)| *each == key)) { + Some((key, _, file, ratio)) => sent( + "messaging-gif-sent", + div().w(px(200.)).h(px(200. / ratio)).child( + Image::new( + (ElementId::from("messaging-gif-picture"), key.to_string()), + Path::new(*file), + ) + .size_full() + .rounded(round), + ), + ) + .into_any_element(), + None => hint("Pick a GIF to send it.", cx).into_any_element(), + }; + section( + "GifPicker", + "GIFs from the host in two columns packed tight, each moving. The host answers the search as it changes; while it is empty, categories fill it. Each GIF is a Tab stop that a press, Enter or Space sends.", + cx, + ) + .child(probe( + "messaging-gifs", + div() + .flex() + .flex_wrap() + .items_start() + .gap_6() + .child( + div().w(px(320.)).child( + GifPicker::new("messaging-gifs", Gifs::Found(results)) + .categories(["Stair", "Olive", "Dunes", "Atrium"]) + .on_query(move |query, _, cx| set(&words, query.clone(), cx)) + .on_pick(move |key, _, cx| { + let key = GIFS + .iter() + .map(|(each, _, _, _)| *each) + .find(|each| *each == key.as_ref()) + .expect("a picked GIF is listed"); + set(&chosen, Some(key), cx) + }), + ), + ) + .child(div().w(px(260.)).child(message)), + )) + .child(specimens().child(specimen( + "searching", + div() + .w(px(240.)) + .child(GifPicker::new("messaging-gifs-waiting", Gifs::Loading)), + cx, + ))) +} diff --git a/src/forms/glyphs.rs b/src/forms/glyphs.rs index 0f45f7e..67cbf3b 100644 --- a/src/forms/glyphs.rs +++ b/src/forms/glyphs.rs @@ -1,266 +1,21 @@ -use std::{ops::Range, rc::Rc}; +use std::rc::Rc; use emojis::Group; -use gpui::{ - AnyElement, App, Context, ElementId, Entity, InteractiveElement, IntoElement, ParentElement, - RenderOnce, ScrollStrategy, SharedString, StatefulInteractiveElement, Styled, - UniformListScrollHandle, Window, div, prelude::*, uniform_list, -}; +use gpui::{App, ElementId, IntoElement, RenderOnce, SharedString, Window}; -use super::{ - Input, TextInput, - text::{Down, Enter}, -}; +use super::picks::{Grid, Pick, finder, grid, rows}; use crate::{ - primitives::{Icon, IconName, Tooltip, tab_stop}, - theme::{ActiveTheme, ControlSize, IconSize, Radius, TextSize}, + primitives::{Icon, IconName}, + theme::{ActiveTheme, ControlSize, IconSize, TextSize}, typography::Emoji, }; +/// Glyphs across an emoji or icon grid. const COLUMNS: usize = 8; -/// A row of a glyph grid: a group's title, or a run of glyphs. -#[derive(Clone, Debug, PartialEq)] -pub(crate) enum Row { - Title(&'static str), - Glyphs(Range), -} - -/// Rows for glyph groups given as (title, count), laid in order. -pub(crate) fn rows(groups: impl IntoIterator, usize)>) -> Vec { - let (mut out, mut start) = (Vec::new(), 0); - for (title, count) in groups.into_iter().filter(|(_, count)| *count > 0) { - out.extend(title.map(Row::Title)); - let end = start + count; - while start < end { - let stop = (start + COLUMNS).min(end); - out.push(Row::Glyphs(start..stop)); - start = stop; - } - } - out -} - -fn row_of(rows: &[Row], ix: usize) -> usize { - rows.iter() - .position(|row| matches!(row, Row::Glyphs(range) if range.contains(&ix))) - .expect("every glyph sits in a row") -} - -/// The glyph an arrow key moves to from `at`: same column in the next row, or the last one there. -pub(crate) fn stepped(rows: &[Row], at: usize, key: &str) -> Option { - let row = row_of(rows, at); - let Row::Glyphs(here) = &rows[row] else { - unreachable!("row_of finds glyph rows"); - }; - let column = at - here.start; - let land = |row: &Row| match row { - Row::Glyphs(range) => Some((range.start + column).min(range.end - 1)), - Row::Title(_) => None, - }; - let last = rows - .iter() - .rev() - .find_map(|row| match row { - Row::Glyphs(range) => Some(range.end - 1), - Row::Title(_) => None, - }) - .expect("a grid with a cursor has glyphs"); - match key { - "left" => at.checked_sub(1), - "right" => (at < last).then_some(at + 1), - "up" => rows[..row].iter().rev().find_map(land), - "down" => rows[row + 1..].iter().find_map(land), - _ => None, - } -} - -/// A grid's search field, its cursor, and where its list has scrolled. -struct Finder { - search: Entity, - query: String, - cursor: usize, - scroll: UniformListScrollHandle, -} - -fn finder(id: &ElementId, hint: &'static str, window: &mut Window, cx: &mut App) -> Entity { - let finder = window.use_keyed_state( - (id.clone(), "finder"), - cx, - |window, cx: &mut Context| Finder { - search: cx.new(|cx| TextInput::new(window, cx).placeholder(hint)), - query: String::new(), - cursor: 0, - scroll: UniformListScrollHandle::new(), - }, - ); - let query = { - let search = finder.read(cx).search.clone(); - search.read(cx).text().trim().to_lowercase() - }; - if finder.read(cx).query != query { - finder.update(cx, |finder, _| { - finder.query = query; - finder.cursor = 0; - finder.scroll.scroll_to_item(0, ScrollStrategy::Top); - }); - } - finder -} - -type Pick = Rc; -type Draw = Rc AnyElement>; type OnIcon = Rc; type OnEmoji = Rc; -/// What a glyph grid shows: names for tooltips, rows, a painter, and what a pick does. -struct Grid { - id: ElementId, - names: Rc>, - rows: Rc>, - selected: Option, - draw: Draw, - pick: Pick, - none: &'static str, -} - -fn grid(grid: Grid, finder: &Entity, window: &mut Window, cx: &mut App) -> AnyElement { - let focus = tab_stop((grid.id.clone(), "grid").into(), true, window, cx); - let focused = focus.is_focused(window); - let count = grid.names.len(); - let (search, cursor, scroll) = { - let finder = finder.read(cx); - let cursor = finder.cursor.min(count.saturating_sub(1)); - (finder.search.clone(), cursor, finder.scroll.clone()) - }; - let theme = cx.theme(); - let colors = theme.colors.clone(); - let cell = theme.control_height(ControlSize::Lg); - let (into, enter, keys) = (focus.clone(), grid.pick.clone(), grid.pick.clone()); - let field = div() - .capture_action(move |_: &Down, window, cx| { - if count > 0 { - cx.stop_propagation(); - window.focus(&into); - } - }) - .capture_action(move |_: &Enter, window, cx| { - if count > 0 { - cx.stop_propagation(); - enter(cursor, window, cx); - } - }) - .child( - Input::new(&search).prefix( - Icon::new(IconName::Search) - .size(IconSize::Sm) - .color(colors.fg_subtle), - ), - ); - let body = if count == 0 { - div() - .h(theme.list_max_height()) - .pt_6() - .text_center() - .text_color(colors.fg_subtle) - .child(grid.none) - .into_any_element() - } else { - let (rows, names, draw, pick) = (grid.rows.clone(), grid.names, grid.draw, grid.pick); - let selected = grid.selected; - let (moved, key_rows, place) = (finder.clone(), grid.rows, scroll.clone()); - let clicked = finder.clone(); - let list = uniform_list((grid.id, "rows"), rows.len(), move |range, _, cx| { - let theme = cx.theme(); - rows[range] - .iter() - .map(|row| match row { - Row::Title(title) => div() - .h(cell) - .flex() - .items_end() - .pb_1() - .text_size(theme.text_size(TextSize::Xs)) - .text_color(colors.fg_subtle) - .child(*title) - .into_any_element(), - Row::Glyphs(glyphs) => div() - .flex() - .children(glyphs.clone().map(|ix| { - let (pick, name, clicked) = - (pick.clone(), names[ix].clone(), clicked.clone()); - let ring = if focused && ix == cursor { - colors.focus - } else if selected == Some(ix) { - colors.accent - } else { - gpui::transparent_black() - }; - div() - .id(("glyph", ix)) - .flex() - .items_center() - .justify_center() - .size(cell) - .rounded(theme.radius(Radius::Md)) - .border_1() - .border_color(ring) - .when(selected == Some(ix), |cell| cell.bg(colors.active)) - .cursor_pointer() - .hover(|style| style.bg(colors.hover)) - .tooltip(Tooltip::text(name)) - .on_click(move |_, window, cx| { - clicked.update(cx, |finder, cx| { - finder.cursor = ix; - cx.notify(); - }); - pick(ix, window, cx); - }) - .child(draw(ix, cx)) - })) - .into_any_element(), - }) - .collect() - }) - .track_scroll(scroll) - .h(theme.list_max_height()); - div() - .track_focus(&focus) - .on_key_down(move |event, window, cx| { - let key = event.keystroke.key.as_str(); - if matches!(key, "enter" | "space") { - cx.stop_propagation(); - keys(cursor, window, cx); - return; - } - let Some(to) = stepped(&key_rows, cursor, key) else { - return; - }; - cx.stop_propagation(); - let strategy = if to < cursor { - ScrollStrategy::Top - } else { - ScrollStrategy::Bottom - }; - place.scroll_to_item(row_of(&key_rows, to), strategy); - moved.update(cx, |finder, cx| { - finder.cursor = to; - cx.notify(); - }); - }) - .child(list) - .into_any_element() - }; - div() - .flex() - .flex_col() - .gap_2() - .w(cell * COLUMNS as f32) - .child(field) - .child(body) - .into_any_element() -} - /// A searchable grid of the library's icons. #[derive(IntoElement)] pub struct IconPicker { @@ -317,7 +72,7 @@ impl RenderOnce for IconPicker { Grid { id: self.id, names: Rc::new(found.iter().map(|icon| icon.name().into()).collect()), - rows: Rc::new(rows([(None, found.len())])), + rows: Rc::new(rows([(None, found.len())], COLUMNS)), selected: self .selected .and_then(|icon| found.iter().position(|f| *f == icon)), @@ -329,6 +84,8 @@ impl RenderOnce for IconPicker { }), pick, none: "No icons match", + columns: COLUMNS, + cell: cx.theme().control_height(ControlSize::Lg), }, &finder, window, @@ -396,9 +153,12 @@ impl RenderOnce for EmojiPicker { 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)), group.emojis().count()))) + rows( + Group::iter().map(|group| (Some(title(group).into()), group.emojis().count())), + COLUMNS, + ) } else { - rows([(None, found.len())]) + rows([(None, found.len())], COLUMNS) }; let found = Rc::new(found); let (painted, picked) = (found.clone(), found.clone()); @@ -427,6 +187,8 @@ impl RenderOnce for EmojiPicker { }), pick, none: "No emoji match", + columns: COLUMNS, + cell: cx.theme().control_height(ControlSize::Lg), }, &finder, window, @@ -437,7 +199,7 @@ impl RenderOnce for EmojiPicker { #[cfg(test)] mod tests { - use super::{Row, emoji_found, rows, stepped}; + use super::emoji_found; #[test] fn emoji_are_found_whatever_the_case_of_their_names() { @@ -451,31 +213,4 @@ mod tests { assert!(glyphs("sparkles").contains(&"✨")); assert_eq!(glyphs("").len(), emojis::iter().count()); } - - #[test] - fn groups_break_into_titled_rows_of_eight() { - let laid = rows([(Some("A"), 10), (None, 0), (Some("B"), 3)]); - assert_eq!( - laid, - [ - Row::Title("A"), - Row::Glyphs(0..8), - Row::Glyphs(8..10), - Row::Title("B"), - Row::Glyphs(10..13), - ] - ); - } - - #[test] - fn arrows_keep_the_column_and_cross_titles() { - let laid = rows([(Some("A"), 10), (Some("B"), 3)]); - assert_eq!(stepped(&laid, 6, "down"), Some(9)); - assert_eq!(stepped(&laid, 9, "down"), Some(11)); - assert_eq!(stepped(&laid, 11, "up"), Some(9)); - assert_eq!(stepped(&laid, 1, "up"), None); - assert_eq!(stepped(&laid, 12, "right"), None); - assert_eq!(stepped(&laid, 0, "left"), None); - assert_eq!(stepped(&laid, 8, "left"), Some(7)); - } } diff --git a/src/forms/mod.rs b/src/forms/mod.rs index e3b111f..08819b0 100644 --- a/src/forms/mod.rs +++ b/src/forms/mod.rs @@ -26,6 +26,7 @@ mod options; mod pairs; mod path; mod pattern; +mod picks; mod pin; mod radio; mod rating; @@ -91,6 +92,7 @@ pub use pairs::{FieldArray, KeyValueInput, ListInput}; pub use path::PathInput; pub(crate) use path::choose; pub use pattern::{RegexInput, regex_highlights}; +pub(crate) use picks::{Grid, finder, grid, rows}; pub use pin::PinInput; pub use radio::{Radio, RadioGroup}; pub use rating::Rating; diff --git a/src/forms/picks.rs b/src/forms/picks.rs new file mode 100644 index 0000000..50b016b --- /dev/null +++ b/src/forms/picks.rs @@ -0,0 +1,306 @@ +use std::{ops::Range, rc::Rc}; + +use gpui::{ + AnyElement, App, Context, ElementId, Entity, InteractiveElement, IntoElement, ParentElement, + Rems, ScrollStrategy, SharedString, StatefulInteractiveElement, Styled, + UniformListScrollHandle, Window, div, prelude::*, uniform_list, +}; + +use super::{ + Input, TextInput, + text::{Down, Enter}, +}; +use crate::{ + primitives::{Icon, IconName, Tooltip, tab_stop}, + theme::{ActiveTheme, IconSize, Radius, TextSize}, +}; + +/// A row of a glyph grid: a group's title, or a run of glyphs. +#[derive(Clone, Debug, PartialEq)] +pub(crate) enum Row { + Title(SharedString), + Glyphs(Range), +} + +/// Rows for glyph groups given as (title, count), laid in order, `columns` across. +pub(crate) fn rows( + groups: impl IntoIterator, usize)>, + columns: usize, +) -> Vec { + let (mut out, mut start) = (Vec::new(), 0); + for (title, count) in groups.into_iter().filter(|(_, count)| *count > 0) { + out.extend(title.map(Row::Title)); + let end = start + count; + while start < end { + let stop = (start + columns).min(end); + out.push(Row::Glyphs(start..stop)); + start = stop; + } + } + out +} + +fn row_of(rows: &[Row], ix: usize) -> usize { + rows.iter() + .position(|row| matches!(row, Row::Glyphs(range) if range.contains(&ix))) + .expect("every glyph sits in a row") +} + +/// The glyph an arrow key moves to from `at`: same column in the next row, or the last one there. +pub(crate) fn stepped(rows: &[Row], at: usize, key: &str) -> Option { + let row = row_of(rows, at); + let Row::Glyphs(here) = &rows[row] else { + unreachable!("row_of finds glyph rows"); + }; + let column = at - here.start; + let land = |row: &Row| match row { + Row::Glyphs(range) => Some((range.start + column).min(range.end - 1)), + Row::Title(_) => None, + }; + let last = rows + .iter() + .rev() + .find_map(|row| match row { + Row::Glyphs(range) => Some(range.end - 1), + Row::Title(_) => None, + }) + .expect("a grid with a cursor has glyphs"); + match key { + "left" => at.checked_sub(1), + "right" => (at < last).then_some(at + 1), + "up" => rows[..row].iter().rev().find_map(land), + "down" => rows[row + 1..].iter().find_map(land), + _ => None, + } +} + +/// A grid's search field, its cursor, and where its list has scrolled. +pub(crate) struct Finder { + search: Entity, + pub query: String, + cursor: usize, + scroll: UniformListScrollHandle, +} + +pub(crate) fn finder( + id: &ElementId, + hint: &'static str, + window: &mut Window, + cx: &mut App, +) -> Entity { + let finder = window.use_keyed_state( + (id.clone(), "finder"), + cx, + |window, cx: &mut Context| Finder { + search: cx.new(|cx| TextInput::new(window, cx).placeholder(hint)), + query: String::new(), + cursor: 0, + scroll: UniformListScrollHandle::new(), + }, + ); + let query = { + let search = finder.read(cx).search.clone(); + search.read(cx).text().trim().to_lowercase() + }; + if finder.read(cx).query != query { + finder.update(cx, |finder, _| { + finder.query = query; + finder.cursor = 0; + finder.scroll.scroll_to_item(0, ScrollStrategy::Top); + }); + } + finder +} + +pub(crate) type Pick = Rc; +pub(crate) type Draw = Rc AnyElement>; + +/// What a glyph grid shows: names for tooltips, rows, a painter, what a pick does, and its cells. +pub(crate) struct Grid { + pub id: ElementId, + pub names: Rc>, + pub rows: Rc>, + pub selected: Option, + pub draw: Draw, + pub pick: Pick, + pub none: &'static str, + pub columns: usize, + pub cell: Rems, +} + +pub(crate) fn grid( + grid: Grid, + finder: &Entity, + window: &mut Window, + cx: &mut App, +) -> AnyElement { + let focus = tab_stop((grid.id.clone(), "grid").into(), true, window, cx); + let focused = focus.is_focused(window); + let count = grid.names.len(); + let (search, cursor, scroll) = { + let finder = finder.read(cx); + let cursor = finder.cursor.min(count.saturating_sub(1)); + (finder.search.clone(), cursor, finder.scroll.clone()) + }; + let theme = cx.theme(); + let colors = theme.colors.clone(); + let (cell, columns) = (grid.cell, grid.columns); + let (into, enter, keys) = (focus.clone(), grid.pick.clone(), grid.pick.clone()); + let field = div() + .capture_action(move |_: &Down, window, cx| { + if count > 0 { + cx.stop_propagation(); + window.focus(&into); + } + }) + .capture_action(move |_: &Enter, window, cx| { + if count > 0 { + cx.stop_propagation(); + enter(cursor, window, cx); + } + }) + .child( + Input::new(&search).prefix( + Icon::new(IconName::Search) + .size(IconSize::Sm) + .color(colors.fg_subtle), + ), + ); + let body = if count == 0 { + div() + .h(theme.list_max_height()) + .pt_6() + .text_center() + .text_color(colors.fg_subtle) + .child(grid.none) + .into_any_element() + } else { + let (rows, names, draw, pick) = (grid.rows.clone(), grid.names, grid.draw, grid.pick); + let selected = grid.selected; + let (moved, key_rows, place) = (finder.clone(), grid.rows, scroll.clone()); + let clicked = finder.clone(); + let list = uniform_list((grid.id, "rows"), rows.len(), move |range, _, cx| { + let theme = cx.theme(); + rows[range] + .iter() + .map(|row| match row { + Row::Title(title) => div() + .h(cell) + .flex() + .items_end() + .pb_1() + .text_size(theme.text_size(TextSize::Xs)) + .text_color(colors.fg_subtle) + .child(title.clone()) + .into_any_element(), + Row::Glyphs(glyphs) => div() + .flex() + .children(glyphs.clone().map(|ix| { + let (pick, name, clicked) = + (pick.clone(), names[ix].clone(), clicked.clone()); + let ring = if focused && ix == cursor { + colors.focus + } else if selected == Some(ix) { + colors.accent + } else { + gpui::transparent_black() + }; + div() + .id(("glyph", ix)) + .flex() + .items_center() + .justify_center() + .size(cell) + .rounded(theme.radius(Radius::Md)) + .border_1() + .border_color(ring) + .when(selected == Some(ix), |cell| cell.bg(colors.active)) + .cursor_pointer() + .hover(|style| style.bg(colors.hover)) + .tooltip(Tooltip::text(name)) + .on_click(move |_, window, cx| { + clicked.update(cx, |finder, cx| { + finder.cursor = ix; + cx.notify(); + }); + pick(ix, window, cx); + }) + .child(draw(ix, cx)) + })) + .into_any_element(), + }) + .collect() + }) + .track_scroll(scroll) + .h(theme.list_max_height()); + div() + .track_focus(&focus) + .on_key_down(move |event, window, cx| { + let key = event.keystroke.key.as_str(); + if matches!(key, "enter" | "space") { + cx.stop_propagation(); + keys(cursor, window, cx); + return; + } + let Some(to) = stepped(&key_rows, cursor, key) else { + return; + }; + cx.stop_propagation(); + let strategy = if to < cursor { + ScrollStrategy::Top + } else { + ScrollStrategy::Bottom + }; + place.scroll_to_item(row_of(&key_rows, to), strategy); + moved.update(cx, |finder, cx| { + finder.cursor = to; + cx.notify(); + }); + }) + .child(list) + .into_any_element() + }; + div() + .flex() + .flex_col() + .gap_2() + .w(cell * columns as f32) + .child(field) + .child(body) + .into_any_element() +} + +#[cfg(test)] +mod tests { + use super::{Row, rows, stepped}; + + #[test] + fn groups_break_into_titled_rows_of_eight() { + let laid = rows( + [(Some("A".into()), 10), (None, 0), (Some("B".into()), 3)], + 8, + ); + assert_eq!( + laid, + [ + Row::Title("A".into()), + Row::Glyphs(0..8), + Row::Glyphs(8..10), + Row::Title("B".into()), + Row::Glyphs(10..13), + ] + ); + } + + #[test] + fn arrows_keep_the_column_and_cross_titles() { + let laid = rows([(Some("A".into()), 10), (Some("B".into()), 3)], 8); + assert_eq!(stepped(&laid, 6, "down"), Some(9)); + assert_eq!(stepped(&laid, 9, "down"), Some(11)); + assert_eq!(stepped(&laid, 11, "up"), Some(9)); + assert_eq!(stepped(&laid, 1, "up"), None); + assert_eq!(stepped(&laid, 12, "right"), None); + assert_eq!(stepped(&laid, 0, "left"), None); + assert_eq!(stepped(&laid, 8, "left"), Some(7)); + } +} diff --git a/src/messaging/call.rs b/src/messaging/call.rs index 0f106ac..ea89f98 100644 --- a/src/messaging/call.rs +++ b/src/messaging/call.rs @@ -108,7 +108,7 @@ impl RenderOnce for CallControls { ), toggle( "share", - (IconName::ScreenShareOff, IconName::ScreenShare), + (IconName::ScreenShare, IconName::ScreenShareOff), "Share screen", self.share, ), diff --git a/src/messaging/gifs.rs b/src/messaging/gifs.rs new file mode 100644 index 0000000..ea502ba --- /dev/null +++ b/src/messaging/gifs.rs @@ -0,0 +1,266 @@ +use std::rc::Rc; + +use gpui::{ + App, Context, ElementId, Entity, ImageSource, InteractiveElement, IntoElement, MouseButton, + ParentElement, RenderOnce, SharedString, StatefulInteractiveElement, Styled, Subscription, + Window, div, prelude::*, rems, +}; + +use crate::{ + buttons::{Button, ButtonVariant}, + forms::{Input, InputEvent, OnValue, TextInput}, + layout::{Masonry, on_axis}, + motion::Skeleton, + primitives::{FocusRing, Icon, IconName, Image, Tooltip, checked_ratio, framed}, + theme::{ActiveTheme, ControlSize, IconSize, Radius, TextSize}, +}; + +/// Shapes the loading tiles take, so the wait looks like GIFs. +const WAITING: [f32; 6] = [1.5, 1.0, 1.8, 1.25, 1.6, 1.1]; + +/// A GIF: its key, its title, its picture, and its width over its height. +#[derive(Clone)] +pub struct Gif { + pub key: SharedString, + pub title: SharedString, + pub picture: ImageSource, + pub ratio: f32, +} + +impl Gif { + pub fn new( + key: impl Into, + title: impl Into, + picture: impl Into, + ratio: f32, + ) -> Self { + Self { + key: key.into(), + title: title.into(), + picture: picture.into(), + ratio: checked_ratio(ratio), + } + } +} + +/// Where a GIF search stands: waiting on the host, or what it found. +#[derive(Clone)] +pub enum Gifs { + Loading, + Found(Vec), +} + +/// The search field, the words last reported, and who gets them. +struct Search { + input: Entity, + words: SharedString, + on_query: Option, + _changes: Subscription, +} + +/// GIFs from the host in two columns packed tight, under a search the host answers; while it is empty, categories fill it. Each GIF is a Tab stop, picked by a press, Enter or Space. +#[derive(IntoElement)] +pub struct GifPicker { + id: ElementId, + gifs: Gifs, + categories: Vec, + on_query: Option, + on_pick: Option, +} + +impl GifPicker { + pub fn new(id: impl Into, gifs: Gifs) -> Self { + if let Gifs::Found(found) = &gifs { + for (ix, gif) in found.iter().enumerate() { + let twice = found[..ix].iter().any(|other| other.key == gif.key); + assert!(!twice, "gif {} twice", gif.key); + } + } + Self { + id: id.into(), + gifs, + categories: Vec::new(), + on_query: None, + on_pick: None, + } + } + + /// Searches offered while the field is empty; a press puts one in it. + pub fn categories( + mut self, + categories: impl IntoIterator>, + ) -> Self { + self.categories = categories.into_iter().map(Into::into).collect(); + self + } + + /// Gets the search's words, trimmed, each time they change. + pub fn on_query( + mut self, + handler: impl Fn(&SharedString, &mut Window, &mut App) + 'static, + ) -> Self { + self.on_query = Some(Rc::new(handler)); + self + } + + /// Gets the key of the GIF picked. + pub fn on_pick( + mut self, + handler: impl Fn(&SharedString, &mut Window, &mut App) + 'static, + ) -> Self { + self.on_pick = Some(Rc::new(handler)); + self + } +} + +impl RenderOnce for GifPicker { + fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { + let search = window.use_keyed_state( + (self.id.clone(), "search"), + cx, + |window, cx: &mut Context| { + let input = cx.new(|cx| TextInput::new(window, cx).placeholder("Search GIFs")); + let changes = + cx.subscribe_in(&input, window, |search, input, event, window, cx| { + if *event != InputEvent::Changed { + return; + } + let query = SharedString::from(input.read(cx).text().trim().to_string()); + if query == search.words { + return; + } + search.words = query.clone(); + log::info!("gif picker: search {query:?}"); + if let Some(on_query) = search.on_query.clone() { + on_query(&query, window, cx); + } + cx.notify(); + }); + Search { + input, + words: SharedString::default(), + on_query: None, + _changes: changes, + } + }, + ); + search.update(cx, |search, _| search.on_query = self.on_query.clone()); + let input = search.read(cx).input.clone(); + let empty = input.read(cx).text().trim().is_empty(); + let theme = cx.theme(); + let colors = &theme.colors; + let round = theme.radius(Radius::Md); + let gap = rems(0.5).to_pixels(window.rem_size()); + let categories = (empty && !self.categories.is_empty()).then(|| { + div() + .flex() + .flex_wrap() + .gap_1() + .children(self.categories.iter().map(|category| { + let (field, words) = (input.clone(), category.clone()); + Button::new( + (self.id.clone(), format!("category-{category}")), + category.clone(), + ) + .variant(ButtonVariant::Subtle) + .size(ControlSize::Sm) + .on_click(move |_, _, cx| { + log::info!("gif picker: category {words}"); + let words = words.clone(); + field.update(cx, |input, cx| input.set_text(words.to_string(), cx)); + }) + })) + }); + let tiles: Vec = match self.gifs { + Gifs::Loading => WAITING + .iter() + .enumerate() + .map(|(ix, ratio)| { + framed(*ratio, cx) + .rounded(round) + .child( + Skeleton::new((self.id.clone(), format!("waiting-{ix}"))).size_full(), + ) + .into_any_element() + }) + .collect(), + Gifs::Found(found) => found + .into_iter() + .map(|gif| { + let (key, on_pick, owner) = + (gif.key.clone(), self.on_pick.clone(), self.id.clone()); + div() + .id((self.id.clone(), format!("gif-{}", gif.key))) + .rounded(round) + .border_1() + .border_color(gpui::transparent_black()) + .tab_index(0) + .focus_ring(cx) + .cursor_pointer() + .tooltip(Tooltip::text(gif.title.clone())) + .on_mouse_down(MouseButton::Left, |_, window, _| window.prevent_default()) + .on_click(move |_, window, cx| { + log::info!("gif picker {owner}: {key}"); + if let Some(on_pick) = &on_pick { + on_pick(&key, window, cx); + } + }) + .child( + framed(gif.ratio, cx).rounded(round).child( + Image::new( + (self.id.clone(), format!("picture-{}", gif.key)), + gif.picture, + ) + .size_full() + .rounded(round), + ), + ) + .into_any_element() + }) + .collect(), + }; + let none = tiles.is_empty().then(|| { + div() + .debug_selector(|| "gifs-none".into()) + .py_6() + .flex() + .flex_col() + .items_center() + .gap_2() + .text_size(theme.text_size(TextSize::Sm)) + .text_color(colors.fg_muted) + .child( + Icon::new(IconName::SearchX) + .size(IconSize::Lg) + .color(colors.fg_subtle), + ) + .child("No GIFs match") + }); + let masonry = (!tiles.is_empty()) + .then(|| Masonry::new((self.id.clone(), "gifs"), 2, gap).children(tiles)); + div() + .debug_selector(|| "gif-picker".into()) + .w_full() + .flex() + .flex_col() + .gap_2() + .child( + Input::new(&input).prefix( + Icon::new(IconName::Search) + .size(IconSize::Sm) + .color(colors.fg_subtle), + ), + ) + .children(categories) + .child( + on_axis( + div() + .id((self.id.clone(), "results")) + .max_h(theme.list_max_height()) + .overflow_y_scroll(), + ) + .children(none) + .children(masonry), + ) + } +} diff --git a/src/messaging/mod.rs b/src/messaging/mod.rs index a8c618f..974fef2 100644 --- a/src/messaging/mod.rs +++ b/src/messaging/mod.rs @@ -1,5 +1,6 @@ mod call; mod channels; +mod gifs; mod header; mod incoming; mod members; @@ -8,6 +9,7 @@ mod people; mod pins; mod receipt; mod status; +mod stickers; mod thread; mod tiles; @@ -16,6 +18,7 @@ mod tests; pub use call::{CallControls, HuddleIndicator, VoiceCallBar}; pub use channels::{ChannelItem, ChannelList, ChatRow, DirectMessageItem}; +pub use gifs::{Gif, GifPicker, Gifs}; pub use header::ChannelHeader; pub use incoming::IncomingCallDialog; pub use members::{Member, MemberList}; @@ -24,5 +27,6 @@ pub use people::{OnlineStatus, UserProfileCard}; pub use pins::{PinnedMessage, PinnedMessages}; pub use receipt::{Delivery, ReadReceipt}; pub use status::{ClearAfter, Status, StatusSetter}; +pub use stickers::{Sticker, StickerPack, StickerPicker}; pub use thread::{MessageThread, ThreadPanel}; pub use tiles::{ParticipantTile, ScreenShareView, VideoCallGrid}; diff --git a/src/messaging/stickers.rs b/src/messaging/stickers.rs new file mode 100644 index 0000000..211c7cd --- /dev/null +++ b/src/messaging/stickers.rs @@ -0,0 +1,179 @@ +use std::rc::Rc; + +use gpui::{ + App, ElementId, ImageSource, InteractiveElement, IntoElement, ObjectFit, ParentElement, + RenderOnce, SharedString, Styled, Window, div, +}; + +use crate::{ + buttons::SegmentedControl, + forms::{Grid, OnValue, finder, grid, rows}, + primitives::Image, + theme::{ActiveTheme, ControlSize}, +}; + +/// Stickers across a picker's row, so it fits a narrow window. +const COLUMNS: usize = 4; + +/// A sticker: its key, its name, and its square picture. +#[derive(Clone)] +pub struct Sticker { + pub key: SharedString, + pub name: SharedString, + pub picture: ImageSource, +} + +impl Sticker { + pub fn new( + key: impl Into, + name: impl Into, + picture: impl Into, + ) -> Self { + Self { + key: key.into(), + name: name.into(), + picture: picture.into(), + } + } +} + +/// Stickers under their pack's name. +#[derive(Clone)] +pub struct StickerPack { + pub name: SharedString, + pub stickers: Vec, +} + +impl StickerPack { + pub fn new(name: impl Into, stickers: impl IntoIterator) -> Self { + Self { + name: name.into(), + stickers: stickers.into_iter().collect(), + } + } +} + +/// Stickers in a grid, a pack at a time, chosen above; a search spans every pack and keeps the stickers whose names hold it. Arrows move, and Enter or a press picks. +#[derive(IntoElement)] +pub struct StickerPicker { + id: ElementId, + packs: Vec, + on_pick: Option, +} + +impl StickerPicker { + pub fn new(id: impl Into, packs: impl IntoIterator) -> Self { + let packs: Vec = packs.into_iter().collect(); + assert!(!packs.is_empty(), "a sticker picker holds a pack"); + for (ix, pack) in packs.iter().enumerate() { + let twice = packs[..ix].iter().any(|other| other.name == pack.name); + assert!(!twice, "pack {} twice", pack.name); + } + let keys: Vec<&SharedString> = packs + .iter() + .flat_map(|pack| pack.stickers.iter().map(|sticker| &sticker.key)) + .collect(); + for (ix, key) in keys.iter().enumerate() { + assert!(!keys[..ix].contains(key), "sticker {key} twice"); + } + Self { + id: id.into(), + packs, + on_pick: None, + } + } + + /// Gets the key of the sticker picked. + pub fn on_pick( + mut self, + handler: impl Fn(&SharedString, &mut Window, &mut App) + 'static, + ) -> Self { + self.on_pick = Some(Rc::new(handler)); + self + } +} + +impl RenderOnce for StickerPicker { + fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { + let first = self.packs[0].name.clone(); + let chosen = window.use_keyed_state((self.id.clone(), "pack"), cx, |_, _| first.clone()); + if !self.packs.iter().any(|pack| pack.name == *chosen.read(cx)) { + log::info!("sticker picker {}: pack gone, showing {first}", self.id); + chosen.update(cx, |chosen, _| *chosen = first); + } + let pack = chosen.read(cx).clone(); + let finder = finder(&self.id, "Search stickers", window, cx); + let query = finder.read(cx).query.clone(); + let packs = (self.packs.len() > 1).then(|| { + let (id, choose) = (self.id.clone(), chosen.clone()); + self.packs + .iter() + .fold( + SegmentedControl::new((self.id.clone(), "packs"), pack.clone()), + |packs, each| packs.segment(each.name.clone(), each.name.clone(), None), + ) + .size(ControlSize::Sm) + .on_change(move |name, _, cx| { + log::info!("sticker picker {id}: pack {name}"); + choose.update(cx, |chosen, cx| { + *chosen = name.clone(); + cx.notify(); + }) + }) + }); + let found: Rc> = Rc::new( + self.packs + .into_iter() + .filter(|each| !query.is_empty() || each.name == pack) + .flat_map(|each| each.stickers) + .filter(|sticker| sticker.name.to_lowercase().contains(&query)) + .collect(), + ); + let (painted, picked) = (found.clone(), found.clone()); + let (id, owner, on_pick) = (self.id.clone(), self.id.clone(), self.on_pick); + let stickers = grid( + Grid { + id: self.id, + names: Rc::new(found.iter().map(|sticker| sticker.name.clone()).collect()), + rows: Rc::new(rows([(None, found.len())], COLUMNS)), + selected: None, + draw: Rc::new(move |ix, _| { + let sticker = &painted[ix]; + div() + .size_full() + .p_1() + .child( + Image::new( + (owner.clone(), format!("sticker-{}", sticker.key)), + sticker.picture.clone(), + ) + .fit(ObjectFit::Contain) + .size_full(), + ) + .into_any_element() + }), + pick: Rc::new(move |ix, window, cx| { + let key = &picked[ix].key; + log::info!("sticker picker {id}: {key}"); + if let Some(on_pick) = &on_pick { + on_pick(key, window, cx); + } + }), + none: "No stickers match", + columns: COLUMNS, + cell: cx.theme().messaging().sticker, + }, + &finder, + window, + cx, + ); + div() + .debug_selector(|| "sticker-picker".into()) + .flex_none() + .flex() + .flex_col() + .gap_2() + .children(packs) + .child(stickers) + } +} diff --git a/src/messaging/tests/mod.rs b/src/messaging/tests/mod.rs index 911cc8e..a639867 100644 --- a/src/messaging/tests/mod.rs +++ b/src/messaging/tests/mod.rs @@ -12,6 +12,7 @@ use crate::{data_display::Presence, primitives::FocusNext, theme::Theme}; mod calls; mod messages; mod people; +mod pickers; fn setup(cx: &mut TestAppContext) { cx.update(|cx| { diff --git a/src/messaging/tests/pickers.rs b/src/messaging/tests/pickers.rs new file mode 100644 index 0000000..a5e35f7 --- /dev/null +++ b/src/messaging/tests/pickers.rs @@ -0,0 +1,200 @@ +use std::path::Path; + +use gpui::{ + Context, Entity, IntoElement, ParentElement, Render, SharedString, Styled, TestAppContext, + VisualTestContext, Window, div, px, +}; + +use super::{press, settle, setup, tab_to}; +use crate::{ + forms, + messaging::{Gif, GifPicker, Gifs, Sticker, StickerPack, StickerPicker}, +}; + +fn sticker(key: &'static str, name: &'static str) -> Sticker { + Sticker::new(key, name, Path::new("/nowhere.png")) +} + +fn packs() -> [StickerPack; 2] { + [ + StickerPack::new( + "Atrium", + [ + sticker("arch", "Arch"), + sticker("stair", "Stair"), + sticker("column", "Column"), + ], + ), + StickerPack::new( + "Garden", + [sticker("olive", "Olive"), sticker("stone", "Stone")], + ), + ] +} + +/// A picker and what it picked. +struct Stickers { + picked: Vec, +} + +impl Render for Stickers { + fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { + let owner = cx.entity(); + StickerPicker::new("stickers", packs()) + .on_pick(move |key, _, cx| owner.update(cx, |host, _| host.picked.push(key.clone()))) + } +} + +fn stickering(cx: &mut TestAppContext) -> (Entity, &mut VisualTestContext) { + setup(cx); + cx.update(forms::bind_keys); + let (host, cx) = cx.add_window_view(|_, _| Stickers { picked: Vec::new() }); + cx.update(|window, _| window.activate_window()); + settle(cx); + (host, cx) +} + +fn picked(host: &Entity, cx: &mut VisualTestContext) -> Vec { + host.read_with(cx, |host, _| host.picked.clone()) +} + +#[gpui::test] +fn a_search_spans_every_pack(cx: &mut TestAppContext) { + let (host, cx) = stickering(cx); + tab_to(3, cx); + cx.simulate_input("st"); + settle(cx); + cx.simulate_keystrokes("down right enter"); + settle(cx); + assert_eq!( + picked(&host, cx), + ["stone"], + "Stair and Stone sit side by side, a pack apart" + ); +} + +#[gpui::test] +fn the_chosen_pack_fills_the_grid(cx: &mut TestAppContext) { + let (host, cx) = stickering(cx); + tab_to(4, cx); + press("enter", cx); + tab_to(2, cx); + press("space", cx); + tab_to(4, cx); + press("enter", cx); + assert_eq!(picked(&host, cx), ["arch", "olive"]); +} + +#[test] +#[should_panic(expected = "pack Atrium twice")] +fn a_pack_is_named_once() { + let _ = StickerPicker::new( + "stickers", + [ + StickerPack::new("Atrium", [sticker("arch", "Arch")]), + StickerPack::new("Atrium", [sticker("stair", "Stair")]), + ], + ); +} + +#[test] +#[should_panic(expected = "sticker arch twice")] +fn a_sticker_is_listed_once() { + let _ = StickerPicker::new( + "stickers", + [ + StickerPack::new("A", [sticker("arch", "Arch")]), + StickerPack::new("B", [sticker("arch", "Arch again")]), + ], + ); +} + +fn gif(key: &'static str) -> Gif { + Gif::new(key, key, Path::new("/nowhere.gif"), 1.5) +} + +/// A GIF picker showing `gifs`, and what it heard. +struct Gifing { + gifs: Gifs, + heard: Vec, +} + +impl Render for Gifing { + fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { + let (query, pick) = (cx.entity(), cx.entity()); + div().w(px(360.0)).child( + GifPicker::new("gifs", self.gifs.clone()) + .categories(["Dunes", "Stairs"]) + .on_query(move |words, _, cx| { + query.update(cx, |host, _| host.heard.push(format!("query {words}"))) + }) + .on_pick(move |key, _, cx| { + pick.update(cx, |host, _| host.heard.push(format!("pick {key}"))) + }), + ) + } +} + +fn gifing(gifs: Gifs, cx: &mut TestAppContext) -> (Entity, &mut VisualTestContext) { + setup(cx); + cx.update(forms::bind_keys); + let (host, cx) = cx.add_window_view(|_, _| Gifing { + gifs, + heard: Vec::new(), + }); + cx.update(|window, _| window.activate_window()); + settle(cx); + (host, cx) +} + +fn heard(host: &Entity, cx: &mut VisualTestContext) -> Vec { + host.read_with(cx, |host, _| host.heard.clone()) +} + +#[gpui::test] +fn typed_words_and_a_category_reach_the_host(cx: &mut TestAppContext) { + let (host, cx) = gifing(Gifs::Found(vec![gif("a")]), cx); + tab_to(3, cx); + press("space", cx); + tab_to(1, cx); + cx.simulate_input(" more"); + settle(cx); + assert_eq!( + heard(&host, cx), + [ + "query Stairs", + "query Stairs m", + "query Stairs mo", + "query Stairs mor", + "query Stairs more" + ], + "the second category fills the field, typing goes on from it, and a space alone asks nothing" + ); +} + +#[gpui::test] +fn a_gif_is_a_tab_stop_that_space_picks(cx: &mut TestAppContext) { + let (host, cx) = gifing(Gifs::Found(vec![gif("a"), gif("b")]), cx); + tab_to(5, cx); + press("space", cx); + assert_eq!(heard(&host, cx), ["pick b"]); +} + +#[gpui::test] +fn nothing_found_says_so(cx: &mut TestAppContext) { + let (_, cx) = gifing(Gifs::Found(Vec::new()), cx); + assert!(cx.debug_bounds("gifs-none").is_some()); +} + +#[gpui::test] +fn a_search_under_way_waits_without_saying_nothing(cx: &mut TestAppContext) { + let (_, cx) = gifing(Gifs::Loading, cx); + assert!(cx.debug_bounds("gif-picker").is_some()); + assert!(cx.debug_bounds("gifs-none").is_none()); +} + +#[test] +#[should_panic(expected = "gif a twice")] +fn a_gif_is_listed_once() { + let _ = GifPicker::new("gifs", Gifs::Found(vec![gif("a"), gif("a")])); +} diff --git a/src/theme/messaging.rs b/src/theme/messaging.rs index 6ce3ad0..48de3c6 100644 --- a/src/theme/messaging.rs +++ b/src/theme/messaging.rs @@ -9,6 +9,8 @@ pub struct MessagingSizes { pub tiles: Rems, /// A call's tile at its narrowest, before a grid drops a column. pub tile: Rems, + /// A sticker's cell in a picker, square. + pub sticker: Rems, } impl Theme { @@ -16,6 +18,7 @@ impl Theme { MessagingSizes { tiles: px_to_rems(200.0), tile: px_to_rems(160.0), + sticker: px_to_rems(64.0), } } } diff --git a/tasks/ch21-30.md b/tasks/ch21-30.md index 4881427..f63aaa4 100644 --- a/tasks/ch21-30.md +++ b/tasks/ch21-30.md @@ -357,8 +357,8 @@ ## T28e Messaging · Stickers and GIFs -- [ ] StickerPicker -- [ ] GifPicker — host images +- [x] StickerPicker +- [x] GifPicker — host images ## T29 Mail — `src/mail`