cnf 0.6.1

Distribution-agnostic 'command not found'-handler
Documentation
// SPDX-License-Identifier: GPL-3.0-or-later
// SPDX-FileCopyrightText: (C) 2023 Andreas Hartmann <hartan@7x.de>
// This file is part of cnf, available at <https://gitlab.com/hartang/rust/cnf>

//! # Application TUI components
//!
//! The TUI displays all results in a tree-like fashion, allowing the user to navigate, inspect,
//! install and execute all the results found, if available.

pub mod keybinds;
mod spinner;
mod tree;

use std::{
    cmp::Ordering,
    io::{self, Read},
    rc::Rc,
    time::Duration,
};

use tokio::sync::mpsc::{UnboundedReceiver, error::TryRecvError};

use anyhow::{Context, Result};
use cnf_lib::Query;
use crossterm::{
    event::{self, DisableMouseCapture, EnableMouseCapture, Event},
    execute,
    terminal::{self, EnterAlternateScreen, LeaveAlternateScreen},
};
use logerr::LoggableError;
use ratatui::{
    Terminal,
    backend::{Backend, CrosstermBackend},
    layout::{Constraint, Layout},
};
use spinner::Spinner;
use tree::UiTree;

/// Sorting function for [`Query`] types.
fn sort_by(lhs: &Rc<Query>, rhs: &Rc<Query>) -> Ordering {
    use cnf_lib::ProviderError::Requirements;

    match (&lhs.results, &rhs.results) {
        (Ok(_), Ok(_)) => lhs.provider.to_string().cmp(&rhs.provider.to_string()),
        (Ok(_), Err(_)) => Ordering::Greater,
        (Err(_), Ok(_)) => Ordering::Less,
        (Err(Requirements(_)), Err(Requirements(_))) => {
            lhs.provider.to_string().cmp(&rhs.provider.to_string())
        }
        (Err(_), Err(Requirements(_))) => Ordering::Greater,
        (Err(Requirements(_)), Err(_)) => Ordering::Less,
        _ => lhs.provider.to_string().cmp(&rhs.provider.to_string()),
    }
}

pub(crate) async fn tui(channel: UnboundedReceiver<Query>, args: &[String]) -> Result<()> {
    // setup terminal
    terminal::enable_raw_mode()?;
    let mut stdout = io::stdout();
    execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
    let backend = CrosstermBackend::new(stdout);
    let mut terminal = Terminal::new(backend)?;

    let app = App::new(channel);
    let result = app.run(&mut terminal).await;

    // restore terminal
    terminal::disable_raw_mode()?;
    execute!(
        terminal.backend_mut(),
        LeaveAlternateScreen,
        DisableMouseCapture
    )?;
    terminal.show_cursor()?;

    match result {
        Ok(Some((query, index))) => {
            let status = query
                .run(
                    index,
                    &args.iter().skip(1).map(|s| &s[..]).collect::<Vec<_>>(),
                )
                .await
                .context("failed to execute selection")?;
            match status.code() {
                Some(code) if code != 0 => std::process::exit(code),
                None => std::process::exit(255),
                _ => Ok(()),
            }
        }
        Ok(None) => Ok(()),
        Err(e) => Err::<(), _>(e),
    }
}

async fn install_selection<B: Backend>(
    query: &Query,
    index: usize,
    terminal: &mut Terminal<B>,
) -> Result<()> {
    terminal.clear()?;
    terminal.show_cursor()?;
    terminal.set_cursor(0, 0)?;
    terminal::disable_raw_mode()?;
    query.install(index).await?;
    terminal::enable_raw_mode()?;
    terminal.hide_cursor()?;

    let raw_text = "Press Enter to continue";
    let message = ansi_term::Color::Green.bold().paint(raw_text);
    let offset = match terminal.size() {
        Ok(rect) => rect
            .width
            .saturating_sub(raw_text.len().try_into().unwrap_or(0))
            .saturating_div(2),
        _ => 0,
    };
    println!("\n\n");
    (0..offset).for_each(|_| print!(" "));
    println!("{}", message);
    std::io::stdin().read_exact(&mut [0])?;
    terminal.clear()?;
    Ok(())
}

