md-tui 0.9.2

A terminal markdown viewer
Documentation
use std::borrow::ToOwned;
use std::cmp;
use std::path::Path;

use itertools::Itertools;
use ratatui::buffer::Buffer;
use ratatui::layout::{Alignment, Rect};
use ratatui::style::Stylize;
use ratatui::text::Text;
use ratatui::widgets::{HighlightSpacing, Widget};
use ratatui::{
    style::{Modifier, Style},
    widgets::{Block, List, ListItem, ListState, StatefulWidget},
};

use crate::search::find_files;
use crate::util::colors::color_config;

#[derive(Debug, Clone)]
enum MdFileComponent {
    File(MdFile),
    Spacer,
}

#[derive(Debug, Clone, Default)]
pub struct MdFile {
    pub path: String,
    pub name: String,
}

impl MdFile {
    #[must_use]
    pub fn new(path: String, name: String) -> Self {
        Self { path, name }
    }

    #[must_use]
    pub fn path_str(&self) -> &str {
        &self.path
    }

    #[must_use]
    pub fn path(&self) -> &Path {
        Path::new(&self.path)
    }

    #[must_use]
    pub fn name(&self) -> &str {
        &self.name
    }
}

impl From<MdFile> for ListItem<'_> {
    fn from(val: MdFile) -> Self {
        let mut text = Text::default();
        text.extend([
            val.name.clone().fg(color_config().file_tree_name_color),
            val.path
                .clone()
                .italic()
                .fg(color_config().file_tree_path_color),
        ]);
        ListItem::new(text)
    }
}

impl From<MdFileComponent> for ListItem<'_> {
    fn from(value: MdFileComponent) -> Self {
        match value {
            MdFileComponent::File(f) => f.into(),
            MdFileComponent::Spacer => ListItem::new(Text::raw("")),
        }
    }
}

#[derive(Debug, Clone, Default)]
pub struct FileTree {
    all_files: Vec<MdFile>,
    files: Vec<MdFileComponent>,
    page: u32,
    list_state: ListState,
    search: Option<String>,
    loaded: bool,
}

impl FileTree {
    #[must_use]
    pub fn new() -> Self {
        Self {
            all_files: Vec::new(),
            files: Vec::new(),
            list_state: ListState::default(),
            page: 0,
            search: None,
            loaded: false,
        }
    }

    #[must_use]
    pub fn loaded(&self) -> bool {
        self.loaded
    }

    #[must_use]
    pub fn finish(self) -> Self {
        let mut this = self;
        this.loaded = true;
        this
    }

    pub fn sort(&mut self) {
        let filtered: Vec<&MdFile> = self
            .files
            .iter()
            .filter_map(|c| match c {
                MdFileComponent::File(f) => Some(f),
                MdFileComponent::Spacer => None,
            })
            .sorted_unstable_by(|a, b| a.name.cmp(&b.name))
            .collect();

        let spacers = vec![MdFileComponent::Spacer; filtered.len()];

        self.files = filtered
            .into_iter()
            .zip(spacers)
            .flat_map(|(f, s)| vec![MdFileComponent::File(f.to_owned()), s])
            .collect::<Vec<_>>();
    }

    pub fn sort_name(&mut self) {
        // Separate files and spacers into two vectors
        let (mut files, mut spacers): (Vec<_>, Vec<_>) = self
            .files
            .drain(..)
            .partition(|c| matches!(c, MdFileComponent::File(_)));

        // Sort the files in-place by name
        files.sort_unstable_by(|a, b| match (a, b) {
            (MdFileComponent::File(fa), MdFileComponent::File(fb)) => {
                let a = fa
                    .path()
                    .to_str()
                    .unwrap()
                    .trim_start_matches("./")
                    .trim_start_matches(char::is_alphabetic);
                let b = fb
                    .path()
                    .to_str()
                    .unwrap()
                    .trim_start_matches("./")
                    .trim_start_matches(char::is_alphabetic);

                b.to_lowercase().cmp(&a.to_lowercase())
            }
            _ => unreachable!(), // This case should not happen
        });

        // Interleave files and spacers
        let mut result = Vec::with_capacity(files.len() + spacers.len());
        while let (Some(file), Some(spacer)) = (files.pop(), spacers.pop()) {
            result.push(file);
            result.push(spacer);
        }

        // Update self.files with the sorted and interleaved result
        self.files = result;
    }

    pub fn search(&mut self, query: Option<&str>) {
        self.state_mut().select(None);
        self.page = 0;
        self.search = query.map(ToOwned::to_owned);
        match query {
            Some(query) => {
                self.files = find_files(&self.all_files, query)
                    .into_iter()
                    .map(MdFileComponent::File)
                    .collect();
            }
            None => {
                self.files = self
                    .all_files
                    .iter()
                    .cloned()
                    .map(MdFileComponent::File)
                    .collect();
            }
        }
        self.fill_spacers();
    }

