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}