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 { columns: u16, rows: u16 },
74 /// The component's [`tick`](PluginComponent::tick) interval passed.
75 Tick,
76 /// The mouse, in the component's own view (0-based), when it asked for
77 /// it with [`mouse`](PluginComponent::mouse).
78 Mouse {
79 /// `down`, `up`, `drag`, `moved`, `scroll_up` or `scroll_down`.
80 kind: String,
81 column: u16,
82 row: u16,
83 },
84 /// A click on a hyperlink in the component's view: its URL.
85 Link(String),
86}
87
88impl ComponentEvent {
89 /// The key's name, when this is a key.
90 pub fn key(&self) -> Option<&str> {
91 match self {
92 ComponentEvent::Key(name) => Some(name),
93 _ => None,
94 }
95 }
96}
97
98/// What a component wants after an event.
99#[derive(Clone, Debug, PartialEq, Eq)]
100#[non_exhaustive]
101pub enum ComponentFlow {
102 /// Keep going; the view is rendered again.
103 Continue,
104 /// Finished, with an answer for the app that mounted it.
105 Done(String),
106 /// The user backed out.
107 Cancel,
108 /// The event was not for this component: the container it is in may
109 /// use it (Tab to move focus, a binding of its own).
110 Ignored,
111}
112
113/// A rendered view: lines of segments, and where the terminal cursor goes
114/// (a text caret), or `None` to hide it.
115#[derive(Clone, Debug, Default)]
116#[non_exhaustive]
117pub struct ComponentView {
118 pub lines: Vec<Vec<Segment>>,
119 /// (row, column) within the view.
120 pub cursor: Option<(usize, usize)>,
121}
122
123impl ComponentView {
124 pub fn new(lines: Vec<Vec<Segment>>) -> ComponentView {
125 ComponentView {
126 lines,
127 cursor: None,
128 }
129 }
130
131 /// Put the cursor at `row`, `column` of the view.
132 pub fn with_cursor(mut self, row: usize, column: usize) -> ComponentView {
133 self.cursor = Some((row, column));
134 self
135 }
136}
137
138/// What a component renders and handles events for: the console to render
139/// with, and the space it has.
140#[non_exhaustive]
141pub struct ComponentContext<'a> {
142 pub console: &'a Console,
143 /// Columns available.
144 pub width: usize,
145 /// Rows available; a taller view is cut off at the bottom.
146 pub height: usize,
147}
148
149impl<'a> ComponentContext<'a> {
150 pub fn new(console: &'a Console, width: usize, height: usize) -> ComponentContext<'a> {
151 ComponentContext {
152 console,
153 width,
154 height,
155 }
156 }
157
158 /// Render `renderable` at the context's width into lines.
159 pub fn lines(&self, renderable: &dyn Renderable) -> Vec<Vec<Segment>> {
160 let options = self.console.options().update_width(self.width.max(1));
161 self.console.render_lines(renderable, &options, false)
162 }
163
164 /// Render console markup into lines. Markup that does not parse is
165 /// shown as it is.
166 pub fn markup(&self, markup: &str) -> Vec<Vec<Segment>> {
167 let text = Text::from_markup(markup).unwrap_or_else(|_| Text::new(markup));
168 self.lines(&text)
169 }
170}
171
172/// One thing a key does in a component, for a help overlay, a shortcut
173/// sheet or status-bar hints. A host lists it under the component's
174/// registered name, and configuration can rebind it there.
175#[derive(Clone, Debug, PartialEq, Eq)]
176#[non_exhaustive]
177pub struct ComponentBinding {
178 /// What it does, for code and configuration: `down`.
179 pub action: String,
180 /// The keys that do it, by name (see [`ComponentEvent::Key`]); the
181 /// first is the one to show.
182 pub keys: Vec<String>,
183 /// What it does, for people: `move down`.
184 pub description: String,
185}
186
187impl ComponentBinding {
188 pub fn new(
189 action: impl Into<String>,
190 keys: impl IntoIterator<Item = impl Into<String>>,
191 description: impl Into<String>,
192 ) -> ComponentBinding {
193 ComponentBinding {
194 action: action.into(),
195 keys: keys.into_iter().map(Into::into).collect(),
196 description: description.into(),
197 }
198 }
199}
200
201/// An interactive component: a state machine that handles one event at a
202/// time and renders its state. Registered with
203/// [`PluginRegistrar::component`](crate::PluginRegistrar::component)
204/// through a [`ComponentFactory`].
205///
206/// Key presses and pastes come as text from the user, and so does anything
207/// a component shows of them: treat them as untrusted. The host keeps
208/// terminal controls out of what is painted.
209pub trait PluginComponent: Send {
210 /// Handle one event. Ctrl+C never arrives: the host ends the run.
211 fn handle(&mut self, event: &ComponentEvent, context: &ComponentContext<'_>) -> ComponentFlow;
212
213 /// Render the current state.
214 fn render(&self, context: &ComponentContext<'_>) -> ComponentView;
215
216 /// The keys it uses now, for help and hints. None, by default.
217 fn bindings(&self) -> Vec<ComponentBinding> {
218 Vec::new()
219 }
220
221 /// Whether it takes focus in a container. Yes, by default; a view that
222 /// only shows something says no.
223 fn focusable(&self) -> bool {
224 true
225 }
226
227 /// How often it wants [`ComponentEvent::Tick`]. Never, by default.
228 fn tick(&self) -> Option<Duration> {
229 None
230 }
231
232 /// Whether it wants mouse events. No, by default: reporting the mouse
233 /// takes text selection away from the terminal.
234 fn mouse(&self) -> bool {
235 false
236 }
237}