sauva 0.7.0

Terminal Unicode Explorer
use std::ops::Range;

use crate::{
    unicode::{
        CodePoint,
        text::{AnalyzedCodePoint, TextAnalysis},
    },
    viewport::ListViewport,
};

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SequenceMove {
    Previous,
    Next,
    PreviousGroup,
    NextGroup,
    First,
    Last,
}

#[derive(Debug, Clone)]
pub struct SequenceState {
    analysis: TextAnalysis,
    selected_index: usize,
    viewport: ListViewport,
}

impl SequenceState {
    pub fn new(source: String) -> Self {
        let analysis = TextAnalysis::new(source);
        assert!(
            !analysis.code_points().is_empty(),
            "a sequence contains at least one code point"
        );
        Self {
            analysis,
            selected_index: 0,
            viewport: ListViewport::default(),
        }
    }

    pub const fn analysis(&self) -> &TextAnalysis {
        &self.analysis
    }

    pub fn code_points(&self) -> &[AnalyzedCodePoint] {
        self.analysis().code_points()
    }

    pub const fn selected_index(&self) -> usize {
        self.selected_index
    }

    pub fn selected(&self) -> CodePoint {
        self.code_points()[self.selected_index].code_point()
    }

    pub fn visible_range(&self) -> Range<usize> {
        self.viewport.visible_range(self.code_points().len())
    }

    pub fn move_selection(&mut self, movement: SequenceMove) -> bool {
        let last = self.code_points().len() - 1;
        let grapheme_index = self.code_points()[self.selected_index].grapheme_index();
        let next = match movement {
            SequenceMove::Previous => self.selected_index.saturating_sub(1),
            SequenceMove::Next => (self.selected_index + 1).min(last),
            SequenceMove::PreviousGroup => grapheme_index
                .checked_sub(1)
                .and_then(|index| self.analysis.graphemes().get(index))
                .map_or(self.selected_index, |group| group.code_point_range().start),
            SequenceMove::NextGroup => self
                .analysis
                .graphemes()
                .get(grapheme_index + 1)
                .map_or(self.selected_index, |group| group.code_point_range().start),
            SequenceMove::First => 0,
            SequenceMove::Last => last,
        };
        if next == self.selected_index {
            return false;
        }

        self.selected_index = next;
        if matches!(
            movement,
            SequenceMove::PreviousGroup | SequenceMove::NextGroup
        ) {
            self.viewport.align_start(next, self.code_points().len());
        } else {
            self.viewport.ensure_visible(next, self.code_points().len());
        }
        true
    }

    pub fn resize_viewport(&mut self, height: usize) {
        self.viewport
            .resize(height, self.selected_index, self.code_points().len());
    }
}

#[cfg(test)]
mod tests {
    use rstest::rstest;

    use super::*;
    use crate::unicode::text::NormalizationForm;

    fn state() -> SequenceState {
        SequenceState::new("A→A".to_owned())
    }

    #[test]
    fn preserves_input_order_and_duplicates() {
        let state = state();

        assert_eq!(
            state
                .code_points()
                .iter()
                .map(|code_point| code_point.code_point().value())
                .collect::<Vec<_>>(),
            [0x0041, 0x2192, 0x0041]
        );
        assert_eq!(state.selected_index(), 0);
        assert_eq!(state.selected().value(), 0x0041);
        assert_eq!(state.analysis().source(), "A→A");
    }

    #[test]
    #[should_panic(expected = "a sequence contains at least one code point")]
    fn rejects_empty_inputs() {
        SequenceState::new(String::new());
    }

    #[rstest]
    #[case("A")]
    #[case("Á")]
    #[case("👩")]
    fn supports_single_code_point_normalization_results(#[case] source: &str) {
        let mut state = SequenceState::new(source.to_owned());
        state.resize_viewport(1);
        for movement in [
            SequenceMove::Previous,
            SequenceMove::Next,
            SequenceMove::PreviousGroup,
            SequenceMove::NextGroup,
            SequenceMove::First,
            SequenceMove::Last,
        ] {
            assert!(!state.move_selection(movement));
        }
        assert_eq!(state.visible_range(), 0..1);
        assert_eq!(state.selected().to_char().unwrap().to_string(), source);
    }

