dua-cli 2.3.7

A tool to conveniently learn about the disk usage of directories, fast!
Documentation
use crate::interactive::{
    fit_string_graphemes_with_ellipsis, path_of, widgets::entry_color, CursorDirection,
};
use dua::{
    traverse::{Tree, TreeIndex},
    ByteFormat,
};
use itertools::Itertools;
use std::{borrow::Borrow, collections::btree_map::Entry, collections::BTreeMap, path::PathBuf};
use termion::{event::Key, event::Key::*};
use tui::{
    buffer::Buffer,
    layout::Rect,
    layout::{Constraint, Direction, Layout},
    style::{Color, Modifier, Style},
    widgets::Block,
    widgets::Borders,
    widgets::Text,
    widgets::{Paragraph, Widget},
};
use tui_react::{List, ListProps};
use unicode_segmentation::UnicodeSegmentation;

pub enum MarkMode {
    Delete,
}

pub type EntryMarkMap = BTreeMap<TreeIndex, EntryMark>;
pub struct EntryMark {
    pub size: u64,
    pub path: PathBuf,
    pub index: usize,
    pub num_errors_during_deletion: usize,
    pub is_dir: bool,
}

#[derive(Default)]
pub struct MarkPane {
    selected: Option<usize>,
    marked: EntryMarkMap,
    list: List,
    has_focus: bool,
    last_sorting_index: usize,
}

pub struct MarkPaneProps {
    pub border_style: Style,
    pub format: ByteFormat,
}

impl MarkPane {
    #[cfg(test)]
    pub fn has_focus(&self) -> bool {
        self.has_focus
    }
    pub fn set_focus(&mut self, has_focus: bool) {
        self.has_focus = has_focus;
        if has_focus {
            self.selected = Some(self.marked.len().saturating_sub(1));
        } else {
            self.selected = None
        }
    }
    pub fn toggle_index(mut self, index: TreeIndex, tree: &Tree, is_dir: bool) -> Option<Self> {
        match self.marked.entry(index) {
            Entry::Vacant(entry) => {
                if let Some(e) = tree.node_weight(index) {
                    let sorting_index = self.last_sorting_index + 1;
                    self.last_sorting_index = sorting_index;
                    entry.insert(EntryMark {
                        size: e.size,
                        path: path_of(tree, index),
                        index: sorting_index,
                        num_errors_during_deletion: 0,
                        is_dir,
                    });
                }
            }
            Entry::Occupied(entry) => {
                entry.remove();
            }
        };
        if self.marked.is_empty() {
            None
        } else {
            Some(self)
        }
    }
    pub fn marked(&self) -> &EntryMarkMap {
        &self.marked
    }
    pub fn key(mut self, key: Key) -> Option<(Self, Option<MarkMode>)> {
        let action = None;
        match key {
            Ctrl('r') => return self.prepare_deletion(),
            Char('d') | Char(' ') => return self.remove_selected().map(|s| (s, action)),
            Ctrl('u') | PageUp => self.change_selection(CursorDirection::PageUp),
            Char('k') | Up => self.change_selection(CursorDirection::Up),
            Char('j') | Down => self.change_selection(CursorDirection::Down),
            Ctrl('d') | PageDown => self.change_selection(CursorDirection::PageDown),
            _ => {}
        };
        Some((self, action))
    }

    pub fn iterate_deletable_items(
        mut self,
        mut delete_fn: impl FnMut(Self, TreeIndex) -> Result<Self, (Self, usize)>,
    ) -> Option<Self> {
        loop {
            match self.next_entry_for_deletion() {
                Some(entry_to_delete) => match delete_fn(self, entry_to_delete) {
                    Ok(pane) => {
                        self = pane;
                        match self.delete_entry() {
                            Some(p) => self = p,
                            None => return None,
                        }
                    }
                    Err((pane, num_errors)) => {
                        self = pane;
                        self.set_error_on_marked_item(num_errors)
                    }
                },
                None => return Some(self),
            }
        }
    }

    fn next_entry_for_deletion(&mut self) -> Option<TreeIndex> {
        match self.selected.and_then(|selected| {
            self.tree_index_by_list_position(selected)
                .and_then(|idx| self.marked.get(&idx).map(|d| (selected, idx, d)))
        }) {
            Some((position, selected_index, data)) => match data.num_errors_during_deletion {
                0 => Some(selected_index),
                _ => {
                    self.selected = match position + 1 {
                        p if p < self.marked.len() => Some(p),
                        _ => Some(self.marked.len().saturating_sub(1)),
                    };
                    self.tree_index_by_list_position(position + 1)
                }
            },
            None => None,
        }
    }
    fn delete_entry(self) -> Option<Self> {
        self.remove_selected()
    }
    fn set_error_on_marked_item(&mut self, num_errors: usize) {
        if let Some(d) = self
            .selected
            .and_then(|s| self.tree_index_by_list_position(s))
            .and_then(|p| self.marked.get_mut(&p))
        {
            d.num_errors_during_deletion = num_errors;
        }
    }
    fn prepare_deletion(mut self) -> Option<(Self, Option<MarkMode>)> {
        for entry in self.marked.values_mut() {
            entry.num_errors_during_deletion = 0;
        }
        self.selected = Some(0);
        Some((self, Some(MarkMode::Delete)))
    }
    fn remove_selected(mut self) -> Option<Self> {
        if let Some(mut selected) = self.selected {
            let idx = self.tree_index_by_list_position(selected);
            let se_len = self.marked.len();
            if let Some(idx) = idx {
                self.marked.remove(&idx);
                let new_len = se_len.saturating_sub(1);
                if new_len == 0 {
                    return None;
                }
                if new_len == selected {
                    selected = selected.saturating_sub(1);
                }
                self.selected = Some(selected);
            }
            Some(self)
        } else {
            Some(self)
        }
    }

