rucola-notes 0.10.0

Terminal-based markdown note manager.
// Copyright (C) 2024 Linus Mussmaecher <linus@mussmaecher.de>
use clap::Parser;
use ratatui::crossterm::{event, terminal, ExecutableCommand};
use ratatui::prelude::*;
use std::panic;

/// The actual application, combining the ui and data management.
mod app;
/// Config file.
mod config;
/// Data manipulation: Reading, parsing and manipulating note files and calculating statistics.
mod data;
/// Error enum and handling.
mod error;
/// Interaction with the file system & configuration.
mod io;
/// The ui of the app.
mod ui;
/// Initial config file loaded from file and used to create other configuration structs.
pub use config::Config;

/// Command line arguments for the Rucola markdown note management program.
/// This program comes with ABSOLUTELY NO WARRANTY.
/// This is free software, and you are welcome to redistribute it under certain conditions.
/// Type `rucola --license` for details.
/// Copyright (C) 2024 Linus Mussmaecher <linus@mussmaecher.de.
#[derive(Parser)]
#[command(version, about, long_about = None)]
pub struct Arguments {
    /// Target vault folder, containing the notes to index and manage. Overrides the path set in the config file.
    target_folder: Option<String>,
    /// A path to a file (relative to the config directory) containing the styles to use for the UI
    #[arg(short, long)]
    style: Option<String>,
    /// Output the license and warranty.
    #[arg(short, long)]
    license: bool,
}

/// Main function
fn main() {
    // Note: Any terminal errors in this main method are expected (leading to crashing), since they were previously handled by returning an error, which had the same effect.

    // === Read command line arguments
    let args = Arguments::parse();

    // === Help Notices etc. ===
    if args.license {
        print_license();
    }

    // === Actual programm ===

    // Initialize hooks & terminal (ratatui boilerplate)
    init_hooks().expect("Error in hook initialization.");
    let mut terminal = init_terminal().expect("Error in terminal initialization.");

    // create a call back for the loading screen
    // Create the app state
    let (mut app, errors) =
        app::App::new(args, |message| draw_loading_screen(&mut terminal, message));

    // Displayed error
    let mut current_error: Option<error::RucolaError> = errors.into_iter().next_back();

    // Main loop
    'main: loop {
        // Draw the current screen.
        terminal
            .draw(|frame: &mut Frame| {
                let area = frame.area();
                let buf = frame.buffer_mut();

                // Make sure area is large enough or show error
                if area.width < 90 || area.height < 25 {
                    // area too small and no error -> show area error
                    current_error = Some(error::RucolaError::SmallArea);
                }

                let app_area = match &current_error {
                    // If there is an error to be displayed
                    Some(e) => {
                        // Separate the usual app area into a small bottom line for the area and a big area for what can be displayed of the app.
                        let areas = Layout::vertical([Constraint::Fill(1), Constraint::Length(1)])
                            .split(area);

                        // Render the error to the bottom.
                        Widget::render(e.to_ratatui(), areas[1], buf);

                        // Return the rest of the area for the app to render in.
                        areas[0]
                    }
                    // No error => App can render in the entire area.
                    None => area,
                };

                Widget::render(ratatui::widgets::Clear, app_area, buf);

                // Draw the actual application
                app.draw(app_area, buf);
            })
            .expect("Error in terminal drawing.");

        // Inform the app of events
        let maybe_keypress = if event::poll(std::time::Duration::from_millis(500))
            .expect("Error in event polling.")
        {
            // Some event => reset current error
            current_error = None;
            // Check if the event was a keypress
            match event::read().expect("Error in event reading.") {
                event::Event::Key(key) if key.kind == event::KeyEventKind::Press => Some(key),
                _ => None,
            }
        } else {
            None
        };

        // update the app and deal with messages
        match app.update(maybe_keypress) {
            Ok(ui::TerminalMessage::Quit) => {
                break 'main;
            }
            Ok(ui::TerminalMessage::None) => {}
            Ok(ui::TerminalMessage::OpenExternalCommand(mut cmd)) => {
                // Restore the terminal
                restore_terminal().expect("Error in terminal restoration.");
                // Execute the given command
                cmd.status().expect("Error in command execution.");
                // Re-enter the tui state
                terminal = init_terminal().expect("Error in terminal re-initialization.");
            }
            Err(e) => current_error = Some(e),
        }
    }

    //Restore previous terminal state
    restore_terminal().expect("Error in terminal restoration.");
}

/// Ratatui boilerplate to set up panic hooks
fn init_hooks() -> error::Result<()> {
    // Get a default panic hook
    let original_hook = panic::take_hook();
    panic::set_hook(Box::new(move |panic_info| {
        // intentionally ignore errors here since we're already in a panic
        // Just restore the terminal.
        let _ = restore_terminal();
        original_hook(panic_info);
    }));
    Ok(())
}

/// Ratatui boilerplate to put the terminal into a TUI state
fn init_terminal() -> std::io::Result<Terminal<impl ratatui::backend::Backend>> {
    std::io::stdout().execute(terminal::EnterAlternateScreen)?;
    terminal::enable_raw_mode()?;
    let mut terminal = Terminal::new(CrosstermBackend::new(std::io::stdout()))?;
    terminal.clear()?;
    Ok(terminal)
}

/// Ratatui boilerplate to restore the terminal to a usable state after program exits (regularly or by panic)
fn restore_terminal() -> std::io::Result<()> {
    std::io::stdout().execute(terminal::LeaveAlternateScreen)?;
    terminal::disable_raw_mode()?;
    Ok(())
}

/// Prints license information to the console.
fn print_license() {
    print!("Rucola is released under the GNU General Public License v3, available at <https://www.gnu.org/licenses/gpl-3.0>.

Copyright (C) 2024 Linus Mußmächer <linus@mussmaecher.de>

This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version.

This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.

You should have received a copy of the GNU General Public License along with this program.  If not, see <https://www.gnu.org/licenses/>.
    ");
}

/// Draws nothing but a loading screen with an indexing message.
/// Temporary screen while the programm is indexing.
fn draw_loading_screen(terminal: &mut Terminal<impl ratatui::backend::Backend>, message: &str) {
    // Draw 'loading' screen
    // Errors are ignored.
    let _ = terminal.draw(|frame| {
        frame.render_widget(
            ratatui::widgets::Paragraph::new(message).alignment(Alignment::Center),
            Layout::vertical([
                Constraint::Fill(1),
                Constraint::Length(5),
                Constraint::Fill(1),
            ])
            .split(frame.area())[1],
        );
    });
}