    #[test]
    fn selects_each_code_point_without_changing_the_analysis() {
        let source = "A\u{0301} 👩‍💻";
        let mut state = SequenceState::new(source.to_owned());
        let analysis = state.analysis().clone();
        state.resize_viewport(2);

        assert!(state.move_selection(SequenceMove::Next));
        assert_eq!(state.selected().value(), 0x0301);
        assert_eq!(
            state.code_points()[state.selected_index()].grapheme_index(),
            0
        );
        assert!(state.move_selection(SequenceMove::Last));
        assert_eq!(state.selected().value(), 0x1f4bb);
        assert_eq!(state.visible_range(), 4..6);
        assert!(state.move_selection(SequenceMove::Previous));
        assert_eq!(state.selected().value(), 0x200d);
        assert_eq!(
            state.code_points()[state.selected_index()].grapheme_index(),
            2
        );
        state.resize_viewport(1);
        assert_eq!(state.visible_range(), 4..5);
        assert_eq!(state.analysis(), &analysis);
        assert_eq!(state.analysis().source(), source);
        assert_eq!(
            state.analysis().normalized_text(NormalizationForm::Nfc),
            "Á 👩‍💻"
        );
    }

    #[test]
    fn moves_within_the_sequence_and_keeps_the_selection_visible() {
        let mut state = state();
        state.resize_viewport(2);

        assert!(state.move_selection(SequenceMove::Last));
        assert_eq!(state.selected_index(), 2);
        assert_eq!(state.visible_range(), 1..3);
        assert!(state.move_selection(SequenceMove::Previous));
        assert_eq!(state.selected_index(), 1);
        assert_eq!(state.visible_range(), 1..3);
        assert!(state.move_selection(SequenceMove::First));
        assert_eq!(state.selected_index(), 0);
        assert_eq!(state.visible_range(), 0..2);
    }

    #[test]
    fn stops_at_the_sequence_boundaries() {
        let mut state = state();

        assert!(!state.move_selection(SequenceMove::Previous));
        assert!(state.move_selection(SequenceMove::Next));
        assert!(state.move_selection(SequenceMove::Next));
        assert!(!state.move_selection(SequenceMove::Next));
    }

    #[test]
    fn group_jumps_follow_grapheme_boundaries_and_align_their_first_members() {
        let mut state = SequenceState::new("A\u{0301}\r\n👩🏽‍💻🇯🇵BB".to_owned());
        state.resize_viewport(2);
        state.move_selection(SequenceMove::Next);
        assert!(!state.move_selection(SequenceMove::PreviousGroup));
        assert_eq!(state.selected_index(), 1);
        for index in [2, 4, 8, 10, 11] {
            assert!(state.move_selection(SequenceMove::NextGroup));
            assert_eq!(state.selected_index(), index);
            assert_eq!(state.code_points()[index].index_in_grapheme(), 0);
            assert_eq!(state.visible_range().start, index.min(10));
        }
        assert!(!state.move_selection(SequenceMove::NextGroup));
        assert_eq!(state.selected_index(), 11);
        for index in [10, 8] {
            assert!(state.move_selection(SequenceMove::PreviousGroup));
            assert_eq!(state.selected_index(), index);
        }
        state.move_selection(SequenceMove::Next);
        assert_eq!(state.selected_index(), 9);
        assert!(state.move_selection(SequenceMove::PreviousGroup));
        assert_eq!(state.selected_index(), 4);
        assert_eq!(state.visible_range(), 4..6);
        state.resize_viewport(3);
        assert_eq!(state.visible_range(), 4..7);
    }

    #[rstest]
    #[case("A\u{0301}")]
    #[case("👩🏽‍💻")]
    #[case("\r\n")]
    #[case("🇯🇵")]
    fn single_grapheme_group_moves_preserve_the_selected_member(#[case] source: &str) {
        let mut state = SequenceState::new(source.to_owned());
        state.resize_viewport(1);
        state.move_selection(SequenceMove::Last);
        let selected = state.selected_index();
        let visible = state.visible_range();
        for movement in [SequenceMove::PreviousGroup, SequenceMove::NextGroup] {
            assert!(!state.move_selection(movement));
            assert_eq!(state.selected_index(), selected);
            assert_eq!(state.visible_range(), visible);
        }
        assert_eq!(state.analysis().source(), source);
    }
}