diffs-rs 0.1.0

Fast terminal diff viewer and embeddable Rust diff engine
Documentation
use crossterm::event::{Event, KeyCode, KeyModifiers};
use diffs_rs::{
    reading::SourceRead,
    text::{glyphs, safe_label},
};
use ratatui::{Frame, layout::Rect, widgets::Paragraph};
use std::sync::Arc;
use unicode_segmentation::UnicodeSegmentation;

#[derive(Default)]
pub(super) struct Prompt {
    pub(super) mode: Mode,
    input: String,
    cursor: usize,
    error: Option<String>,
}

#[derive(Default)]
pub(super) enum Mode {
    #[default]
    Search,
    Save(Arc<SourceRead>),
}

pub(super) enum Action {
    Edit,
    Submit,
    Cancel,
    Unchanged,
}

impl Prompt {
    pub(super) fn save(read: Arc<SourceRead>, path: String) -> Self {
        Self {
            mode: Mode::Save(read),
            cursor: path.len(),
            input: path,
            error: None,
        }
    }

    pub(super) fn text(&self) -> &str {
        &self.input
    }

    pub(super) fn set_error(&mut self, error: String) {
        self.error = Some(safe_label(error.as_bytes()));
    }

    fn insert(&mut self, text: &str) {
        let label = if matches!(self.mode, Mode::Save(_)) {
            "Save path"
        } else {
            "Search"
        };
        if text.contains(['\r', '\n']) {
            self.error = Some(format!("{label} accepts one line; paste was not inserted"));
        } else if matches!(self.mode, Mode::Save(_)) && text.contains('\0') {
            self.error = Some("Save path cannot contain NUL; paste was not inserted".into());
        } else if self.input.len().saturating_add(text.len()) > 4096 {
            self.error = Some(format!("{label} accepts at most 4096 UTF-8 bytes"));
        } else {
            self.input.insert_str(self.cursor, text);
            self.cursor = self.boundary_at_or_after(self.cursor + text.len());
            self.error = None;
        }
    }

    fn previous(&self) -> usize {
        self.input
            .grapheme_indices(true)
            .take_while(|(at, _)| *at < self.cursor)
            .last()
            .map_or(0, |(at, _)| at)
    }

    fn next(&self) -> usize {
        self.input
            .grapheme_indices(true)
            .find(|(at, _)| *at > self.cursor)
            .map_or(self.input.len(), |(at, _)| at)
    }

    fn boundary_at_or_after(&self, byte: usize) -> usize {
        self.input
            .grapheme_indices(true)
            .find(|(at, _)| *at >= byte)
            .map_or(self.input.len(), |(at, _)| at)
    }

    pub(super) fn handle(&mut self, event: &Event) -> Action {
        match event {
            Event::Paste(text) => self.insert(text),
            Event::Key(key) => match key.code {
                KeyCode::Esc => return Action::Cancel,
                KeyCode::Enter => return Action::Submit,
                KeyCode::Char('u') if key.modifiers.contains(KeyModifiers::CONTROL) => {
                    self.input.clear();
                    self.cursor = 0;
                    self.error = None;
                }
                KeyCode::Char(c)
                    if !key.modifiers.intersects(
                        KeyModifiers::CONTROL | KeyModifiers::ALT | KeyModifiers::SUPER,
                    ) =>
                {
                    self.insert(&c.to_string())
                }
                KeyCode::Tab => self.insert("\t"),
                KeyCode::Left => self.cursor = self.previous(),
                KeyCode::Right => self.cursor = self.next(),
                KeyCode::Home => self.cursor = 0,
                KeyCode::End => self.cursor = self.input.len(),
                KeyCode::Backspace => {
                    let at = self.previous();
                    self.input.drain(at..self.cursor);
                    self.cursor = self.boundary_at_or_after(at);
                    self.error = None;
                }
                KeyCode::Delete => {
                    let end = self.next();
                    self.input.drain(self.cursor..end);
                    self.cursor = self.boundary_at_or_after(self.cursor);
                    self.error = None;
                }
                _ => return Action::Unchanged,
            },
            _ => return Action::Unchanged,
        }
        Action::Edit
    }

