ratflow 0.1.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, create_memo, on_key_press, run};
use reactive_graph::computed::Memo;
use reactive_graph::owner::{expect_context, provide_context};
use reactive_graph::signal::{WriteSignal, signal};
use reactive_graph::traits::{Set, Update};
use std::sync::Arc;
use std::time::Duration;

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

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

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

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

fn menu() -> impl Render {
    let selected = {
        let runtime = expect_context::<Runtime>();
        let views = expect_context::<Views>();
        let (selected, set_selected) = signal(0_usize);

        on_key_press(move |key| match key.code {
            KeyCode::Up => set_selected.update(|s| *s = (*s).saturating_sub(1)),
            KeyCode::Down => set_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(), frame.area());
    }
}

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

    let views = expect_context::<Views>();
    let runtime = expect_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(), frame.area());
    }
}

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

        let (text, set_text) = signal(String::new());
        let (history, set_history) = signal(Vec::new());

        on_key_press(move |key| match key.code {
            KeyCode::Char(c) => set_text.update(|text| text.push(c)),
            KeyCode::Backspace => set_text.update(|text| {
                text.pop();
            }),
            KeyCode::Enter if !text().is_empty() => {
                set_history.update(|history| history.push(text().clone()));
                set_text(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());
        content.push_str("History:\n");
        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(), frame.area());
    }
}

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

fn app() -> impl Render {
    let (view, set_view) = signal(View::Menu);
    provide_context(Views::new(set_view));

    let view = Memo::new_with_compare(
        move |old: Option<&(View, Arc<dyn Render>)>| {
            let view = view();
            if let Some(pair) = old
                && pair.0 == view
            {
                pair.clone()
            } else {
                (view, create_view(view))
            }
        },
        move |old, new| old.map(|e| e.0) != new.map(|e| e.0),
    );

    move |frame: &mut Frame| {
        view().1.render(frame);
    }
}

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