79298245

Date: 2024-12-20 19:50:22
Score: 4
Natty:
Report link

Finally, I found a way to get it work. Thanks to all the advices from @Jmb and some trial and error.

Now after spawning the curl request for the current item, I run an inner loop on matching bg_cmd.try_wait(). If it finish the run successful, the result get assigned to the shared var holding the output. But if the process is still running and another list item is selected, an AtomicBool is set which restarts the main loop of the bg process thread and, thus, the result of the former run is dismissed.

Here is the code. There might be ways to make this more efficient and I would be happy to hear about them. But at least it works now and I nevertheless already learned a lot about multi-threading and bg processes in Rust.

use std::{
    io::{BufRead, BufReader},
    process::{Command, Stdio},
    sync::{
        atomic::{AtomicBool, Ordering},
        Arc, Condvar, Mutex,
    },
    thread,
    time::Duration,
};

use color_eyre::Result;
use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
use ratatui::{
    layout::{Constraint, Layout},
    style::{Modifier, Style},
    widgets::{Block, List, ListState, Paragraph},
    DefaultTerminal, Frame,
};

#[derive(Debug, Clone)]
pub struct Mailbox {
    finished: Arc<AtomicBool>,
    data: Arc<Mutex<Option<String>>>,
    cond: Arc<Condvar>,
    output: Arc<Mutex<String>>,
    kill_proc: Arc<AtomicBool>,
}

impl Mailbox {
    fn new() -> Self {
        Self {
            finished: Arc::new(AtomicBool::new(false)),
            data: Arc::new(Mutex::new(None)),
            cond: Arc::new(Condvar::new()),
            output: Arc::new(Mutex::new(String::new())),
            kill_proc: Arc::new(AtomicBool::new(false)),
        }
    }
}

pub fn run_bg_cmd(
    fetch_item: Arc<Mutex<Option<String>>>,
    cond: Arc<Condvar>,
    output_val: Arc<Mutex<String>>,
    finished: Arc<AtomicBool>,
    kill_bool: Arc<AtomicBool>,
) {
    // Start the main loop which is running in the background as long as
    // the TUI itself runs
    'main: loop {
        let mut request = fetch_item.lock().unwrap();
        // Wait as long as their is no request sent. If one is send, the
        // Condvar lets the loop run further
        while request.is_none() {
            request = cond.wait(request).unwrap();
        }
        let cur_request = request.take().unwrap();

        // Drop MutexGuard to free up the main thread
        drop(request);

        // Spawn `curl` (or any other bg command) using the sent request as arg.
        // To not flood the TUI I pipe stderr to /dev/null
        let mut bg_cmd = Command::new("curl")
            .arg("-LH")
            .arg("Accept: application/x-bibtex")
            .arg(&cur_request)
            .stdout(Stdio::piped())
            .stderr(Stdio::null())
            .spawn()
            .expect("Not running");

        // Start inner loop to wait for process to end or dismiss the result if
        // next item in the TUI is selected
        'waiting: loop {
            match bg_cmd.try_wait() {
                // If bg process ends with exit code 0, break the inner loop
                // to assign the result to the shared variable.
                // If bg process ends with exit code not 0, restart main loop and
                // drop the result from stdout.
                Ok(Some(status)) => {
                    if status.success() {
                        break 'waiting;
                    } else {
                        continue 'main;
                    }
                }
                // If process is still running and the kill bool was set to true
                // since another item was selected, immiditatley restart the main loop
                // waiting for a new request and, therefore, drop the result
                Ok(None) => {
                    if kill_bool.load(Ordering::Relaxed) {
                        continue 'main;
                    }
                }
                // If an error occurs, restart the main loop and drop all output
                Err(e) => {
                    println!("Error {e} occured while trying to fetch infors");
                    continue 'main;
                }
            }
        }

        // If waiting loop was broken due to successful bg process, take the output
        // parse it into a string (or whatever) and assign it to the shared var
        // holding the result
        let out = bg_cmd.stdout.take().unwrap();
        let out_reader = BufReader::new(out);

        let mut out_str = String::new();

        for l in out_reader.lines() {
            if let Ok(l) = l {
                out_str.push_str(&l);
            }
        }

        finished.store(true, Ordering::Relaxed);

        let mut output_str = output_val.lock().unwrap();
        *output_str = out_str;
    }
}

#[derive(Debug)]
pub struct App {
    mb: Mailbox,
    running: bool,
    fetch_info: bool,
    info_text: String,
    list: Vec<String>,
    state: ListState,
}

