ratflow 0.3.0

A minimalistic framework for building TUI applications using a reactive architecture.
Documentation
use color_eyre::Result;
use crossterm::event::KeyCode;
use ratatui::Frame;
use ratatui::widgets::{Block, Borders, List, ListItem, Paragraph};
use ratflow::{Render, Runtime, create_interval, on_key_press, run};
use std::rc::Rc;
use std::time::Duration;
use sycamore_reactive::{Signal, create_memo, create_signal, provide_context, use_context};

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum View {
    Menu,
    Counter,
    Input,
}

#[derive(Debug, Clone, Copy)]
struct Views {
    view: Signal<View>,
}

impl Views {
    pub const fn new(view: Signal<View>) -> Views {
        Views { view }
    }

    pub fn goto(&self, view: View) {
        self.view.set(view);
    }
}

fn menu() -> impl Render {
    let selected = {
        let runtime = use_context::<Runtime>();
        let views = use_context::<Views>();
        let selected = create_signal(0_usize);

        on_key_press(move |key| match key.code {
            KeyCode::Up => selected.update(|s| *s = (*s).saturating_sub(1)),
            KeyCode::Down => selected.update(|s| *s = (*s + 1).min(3)),
            KeyCode::Enter => {
                match selected() {
                    0 => views.goto(View::Counter),
                    1 => views.goto(View::Input),
                    2 => runtime.quit(),
                    _ => (),
                };
            }
            KeyCode::Char('q') => runtime.quit(),
            _ => (),
        });

        selected
    };

    let menu_items = ["Counter", "Input", "Quit"];

    let list = create_memo(move || {
        let items: Vec<ListItem> = menu_items
            .iter()
            .enumerate()
            .map(|(i, item)| {
                let content = if i == selected() {
                    format!("> {item}")
                } else {
                    format!("  {item}")
                };
                ListItem::new(content)
            })
            .collect();

        List::new(items).block(
            Block::default()
                .borders(Borders::ALL)
                .title("Menu (↑/↓: navigate, Enter: select, q: quit)"),
        )
    });

    move |frame: &mut Frame| {
        frame.render_widget(list.get_clone(), frame.area());
    }
}

fn counter() -> impl Render {
    let count = {
        let count = create_signal(0);
        create_interval(move || count.update(|c| *c += 1), Duration::from_secs(1));
        count
    };

    let views = use_context::<Views>();
    let runtime = use_context::<Runtime>();
    on_key_press(move |key| match key.code {
        KeyCode::Char('b') => views.goto(View::Menu),
        KeyCode::Char('q') => runtime.quit(),
        _ => (),
    });

    let paragraph = create_memo(move || {
        let text = format!(
            "Count: {}\n\nPress 'b' or Esc to go back\nPress 'q' to quit",
            count()
        );
        Paragraph::new(text).block(Block::default().borders(Borders::ALL).title("Counter"))
    });

    move |frame: &mut Frame| {
        frame.render_widget(paragraph.get_clone(), frame.area());
    }
}

fn input() -> impl Render {
    let (text, history) = {
        let views = use_context::<Views>();

        let text = create_signal(String::new());
        let history = create_signal(Vec::new());

        on_key_press(move |key| match key.code {
            KeyCode::Char(c) => text.update(|text| text.push(c)),
            KeyCode::Backspace => text.update(|text| {
                text.pop();
            }),
            KeyCode::Enter if !text.get_clone().is_empty() => {
                history.update(|history| history.push(text.get_clone().clone()));
                text.set(String::new());
            }
            KeyCode::Esc => views.goto(View::Menu),
            _ => (),
        });

        (text, history)
    };

    let paragraph = create_memo(move || {
        use std::fmt::Write;

        let mut content = String::from("Type to enter text, Enter to submit, Esc to go back\n\n");
        let _ = write!(content, "Input: {}_\n\n", text.get_clone());
        content.push_str("History:\n");

        history.with(|history| {
            for (i, item) in history.iter().enumerate() {
                let _ = writeln!(content, "  {}. {item}", i + 1);
            }
        });

        Paragraph::new(content).block(Block::default().borders(Borders::ALL).title("Text Input"))
    });

    move |frame: &mut Frame| {
        frame.render_widget(paragraph.get_clone(), frame.area());
    }
}

fn create_view(view: View) -> Rc<dyn Render> {
    match view {
        View::Menu => Rc::new(menu()),
        View::Counter => Rc::new(counter()),
        View::Input => Rc::new(input()),
    }
}

fn app() -> impl Render {
    let view = create_signal(View::Menu);
    provide_context(Views::new(view));
    let view_changes = create_memo(move || view());

    let view_component = create_memo(move || create_view(view_changes()));

    move |frame: &mut Frame| {
        view_component.get_clone().render(frame);
    }
}

#[tokio::main]
async fn main() -> Result<()> {
    color_eyre::install()?;
    run(app).await?;
    Ok(())
}