fm-tui 0.2.3

FM : a file manager inspired by ranger and dired{n}{n}Config files ~/.config/fm/{n}Documentation https://github.com/qkzk/fm{n}
Documentation
use std::io::stdout;
use std::panic;
use std::process::exit;
use std::sync::{mpsc, Arc};

#[cfg(debug_assertions)]
use std::backtrace;

use anyhow::Result;
use clap::Parser;
use crossterm::{
    cursor,
    event::{DisableBracketedPaste, DisableMouseCapture, EnableBracketedPaste, EnableMouseCapture},
    execute,
    terminal::{disable_raw_mode, Clear, ClearType},
};
use parking_lot::Mutex;
use ratatui::{init as init_term, DefaultTerminal};

use crate::app::{Displayer, Refresher, Status};
use crate::common::{clear_input_socket_files, clear_tmp_files, save_final_path, CONFIG_PATH};
use crate::config::{load_config, set_configurable_static, Config, IS_LOGGING};
use crate::event::{remove_socket, EventDispatcher, EventReader, FmEvents};
use crate::io::{Args, FMLogger, Opener};
use crate::log_info;

/// Holds everything about the application itself.
/// Dropping the instance of FM allows to write again to stdout.
/// It should be ran like this : `crate::app::Fm::start().run().quit()`.
///
/// The application is split into several components:
/// - a reader of FmEvents,
/// - a dispatcher of said events,
/// - the state of the application itself, which is mutated by the dispatcher,
/// - a displayer which holds a non mutable reference to the state. The displayer can emits events to force state change if needs be.
/// - a refresher which is used to force a refresh state + display if something happened externally or in some running thread.
pub struct FM {
    /// Poll the event sent to the terminal by the user or the OS
    event_reader: EventReader,
    /// Associate the event to a method, modifing the status.
    event_dispatcher: EventDispatcher,
    /// Current status of the application. Mostly the filetrees
    status: Arc<Mutex<Status>>,
    /// Refresher is used to force a refresh when a file has been modified externally.
    /// It also has a [`std::mpsc::Sender`] to send a quit message and reset the cursor.
    refresher: Refresher,
    /// Used to handle every display on the screen.
    /// It runs a single thread with an mpsc receiver to handle quit events.
    /// Drawing is done 30 times per second.
    displayer: Displayer,
}

impl FM {
    /// Setup everything the application needs in its main loop :
    /// a panic hook for graceful panic and displaying a traceback for debugging purpose,
    /// an `EventReader`,
    /// an `EventDispatcher`,
    /// a `Status`,
    /// a `Display`,
    /// a `Refresher`.
    /// It reads and drops the configuration from the config file.
    /// If the config can't be parsed, it exits with error code 1.
    ///
    /// # Errors
    ///
    /// May fail if the [`ratatui::DefaultTerminal`] can't be started or crashes
    pub fn start() -> Result<Self> {
        Self::set_panic_hook();
        let (mut config, start_folder) = Self::early_exit()?;
        log_info!("start folder: {start_folder}");
        let plugins = std::mem::take(&mut config.plugins);
        let theme = std::mem::take(&mut config.theme);
        set_configurable_static(&start_folder, plugins, theme)?;
        Self::build(config)
    }

    /// Set a panic hook for debugging the application.
    /// In case of panic, we ensure to:
    /// - erase temporary files
    /// - restore the terminal as best as possible (show the cursor, disable the mouse capture)
    /// - if in debug mode (target=debug), display a full traceback.
    /// - if in release mode (target=release), display a sorry message.
    fn set_panic_hook() {
        panic::set_hook(Box::new(|traceback| {
            clear_tmp_files();
            let _ = disable_raw_mode();
            let _ = execute!(
                stdout(),
                cursor::Show,
                DisableMouseCapture,
                DisableBracketedPaste
            );

            if cfg!(debug_assertions) {
                if let Some(payload) = traceback.payload().downcast_ref::<&str>() {
                    eprintln!("Traceback: {payload}",);
                } else if let Some(payload) = traceback.payload().downcast_ref::<String>() {
                    eprintln!("Traceback: {payload}",);
                } else {
                    eprintln!("Traceback:{traceback:?}");
                }
                if let Some(location) = traceback.location() {
                    eprintln!("At {location}");
                }
                #[cfg(debug_assertions)]
                eprintln!("{}", backtrace::Backtrace::capture());
            } else {
                eprintln!("fm exited unexpectedly.");
            }
        }));
    }

