Skip to main content

rich_interact/components/
asset.rs

1//! An asset picker (#461, `rich asset`): emoji by shortcode name, table and
2//! panel box styles, spinners and (with the `micro` feature) micro assets,
3//! with a fuzzy search and a preview.
4//!
5//! Every list comes from core's public API: emoji glyphs through
6//! `rich::emoji::replace` (the names are those of core's table), the box
7//! styles `rich::box` defines, and `rich::spinner::spinner_names`. The
8//! answer is the emoji itself, or the box style's or spinner's name as
9//! `rich` options take it (`rounded`, `dots`).
10
11use std::sync::Arc;
12
13use rich::r#box::{self as boxes, Box as BoxSet};
14use rich::{Console, ConsoleOptions, Renderable, Segment};
15
16use crate::component::{Component, Context, Flow, View};
17use crate::components::{PreviewLayout, Select, Theme};
18use crate::event::{Event, Key};
19use crate::item::{Actions, Item, Preview};
20use crate::keymap::Keymap;
21use crate::names::EMOJI;
22use crate::policy::{LineIo, NotInteractive};
23
24/// What to pick.
25#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
26pub enum AssetKind {
27    #[default]
28    Emoji,
29    /// A box style for tables and panels.
30    Box,
31    Spinner,
32    /// A micro asset (`rs-rich-micro`), by name: drawn in the list and
33    /// magnified in the preview. Needs the `micro` feature.
34    #[cfg(feature = "micro")]
35    Micro,
36}
37
38impl AssetKind {
39    pub fn parse(name: &str) -> Option<AssetKind> {
40        Some(match name {
41            "emoji" => AssetKind::Emoji,
42            "box" => AssetKind::Box,
43            "spinner" => AssetKind::Spinner,
44            #[cfg(feature = "micro")]
45            "micro" => AssetKind::Micro,
46            _ => return None,
47        })
48    }
49}
50
51/// Every box style core defines, by the name `rich` options use.
52pub const BOX_STYLES: &[(&str, BoxSet)] = &[
53    ("ascii", boxes::ASCII),
54    ("ascii2", boxes::ASCII2),
55    ("ascii_double_head", boxes::ASCII_DOUBLE_HEAD),
56    ("square", boxes::SQUARE),
57    ("square_double_head", boxes::SQUARE_DOUBLE_HEAD),
58    ("minimal", boxes::MINIMAL),
59    ("minimal_heavy_head", boxes::MINIMAL_HEAVY_HEAD),
60    ("minimal_double_head", boxes::MINIMAL_DOUBLE_HEAD),
61    ("simple", boxes::SIMPLE),
62    ("simple_head", boxes::SIMPLE_HEAD),
63    ("simple_heavy", boxes::SIMPLE_HEAVY),
64    ("horizontals", boxes::HORIZONTALS),
65    ("rounded", boxes::ROUNDED),
66    ("heavy", boxes::HEAVY),
67    ("heavy_edge", boxes::HEAVY_EDGE),
68    ("heavy_head", boxes::HEAVY_HEAD),
69    ("double", boxes::DOUBLE),
70    ("double_edge", boxes::DOUBLE_EDGE),
71    ("markdown", boxes::MARKDOWN),
72];
73
74/// The emoji a shortcode name stands for, through core.
75pub fn emoji(name: &str) -> Option<String> {
76    let code = format!(":{name}:");
77    let glyph = rich::emoji::replace(&code);
78    (glyph != code).then_some(glyph)
79}
80
81/// The cell size micro previews are magnified at: the default guess, so a
82/// preview looks the same everywhere.
83#[cfg(feature = "micro")]
84fn rich_art_cell() -> rich_micro::CellPixels {
85    rich_micro::CellPixels::DEFAULT
86}
87
88/// A micro asset's preview: the asset inline, its image magnified, and what
89/// it says about itself.
90#[cfg(feature = "micro")]
91struct MicroPreview {
92    asset: Arc<rich_micro::MicroAsset>,
93    still: Option<rich_micro::pipeline::Magnified>,
94}
95
96#[cfg(feature = "micro")]
97impl Renderable for MicroPreview {
98    fn rich_render(&self, console: &Console, options: &ConsoleOptions) -> Vec<Segment> {
99        let asset = &self.asset;
100        let mut head = rich_micro::placeholder(asset, rich_micro::FallbackPreference::Emoji);
101        head.append(
102            &format!(" {}", asset.name()),
103            Some(rich::Style::parse("bold").unwrap_or_default().into()),
104        );
105        let mut out = head.rich_render(console, options);
106        out.push(Segment::line());
107        if let Some(still) = &self.still {
108            out.extend(still.rich_render(console, options));
109        }
110        let fallback = asset.fallback();
111        let mut about = format!(
112            "{}\n{} {} ยท emoji {} ยท text {}",
113            asset.alt(),
114            asset.size(),
115            asset.kind().as_str(),
116            fallback.emoji.as_deref().unwrap_or("-"),
117            fallback.text.as_deref().unwrap_or("-"),
118        );
119        if let Some(license) = asset.license() {
120            about.push_str(&format!("\nlicence {license}"));
121        }
122        about.push_str(&format!("\n:micro:{}:", asset.name()));
123        out.extend(rich::Text::new(about).rich_render(console, options));
124        out
125    }
126}
127
128/// A small table drawn with a box style.
129struct BoxPreview(BoxSet);
130
131impl Renderable for BoxPreview {
132    fn rich_render(&self, console: &Console, options: &ConsoleOptions) -> Vec<Segment> {
133        let mut table = rich::Table::new().box_set(self.0);
134        table.add_column("Name").add_column("Value");
135        table.add_row(&["alpha", "1"]).add_row(&["beta", "2"]);
136        table.rich_render(console, options)
137    }
138}
139
140/// Pick an emoji, a box style or a spinner. Returns the emoji, or the
141/// style's or spinner's name.
142#[must_use = "a component does nothing until it is run or mounted"]
143pub struct AssetPicker {
144    kind: AssetKind,
145    select: Select<String>,
146}
147
148impl AssetPicker {
149    pub fn new(prompt: impl Into<String>, kind: AssetKind) -> AssetPicker {
150        #[cfg(feature = "micro")]
151        if kind == AssetKind::Micro {
152            return AssetPicker::micro(prompt, &rich_micro::MicroRegistry::builtin());
153        }
154        let mut prefixes = Vec::new();
155        let items: Vec<Item<String>> = match kind {
156            AssetKind::Emoji => EMOJI
157                .iter()
158                .filter_map(|name| {
159                    let glyph = emoji(name)?;
160                    prefixes.push(format!("{glyph} "));
161                    let preview = format!("{glyph}\n\n:{name}:");
162                    Some(Item::new(glyph, *name).preview(Preview::Text(preview)))
163                })
164                .collect(),
165            AssetKind::Box => BOX_STYLES
166                .iter()
167                .map(|(name, style)| {
168                    Item::new(name.to_string(), *name)
169                        .preview(Preview::Renderable(Arc::new(BoxPreview(*style))))
170                })
171                .collect(),
172            AssetKind::Spinner => rich::spinner::spinner_names()
173                .iter()
174                .filter_map(|name| {
175                    let (interval, frames) = rich::spinner::spinner_frames(name)?;
176                    prefixes.push(format!("{} ", frames.first().copied().unwrap_or("")));
177                    let preview = format!(
178                        "{}\n\n{} frames, every {interval} ms",
179                        frames.join("  "),
180                        frames.len()
181                    );
182                    Some(Item::new(name.to_string(), *name).preview(Preview::Text(preview)))
183                })
184                .collect(),
185            #[cfg(feature = "micro")]
186            AssetKind::Micro => unreachable!("built by AssetPicker::micro"),
187        };
188        let mut select = Select::new(prompt, items);
189        select.prefixes = prefixes;
190        select.prefix_plain = true;
191        AssetPicker { kind, select }
192    }
193
194    /// Pick one of `registry`'s micro assets; the answer is its name. Each
195    /// row shows the asset (its placeholder, which the painter's graphics
196    /// draw over where the terminal can; its emoji or text elsewhere) and
197    /// its alt text; the preview magnifies its image.
198    #[cfg(feature = "micro")]
199    pub fn micro(prompt: impl Into<String>, registry: &rich_micro::MicroRegistry) -> AssetPicker {
200        let mut cache = rich_micro::ImageCache::new(8 << 20, None);
201        let limits = rich_micro::Limits::default();
202        let mut icons = Vec::new();
203        let items: Vec<Item<String>> = registry
204            .assets()
205            .into_iter()
206            .map(|asset| {
207                icons.push(Some(rich_micro::placeholder(
208                    asset,
209                    rich_micro::FallbackPreference::Emoji,
210                )));
211                let still = cache
212                    .prepare(asset, rich_art_cell(), false, &limits)
213                    .map(|prepared| {
214                        rich_micro::pipeline::Magnified(prepared.frames[0].image.clone())
215                    });
216                let preview = MicroPreview {
217                    asset: std::sync::Arc::clone(asset),
218                    still,
219                };
220                Item::new(asset.name().to_string(), asset.name())
221                    .description(asset.alt())
222                    .preview(Preview::Renderable(Arc::new(preview)))
223            })
224            .collect();
225        let mut select = Select::new(prompt, items);
226        select.set_icons(icons);
227        AssetPicker {
228            kind: AssetKind::Micro,
229            select,
230        }
231    }
232
233    pub fn kind(&self) -> AssetKind {
234        self.kind
235    }
236
237    /// Show at most `rows` at once (default 10).
238    pub fn height(mut self, rows: usize) -> Self {
239        self.select = self.select.height(rows);
240        self
241    }
242
243    pub fn preview(mut self, layout: PreviewLayout) -> Self {
244        self.select = self.select.preview(layout);
245        self
246    }
247
248    /// Start with this filter text.
249    pub fn query(mut self, query: impl Into<String>) -> Self {
250        self.select = self.select.query(query);
251        self
252    }
253
254    /// The asset with this name (an emoji's shortcode name, a style's or
255    /// spinner's name) is focused first, and returned without a terminal.
256    pub fn default(mut self, name: &str) -> Self {
257        let name = name.trim_matches(':');
258        if let Some(index) = self
259            .select
260            .items()
261            .iter()
262            .position(|item| item.label == name)
263        {
264            self.select = self.select.default(index);
265        }
266        self
267    }
268
269    pub fn theme(mut self, theme: Theme) -> Self {
270        self.select = self.select.theme(theme);
271        self
272    }
273
274    /// Actions offered on every asset (#491).
275    pub fn actions(mut self, actions: Actions) -> Self {
276        self.select = self.select.actions(actions);
277        self
278    }
279
280    pub fn menu_key(mut self, key: Key) -> Self {
281        self.select = self.select.menu_key(key);
282        self
283    }
284
285    /// Report the mouse: see [`Select::with_mouse`].
286    pub fn with_mouse(mut self, on: bool) -> Self {
287        self.select = self.select.with_mouse(on);
288        self
289    }
290
291    pub fn action(&self) -> Option<&str> {
292        self.select.action()
293    }
294
295    pub fn items(&self) -> &[Item<String>] {
296        self.select.items()
297    }
298}
299
300impl Component for AssetPicker {
301    type Output = String;
302
303    fn handle(&mut self, event: &Event, context: &Context<'_>) -> Flow<String> {
304        self.select.handle(event, context)
305    }
306
307    fn render(&self, context: &Context<'_>) -> View {
308        self.select.render(context)
309    }
310
311    fn mouse(&self) -> bool {
312        Component::mouse(&self.select)
313    }
314
315    fn keymap(&self) -> Keymap {
316        self.select.visible_keymap()
317    }
318
319    fn default_value(&self) -> Option<String> {
320        self.select.default_value()
321    }
322
323    /// Without a terminal: a name, the best fuzzy match answering. The
324    /// list is too long to print, so it is not listed.
325    fn prompt(&mut self, io: &mut dyn LineIo) -> Result<Option<String>, NotInteractive> {
326        let what = match self.kind {
327            AssetKind::Emoji => "Emoji name",
328            AssetKind::Box => "Box style",
329            AssetKind::Spinner => "Spinner",
330            #[cfg(feature = "micro")]
331            AssetKind::Micro => "Micro asset",
332        };
333        let default = self.select.default_value();
334        let default_label = self
335            .select
336            .focused()
337            .filter(|_| default.is_some())
338            .map(|index| self.select.items()[index].label.clone());
339        match &default_label {
340            Some(label) => io.write(&format!("{what} [{label}]: ")),
341            None => io.write(&format!("{what}: ")),
342        }
343        let Some(line) = io.read_line() else {
344            return default.map(Some).ok_or(NotInteractive::Ended);
345        };
346        let line = line.trim().trim_matches(':');
347        if line.is_empty() {
348            return default
349                .map(Some)
350                .ok_or_else(|| NotInteractive::Invalid("nothing chosen".into()));
351        }
352        let items = self.select.items();
353        if let Some(item) = items.iter().find(|item| item.label == line) {
354            return Ok(Some(item.value.clone()));
355        }
356        let labels = items.iter().map(|item| item.label.as_str());
357        crate::fuzzy::rank(line, labels)
358            .first()
359            .map(|(index, _)| Some(items[*index].value.clone()))
360            .ok_or_else(|| NotInteractive::Invalid(format!("nothing matches {line:?}")))
361    }
362}
363
364/// A text rendering of a preview, for tests.
365#[cfg(test)]
366fn plain(renderable: &dyn Renderable, width: usize) -> String {
367    let console = Console::builder()
368        .width(width)
369        .force_terminal(false)
370        .build();
371    let options = console.options();
372    console
373        .render_lines(renderable, &options, false)
374        .iter()
375        .map(|line| line.iter().map(|s| s.text.as_str()).collect::<String>())
376        .collect::<Vec<_>>()
377        .join("\n")
378}
379
380#[cfg(test)]
381mod tests {
382    use super::*;
383
384    #[test]
385    fn every_listed_emoji_name_resolves_through_core() {
386        for name in EMOJI {
387            assert!(emoji(name).is_some(), "{name}");
388        }
389        assert_eq!(emoji("thumbs_up").as_deref(), Some("๐Ÿ‘"));
390    }
391
392    #[test]
393    fn box_previews_draw_with_their_style() {
394        let shown = plain(&BoxPreview(boxes::DOUBLE), 30);
395        assert!(shown.contains('โ•”') && shown.contains("alpha"), "{shown}");
396    }
397
398    #[test]
399    fn lists_every_kind() {
400        let emoji = AssetPicker::new("Emoji", AssetKind::Emoji);
401        assert!(emoji.items().len() > 2000);
402        let boxes = AssetPicker::new("Box", AssetKind::Box);
403        assert_eq!(boxes.items().len(), BOX_STYLES.len());
404        let spinners = AssetPicker::new("Spinner", AssetKind::Spinner);
405        assert_eq!(spinners.items().len(), rich::spinner::spinner_names().len());
406    }
407
408    #[cfg(feature = "micro")]
409    #[test]
410    fn lists_micro_assets_with_their_icons_and_previews() {
411        let picker = AssetPicker::new("Micro", AssetKind::Micro);
412        assert!(
413            picker
414                .items()
415                .iter()
416                .any(|item| item.label == "status/success")
417        );
418        let item = picker
419            .items()
420            .iter()
421            .find(|item| item.label == "status/loading")
422            .unwrap();
423        let shown = plain(&*item.preview.as_ref().unwrap().renderable(), 40);
424        assert!(
425            shown.contains("status/loading") && shown.contains("ring of dots"),
426            "{shown}"
427        );
428        assert!(shown.contains('โ–€'), "{shown}");
429        let (outcome, record) = crate::headless::run(
430            AssetPicker::new("Micro", AssetKind::Micro).query("heart"),
431            crate::headless::Script::new().keys("enter"),
432            60,
433            12,
434        );
435        assert_eq!(outcome.unwrap().value().unwrap(), "fun/heart");
436        assert!(record.frames.iter().any(|frame| frame.contains("๐Ÿ’–")));
437    }
438}