impl App {
    pub fn new(mb: Mailbox) -> Self {
        Self {
            mb,
            running: false,
            fetch_info: false,
            info_text: String::new(),
            list: vec![
                "http://dx.doi.org/10.1163/9789004524774".into(),
                "http://dx.doi.org/10.1016/j.algal.2015.04.001".into(),
                "https://doi.org/10.1093/acprof:oso/9780199595006.003.0021".into(),
                "https://doi.org/10.1007/978-94-007-4587-2_7".into(),
                "https://doi.org/10.1093/acprof:oso/9780199595006.003.0022".into(),
            ],
            state: ListState::default().with_selected(Some(0)),
        }
    }

    pub fn run(mut self, mut terminal: DefaultTerminal) -> Result<()> {
        self.running = true;
        while self.running {
            terminal.draw(|frame| self.draw(frame))?;
            self.handle_crossterm_events()?;
        }
        Ok(())
    }

    fn draw(&mut self, frame: &mut Frame) {
        let [left, right] =
            Layout::vertical([Constraint::Fill(1), Constraint::Fill(1)]).areas(frame.area());

        let list = List::new(self.list.clone())
            .block(Block::bordered().title_top("List"))
            .highlight_style(Style::new().add_modifier(Modifier::REVERSED));

        let info = Paragraph::new(self.info_text.as_str())
            .block(Block::bordered().title_top("Bibtex-Style"));

        frame.render_stateful_widget(list, left, &mut self.state);
        frame.render_widget(info, right);
    }

    fn handle_crossterm_events(&mut self) -> Result<()> {
        if event::poll(Duration::from_millis(500))? {
            match event::read()? {
                Event::Key(key) if key.kind == KeyEventKind::Press => self.on_key_event(key),
                Event::Mouse(_) => {}
                Event::Resize(_, _) => {}
                _ => {}
            }
        } else {
            if self.fetch_info {
                self.update_info();
            }
            if self.mb.finished.load(Ordering::Relaxed) == true {
                self.info_text = self.mb.output.lock().unwrap().to_string();
                self.mb.finished.store(false, Ordering::Relaxed);
            }
        }
        Ok(())
    }

    fn update_info(&mut self) {
        // Select current item as request
        let sel_doi = self.list[self.state.selected().unwrap_or(0)].clone();
        let mut guard = self.mb.data.lock().unwrap();
        // Send request to bg loop thread
        *guard = Some(sel_doi);
        // Notify the Condvar to break the hold of bg loop
        self.mb.cond.notify_one();
        drop(guard);
        // Set bool to false, so no further process is started
        self.fetch_info = false;
        // Set kill bool to false to allow bg process to complete
        self.mb.kill_proc.store(false, Ordering::Relaxed);
    }

    fn on_key_event(&mut self, key: KeyEvent) {
        match (key.modifiers, key.code) {
            (_, KeyCode::Esc | KeyCode::Char('q'))
            | (KeyModifiers::CONTROL, KeyCode::Char('c') | KeyCode::Char('C')) => self.quit(),
            (_, KeyCode::Down | KeyCode::Char('j')) => {
                if self.state.selected().unwrap() <= 3 {
                    // Set kill bool to true to kill unfinished process from prev item
                    self.mb.kill_proc.store(true, Ordering::Relaxed);
                    // Set text of info box to "Loading" until bg loop sends result
                    self.info_text = "... Loading".to_string();
                    self.state.scroll_down_by(1);
                    // Set fetch bool to true to start fetching of info after set delay
                    self.fetch_info = true;
                }
            }
            (_, KeyCode::Up | KeyCode::Char('k')) => {
                // Set kill bool to true to kill unfinished process from prev item
                self.mb.kill_proc.store(true, Ordering::Relaxed);
                // Set text of info box to "Loading" until bg loop sends result
                self.info_text = "... Loading".to_string();
                self.state.scroll_up_by(1);
                // Set fetch bool to true to start fetching of info after set delay
                self.fetch_info = true;
            }
            _ => {}
        }
    }

    fn quit(&mut self) {
        self.running = false;
    }
}

fn main() -> color_eyre::Result<()> {
    color_eyre::install()?;

    let mb = Mailbox::new();

    let curl_data = Arc::clone(&mb.data);
    let curl_cond = Arc::clone(&mb.cond);
    let curl_output = Arc::clone(&mb.output);
    let curl_bool = Arc::clone(&mb.finished);
    let curl_kill_proc = Arc::clone(&mb.kill_proc);

    thread::spawn(move || {
        run_bg_cmd(curl_data, curl_cond, curl_output, curl_bool, curl_kill_proc);
    });

    let terminal = ratatui::init();
    let result = App::new(mb).run(terminal);
    ratatui::restore();
    result
}
Reasons:
  • Blacklisted phrase (0.5): Thanks
  • RegEx Blacklisted phrase (3): Thanks to all the advices
  • Long answer (-1):
  • Has code block (-0.5):
  • User mentioned (1): @Jmb
  • Self-answer (0.5):
  • Low reputation (0.5):
Posted by: lukeflo