    fn tree_index_by_list_position(&mut self, selected: usize) -> Option<TreeIndex> {
        self.marked_sorted_by_index()
            .get(selected)
            .map(|(k, _)| *k.to_owned())
    }

    fn marked_sorted_by_index(&self) -> Vec<(&TreeIndex, &EntryMark)> {
        self.marked
            .iter()
            .sorted_by_key(|(_, v)| &v.index)
            .collect()
    }

    fn change_selection(&mut self, direction: CursorDirection) {
        self.selected = self.selected.map(|selected| {
            direction
                .move_cursor(selected)
                .min(self.marked.len().saturating_sub(1))
        });
    }

    pub fn render(&mut self, props: impl Borrow<MarkPaneProps>, area: Rect, buf: &mut Buffer) {
        let MarkPaneProps {
            border_style,
            format,
        } = props.borrow();

        let marked: &_ = &self.marked;
        let title = format!(
            "Marked {} items ({}) ",
            marked.len(),
            format.display(marked.iter().map(|(_k, v)| v.size).sum::<u64>())
        );
        let selected = self.selected;
        let has_focus = self.has_focus;
        let entries = marked.values().sorted_by_key(|v| &v.index).enumerate().map(
            |(idx, v): (usize, &EntryMark)| {
                let default_style = match selected {
                    Some(selected) if idx == selected => {
                        let mut modifier = Modifier::REVERSED;
                        if has_focus {
                            modifier.insert(Modifier::BOLD);
                        }
                        Style {
                            modifier,
                            ..Default::default()
                        }
                    }
                    _ => Style::default(),
                };
                let (path, path_len) = {
                    let path = format!(
                        " {}  {}",
                        v.path.display(),
                        if v.num_errors_during_deletion != 0 {
                            format!("{} IO deletion errors", v.num_errors_during_deletion)
                        } else {
                            "".to_string()
                        }
                    );
                    let num_path_graphemes = path.graphemes(true).count();
                    match num_path_graphemes + format.total_width() {
                        n if n > area.width as usize => {
                            let desired_size = num_path_graphemes - (n - area.width as usize);
                            fit_string_graphemes_with_ellipsis(
                                path,
                                num_path_graphemes,
                                desired_size,
                            )
                        }
                        _ => (path, num_path_graphemes),
                    }
                };
                let fg_path = entry_color(Color::Reset, !v.is_dir, true);
                let path = Text::Styled(
                    path.into(),
                    Style {
                        fg: fg_path,
                        ..default_style
                    },
                );
                let bytes = Text::Styled(
                    format!(
                        "{:>byte_column_width$} ",
                        format.display(v.size).to_string(), // we would have to impl alignment/padding ourselves otherwise...
                        byte_column_width = format.width()
                    )
                    .into(),
                    Style {
                        fg: Color::Green,
                        ..default_style
                    },
                );
                let spacer = Text::Styled(
                    format!(
                        "{:-space$}",
                        "",
                        space = (area.width as usize)
                            .saturating_sub(path_len)
                            .saturating_sub(format.total_width())
                    )
                    .into(),
                    Style {
                        fg: fg_path,
                        ..default_style
                    },
                );
                vec![path, spacer, bytes]
            },
        );

        let entry_in_view = match self.selected {
            Some(s) => Some(s),
            None => {
                self.list.offset = 0;
                Some(marked.len().saturating_sub(1))
            }
        };
        let mut block = Block::default()
            .title(&title)
            .border_style(*border_style)
            .borders(Borders::ALL);

        let inner_area = block.inner(area);
        block.draw(area, buf);

        let list_area = if self.has_focus {
            let (help_line_area, list_area) = {
                let help_at_bottom =
                    selected.unwrap_or(0) >= inner_area.height.saturating_sub(1) as usize / 2;
                let constraints = {
                    let mut c = vec![Constraint::Length(1), Constraint::Max(256)];
                    if help_at_bottom {
                        c.reverse();
                    }
                    c
                };
                let regions = Layout::default()
                    .direction(Direction::Vertical)
                    .constraints(constraints)
                    .split(inner_area);

                match help_at_bottom {
                    true => (regions[1], regions[0]),
                    false => (regions[0], regions[1]),
                }
            };

            let default_style = Style {
                fg: Color::Black,
                bg: Color::Yellow,
                modifier: Modifier::BOLD,
            };
            Paragraph::new(
                [
                    Text::Styled(
                        " Ctrl + r".into(),
                        Style {
                            fg: Color::LightRed,
                            modifier: default_style.modifier | Modifier::RAPID_BLINK,
                            ..default_style
                        },
                    ),
                    Text::Styled(
                        " deletes listed entries from disk without prompt".into(),
                        default_style,
                    ),
                ]
                .iter(),
            )
            .style(default_style)
            .draw(help_line_area, buf);
            list_area
        } else {
            inner_area
        };

        let props = ListProps {
            block: None,
            entry_in_view,
        };
        self.list.render(props, entries, list_area, buf)
    }
}