Skip to main content

rich_embed/
pane.rs

1//! The terminal pane: any program, drawn in a node of the app.
2
3use std::cell::RefCell;
4use std::rc::Rc;
5
6use rich::Style;
7use rich_intuituive::interact::{Key, KeyCode};
8use rich_intuituive::widget::{Canvas, DrawCx, EventCx, Used, Widget, WidgetEvent, widget};
9use rich_intuituive::{Ctx, Node, Signal, signal};
10
11use crate::host::{Command, ExitStatus, LocalPty, Notify, PtyHost};
12use crate::term::TermCore;
13use crate::wake::{Fed, connect};
14
15/// Rows a pane keeps that scrolled off its top, unless told otherwise.
16pub const DEFAULT_SCROLLBACK: usize = 1000;
17
18type ExitHandler = Box<dyn FnMut(ExitStatus, &mut Ctx)>;
19
20struct PaneState {
21    core: TermCore,
22    /// Bumped when the screen changed: the pane's draw reads it.
23    wake: Signal<u64>,
24    status: Signal<Option<ExitStatus>>,
25    on_exit: Option<ExitHandler>,
26}
27
28impl Fed for PaneState {
29    fn install(&mut self, notify: Notify) {
30        self.core.set_notify(notify);
31    }
32
33    fn pump(this: &Rc<RefCell<Self>>, cx: &mut Ctx) {
34        let (pumped, wake, status) = {
35            let mut state = this.borrow_mut();
36            (state.core.pump(), state.wake, state.status)
37        };
38        if pumped.output || pumped.exit.is_some() {
39            wake.update(|n| *n = n.wrapping_add(1));
40        }
41        if let Some(exit) = pumped.exit {
42            status.set(Some(exit.clone()));
43            let handler = this.borrow_mut().on_exit.take();
44            if let Some(mut handler) = handler {
45                handler(exit, cx);
46            }
47        }
48    }
49}
50
51/// A terminal pane being built: [`terminal`] or [`terminal_with`], then
52/// [`node`](Self::node) (or `.into()`) to place it.
53///
54/// The program starts when the pane is first laid out, at the pane's size,
55/// and is told every new size after. While the pane has the focus, every
56/// key goes to the program (Ctrl+C and Tab included) except the ones
57/// [`release_keys`](Self::release_keys) names, which go to the app; the
58/// mouse goes to it when it asked for the mouse, and pastes always do.
59/// Shift+PgUp and Shift+PgDn, and the wheel when the program does not want
60/// the mouse, scroll back through what scrolled off the top; any other key
61/// returns to the live screen. When the program exits its last screen
62/// stays, [`status`](Self::status) is set, [`on_exit`](Self::on_exit)
63/// runs, and keys go to the app again. The program is ended when the pane
64/// leaves the tree.
65pub struct TerminalPane {
66    state: Rc<RefCell<PaneState>>,
67    release: Vec<Key>,
68}
69
70/// A pane running `command` on this machine through a [`LocalPty`]: a
71/// program name, or an array of the program and its arguments.
72///
73/// ```no_run
74/// # extern crate rich_intuituive as intuituive;
75/// use intuituive::prelude::*;
76/// use rich_embed::terminal;
77///
78/// App::new(|| {
79///     row([
80///         terminal("bash").on_exit(|_, cx| cx.quit()).node().panel("shell"),
81///         terminal(["htop", "-d", "10"]).node().panel("htop"),
82///     ])
83/// })
84/// .run()
85/// # ; Ok::<(), std::io::Error>(())
86/// ```
87///
88/// # Panics
89/// Outside a running or building app, like [`signal`].
90pub fn terminal(command: impl Into<Command>) -> TerminalPane {
91    terminal_with(LocalPty::new(command))
92}
93
94/// A pane over any [`PtyHost`]: an SSH session, a container, a
95/// [`ReplayHost`](crate::ReplayHost) in a test.
96///
97/// # Panics
98/// Outside a running or building app, like [`signal`].
99pub fn terminal_with(host: impl PtyHost + 'static) -> TerminalPane {
100    let state = Rc::new(RefCell::new(PaneState {
101        core: TermCore::new(Box::new(host), DEFAULT_SCROLLBACK),
102        wake: signal(0),
103        status: signal(None),
104        on_exit: None,
105    }));
106    connect(&state);
107    TerminalPane {
108        state,
109        // Released by default: nothing. The pane is a terminal.
110        release: Vec::new(),
111    }
112}
113
114impl TerminalPane {
115    /// Run `handler` on the app's thread when the program exits, with how
116    /// it ended.
117    #[must_use]
118    pub fn on_exit(self, handler: impl FnMut(ExitStatus, &mut Ctx) + 'static) -> TerminalPane {
119        self.state.borrow_mut().on_exit = Some(Box::new(handler));
120        self
121    }
122
123    /// How the program ended: `None` while it runs. Read it in a node to
124    /// show it.
125    pub fn status(&self) -> Signal<Option<ExitStatus>> {
126        self.state.borrow().status
127    }
128
129    /// Keep up to `rows` rows that scrolled off the top (default
130    /// [`DEFAULT_SCROLLBACK`]). Before the pane is first laid out.
131    #[must_use]
132    pub fn scrollback(self, rows: usize) -> TerminalPane {
133        self.state.borrow_mut().core.set_scrollback(rows);
134        self
135    }
136
137    /// Keys (space-separated names, as [`Node::on_key`] takes them) that
138    /// are not sent to the program but go on to the app's bindings: a way
139    /// out of the pane, such as `"ctrl+q f10"`.
140    #[must_use]
141    pub fn release_keys(mut self, keys: &str) -> TerminalPane {
142        self.release
143            .extend(keys.split_whitespace().filter_map(Key::parse));
144        self
145    }
146
147    /// The pane as a node: focusable, and flexible like any node until
148    /// sized.
149    pub fn node(self) -> Node {
150        widget(PaneWidget {
151            state: self.state,
152            release: self.release,
153            caret: None,
154        })
155        // The app quits on Ctrl+C unless a node on the focused path binds
156        // it: bound here, so it reaches the program. Once the program has
157        // exited the pane leaves it to this binding, which quits as before.
158        .on_key("ctrl+c", |cx| cx.quit())
159    }
160}
161
162impl From<TerminalPane> for Node {
163    fn from(pane: TerminalPane) -> Node {
164        pane.node()
165    }
166}
167
168struct PaneWidget {
169    state: Rc<RefCell<PaneState>>,
170    release: Vec<Key>,
171    caret: Option<(u16, u16)>,
172}
173
174impl Widget for PaneWidget {
175    fn name(&self) -> &'static str {
176        "terminal"
177    }
178
179    fn describe(&self) -> Option<String> {
180        let state = self.state.borrow();
181        Some(match &state.core.exit {
182            Some(status) => status.to_string(),
183            None if state.core.started() => "running".into(),
184            None => "not started".into(),
185        })
186    }
187
188    fn draw(&mut self, cx: &mut DrawCx, canvas: &mut Canvas) {
189        let mut state = self.state.borrow_mut();
190        state.wake.get();
191        let (width, height) = (canvas.width(), canvas.height());
192        state.core.fit(width, height);
193        if let Some(error) = state.core.error.clone() {
194            let style = cx.style("error", "red");
195            canvas.markup(cx.console(), 0, 0, width, &escape(&error), Some(&style));
196            self.caret = None;
197            return;
198        }
199        let lines = state.core.lines();
200        canvas.lines(&lines);
201        let back = state.core.scrolled();
202        if back > 0 {
203            let note = format!(" ↑{back} ");
204            let w = rich::cells::cell_len(&note) as u16;
205            let style = Style::parse("reverse").unwrap_or_default();
206            canvas.print(width.saturating_sub(w), 0, &note, Some(&style));
207        }
208        self.caret = state.core.cursor();
209    }
210
211    fn event(&mut self, cx: &mut EventCx, event: &WidgetEvent) -> Used {
212        let mut state = self.state.borrow_mut();
213        // No program to give input to: it exited, has not started, or
214        // could not start. Keys go back to the app.
215        if state.core.exit.is_some() || state.core.error.is_some() || !state.core.started() {
216            return Used::No;
217        }
218        match event {
219            WidgetEvent::Key(key) => {
220                if self.release.contains(key) {
221                    return Used::No;
222                }
223                let page = cx.size().1.max(1) as isize;
224                match (key.code, key.modifiers.shift) {
225                    (KeyCode::PageUp, true) => state.core.scroll_by(page),
226                    (KeyCode::PageDown, true) => state.core.scroll_by(-page),
227                    _ => state.core.key(*key),
228                }
229                cx.redraw();
230                Used::Yes
231            }
232            WidgetEvent::Paste(text) => {
233                state.core.paste(text);
234                cx.redraw();
235                Used::Yes
236            }
237            WidgetEvent::Mouse(mouse) => {
238                if !state.core.mouse(*mouse) {
239                    return Used::No;
240                }
241                if state.core.wants_mouse() {
242                    if mouse.kind
243                        == rich_intuituive::interact::MouseKind::Down(
244                            rich_intuituive::interact::Button::Left,
245                        )
246                    {
247                        cx.capture_mouse();
248                    }
249                } else {
250                    cx.redraw();
251                }
252                Used::Yes
253            }
254            _ => Used::No,
255        }
256    }
257
258    fn focusable(&self) -> bool {
259        true
260    }
261
262    fn caret(&self) -> Option<(u16, u16)> {
263        self.caret
264    }
265}
266
267/// Text shown as console markup, as it is.
268fn escape(text: &str) -> String {
269    text.replace('[', "\\[")
270}