    fn fill_spacers(&mut self) {
        let spacers = vec![MdFileComponent::Spacer; self.files.len()];
        self.files = self
            .files
            .iter()
            .cloned()
            .zip(spacers)
            .flat_map(|(f, s)| vec![f, s])
            .collect::<Vec<_>>();
    }

    pub fn next(&mut self, height: u16) {
        let i = match self.list_state.selected() {
            Some(i) => {
                if i >= self.files.len() - 2 {
                    0
                } else {
                    i + 2
                }
            }
            None => 0,
        };
        self.page = (i / self.partition(height)) as u32;
        self.list_state.select(Some(i));
    }

    pub fn previous(&mut self, height: u16) {
        let i = match self.list_state.selected() {
            Some(i) => {
                if i == 0 {
                    self.files.len() - 2
                } else {
                    i.saturating_sub(2)
                }
            }
            None => 0,
        };
        self.page = (i / self.partition(height)) as u32;
        self.list_state.select(Some(i));
    }

    pub fn next_page(&mut self, height: u16) {
        let partition = self.partition(height);
        let i = match self.list_state.selected() {
            Some(i) => {
                if i + partition >= self.files.len() {
                    0
                } else {
                    i + partition
                }
            }
            None => 0,
        };
        self.page = (i / partition) as u32;
        self.list_state.select(Some(i));
    }

    pub fn previous_page(&mut self, height: u16) {
        let partition = self.partition(height);
        let i = match self.list_state.selected() {
            Some(i) => {
                if i < partition {
                    self.files.len().saturating_sub(partition)
                } else {
                    i.saturating_sub(partition)
                }
            }
            None => 0,
        };
        self.page = (i / partition) as u32;
        self.list_state.select(Some(i));
    }

    pub fn first(&mut self) {
        self.list_state.select(Some(0));
        self.page = 0;
    }

    pub fn last(&mut self, height: u16) {
        let partition = self.partition(height);
        let i = self.files.len() - 2;
        self.list_state.select(Some(i));
        self.page = (i / partition) as u32;
    }

    pub fn unselect(&mut self) {
        self.list_state.select(None);
    }

    #[must_use]
    pub fn selected(&self) -> Option<&MdFile> {
        match self.list_state.selected() {
            Some(i) => self.files.get(i).and_then(|f| match f {
                MdFileComponent::File(f) => Some(f),
                MdFileComponent::Spacer => None,
            }),
            None => None,
        }
    }

    pub fn add_file(&mut self, file: MdFile) {
        self.all_files.push(file.clone());
        self.files.push(MdFileComponent::File(file));
        self.files.push(MdFileComponent::Spacer);
    }

    #[must_use]
    pub fn files(&self) -> Vec<&MdFile> {
        self.files
            .iter()
            .filter_map(|f| match f {
                MdFileComponent::File(f) => Some(f),
                MdFileComponent::Spacer => None,
            })
            .collect::<Vec<&MdFile>>()
    }

    #[must_use]
    pub fn all_files(&self) -> &Vec<MdFile> {
        &self.all_files
    }

    fn partition(&self, height: u16) -> usize {
        let partition_size = usize::midpoint(height as usize, 2);

        if partition_size.is_multiple_of(2) {
            partition_size
        } else {
            partition_size + 1
        }
    }

    #[must_use]
    pub fn state(&self) -> &ListState {
        &self.list_state
    }

    #[must_use]
    pub fn height(&self, height: u16) -> usize {
        cmp::min(
            self.partition(height) / 2 * 3,
            self.files
                .iter()
                .filter(|f| matches!(f, MdFileComponent::File(_)))
                .count()
                * 3,
        )
    }

    pub fn state_mut(&mut self) -> &mut ListState {
        &mut self.list_state
    }
}

impl Widget for FileTree {
    fn render(self, area: Rect, buf: &mut Buffer) {
        let mut state = self.state().to_owned();
        let file_len = self.files.len();
        let partition = self.partition(area.height);

        let items = if let Some(iter) = self
            .files
            .chunks(self.partition(area.height))
            .nth(self.page as usize)
        {
            iter.to_owned()
        } else {
            self.files
        };

        state.select(state.selected().map(|i| i % partition));

        let y_height = items.len() / 2 * 3;

        let total_pages = usize::div_ceil(file_len, partition);

        let page_count = format!("  {}/{}", self.page + 1, total_pages);

        let paragraph = Text::styled(
            page_count,
            Style::default().fg(color_config().file_tree_page_count_color),
        );

        let items = List::new(items)
            .block(
                Block::default()
                    .title("MD-TUI")
                    .add_modifier(Modifier::BOLD)
                    .title_alignment(Alignment::Center),
            )
            .highlight_style(
                Style::default()
                    .fg(color_config().file_tree_selected_fg_color)
                    .add_modifier(Modifier::BOLD),
            )
            .highlight_symbol("\u{02503} ")
            .repeat_highlight_symbol(true)
            .highlight_spacing(HighlightSpacing::Always);

        StatefulWidget::render(items, area, buf, &mut state);

        let area = Rect {
            y: area.y + y_height as u16 + 2,
            ..area
        };

        paragraph.render(area, buf);
    }
}