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}