webatui/
lib.rs

1//! Run your TUI apps in the browser!
2//!
3//! Webatui is a glue crate for running [Ratatui](https://crates.io/crates/ratatui)-based TUI apps
4//! in the broswer with the help of [Yew](https://crates.io/crates/yew). Currently, this crate is
5//! centered around transforming the text-based displays that ratatui generates into HTML
6//! DOM-elements. Some interactivity is supported, like hyperlinks, on-click callbacks, and
7//! scrolling (both on mobile devices and with a mouse); however, webatui is not a fully
8//! interactive terminal yet. Many things, such as cursor location, editing text, etc, are not
9//! supported. Other things, like getting keyboard inputs, are possible via the
10//! [web-sys](https://crates.io/crates/web-sys) crate, but there is no direct ability to setup and
11//! configure this, yet.
12//!
13//! Many of the web-specific details have been abstracted away so that porting existing apps
14//! is as easy as possible. To get started, create a struct that will hold your app's logic,
15//! implement the [`TerminalApp`] trait for it, and run the [`run_tui`] function with an instance
16//! of your app.
17//! ```no_run
18//! use ratatui::{prelude::*, widgets::*};
19//! use webatui::prelude::*;
20//!
21//! #[derive(PartialEq, Clone)]
22//! struct MyApp {
23//!   title: String,
24//! }
25//!
26//! impl TerminalApp for MyApp {
27//!     // This simple app does not update
28//!     type Message = ();
29//!
30//!     // This simple app does not update
31//!     fn update(&mut self, _: TermContext<'_, Self>, _: Self::Message) -> bool { false }
32//!
33//!     fn render(&self, area: Rect, frame: &mut Frame<'_>) {
34//!         let para = Paragraph::new(self.title.as_str());
35//!         frame.render_widget(para, area);
36//!     }
37//! }
38//!
39//! let my_app = MyApp { title: "Hello WWW!".into() };
40//! run_tui(my_app)
41//! ```
42//!
43//! Webatui closely follows Yew's flow for creating and rendering an app. Once create, an app can
44//! be updated via a message (which might be generated by a callback). After the app processes its
45//! update, it returns whether or not it needs to be re-rendered. The rendering process is split
46//! into two sub-steps (the main difference between webataui and yew). In the first step, the app
47//! renders widgets into the [`Terminal`]. Once rendered and flushed, the [`YewBackend`] converts
48//! the text into a series of spans. These spans are then used during the next sub-step, hydration.
49//! Hydration allows an app to attach additional data that can't be passed via a widget, namely
50//! callbacks and hyperlinks. To mark a stylible element as "in need of hydration", add the
51//! [`HYDRATION`](crate::backend::HYDRATION) modifier to its style or use the `to_hydrate` method
52//! available via the [`NeedsHydration`](crate::backend::NeedsHydration) trait. Once all the
53//! necessary spans have been hydrated, those spans are composed into a series of HTML `<pre>` tags
54//! and rendered into the DOM.
55//!
56//! A note about hydration: When a widget is rendered, the [`YewBackend`] gets it
57//! character-by-character. This limits the backend's ability to create blocks that need hydrated.
58//! So, a multi-line widget will be split into a series of dehydrated spans that will be hydrated
59//! individually.
60
61#![deny(
62    rustdoc::broken_intra_doc_links,
63    unreachable_pub,
64    unreachable_patterns,
65    unused,
66    unused_qualifications,
67    missing_docs,
68    while_true,
69    trivial_casts,
70    trivial_bounds,
71    trivial_numeric_casts,
72    unconditional_panic,
73    clippy::all
74)]
75
76use std::{cell::RefCell, rc::Rc};
77
78use backend::{DehydratedSpan, YewBackend};
79use base16_palettes::Palette;
80use prelude::utils::{
81    process_resize_event, process_touch_init_event, process_touch_move_event, process_wheel_event,
82    TouchScroll,
83};
84use ratatui::{prelude::Rect, Frame, Terminal};
85use yew::{Component, Context, Properties};
86
87/// Contains the terminal backend that transforms the text rendered from ratatui widgets into HTML.
88pub mod backend;
89/// Common includes needed when working with this crate.
90pub mod prelude;
91mod utils;
92
93/// A container for a TUI app that renders to HTML.
94pub struct WebTerminal<A> {
95    app: A,
96    term: RefCell<Terminal<YewBackend>>,
97}
98
99/// The message type generated by callbacks and sent to the [`WebTerminal`].
100pub enum WebTermMessage<M> {
101    /// Contains a message that will be processed by the inner [`TerminalApp`] that is held by the
102    /// `WebTerminal`.
103    Inner(M),
104    /// The browser window has changed size.
105    Resized,
106    /// The user as scrolled one unit.
107    Scrolled(ScrollMotion),
108}
109
110/// The direction that a user has scrolled
111#[derive(Debug, Clone, Copy, PartialEq, Eq)]
112pub enum ScrollMotion {
113    /// The user has scrolled towards the top of the screen.
114    Up,
115    /// The user has scrolled towards the bottom of the screen.
116    Down,
117}
118
119impl<M> WebTermMessage<M> {
120    /// Creates a
121    pub fn new<I: Into<M>>(inner: I) -> Self {
122        Self::Inner(inner.into())
123    }
124}
125
126impl<M> From<M> for WebTermMessage<M> {
127    fn from(value: M) -> Self {
128        Self::Inner(value)
129    }
130}
131
132/// In the public API because of the component impl of WebTerminal
133#[derive(Properties, PartialEq)]
134pub struct WebTermProps<M: PartialEq> {
135    inner: M,
136    palette: Palette,
137}
138
139impl<M: PartialEq> WebTermProps<M> {
140    /// A constructor for the `WebTermProps` that uses the default color pallete.
141    pub fn new(inner: M) -> Self {
142        Self {
143            inner,
144            palette: Palette::default(),
145        }
146    }
147
148    /// A constructor for the `WebTermProps` that uses the given color pallete
149    pub fn new_with_palette(inner: M, palette: Palette) -> Self {
150        Self { inner, palette }
151    }
152}
153
154/// A wrapper around Yew's [`Context`] that is passed to methods like `TerminalApp::update`.
155pub struct TermContext<'a, A: TerminalApp> {
156    ctx: &'a Context<WebTerminal<A>>,
157    term: &'a mut Terminal<YewBackend>,
158}
159
160impl<'a, A: TerminalApp> TermContext<'a, A> {
161    /// Returns the reference to the [`Terminal`] that is used to render widgets.
162    pub fn terminal(&mut self) -> &mut Terminal<YewBackend> {
163        self.term
164    }
165
166    /// Returns the reference to the [`Context`] that is used to interact with Yew, such as
167    /// creating callbacks.
168    pub fn ctx(&self) -> &Context<WebTerminal<A>> {
169        self.ctx
170    }
171}
172
173/// The core user-facing abstraction of this crate. A terminal app is a type that can be wrapped by
174/// a [`WebTerminal`] and be displayed by Yew.
175///
176/// Because the app needs to be passed via [`Properties`], it needs to be `'static`, `Clone`, and
177/// `PartialEq`.
178pub trait TerminalApp: 'static + Clone + PartialEq {
179    /// The message type that this type uses to update.
180    type Message;
181
182    /// Allows the app to initialize its environment, such as setting up callbacks to window
183    /// events.
184    #[allow(unused_variables)]
185    fn setup(&mut self, ctx: &Context<WebTerminal<Self>>) {}
186
187    // TODO: Add optional resize method
188
189    /// Allows the app to initialize its environment, such as setting up callbacks to window
190    /// events.
191    #[allow(unused_variables)]
192    fn scroll(&mut self, scroll: ScrollMotion) -> bool {
193        false
194    }
195
196    /// Updates the app with a message.
197    fn update(&mut self, ctx: TermContext<'_, Self>, msg: Self::Message) -> bool;
198
199    /// Takes a Ratatui [`Frame`] and renders widgets onto it.
200    fn render(&self, area: Rect, frame: &mut Frame<'_>);
201
202    /// Takes a dehydrated spans from the backend and hydrates them by adding callbacks,
203    /// hyperlinks, etc.
204    #[allow(unused_variables)]
205    fn hydrate(&self, ctx: &Context<WebTerminal<Self>>, span: &mut DehydratedSpan) {}
206}
207
208impl<A: Default + TerminalApp> WebTerminal<A> {
209    /// Creates a default instance of the `TerminalApp` and creates a component that renders the
210    /// app.
211    pub fn render() {
212        yew::Renderer::<Self>::new().render();
213    }
214}
215
216impl<A: Default> Default for WebTerminal<A> {
217    fn default() -> Self {
218        Self {
219            app: A::default(),
220            term: RefCell::new(Terminal::new(YewBackend::new()).unwrap()),
221        }
222    }
223}
224
225impl<M: PartialEq + Default> Default for WebTermProps<M> {
226    fn default() -> Self {
227        Self {
228            inner: M::default(),
229            palette: Palette::default(),
230        }
231    }
232}
233
234/// Launches the rendering process using the given app state.
235pub fn run_tui<A: TerminalApp>(app: A) {
236    yew::Renderer::<WebTerminal<A>>::with_props(WebTermProps::new(app)).render();
237}
238
239impl<A: TerminalApp> Component for WebTerminal<A> {
240    type Message = WebTermMessage<A::Message>;
241    type Properties = WebTermProps<A>;
242
243    fn create(ctx: &Context<Self>) -> Self {
244        let mut app = ctx.props().inner.clone();
245        app.setup(ctx);
246        let palette = ctx.props().palette;
247        let term = RefCell::new(Terminal::new(YewBackend::new_with_palette(palette)).unwrap());
248        /* ---------- Window callback setup --------- */
249        let window = web_sys::window().unwrap();
250
251        // Bind a function to the "on-resize" window event
252        window.set_onresize(Some(&process_resize_event(ctx)));
253
254        // Bind a function to the "on-wheel" window event
255        window.set_onwheel(Some(&process_wheel_event(ctx)));
256
257        // Bind a function to the "touch-start" window event
258        let acc = Rc::new(RefCell::new(TouchScroll::new()));
259        window.set_ontouchstart(Some(&process_touch_init_event(acc.clone())));
260
261        // Bind a function to the "touch-move" window event
262        window.set_ontouchmove(Some(&process_touch_move_event(ctx, acc)));
263
264        Self { app, term }
265    }
266
267    fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
268        let ctx = TermContext {
269            ctx,
270            term: self.term.get_mut(),
271        };
272        match msg {
273            WebTermMessage::Inner(msg) => self.app.update(ctx, msg),
274            WebTermMessage::Scrolled(dir) => self.app.scroll(dir),
275            WebTermMessage::Resized => {
276                self.term.get_mut().backend_mut().resize_buffer();
277                true
278            }
279        }
280    }
281
282    fn view(&self, ctx: &Context<Self>) -> yew::Html {
283        let mut term = self.term.borrow_mut();
284        let area = term.size().unwrap();
285        term.draw(|frame| self.app.render(area, frame)).unwrap();
286        term.backend_mut()
287            .hydrate(|span| self.app.hydrate(ctx, span))
288    }
289}