webatui 0.1.1

Run your TUI apps in the broswer!
Documentation
//! Run your TUI apps in the browser!
//!
//! Webatui is a glue crate for running [Ratatui](https://crates.io/crates/ratatui)-based TUI apps
//! in the broswer with the help of [Yew](https://crates.io/crates/yew). Currently, this crate is
//! centered around transforming the text-based displays that ratatui generates into HTML
//! DOM-elements. Some interactivity is supported, like hyperlinks, on-click callbacks, and
//! scrolling (both on mobile devices and with a mouse); however, webatui is not a fully
//! interactive terminal yet. Many things, such as cursor location, editing text, etc, are not
//! supported. Other things, like getting keyboard inputs, are possible via the
//! [web-sys](https://crates.io/crates/web-sys) crate, but there is no direct ability to setup and
//! configure this, yet.
//!
//! Many of the web-specific details have been abstracted away so that porting existing apps
//! is as easy as possible. To get started, create a struct that will hold your app's logic,
//! implement the [`TerminalApp`] trait for it, and run the [`run_tui`] function with an instance
//! of your app.
//! ```no_run
//! use ratatui::{prelude::*, widgets::*};
//! use webatui::prelude::*;
//!
//! #[derive(PartialEq, Clone)]
//! struct MyApp {
//!   title: String,
//! }
//!
//! impl TerminalApp for MyApp {
//!     // This simple app does not update
//!     type Message = ();
//!
//!     // This simple app does not update
//!     fn update(&mut self, _: TermContext<'_, Self>, _: Self::Message) -> bool { false }
//!
//!     fn render(&self, area: Rect, frame: &mut Frame<'_>) {
//!         let para = Paragraph::new(self.title.as_str());
//!         frame.render_widget(para, area);
//!     }
//! }
//!
//! let my_app = MyApp { title: "Hello WWW!".into() };
//! run_tui(my_app)
//! ```
//!
//! Webatui closely follows Yew's flow for creating and rendering an app. Once create, an app can
//! be updated via a message (which might be generated by a callback). After the app processes its
//! update, it returns whether or not it needs to be re-rendered. The rendering process is split
//! into two sub-steps (the main difference between webataui and yew). In the first step, the app
//! renders widgets into the [`Terminal`]. Once rendered and flushed, the [`YewBackend`] converts
//! the text into a series of spans. These spans are then used during the next sub-step, hydration.
//! Hydration allows an app to attach additional data that can't be passed via a widget, namely
//! callbacks and hyperlinks. To mark a stylible element as "in need of hydration", add the
//! [`HYDRATION`](crate::backend::HYDRATION) modifier to its style or use the `to_hydrate` method
//! available via the [`NeedsHydration`](crate::backend::NeedsHydration) trait. Once all the
//! necessary spans have been hydrated, those spans are composed into a series of HTML `<pre>` tags
//! and rendered into the DOM.
//!
//! A note about hydration: When a widget is rendered, the [`YewBackend`] gets it
//! character-by-character. This limits the backend's ability to create blocks that need hydrated.
//! So, a multi-line widget will be split into a series of dehydrated spans that will be hydrated
//! individually.

#![deny(
    rustdoc::broken_intra_doc_links,
    unreachable_pub,
    unreachable_patterns,
    unused,
    unused_qualifications,
    missing_docs,
    while_true,
    trivial_casts,
    trivial_bounds,
    trivial_numeric_casts,
    unconditional_panic,
    clippy::all
)]

use std::{cell::RefCell, rc::Rc};

use backend::{DehydratedSpan, YewBackend};
use base16_palettes::Palette;
use prelude::utils::{
    process_resize_event, process_touch_init_event, process_touch_move_event, process_wheel_event,
    TouchScroll,
};
use ratatui::{prelude::Rect, Frame, Terminal};
use yew::{Component, Context, Properties};

/// Contains the terminal backend that transforms the text rendered from ratatui widgets into HTML.
pub mod backend;
/// Common includes needed when working with this crate.
pub mod prelude;
mod utils;

/// A container for a TUI app that renders to HTML.
pub struct WebTerminal<A> {
    app: A,
    term: RefCell<Terminal<YewBackend>>,
}

/// The message type generated by callbacks and sent to the [`WebTerminal`].
pub enum WebTermMessage<M> {
    /// Contains a message that will be processed by the inner [`TerminalApp`] that is held by the
    /// `WebTerminal`.
    Inner(M),
    /// The browser window has changed size.
    Resized,
    /// The user as scrolled one unit.
    Scrolled(ScrollMotion),
}

/// The direction that a user has scrolled
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ScrollMotion {
    /// The user has scrolled towards the top of the screen.
    Up,
    /// The user has scrolled towards the bottom of the screen.
    Down,
}

impl<M> WebTermMessage<M> {
    /// Creates a
    pub fn new<I: Into<M>>(inner: I) -> Self {
        Self::Inner(inner.into())
    }
}

impl<M> From<M> for WebTermMessage<M> {
    fn from(value: M) -> Self {
        Self::Inner(value)
    }
}

/// In the public API because of the component impl of WebTerminal
#[derive(Properties, PartialEq)]
pub struct WebTermProps<M: PartialEq> {
    inner: M,
    palette: Palette,
}

