tui-rs icon indicating copy to clipboard operation
tui-rs copied to clipboard

thread-safe drawing ✍🏼

Open jrnxf opened this issue 4 years ago • 0 comments

Problem

There is probably a valid reason for why the terminal is not thread-safe, but humor me with the conversation...

I have a bit of an interesting use-case for tui-rs, one that I haven't seen replicated in any of the examples listed in the README. Essentially I want to draw the ui on each key press from the user, as well as every second after the user enters their first keypress. (you can imagine this as a input-aware countdown)

Unfortunately, trying to spawn a thread on first keypress results in the following error. I understand this error and what it's detailing, but after a few days of problem solving I haven't been able to solidly answer whether or not there is a viable workaround. Is what I'm trying to accomplish even possible given B is not thread safe?

image

Solution

If possible, I'd love to be able to draw in child threads.

Alternatives

I was able to hack together a simple working solution by intercepting the first keypress in the key_events function like so...

fn key_events() -> mpsc::Receiver<Key> {
    let (tx, rx) = mpsc::channel();

    thread::spawn(move || {
        let stdin = io::stdin();
        for key in stdin.keys().flatten() {
            // if first keypress, spawn thread here and send a tick_event every 1 sec
            tx.send(key).unwrap();
        }
    });

    rx
}

The problem with this approach however, is if the user restarts the application (possible within the app itself, not having to close the app and re-open), I'd have to somehow close this key_events channel and start a new one since "first keypress" has already happened. I tried this and was getting errors about sending on closed channels.

Ultimately the approach I settled on, which I see implemented in many of the examples, is specifying a fast tick_rate (100ms) like so

fn get_events(should_tick: bool) -> mpsc::Receiver<Events> {
    let (tx, rx) = mpsc::channel();

    if should_tick {
        let tick_x = tx.clone();
        thread::spawn(move || loop {
            tick_x.send(Events::Tick).unwrap();
            thread::sleep(Duration::from_millis(100))
        });
    }


    thread::spawn(move || {
        let stdin = io::stdin();
        for key in stdin.keys().flatten() {
            tx.send(Events::Input(key)).unwrap();
        }
    });

    rx
}

and then modifying the looping function to operate like this

            match events.recv()? {
                Events::Tick => {
                    if app.thok.has_started() && !app.thok.has_finished() {
                        app.thok.on_tick();
                        terminal.draw(|f| ui(f, app))?;
                    } else if app.thok.has_finished() && app.screen == Screen::Prompt {
                        app.thok.calc_results();
                        app.screen = Screen::Results;
                    }
                }
...

This approach is super simple, and works great. The only piece that bugs me is that it doesn't start the countdown immediately. It just checks every 100ms to see if it should start. In other words you might get a free 99ms. Obviously this is such a short amount of time it's not noticeable to any user, but I'm not sure I have any other option given, again, B is not thread safe.

Additional context

Obviously this isn't working code, but this is essentially what I had originally envisioned as my ideal solution.

fn main() -> Result<(), Box<dyn Error>> {
    simple_logging::log_to_file("out.log", log::LevelFilter::Info).unwrap();
    // check for input on stdin here, if it exists, store it,
    // otherwise continue
    let args = Args::parse();

    let stdout = io::stdout().into_raw_mode()?;
    let stdout = MouseTerminal::from(stdout);
    let stdout = AlternateScreen::from(stdout);
    let backend = TermionBackend::new(stdout);
    let mut terminal = Terminal::new(backend)?;

    let mut app = App::new(args);
    let result = run_app(&mut terminal, &mut app);

    if let Err(err) = result {
        println!("{:?}", err)
    }

    Ok(())
}

fn run_app<B: Backend>(
    terminal: &mut Terminal<B>,
    mut app: &mut App,
) -> Result<(), Box<dyn Error>> {
    let events = key_events();

    loop {
        let mut exit_type: ExitType = ExitType::Quit;
        terminal.draw(|f| ui(f, &mut app))?;
        loop {
            let app = &mut app;

            let key = events.recv()?;
            match key {
                Key::Esc => {
                    break;
                }
                Key::Char(c) => match app.screen {
                    Screen::Prompt => {
                        if !app.thok.has_started() {
                            thread::spawn(move || loop {
                                app.thok.on_tick();
                                thread::sleep(Duration::from_secs(1));
                                terminal.draw(|f| ui(f, &mut app))?;
                            });
                        }
                        app.thok.write(c);
                        if app.thok.has_finished() {
                            app.thok.calc_results();
                            app.screen = Screen::Results;
                        }
                    }
                    Screen::Results => match key {
                        Key::Char('r') => {
                            exit_type = ExitType::Restart;
                            break;
                        }
                        Key::Char('n') => {
                            exit_type = ExitType::New;
                            break;
                        }
                        _ => {}
                    },
                },
                _ => {}
            }
            terminal.draw(|f| ui(f, app))?;
        }

        match exit_type {
            ExitType::Restart => {
                app.reset(Some(app.thok.prompt.clone()));
            }
            ExitType::New => {
                app.reset(None);
            }
            ExitType::Quit => {
                break;
            }
        }
    }

    Ok(())
}

fn key_events() -> mpsc::Receiver<Key> {
    let (tx, rx) = mpsc::channel();

    thread::spawn(move || {
        let stdin = io::stdin();
        for key in stdin.keys().flatten() {
            tx.send(key).unwrap();
        }
    });

    rx
}

fn ui<B: Backend>(f: &mut Frame<B>, app: &mut App) {
    match app.screen {
        Screen::Prompt => {
            app.thok.draw_prompt(f).unwrap();
        }
        Screen::Results => {
            app.thok.draw_results(f).unwrap();
        }
    }
}

jrnxf avatar Apr 04 '22 22:04 jrnxf