Skip to main content

rich_interact/
item.rs

1//! The universal item model (#452): one type behind every picker.
2//!
3//! File pickers, command palettes, plugin browsers and history search all
4//! show the same thing: a value with a label, an optional description,
5//! details, a preview and actions. [`Item`] carries it, so each selector
6//! renders, searches and previews items the same way instead of inventing
7//! its own row type.
8//!
9//! Actions (#491) attach to one item, or to every target of a view through
10//! [`Actions`]: list items, table rows, tree nodes and file entries alike,
11//! told apart by an [`ActionTarget`]'s [`TargetKind`]. Plugins add actions
12//! through `rs-rich-plugin-api`'s `CustomAction`, which
13//! [`Actions::from_registry`] turns into these.
14
15use std::fmt;
16use std::sync::Arc;
17
18use rich::{Renderable, Text};
19
20use crate::event::Key;
21
22/// What a preview pane shows for an item.
23#[derive(Clone)]
24pub enum Preview {
25    /// Plain text.
26    Text(String),
27    /// Console markup (`[bold]…[/]`).
28    Markup(String),
29    /// Any renderable: a `Syntax`, a `Table`, a `Panel`.
30    Renderable(Arc<dyn Renderable + Send + Sync>),
31}
32
33impl Preview {
34    /// The preview as something to render.
35    pub fn renderable(&self) -> Arc<dyn Renderable + Send + Sync> {
36        match self {
37            Preview::Text(text) => Arc::new(Text::new(text.clone())),
38            Preview::Markup(markup) => {
39                Arc::new(Text::from_markup(markup).unwrap_or_else(|_| Text::new(markup.clone())))
40            }
41            Preview::Renderable(renderable) => Arc::clone(renderable),
42        }
43    }
44}
45
46impl fmt::Debug for Preview {
47    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
48        match self {
49            Preview::Text(text) => f.debug_tuple("Text").field(text).finish(),
50            Preview::Markup(markup) => f.debug_tuple("Markup").field(markup).finish(),
51            Preview::Renderable(_) => f.write_str("Renderable(..)"),
52        }
53    }
54}
55
56/// Something that can be done to an item, bound to a key: "open in
57/// $EDITOR" on `ctrl+e`, "delete" on `ctrl+d`. Every action is also in the
58/// view's action menu; one without a key is only there.
59#[derive(Clone, Debug, PartialEq, Eq)]
60pub struct Action {
61    /// A stable name the caller matches on.
62    pub id: String,
63    /// The label shown in a help line and the action menu.
64    pub label: String,
65    pub key: Option<Key>,
66}
67
68impl Action {
69    pub fn new(id: impl Into<String>, label: impl Into<String>, key: Key) -> Action {
70        Action {
71            id: id.into(),
72            label: label.into(),
73            key: Some(key),
74        }
75    }
76
77    /// An action with no key of its own: picked from the action menu.
78    pub fn menu(id: impl Into<String>, label: impl Into<String>) -> Action {
79        Action {
80            id: id.into(),
81            label: label.into(),
82            key: None,
83        }
84    }
85}
86
87/// What kind of thing an action is done to.
88#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
89pub enum TargetKind {
90    /// An item of a list ([`Select`](crate::Select)).
91    #[default]
92    Item,
93    /// A row of a table ([`TableSelect`](crate::TableSelect)).
94    Row,
95    /// A node of a tree ([`TreeSelect`](crate::TreeSelect)).
96    Node,
97    /// A file or directory ([`FilePicker`](crate::FilePicker)).
98    File,
99    /// A region of the screen rather than an item in it: a pane, a tab, or
100    /// any component wrapped in [`Overlays`](crate::overlay::Overlays)
101    /// with a region of its own (#474).
102    Region,
103}
104
105impl TargetKind {
106    /// `item`, `row`, `node`, `file` or `region`: the name plugins see.
107    pub fn as_str(self) -> &'static str {
108        match self {
109            TargetKind::Item => "item",
110            TargetKind::Row => "row",
111            TargetKind::Node => "node",
112            TargetKind::File => "file",
113            TargetKind::Region => "region",
114        }
115    }
116
117    pub fn parse(name: &str) -> Option<TargetKind> {
118        Some(match name {
119            "item" => TargetKind::Item,
120            "row" => TargetKind::Row,
121            "node" => TargetKind::Node,
122            "file" => TargetKind::File,
123            "region" => TargetKind::Region,
124            _ => return None,
125        })
126    }
127}
128
129/// The thing an action is about to be done to.
130#[derive(Clone, Debug, PartialEq, Eq)]
131pub struct ActionTarget {
132    pub kind: TargetKind,
133    /// The item's index in the view's items.
134    pub index: usize,
135    /// Its label, as shown.
136    pub label: String,
137    /// What identifies it: an item's label, a row's cells joined by tabs, a
138    /// node's path of labels joined by `/`, a file's path (with bytes that
139    /// are not UTF-8 as `\xNN`).
140    pub value: String,
141}
142
143/// Whether an action applies to a target.
144pub type ActionFilter = Arc<dyn Fn(&ActionTarget) -> bool + Send + Sync>;
145
146/// Actions a view offers on every target, beside each item's own: the
147/// caller's, and those plugins registered. Each may be limited to some
148/// targets.
149#[derive(Clone, Default)]
150pub struct Actions {
151    entries: Vec<(Action, Option<ActionFilter>)>,
152}
153
154impl fmt::Debug for Actions {
155    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
156        f.debug_list()
157            .entries(self.entries.iter().map(|(action, _)| action))
158            .finish()
159    }
160}
161
162impl Actions {
163    pub fn new() -> Actions {
164        Actions::default()
165    }
166
167    /// Offer `action` on every target.
168    #[must_use]
169    pub fn action(mut self, action: Action) -> Actions {
170        self.entries.push((action, None));
171        self
172    }
173
174    /// Offer `action` on targets of one kind.
175    #[must_use]
176    pub fn action_for(self, kind: TargetKind, action: Action) -> Actions {
177        self.action_if(action, move |target| target.kind == kind)
178    }
179
180    /// Offer `action` where `filter` says it applies.
181    #[must_use]
182    pub fn action_if(
183        mut self,
184        action: Action,
185        filter: impl Fn(&ActionTarget) -> bool + Send + Sync + 'static,
186    ) -> Actions {
187        self.entries.push((action, Some(Arc::new(filter))));
188        self
189    }
190
191    /// Every action from `other` after these.
192    #[must_use]
193    pub fn extend(mut self, other: Actions) -> Actions {
194        self.entries.extend(other.entries);
195        self
196    }
197
198    pub fn is_empty(&self) -> bool {
199        self.entries.is_empty()
200    }
201
202    pub fn len(&self) -> usize {
203        self.entries.len()
204    }
205
206    /// The actions for `target`: the item's `own` first, then these where
207    /// they apply. An id already offered is not offered again.
208    pub fn for_target(&self, target: &ActionTarget, own: &[Action]) -> Vec<Action> {
209        let mut out: Vec<Action> = Vec::new();
210        let applies = self
211            .entries
212            .iter()
213            .filter(|(_, filter)| filter.as_ref().is_none_or(|filter| filter(target)));
214        for action in own.iter().chain(applies.map(|(action, _)| action)) {
215            if !out.iter().any(|known| known.id == action.id) {
216                out.push(action.clone());
217            }
218        }
219        out
220    }
221
222    /// The custom actions plugins registered in `registry`
223    /// (`PluginRegistrar::action`): each under its registered name, on the
224    /// targets it says it applies to. A key name that does not parse leaves
225    /// the action in the menu only.
226    pub fn from_registry(registry: &rich_ext::registry::ExtensionRegistry) -> Actions {
227        let mut actions = Actions::new();
228        for (name, _, action) in registry.actions() {
229            let entry = Action {
230                id: name.to_string(),
231                label: action.label(),
232                key: action.key().as_deref().and_then(Key::parse),
233            };
234            actions = actions.action_if(entry, move |target| {
235                action.applies(target.kind.as_str(), &target.value)
236            });
237        }
238        actions
239    }
240}
241
242/// One choosable thing: a value of any type, and how to show and find it.
243#[derive(Clone, Debug)]
244#[must_use = "builder methods return a new value; it does nothing until it is rendered or used"]
245pub struct Item<T> {
246    pub value: T,
247    /// The main line.
248    pub label: String,
249    /// A dimmer second line or trailing note.
250    pub description: Option<String>,
251    /// Details shown as `key: value`, and searched.
252    pub metadata: Vec<(String, String)>,
253    pub preview: Option<Preview>,
254    pub actions: Vec<Action>,
255    /// Extra text that matches a search without being shown (aliases,
256    /// keywords, a full path behind a short label).
257    pub keywords: Vec<String>,
258}
259
260impl<T> Item<T> {
261    pub fn new(value: T, label: impl Into<String>) -> Item<T> {
262        Item {
263            value,
264            label: label.into(),
265            description: None,
266            metadata: Vec::new(),
267            preview: None,
268            actions: Vec::new(),
269            keywords: Vec::new(),
270        }
271    }
272
273    pub fn description(mut self, description: impl Into<String>) -> Self {
274        self.description = Some(description.into());
275        self
276    }
277
278    pub fn meta(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
279        self.metadata.push((key.into(), value.into()));
280        self
281    }
282
283    pub fn preview(mut self, preview: Preview) -> Self {
284        self.preview = Some(preview);
285        self
286    }
287
288    pub fn action(mut self, action: Action) -> Self {
289        self.actions.push(action);
290        self
291    }
292
293    pub fn keyword(mut self, keyword: impl Into<String>) -> Self {
294        self.keywords.push(keyword.into());
295        self
296    }
297
298    /// Everything a search matches against, label first, one field per line.
299    pub fn search_text(&self) -> String {
300        let mut text = self.label.clone();
301        for field in self
302            .description
303            .iter()
304            .chain(self.metadata.iter().map(|(_, value)| value))
305            .chain(&self.keywords)
306        {
307            text.push('\n');
308            text.push_str(field);
309        }
310        text
311    }
312
313    /// The action bound to `key`, if any: one bound to `key` itself before
314    /// one it only [`matches`](Key::matches).
315    pub fn action_for(&self, key: Key) -> Option<&Action> {
316        key.pick(self.actions.iter().map(|action| action.key.as_slice()))
317            .map(|at| &self.actions[at])
318    }
319
320    /// The same item with its value mapped.
321    pub fn map<U>(self, f: impl FnOnce(T) -> U) -> Item<U> {
322        Item {
323            value: f(self.value),
324            label: self.label,
325            description: self.description,
326            metadata: self.metadata,
327            preview: self.preview,
328            actions: self.actions,
329            keywords: self.keywords,
330        }
331    }
332}
333
334impl<T: fmt::Display> From<T> for Item<T> {
335    /// An item labelled with its value's `Display`.
336    fn from(value: T) -> Item<T> {
337        let label = value.to_string();
338        Item::new(value, label)
339    }
340}
341
342#[cfg(test)]
343mod tests {
344    use super::*;
345
346    #[test]
347    fn an_action_bound_to_the_key_itself_wins_over_an_earlier_match() {
348        let key = |name| Key::parse(name).unwrap();
349        let item = Item::new((), "row")
350            .action(Action::new("indent", "Indent", key("ctrl+i")))
351            .action(Action::new("next", "Next", key("tab")));
352        // A legacy Tab fires both bindings; the one that names it wins.
353        assert_eq!(item.action_for(key("tab")).unwrap().id, "next");
354        assert_eq!(item.action_for(key("ctrl+i")).unwrap().id, "indent");
355        let item = Item::new((), "row").action(Action::new("indent", "Indent", key("ctrl+i")));
356        assert_eq!(item.action_for(key("tab")).unwrap().id, "indent");
357    }
358
359    #[test]
360    fn builds_and_searches_items() {
361        let item = Item::new(std::path::PathBuf::from("src/main.rs"), "main.rs")
362            .description("the entry point")
363            .meta("size", "2 KB")
364            .keyword("src/main.rs")
365            .preview(Preview::Text("fn main() {}".into()))
366            .action(Action::new("edit", "Open in $EDITOR", Key::ctrl('e')));
367        assert_eq!(
368            item.search_text(),
369            "main.rs\nthe entry point\n2 KB\nsrc/main.rs"
370        );
371        assert_eq!(item.action_for(Key::ctrl('e')).unwrap().id, "edit");
372        assert!(item.action_for(Key::ctrl('d')).is_none());
373        let named = item.map(|path| path.display().to_string());
374        assert_eq!(named.value, "src/main.rs");
375        assert_eq!(Item::from(42).label, "42");
376    }
377
378    fn target(kind: TargetKind, value: &str) -> ActionTarget {
379        ActionTarget {
380            kind,
381            index: 0,
382            label: value.into(),
383            value: value.into(),
384        }
385    }
386
387    #[test]
388    fn view_actions_apply_by_target() {
389        let actions = Actions::new()
390            .action(Action::menu("copy", "Copy"))
391            .action_for(
392                TargetKind::File,
393                Action::new("edit", "Edit", Key::ctrl('e')),
394            )
395            .action_if(Action::menu("rust", "Rust only"), |target| {
396                target.value.ends_with(".rs")
397            });
398        let own = [Action::new("open", "Open", Key::ctrl('o'))];
399        let ids = |target: &ActionTarget| -> Vec<String> {
400            actions
401                .for_target(target, &own)
402                .into_iter()
403                .map(|action| action.id)
404                .collect()
405        };
406        assert_eq!(
407            ids(&target(TargetKind::File, "main.rs")),
408            ["open", "copy", "edit", "rust"]
409        );
410        assert_eq!(ids(&target(TargetKind::Row, "a\tb")), ["open", "copy"]);
411        // An item's own action wins over a view's of the same id.
412        let shadow = [Action::menu("copy", "Copy path")];
413        let found = actions.for_target(&target(TargetKind::Item, "x"), &shadow);
414        assert_eq!(found[0].label, "Copy path");
415        assert_eq!(found.len(), 1);
416        assert_eq!(TargetKind::parse("node"), Some(TargetKind::Node));
417        assert_eq!(TargetKind::Node.as_str(), "node");
418    }
419
420    #[test]
421    fn plugin_actions_come_from_the_registry() {
422        use rich_plugin_api::{CustomAction, Plugin, PluginError, PluginMetadata, PluginRegistrar};
423        struct Reveal;
424        impl CustomAction for Reveal {
425            fn label(&self) -> String {
426                "Reveal".into()
427            }
428            fn key(&self) -> Option<String> {
429                Some("ctrl+r".into())
430            }
431            fn applies(&self, kind: &str, value: &str) -> bool {
432                kind == "file" && !value.starts_with('.')
433            }
434        }
435        struct Files;
436        impl Plugin for Files {
437            fn metadata(&self) -> PluginMetadata {
438                PluginMetadata::new("files", "Files", "0.0.0")
439            }
440            fn register(&self, registrar: &mut dyn PluginRegistrar) -> Result<(), PluginError> {
441                registrar.action("reveal", Arc::new(Reveal));
442                Ok(())
443            }
444        }
445        let mut registry = rich_ext::registry::ExtensionRegistry::new();
446        registry.add_plugin(&Files).unwrap();
447        let actions = Actions::from_registry(&registry);
448        let found = actions.for_target(&target(TargetKind::File, "a.txt"), &[]);
449        assert_eq!(found, [Action::new("reveal", "Reveal", Key::ctrl('r'))]);
450        assert!(
451            actions
452                .for_target(&target(TargetKind::File, ".env"), &[])
453                .is_empty()
454        );
455        assert!(
456            actions
457                .for_target(&target(TargetKind::Item, "a.txt"), &[])
458                .is_empty()
459        );
460    }
461}