#[derive(Debug)]
struct App {
    queries: Vec<Rc<Query>>,
    channel: UnboundedReceiver<Query>,
    tree: UiTree,
    spinner: Spinner,
    all_there: bool,
    keybinds: &'static keybinds::AppKeybinds,
}

impl App {
    fn new(channel: UnboundedReceiver<Query>) -> Self {
        App {
            queries: vec![],
            channel,
            tree: UiTree::new(),
            spinner: Spinner::new(),
            all_there: false,
            keybinds: &crate::config::get().keybindings,
        }
    }

    async fn run<B: Backend>(
        mut self,
        terminal: &mut Terminal<B>,
    ) -> Result<Option<(Rc<Query>, usize)>> {
        loop {
            match self.channel.try_recv() {
                Ok(result) => self.push(result),
                Err(TryRecvError::Empty) => (),
                Err(TryRecvError::Disconnected) => self.all_there = true,
            }

            let mut cur_candidate = self.tree.get_selected_candidate();

            // Use 0 to return immediately
            if event::poll(Duration::from_millis(50))?
                && let Event::Key(key) = event::read()?
            {
                if self.keybinds.select_up == key {
                    self.tree.selection_minus();
                } else if self.keybinds.select_down == key {
                    self.tree.selection_plus();
                } else if self.keybinds.expand == key {
                    self.tree.expand_node();
                } else if self.keybinds.collapse == key {
                    self.tree.collapse_node();
                } else if self.keybinds.add_alias == key {
                    if let Some((query, index)) = cur_candidate.clone() {
                        let err_context = "failed to create new command alias";

                        let candidate =
                            match query.results.as_ref().map(|result| result.get(index)) {
                                Ok(Some(candidate)) => Ok(candidate),
                                Ok(None) => Err(anyhow::anyhow!(
                                    "selected query '{:?}' has no candidate at index '{}'",
                                    query,
                                    index
                                )),
                                Err(e) => Err(anyhow::anyhow!(
                                    "selected query '{:?}' has no valid results; error: '{}'",
                                    query,
                                    e
                                )),
                            }
                            .context(err_context)
                            .to_log()?;
                        let mut cmd = candidate.actions.execute.clone();
                        cmd.is_interactive(true);
                        let alias = crate::alias::Alias {
                            source_env: cnf_lib::env::current().to_json(),
                            target_env: query.env.to_json(),
                            command: query.term.clone(),
                            alias: cmd,
                        };
                        alias.to_shell_wrapper().with_context(|| {
                            format!("failed to create alias for '{}'", query.term)
                        })?;
                    }
                } else if self.keybinds.install == key {
                    if let Some((query, index)) = cur_candidate.clone() {
                        install_selection(&query, index, terminal)
                            .await
                            .context("failed to install selected entry")?;
                    }
                } else if self.keybinds.execute == key {
                    if let Some((query, index)) = cur_candidate.clone() {
                        return Ok(Some((query, index)));
                    }
                } else if self.keybinds.quit == key {
                    return Ok(None);
                } else {
                    // unknown key
                }
            }

            terminal.draw(|f| {
                let spinner_chunk_size = if self.all_there { 0 } else { 1 };
                let chunks = Layout::default()
                    .constraints(
                        [
                            Constraint::Length(spinner_chunk_size),
                            Constraint::Min(0),
                            Constraint::Length(1),
                        ]
                        .as_ref(),
                    )
                    .split(f.size());
                // Spinner
                if !self.all_there {
                    f.render_widget(self.spinner.display(), chunks[0]);
                }
                // Tree view
                f.render_widget(&mut self.tree, chunks[1]);
                // Keybindings
                f.render_stateful_widget(&mut self.keybinds, chunks[2], &mut cur_candidate);
            })?;
        }
    }

    /// Store a new query result in the app.
    fn push(&mut self, query: Query) {
        let query = Rc::new(query);
        self.queries.push(query.clone());
        self.tree.add_env(query.env.clone());
        self.tree.add_query(query);
    }
}