zi_term/
lib.rs

1//! A terminal backend implementation for [Zi](https://docs.rs/zi) using
2//! [crossterm](https://docs.rs/crossterm)
3mod error;
4mod painter;
5mod utils;
6
7pub use self::error::{Error, Result};
8
9use crossterm::{self, queue, QueueableCommand};
10use futures::stream::{Stream, StreamExt};
11use std::{
12    io::{self, BufWriter, Stdout, Write},
13    pin::Pin,
14    time::{Duration, Instant},
15};
16use tokio::{
17    self,
18    runtime::{Builder as RuntimeBuilder, Runtime},
19    sync::mpsc::{self, UnboundedReceiver, UnboundedSender},
20};
21
22use self::{
23    painter::{FullPainter, IncrementalPainter, PaintOperation, Painter},
24    utils::MeteredWriter,
25};
26use zi::{
27    app::{App, ComponentMessage, MessageSender},
28    terminal::{Canvas, Colour, Key, Size, Style},
29    Layout,
30};
31
32/// Creates a new backend with an incremental painter. It only draws those
33/// parts of the terminal that have changed since last drawn.
34///
35/// ```no_run
36/// # use zi::prelude::*;
37/// # use zi::components::text::{Text, TextProperties};
38/// fn main() -> zi_term::Result<()> {
39///     zi_term::incremental()?
40///         .run_event_loop(Text::with(TextProperties::new().content("Hello, world!")))
41/// }
42/// ```
43pub fn incremental() -> Result<Crossterm<IncrementalPainter>> {
44    Crossterm::<IncrementalPainter>::new()
45}
46
47/// Creates a new backend with a full painter. It redraws the whole canvas on
48/// every canvas.
49///
50/// ```no_run
51/// # use zi::prelude::*;
52/// # use zi::components::text::{Text, TextProperties};
53/// fn main() -> zi_term::Result<()> {
54///     zi_term::full()?
55///         .run_event_loop(Text::with(TextProperties::new().content("Hello, world!")))
56/// }
57/// ```
58pub fn full() -> Result<Crossterm<FullPainter>> {
59    Crossterm::<FullPainter>::new()
60}
61
62/// A terminal backend implementation for [Zi](https://docs.rs/zi) using
63/// [crossterm](https://docs.rs/crossterm)
64///
65/// ```no_run
66/// # use zi::prelude::*;
67/// # use zi::components::text::{Text, TextProperties};
68/// fn main() -> zi_term::Result<()> {
69///     zi_term::incremental()?
70///         .run_event_loop(Text::with(TextProperties::new().content("Hello, world!")))
71/// }
72/// ```
73pub struct Crossterm<PainterT: Painter = IncrementalPainter> {
74    target: MeteredWriter<BufWriter<Stdout>>,
75    painter: PainterT,
76    events: Option<EventStream>,
77    link: LinkChannel,
78}
79
80impl<PainterT: Painter> Crossterm<PainterT> {
81    /// Create a new backend instance.
82    ///
83    /// This method initialises the underlying tty device, enables raw mode,
84    /// hides the cursor and enters alternative screen mode. Additionally, an
85    /// async event stream with input events from stdin is started.
86    pub fn new() -> Result<Self> {
87        let mut backend = Self {
88            target: MeteredWriter::new(BufWriter::with_capacity(1 << 20, io::stdout())),
89            painter: PainterT::create(
90                crossterm::terminal::size()
91                    .map(|(width, height)| Size::new(width as usize, height as usize))?,
92            ),
93            events: Some(new_event_stream()),
94            link: LinkChannel::new(),
95        };
96        initialise_tty::<PainterT, _>(&mut backend.target)?;
97        Ok(backend)
98    }
99
100    /// Starts the event loop. This is the main entry point of a Zi application.
101    /// It draws and presents the components to the backend, handles user input
102    /// and delivers messages to components. This method returns either when
103    /// prompted using the [`exit`](struct.ComponentLink.html#method.exit)
104    /// method on [`ComponentLink`](struct.ComponentLink.html) or on error.
105    ///
106    /// ```no_run
107    /// # use zi::prelude::*;
108    /// # use zi::components::text::{Text, TextProperties};
109    /// fn main() -> zi_term::Result<()> {
110    ///     zi_term::incremental()?
111    ///         .run_event_loop(Text::with(TextProperties::new().content("Hello, world!")))
112    /// }
113    /// ```
114    pub fn run_event_loop(&mut self, layout: Layout) -> Result<()> {
115        let mut tokio_runtime = RuntimeBuilder::new_current_thread().enable_all().build()?;
116        let mut app = App::new(
117            UnboundedMessageSender(self.link.sender.clone()),
118            self.size()?,
119            layout,
120        );
121
122        while !app.poll_state().exit() {
123            let canvas = app.draw();
124
125            let last_drawn = Instant::now();
126            let num_bytes_presented = self.present(canvas)?;
127            let presented_time = last_drawn.elapsed();
128
129            log::debug!(
130                "Frame: pres {:.1}ms diff {}b",
131                presented_time.as_secs_f64() * 1000.0,
132                num_bytes_presented,
133            );
134
135            self.poll_events_batch(&mut tokio_runtime, &mut app, last_drawn)?;
136        }
137
138        Ok(())
139    }
140
141    /// Suspends the event stream.
142    ///
143    /// This is used when running something that needs exclusive access to the underlying
144    /// terminal (i.e. to stdin and stdout). For example spawning an external editor to collect
145    /// or display text. The `resume` function is called upon returning to the application.
146    #[inline]
147    pub fn suspend(&mut self) -> Result<()> {
148        self.events = None;
149        Ok(())
150    }
151
152    /// Recreates the event stream and reinitialises the underlying terminal.
153    ///
154    /// This function is used to return execution to the application after running something
155    /// that needs exclusive access to the underlying backend. It will only be called after a
156    /// call to `suspend`.
157    ///
158    /// In addition to restarting the event stream, this function should perform any other
159    /// required initialisation of the backend. For ANSI terminals, this typically hides the
160    /// cursor and saves the current screen content (i.e. "alternative screen mode") in order
161    /// to restore the previous terminal content on exit.
162    #[inline]
163    pub fn resume(&mut self) -> Result<()> {
164        self.painter = PainterT::create(self.size()?);
165        self.events = Some(new_event_stream());
166        initialise_tty::<PainterT, _>(&mut self.target)
167    }
168
169    /// Poll as many events as we can respecting REDRAW_LATENCY and REDRAW_LATENCY_SUSTAINED_IO
170    #[inline]
171    fn poll_events_batch(
172        &mut self,
173        runtime: &mut Runtime,
174        app: &mut App,
175        last_drawn: Instant,
176    ) -> Result<()> {
177        let Self {
178            ref mut link,
179            ref mut events,
180            ..
181        } = *self;
182        let mut force_redraw = false;
183        let mut first_event_time: Option<Instant> = None;
184
185        while !force_redraw && !app.poll_state().exit() {
186            let timeout_duration = {
187                let since_last_drawn = last_drawn.elapsed();
188                if app.poll_state().dirty() && since_last_drawn >= REDRAW_LATENCY {
189                    Duration::from_millis(0)
190                } else if app.poll_state().dirty() {
191                    REDRAW_LATENCY - since_last_drawn
192                } else {
193                    Duration::from_millis(if app.is_tickable() { 60 } else { 60_000 })
194                }
195            };
196            (runtime.block_on(async {
197                tokio::select! {
198                    link_message = link.receiver.recv() => {
199                        app.handle_message(
200                            link_message.expect("at least one sender exists"),
201                        );
202                        Ok(())
203                    }
204                    input_event = events.as_mut().expect("backend events are suspended").next() => {
205                        match input_event.expect(
206                            "at least one sender exists",
207                        )? {
208                            FilteredEvent::Input(input_event) => app.handle_input(input_event),
209                            FilteredEvent::Resize(size) => app.handle_resize(size),
210                        };
211                        force_redraw = app.poll_state().dirty()
212                            && (first_event_time.get_or_insert_with(Instant::now).elapsed()
213                                >= SUSTAINED_IO_REDRAW_LATENCY
214                                || app.poll_state().resized());
215                        Ok(())
216                    }
217                    _ = tokio::time::sleep(timeout_duration) => {
218                        app.tick();
219                        force_redraw = true;
220                        Ok(())
221                    }
222                }
223            }) as Result<()>)?;
224        }
225
226        Ok(())
227    }
228
229    /// Returns the size of the underlying terminal.
230    #[inline]
231    fn size(&self) -> Result<Size> {
232        Ok(crossterm::terminal::size()
233            .map(|(width, height)| Size::new(width as usize, height as usize))?)
234    }
235
236    /// Draws the [`Canvas`](../terminal/struct.Canvas.html) to the terminal.
237    #[inline]
238    fn present(&mut self, canvas: &Canvas) -> Result<usize> {
239        let Self {
240            ref mut target,
241            ref mut painter,
242            ..
243        } = *self;
244        let initial_num_bytes_written = target.num_bytes_written();
245        painter.paint(canvas, |operation| {
246            match operation {
247                PaintOperation::WriteContent(grapheme) => {
248                    queue!(target, crossterm::style::Print(grapheme))?
249                }
250                PaintOperation::SetStyle(style) => queue_set_style(target, style)?,
251                PaintOperation::MoveTo(position) => queue!(
252                    target,
253                    crossterm::cursor::MoveTo(position.x as u16, position.y as u16)
254                )?, // Go to the begining of line (`MoveTo` uses 0-based indexing)
255            }
256            Ok(())
257        })?;
258        target.flush()?;
259        Ok(target.num_bytes_written() - initial_num_bytes_written)
260    }
261}
262
263impl<PainterT: Painter> Drop for Crossterm<PainterT> {
264    fn drop(&mut self) {
265        queue!(
266            self.target,
267            crossterm::style::ResetColor,
268            crossterm::terminal::Clear(crossterm::terminal::ClearType::All),
269            crossterm::cursor::Show,
270            crossterm::terminal::LeaveAlternateScreen
271        )
272        .expect("Failed to clear screen when closing `crossterm` backend");
273        crossterm::terminal::disable_raw_mode()
274            .expect("Failed to disable raw mode when closing `crossterm` backend");
275        self.target
276            .flush()
277            .expect("Failed to flush when closing `crossterm` backend");
278    }
279}
280
281const REDRAW_LATENCY: Duration = Duration::from_millis(10);
282const SUSTAINED_IO_REDRAW_LATENCY: Duration = Duration::from_millis(100);
283
284struct LinkChannel {
285    sender: UnboundedSender<ComponentMessage>,
286    receiver: UnboundedReceiver<ComponentMessage>,
287}
288
289impl LinkChannel {
290    fn new() -> Self {
291        let (sender, receiver) = mpsc::unbounded_channel();
292        Self { sender, receiver }
293    }
294}
295
296#[derive(Debug, Clone)]
297struct UnboundedMessageSender(UnboundedSender<ComponentMessage>);
298
299impl MessageSender for UnboundedMessageSender {
300    fn send(&self, message: ComponentMessage) {
301        self.0
302            .send(message)
303            .map_err(|_| ()) // tokio's SendError doesn't implement Debug
304            .expect("App receiver needs to outlive senders for inter-component messages");
305    }
306
307    fn clone_box(&self) -> Box<dyn MessageSender> {
308        Box::new(self.clone())
309    }
310}
311
312#[inline]
313fn initialise_tty<PainterT: Painter, TargetT: Write>(target: &mut TargetT) -> Result<()> {
314    target
315        .queue(crossterm::terminal::EnterAlternateScreen)?
316        .queue(crossterm::cursor::Hide)?;
317    crossterm::terminal::enable_raw_mode()?;
318    queue_set_style(target, &PainterT::INITIAL_STYLE)?;
319    target.flush()?;
320    Ok(())
321}
322
323#[inline]
324fn queue_set_style(target: &mut impl Write, style: &Style) -> Result<()> {
325    use crossterm::style::{
326        Attribute, Color, SetAttribute, SetBackgroundColor, SetForegroundColor,
327    };
328
329    // Bold
330    if style.bold {
331        queue!(target, SetAttribute(Attribute::Bold))?;
332    } else {
333        // Using Reset is not ideal as it resets all style attributes. The correct thing to do
334        // would be to use `NoBold`, but it seems this is not reliably supported (at least it
335        // didn't work for me in tmux, although it does in alacritty).
336        // Also see https://github.com/crossterm-rs/crossterm/issues/294
337        queue!(target, SetAttribute(Attribute::Reset))?;
338    }
339
340    // Underline
341    if style.underline {
342        queue!(target, SetAttribute(Attribute::Underlined))?;
343    } else {
344        queue!(target, SetAttribute(Attribute::NoUnderline))?;
345    }
346
347    // Background
348    {
349        let Colour { red, green, blue } = style.background;
350        queue!(
351            target,
352            SetBackgroundColor(Color::Rgb {
353                r: red,
354                g: green,
355                b: blue
356            })
357        )?;
358    }
359
360    // Foreground
361    {
362        let Colour { red, green, blue } = style.foreground;
363        queue!(
364            target,
365            SetForegroundColor(Color::Rgb {
366                r: red,
367                g: green,
368                b: blue
369            })
370        )?;
371    }
372
373    Ok(())
374}
375
376enum FilteredEvent {
377    Input(zi::terminal::Event),
378    Resize(Size),
379}
380
381type EventStream = Pin<Box<dyn Stream<Item = Result<FilteredEvent>> + Send + 'static>>;
382
383#[inline]
384fn new_event_stream() -> EventStream {
385    Box::pin(
386        crossterm::event::EventStream::new()
387            .filter_map(|event| async move {
388                match event {
389                    Ok(crossterm::event::Event::Key(key_event)) => Some(Ok(FilteredEvent::Input(
390                        zi::terminal::Event::KeyPress(map_key(key_event)),
391                    ))),
392                    Ok(crossterm::event::Event::Resize(width, height)) => Some(Ok(
393                        FilteredEvent::Resize(Size::new(width as usize, height as usize)),
394                    )),
395                    Ok(_) => None,
396                    Err(error) => Some(Err(error.into())),
397                }
398            })
399            .fuse(),
400    )
401}
402
403#[inline]
404fn map_key(key: crossterm::event::KeyEvent) -> Key {
405    use crossterm::event::{KeyCode, KeyModifiers};
406    match key.code {
407        KeyCode::Backspace => Key::Backspace,
408        KeyCode::Left => Key::Left,
409        KeyCode::Right => Key::Right,
410        KeyCode::Up => Key::Up,
411        KeyCode::Down => Key::Down,
412        KeyCode::Home => Key::Home,
413        KeyCode::End => Key::End,
414        KeyCode::PageUp => Key::PageUp,
415        KeyCode::PageDown => Key::PageDown,
416        KeyCode::BackTab => Key::BackTab,
417        KeyCode::Delete => Key::Delete,
418        KeyCode::Insert => Key::Insert,
419        KeyCode::F(u8) => Key::F(u8),
420        KeyCode::Null => Key::Null,
421        KeyCode::Esc => Key::Esc,
422        KeyCode::Char(char) if key.modifiers.contains(KeyModifiers::CONTROL) => Key::Ctrl(char),
423        KeyCode::Char(char) if key.modifiers.contains(KeyModifiers::ALT) => Key::Alt(char),
424        KeyCode::Char(char) => Key::Char(char),
425        KeyCode::Enter => Key::Char('\n'),
426        KeyCode::Tab => Key::Char('\t'),
427    }
428}