diffs-rs 0.1.1

Fast terminal diff viewer and embeddable Rust diff engine
Documentation
use ratatui::layout::Rect;

use super::{App, Position};
use crate::tree::{FileId, Kind, NodeId, Tree};

const TREE_WIDTH: u16 = 32;
const MIN_WIDTH: u16 = TREE_WIDTH + 1 + 64;

#[derive(Default, Clone, Copy)]
pub enum SidebarPreference {
    #[default]
    Auto,
    Show,
    Hide,
}

#[derive(Default, Clone, Copy, PartialEq, Eq)]
pub(crate) enum Focus {
    #[default]
    Diff,
    Files,
}

#[derive(Default)]
pub(crate) struct Sidebar {
    pub tree: Tree,
    pub preference: SidebarPreference,
    pub focus: Focus,
    pub cursor: Option<NodeId>,
    pub top: usize,
    pub current: Option<FileId>,
    pub screen: Rect,
    pub area: Rect,
    pub diff: Rect,
    pub notice: Option<&'static str>,
}

impl Sidebar {
    pub fn focused(&self) -> bool {
        self.focus == Focus::Files
    }
    pub fn page(&self) -> usize {
        usize::from(self.area.height.saturating_sub(1)).max(1)
    }

    pub fn keep_visible(&mut self) {
        let page = self.page();
        if let Some(position) = self.cursor.and_then(|id| self.tree.position(id)) {
            self.top = self
                .top
                .min(position)
                .max(position.saturating_sub(page - 1));
        }
        self.top = self.top.min(self.tree.rows().len().saturating_sub(page));
    }

    pub fn move_cursor(&mut self, amount: isize) {
        let position = self
            .cursor
            .and_then(|id| self.tree.position(id))
            .unwrap_or(0);
        let target = position
            .saturating_add_signed(amount)
            .min(self.tree.rows().len().saturating_sub(1));
        self.cursor = self.tree.rows().get(target).map(|r| r.node);
        self.keep_visible();
    }

    pub fn scroll(&mut self, amount: isize) {
        let page = self.page();
        self.top = self
            .top
            .saturating_add_signed(amount)
            .min(self.tree.rows().len().saturating_sub(page));
        if let Some(position) = self.cursor.and_then(|id| self.tree.position(id)) {
            let target = position.clamp(self.top, self.top + page - 1);
            self.cursor = self.tree.rows().get(target).map(|r| r.node);
        }
    }

    pub fn toggle(&mut self, id: NodeId) {
        if let Kind::Folder { expanded, .. } = self.tree.node(id).kind {
            self.tree.set_expanded(id, !expanded);
            self.tree.refresh();
            self.keep_visible();
        }
    }

    pub fn right(&mut self) {
        let Some(id) = self.cursor else {
            return;
        };
        if let Kind::Folder { expanded, .. } = self.tree.node(id).kind {
            if expanded {
                self.move_cursor(1);
            } else {
                self.toggle(id);
            }
        }
    }

    pub fn left(&mut self) {
        let Some(id) = self.cursor else {
            return;
        };
        if matches!(self.tree.node(id).kind, Kind::Folder { expanded: true, .. }) {
            self.toggle(id);
        } else if let Some(parent) = self.tree.parent(id) {
            self.cursor = Some(parent);
            self.keep_visible();
        }
    }
}

impl App {
    pub fn set_sidebar_preference(&mut self, preference: SidebarPreference) {
        self.sidebar.preference = preference;
    }

    pub(crate) fn prepare_view(&mut self, screen: Rect) -> anyhow::Result<()> {
        for file in &self.doc.files[self.sidebar.tree.len()..] {
            self.sidebar.tree.append(file)?;
        }
        let current = self.row(self.cursor.row).map(|row| FileId(row.file));
        if current != self.sidebar.current {
            self.sidebar.current = current;
            if self.sidebar.focus == Focus::Diff
                && let Some(node) = current.and_then(|id| self.sidebar.tree.file_node(id))
            {
                self.sidebar.tree.reveal(node);
                self.sidebar.cursor = Some(node);
            }
        }
        self.sidebar.tree.refresh();
        if self.sidebar.cursor.is_none() {
            self.sidebar.cursor = self.sidebar.tree.rows().first().map(|r| r.node);
        }
        let wants_tree = match self.sidebar.preference {
            SidebarPreference::Auto => self.doc.files.len() >= 2,
            SidebarPreference::Show => true,
            SidebarPreference::Hide => false,
        };
        let shown = wants_tree && screen.width >= MIN_WIDTH && screen.height >= 10;
        if shown {
            self.sidebar.notice = None;
        }
        self.sidebar.screen = screen;
        self.sidebar.area = if shown {
            Rect::new(screen.x, screen.y + 1, TREE_WIDTH, screen.height - 2)
        } else {
            Rect::default()
        };
        let inset = if shown { TREE_WIDTH + 1 } else { 0 };
        self.sidebar.diff = if screen.width >= 40 && screen.height >= 10 {
            Rect::new(
                screen.x + inset,
                screen.y + 1,
                screen.width - inset,
                screen.height - 2,
            )
        } else {
            Rect::default()
        };
        if !shown {
            self.sidebar.focus = Focus::Diff;
        }
        self.sidebar.keep_visible();
        if self.sidebar.diff.is_empty() {
            self.visible.clear();
            return Ok(());
        }
        let changed = self.width != self.sidebar.diff.width || self.height != screen.height;
        if changed {
            self.reflow(self.layout, self.sidebar.diff.width, screen.height)?;
        }
        Ok(())
    }

    pub(crate) fn files_command(&mut self) -> anyhow::Result<()> {
        if self.sidebar.focused() {
            self.sidebar.preference = SidebarPreference::Hide;
            self.sidebar.focus = Focus::Diff;
        } else {
            self.sidebar.preference = SidebarPreference::Show;
            if self.sidebar.screen.width < MIN_WIDTH || self.sidebar.screen.height < 10 {
                self.sidebar.notice = Some("Files need at least 97 columns × 10 rows");
            } else {
                self.sidebar.focus = Focus::Files;
                if let Some(id) = self
                    .row(self.cursor.row)
                    .and_then(|r| self.sidebar.tree.file_node(FileId(r.file)))
                {
                    self.sidebar.tree.reveal(id);
                    self.sidebar.cursor = Some(id);
                }
            }
        }
        self.prepare_view(self.sidebar.screen)?;
        Ok(())
    }

    pub(crate) fn activate_tree(&mut self) -> bool {
        let Some(id) = self.sidebar.cursor else {
            return false;
        };
        if let Some(file) = self.sidebar.tree.node(id).file() {
            self.cursor = Position {
                row: self.view.file_row(&self.doc, file.0, self.split()),
                part: 0,
            };
            self.top = self.cursor;
            self.reading_anchor = None;
            self.sidebar.focus = Focus::Diff;
            true
        } else {
            self.sidebar.toggle(id);
            false
        }
    }
}