    pub(super) fn draw(&self, frame: &mut Frame<'_>) {
        let area = frame.area();
        if area.width < 12 || area.height == 0 {
            return;
        }
        let y = area.bottom() - 1;
        let style = frame.buffer_mut()[(area.x, y)].style();
        let saving = matches!(self.mode, Mode::Save(_));
        let prefix = if saving { "/save: " } else { "/diff: " };
        let hint = if area.width >= 70 {
            if saving {
                " Enter save new file · Esc cancel "
            } else {
                " Enter search · Esc cancel "
            }
        } else {
            ""
        };
        let available = area.width as usize - prefix.len() - hint.chars().count();
        let display = safe_label(self.input.as_bytes());
        let prefix_text = safe_label(&self.input.as_bytes()[..self.cursor]);
        let column = glyphs(&prefix_text, 4)
            .last()
            .map_or(0, |g| g.column + g.width);
        let start = column.saturating_sub(available.saturating_sub(1));
        let first = glyphs(&display, 4).find(|g| g.column >= start);
        let (byte, offset) = first.map_or((display.len(), column), |g| (g.byte, g.column));
        let input_area = Rect::new(area.x + prefix.len() as u16, y, available as u16, 1);
        frame.render_widget(
            Paragraph::new(" ".repeat(area.width as usize)).style(style),
            Rect::new(area.x, y, area.width, 1),
        );
        frame.render_widget(
            Paragraph::new(prefix).style(style),
            Rect::new(area.x, y, prefix.len() as u16, 1),
        );
        frame.render_widget(Paragraph::new(&display[byte..]).style(style), input_area);
        frame.render_widget(
            Paragraph::new(hint).style(style),
            Rect::new(input_area.right(), y, area.right() - input_area.right(), 1),
        );
        frame.set_cursor_position((
            input_area.x + column.saturating_sub(offset).min(available - 1) as u16,
            y,
        ));
        if let Some(error) = &self.error
            && area.height > 1
        {
            frame.render_widget(
                Paragraph::new(" ".repeat(area.width as usize)).style(style),
                Rect::new(area.x, y - 1, area.width, 1),
            );
            frame.render_widget(
                Paragraph::new(format!(" {error}")).style(style),
                Rect::new(area.x, y - 1, area.width, 1),
            );
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crossterm::event::KeyEvent;

    fn key(prompt: &mut Prompt, code: KeyCode) {
        prompt.handle(&Event::Key(KeyEvent::new(code, KeyModifiers::NONE)));
    }

    #[test]
    fn joining_edits_keep_the_cursor_on_whole_input_grapheme_boundaries() {
        let mut prompt = Prompt::default();
        prompt.insert("👩💻!");
        key(&mut prompt, KeyCode::Home);
        key(&mut prompt, KeyCode::Right);
        prompt.insert("\u{200d}");
        assert_eq!(prompt.text(), "👩‍💻!");
        assert_eq!(prompt.cursor, "👩‍💻".len());
        key(&mut prompt, KeyCode::Left);
        assert_eq!(prompt.cursor, 0);
        key(&mut prompt, KeyCode::Right);
        assert_eq!(prompt.cursor, "👩‍💻".len());
        key(&mut prompt, KeyCode::Backspace);
        assert_eq!(prompt.text(), "!");
        assert_eq!(prompt.cursor, 0);

        for deletion in [KeyCode::Backspace, KeyCode::Delete] {
            let mut prompt = Prompt::default();
            prompt.insert("e\t\u{301}!");
            key(&mut prompt, KeyCode::Home);
            key(&mut prompt, KeyCode::Right);
            if deletion == KeyCode::Backspace {
                key(&mut prompt, KeyCode::Right);
            }
            key(&mut prompt, deletion);
            assert_eq!(prompt.text(), "e\u{301}!");
            assert_eq!(prompt.cursor, "e\u{301}".len());
            key(&mut prompt, KeyCode::Left);
            key(&mut prompt, KeyCode::Delete);
            assert_eq!(prompt.text(), "!");
            assert_eq!(prompt.cursor, 0);
        }
    }
}