1use 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#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
26pub enum AssetKind {
27 #[default]
28 Emoji,
29 Box,
31 Spinner,
32 #[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
51pub 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
74pub 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#[cfg(feature = "micro")]
84fn rich_art_cell() -> rich_micro::CellPixels {
85 rich_micro::CellPixels::DEFAULT
86}
87
88#[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
128struct 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#[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 #[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 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 pub fn query(mut self, query: impl Into<String>) -> Self {
250 self.select = self.select.query(query);
251 self
252 }
253
254 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 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 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 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#[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}