Skip to main content

rich_plugin_api/
component.rs

1//! Interactive components a plugin registers by name (0.0.14), with
2//! [`PluginRegistrar::component`](crate::PluginRegistrar::component).
3//!
4//! A plugin depends on this crate and core only, never on
5//! `rs-rich-interact`, so the contract here is its own small one: events
6//! arrive as [`ComponentEvent`]s, with keys by name (`down`, `ctrl+k`); a
7//! component answers with a [`ComponentFlow`] and renders a
8//! [`ComponentView`] of core [`Segment`]s. A host adapts it to its own
9//! component model: `rs-rich-interact`'s `PluginView` mounts one in a split,
10//! tabs or a modal beside the built-ins, and runs it through the same
11//! drivers, headless included.
12//!
13//! A component finishes with text ([`ComponentFlow::Done`]): whatever it
14//! chose or typed, for the app that mounted it to interpret.
15//!
16//! ```
17//! use rich_plugin_api::component::{
18//!     ComponentContext, ComponentEvent, ComponentFlow, ComponentView, PluginComponent,
19//! };
20//!
21//! /// Counts Up presses; Enter answers with the count.
22//! #[derive(Default)]
23//! struct Counter(u32);
24//!
25//! impl PluginComponent for Counter {
26//!     fn handle(&mut self, event: &ComponentEvent, _: &ComponentContext<'_>) -> ComponentFlow {
27//!         match event.key() {
28//!             Some("up") => self.0 += 1,
29//!             Some("enter") => return ComponentFlow::Done(self.0.to_string()),
30//!             _ => return ComponentFlow::Ignored,
31//!         }
32//!         ComponentFlow::Continue
33//!     }
34//!
35//!     fn render(&self, context: &ComponentContext<'_>) -> ComponentView {
36//!         ComponentView::new(context.markup(&format!("count: [bold]{}[/]", self.0)))
37//!     }
38//! }
39//!
40//! let console = rich::Console::new();
41//! let context = ComponentContext::new(&console, 20, 1);
42//! let mut counter = Counter::default();
43//! counter.handle(&ComponentEvent::Key("up".into()), &context);
44//! assert!(matches!(
45//!     counter.handle(&ComponentEvent::Key("enter".into()), &context),
46//!     ComponentFlow::Done(count) if count == "1"
47//! ));
48//! ```
49
50use std::sync::Arc;
51use std::time::Duration;
52
53use rich::{Console, Renderable, Segment, Text};
54
55/// Makes a fresh component each time a host mounts one, so one
56/// registration can be mounted many times.
57pub type ComponentFactory = Arc<dyn Fn() -> Box<dyn PluginComponent> + Send + Sync>;
58
59/// An event for a [`PluginComponent`].
60#[derive(Clone, Debug, PartialEq, Eq)]
61#[non_exhaustive]
62pub enum ComponentEvent {
63    /// A key, by name: `a`, `A`, `enter`, `down`, `space`, `tab`,
64    /// `backspace`, `f5`, with `ctrl+`, `alt+` and `shift+` before it, in
65    /// that order (`ctrl+shift+left`, `shift+tab`). A key that does one of
66    /// the component's [`bindings`](PluginComponent::bindings) arrives as
67    /// the first key declared for that action, spelled as declared (`esc`
68    /// stays `esc`), even when the user rebound it.
69    Key(String),
70    /// Text pasted at once.
71    Paste(String),
72    /// The terminal is now this size.
73    Resize {
74        /// The width in columns.
75        columns: u16,
76        /// The height in rows.
77        rows: u16,
78    },
79    /// The component's [`tick`](PluginComponent::tick) interval passed.
80    Tick,
81    /// The mouse, in the component's own view (0-based), when it asked for
82    /// it with [`mouse`](PluginComponent::mouse).
83    Mouse {
84        /// `down`, `up`, `drag`, `moved`, `scroll_up` or `scroll_down`.
85        kind: String,
86        /// The column, 0-based.
87        column: u16,
88        /// The row, 0-based.
89        row: u16,
90    },
91    /// A click on a hyperlink in the component's view: its URL.
92    Link(String),
93}
94
95impl ComponentEvent {
96    /// The key's name, when this is a key.
97    pub fn key(&self) -> Option<&str> {
98        match self {
99            ComponentEvent::Key(name) => Some(name),
100            _ => None,
101        }
102    }
103}
104
105/// What a component wants after an event.
106#[derive(Clone, Debug, PartialEq, Eq)]
107#[non_exhaustive]
108pub enum ComponentFlow {
109    /// Keep going; the view is rendered again.
110    Continue,
111    /// Finished, with an answer for the app that mounted it.
112    Done(String),
113    /// The user backed out.
114    Cancel,
115    /// The event was not for this component: the container it is in may
116    /// use it (Tab to move focus, a binding of its own).
117    Ignored,
118}
119
120/// A rendered view: lines of segments, and where the terminal cursor goes
121/// (a text caret), or `None` to hide it.
122#[derive(Clone, Debug, Default)]
123#[non_exhaustive]
124pub struct ComponentView {
125    /// The lines, top to bottom.
126    pub lines: Vec<Vec<Segment>>,
127    /// (row, column) within the view.
128    pub cursor: Option<(usize, usize)>,
129}
130
131impl ComponentView {
132    /// A view of `lines`, with the cursor hidden.
133    pub fn new(lines: Vec<Vec<Segment>>) -> ComponentView {
134        ComponentView {
135            lines,
136            cursor: None,
137        }
138    }
139
140    /// Put the cursor at `row`, `column` of the view.
141    #[must_use]
142    pub fn with_cursor(mut self, row: usize, column: usize) -> ComponentView {
143        self.cursor = Some((row, column));
144        self
145    }
146}
147
148/// What a component renders and handles events for: the console to render
149/// with, and the space it has.
150#[non_exhaustive]
151pub struct ComponentContext<'a> {
152    /// The console to render with.
153    pub console: &'a Console,
154    /// Columns available.
155    pub width: usize,
156    /// Rows available; a taller view is cut off at the bottom.
157    pub height: usize,
158}
159
160impl<'a> ComponentContext<'a> {
161    /// A context for rendering with `console` in `width` x `height` cells.
162    pub fn new(console: &'a Console, width: usize, height: usize) -> ComponentContext<'a> {
163        ComponentContext {
164            console,
165            width,
166            height,
167        }
168    }
169
170    /// Render `renderable` at the context's width into lines.
171    pub fn lines(&self, renderable: &dyn Renderable) -> Vec<Vec<Segment>> {
172        let options = self.console.options().update_width(self.width.max(1));
173        self.console.render_lines(renderable, &options, false)
174    }
175
176    /// Render console markup into lines. Markup that does not parse is
177    /// shown as it is.
178    pub fn markup(&self, markup: &str) -> Vec<Vec<Segment>> {
179        let text = Text::from_markup(markup).unwrap_or_else(|_| Text::new(markup));
180        self.lines(&text)
181    }
182}
183
184/// One thing a key does in a component, for a help overlay, a shortcut
185/// sheet or status-bar hints. A host lists it under the component's
186/// registered name, and configuration can rebind it there.
187#[derive(Clone, Debug, PartialEq, Eq)]
188#[non_exhaustive]
189pub struct ComponentBinding {
190    /// What it does, for code and configuration: `down`.
191    pub action: String,
192    /// The keys that do it, by name (see [`ComponentEvent::Key`]); the
193    /// first is the one to show.
194    pub keys: Vec<String>,
195    /// What it does, for people: `move down`.
196    pub description: String,
197}
198
199impl ComponentBinding {
200    /// A binding of `keys` to `action`, described for people as `description`.
201    pub fn new(
202        action: impl Into<String>,
203        keys: impl IntoIterator<Item = impl Into<String>>,
204        description: impl Into<String>,
205    ) -> ComponentBinding {
206        ComponentBinding {
207            action: action.into(),
208            keys: keys.into_iter().map(Into::into).collect(),
209            description: description.into(),
210        }
211    }
212}
213
214/// An interactive component: a state machine that handles one event at a
215/// time and renders its state. Registered with
216/// [`PluginRegistrar::component`](crate::PluginRegistrar::component)
217/// through a [`ComponentFactory`].
218///
219/// Key presses and pastes come as text from the user, and so does anything
220/// a component shows of them: treat them as untrusted. The host keeps
221/// terminal controls out of what is painted.
222pub trait PluginComponent: Send {
223    /// Handle one event. Ctrl+C never arrives: the host ends the run.
224    fn handle(&mut self, event: &ComponentEvent, context: &ComponentContext<'_>) -> ComponentFlow;
225
226    /// Render the current state.
227    fn render(&self, context: &ComponentContext<'_>) -> ComponentView;
228
229    /// The keys it uses now, for help and hints. None, by default.
230    fn bindings(&self) -> Vec<ComponentBinding> {
231        Vec::new()
232    }
233
234    /// Whether it takes focus in a container. Yes, by default; a view that
235    /// only shows something says no.
236    fn focusable(&self) -> bool {
237        true
238    }
239
240    /// How often it wants [`ComponentEvent::Tick`]. Never, by default.
241    fn tick(&self) -> Option<Duration> {
242        None
243    }
244
245    /// Whether it wants mouse events. No, by default: reporting the mouse
246    /// takes text selection away from the terminal.
247    fn mouse(&self) -> bool {
248        false
249    }
250}