    /// Read config and args, leaving immediatly if the arguments say so.
    /// It will return the fully set [`crate::fm::config::Config`] and the starting path
    /// as a String.
    fn early_exit() -> Result<(Config, String)> {
        let args = Args::parse();
        IS_LOGGING.get_or_init(|| args.log);
        if args.log {
            FMLogger::default().init()?;
        }
        log_info!("args {args:#?}");
        let Ok(config) = load_config(CONFIG_PATH) else {
            Self::exit_wrong_config()
        };
        Ok((config, args.path))
    }

    /// Exit the application and log a message.
    /// Used when the config can't be read.
    pub fn exit_wrong_config() -> ! {
        eprintln!("Couldn't load the config file at {CONFIG_PATH}. See https://raw.githubusercontent.com/qkzk/fm/master/config_files/fm/config.yaml for an example.");
        log_info!("Couldn't read the config file {CONFIG_PATH}");
        exit(1)
    }

    /// Internal builder. Builds an Fm instance from the config.
    /// We have to create the terminal before starting the display.
    /// - status requires the terminal size to be initialized (can we display right tab etc. ?)
    /// - displays has a cloned arc to status
    ///
    /// The terminal is intancied there and passed to the display.
    fn build(config: Config) -> Result<Self> {
        let (fm_sender, fm_receiver) = mpsc::channel::<FmEvents>();
        let event_reader = EventReader::new(fm_receiver);
        let fm_sender = Arc::new(fm_sender);
        let term = Self::init_term()?;
        let status = Status::arc_mutex_new(
            term.size()?,
            Opener::default(),
            &config.binds,
            fm_sender.clone(),
        )?;
        let event_dispatcher = EventDispatcher::new(config.binds);
        let refresher = Refresher::new(fm_sender);
        let displayer = Displayer::new(term, status.clone());

        Ok(Self {
            event_reader,
            event_dispatcher,
            status,
            refresher,
            displayer,
        })
    }

    fn init_term() -> Result<DefaultTerminal> {
        let term = init_term();
        execute!(stdout(), EnableMouseCapture, EnableBracketedPaste)?;
        Ok(term)
    }

    /// Update itself, changing its status.
    /// It will dispatch every [`FmEvents`], updating [`Status`].
    fn update(&mut self, event: FmEvents) -> Result<()> {
        let mut status = self.status.lock();
        self.event_dispatcher.dispatch(&mut status, event)?;

        Ok(())
    }

    /// True iff the application must quit.
    fn must_quit(&self) -> Result<bool> {
        let status = self.status.lock();
        Ok(status.must_quit())
    }

    /// Run the update status loop and returns itself after completion.
    pub fn run(mut self) -> Result<Self> {
        while !self.must_quit()? {
            self.update(self.event_reader.poll_event())?;
        }
        Ok(self)
    }

    /// Clear before normal exit.
    fn clear() -> Result<()> {
        execute!(stdout(), Clear(ClearType::All))?;
        Ok(())
    }

    /// Disable the mouse capture before normal exit.
    fn disable_mouse_capture() -> Result<()> {
        execute!(stdout(), DisableMouseCapture, DisableBracketedPaste)?;
        Ok(())
    }

    /// Reset everything as best as possible, stop any long thread in a loop and exit.
    ///
    /// More specifically :
    /// - Display the cursor,
    /// - drop itself, which allow us to print normally afterward
    /// - print the final path
    ///
    /// # Errors
    ///
    /// May fail if the terminal crashes
    /// May also fail if the thread running in [`crate::app::Refresher`] crashed
    pub fn quit(self) -> Result<()> {
        let final_path = self.status.lock().current_tab_path_str().to_owned();

        clear_tmp_files();

        remove_socket(&self.event_reader.socket_path);
        drop(self.event_reader);
        drop(self.event_dispatcher);
        self.displayer.quit();
        self.refresher.quit();
        let status = self.status.lock();
        status.previewer.quit();
        if status.internal_settings.clear_before_quit {
            Self::clear()?;
        }
        drop(status);

        drop(self.status);
        clear_input_socket_files()?;
        Self::disable_mouse_capture()?;
        save_final_path(&final_path);
        Ok(())
    }
}