impl<M: PartialEq> WebTermProps<M> {
    /// A constructor for the `WebTermProps` that uses the default color pallete.
    pub fn new(inner: M) -> Self {
        Self {
            inner,
            palette: Palette::default(),
        }
    }

    /// A constructor for the `WebTermProps` that uses the given color pallete
    pub fn new_with_palette(inner: M, palette: Palette) -> Self {
        Self { inner, palette }
    }
}

/// A wrapper around Yew's [`Context`] that is passed to methods like `TerminalApp::update`.
pub struct TermContext<'a, A: TerminalApp> {
    ctx: &'a Context<WebTerminal<A>>,
    term: &'a mut Terminal<YewBackend>,
}

impl<'a, A: TerminalApp> TermContext<'a, A> {
    /// Returns the reference to the [`Terminal`] that is used to render widgets.
    pub fn terminal(&mut self) -> &mut Terminal<YewBackend> {
        self.term
    }

    /// Returns the reference to the [`Context`] that is used to interact with Yew, such as
    /// creating callbacks.
    pub fn ctx(&self) -> &Context<WebTerminal<A>> {
        self.ctx
    }
}

/// The core user-facing abstraction of this crate. A terminal app is a type that can be wrapped by
/// a [`WebTerminal`] and be displayed by Yew.
///
/// Because the app needs to be passed via [`Properties`], it needs to be `'static`, `Clone`, and
/// `PartialEq`.
pub trait TerminalApp: 'static + Clone + PartialEq {
    /// The message type that this type uses to update.
    type Message;

    /// Allows the app to initialize its environment, such as setting up callbacks to window
    /// events.
    #[allow(unused_variables)]
    fn setup(&mut self, ctx: &Context<WebTerminal<Self>>) {}

    // TODO: Add optional resize method

    /// Allows the app to initialize its environment, such as setting up callbacks to window
    /// events.
    #[allow(unused_variables)]
    fn scroll(&mut self, scroll: ScrollMotion) -> bool {
        false
    }

    /// Updates the app with a message.
    fn update(&mut self, ctx: TermContext<'_, Self>, msg: Self::Message) -> bool;

    /// Takes a Ratatui [`Frame`] and renders widgets onto it.
    fn render(&self, area: Rect, frame: &mut Frame<'_>);

    /// Takes a dehydrated spans from the backend and hydrates them by adding callbacks,
    /// hyperlinks, etc.
    #[allow(unused_variables)]
    fn hydrate(&self, ctx: &Context<WebTerminal<Self>>, span: &mut DehydratedSpan) {}
}

impl<A: Default + TerminalApp> WebTerminal<A> {
    /// Creates a default instance of the `TerminalApp` and creates a component that renders the
    /// app.
    pub fn render() {
        yew::Renderer::<Self>::new().render();
    }
}

impl<A: Default> Default for WebTerminal<A> {
    fn default() -> Self {
        Self {
            app: A::default(),
            term: RefCell::new(Terminal::new(YewBackend::new()).unwrap()),
        }
    }
}

impl<M: PartialEq + Default> Default for WebTermProps<M> {
    fn default() -> Self {
        Self {
            inner: M::default(),
            palette: Palette::default(),
        }
    }
}

/// Launches the rendering process using the given app state.
pub fn run_tui<A: TerminalApp>(app: A) {
    yew::Renderer::<WebTerminal<A>>::with_props(WebTermProps::new(app)).render();
}

impl<A: TerminalApp> Component for WebTerminal<A> {
    type Message = WebTermMessage<A::Message>;
    type Properties = WebTermProps<A>;

    fn create(ctx: &Context<Self>) -> Self {
        let mut app = ctx.props().inner.clone();
        app.setup(ctx);
        let palette = ctx.props().palette;
        let term = RefCell::new(Terminal::new(YewBackend::new_with_palette(palette)).unwrap());
        /* ---------- Window callback setup --------- */
        let window = web_sys::window().unwrap();

        // Bind a function to the "on-resize" window event
        window.set_onresize(Some(&process_resize_event(ctx)));

        // Bind a function to the "on-wheel" window event
        window.set_onwheel(Some(&process_wheel_event(ctx)));

        // Bind a function to the "touch-start" window event
        let acc = Rc::new(RefCell::new(TouchScroll::new()));
        window.set_ontouchstart(Some(&process_touch_init_event(acc.clone())));

        // Bind a function to the "touch-move" window event
        window.set_ontouchmove(Some(&process_touch_move_event(ctx, acc)));

        Self { app, term }
    }

    fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
        let ctx = TermContext {
            ctx,
            term: self.term.get_mut(),
        };
        match msg {
            WebTermMessage::Inner(msg) => self.app.update(ctx, msg),
            WebTermMessage::Scrolled(dir) => self.app.scroll(dir),
            WebTermMessage::Resized => {
                self.term.get_mut().backend_mut().resize_buffer();
                true
            }
        }
    }

    fn view(&self, ctx: &Context<Self>) -> yew::Html {
        let mut term = self.term.borrow_mut();
        let area = term.size().unwrap();
        term.draw(|frame| self.app.render(area, frame)).unwrap();
        term.backend_mut()
            .hydrate(|span| self.app.hydrate(ctx, span))
    }
}