sauva 0.6.0

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

use unicode_segmentation::UnicodeSegmentation;

use crate::{
    sequence::SequenceState,
    unicode::{
        diff::NormalizationDiff,
        text::{NormalizationForm, TextAnalysis},
    },
    viewport::ListViewport,
};

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

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct TextMetrics {
    pub code_points: usize,
    pub bytes: usize,
    pub graphemes: usize,
}

impl TextMetrics {
    pub fn original(analysis: &TextAnalysis) -> Self {
        Self {
            code_points: analysis.code_points().len(),
            bytes: analysis.byte_count(),
            graphemes: analysis.graphemes().len(),
        }
    }
}

#[derive(Debug, Clone)]
pub struct NormalizationComparison {
    pub result: SequenceState,
    pub diff: NormalizationDiff,
    original_viewport: ListViewport,
}

impl NormalizationComparison {
    pub fn original_selection(&self, original: &TextAnalysis) -> Range<usize> {
        let index = self.result.code_points()[self.result.selected_index()].grapheme_index();
        let Some(range) = self
            .diff
            .original_graphemes(index)
            .filter(|range| !range.is_empty())
        else {
            return 0..0;
        };
        original.graphemes()[range.start].code_point_range().start
            ..original.graphemes()[range.end - 1].code_point_range().end
    }

    pub fn original_visible_range(&self, original: &TextAnalysis) -> Range<usize> {
        self.original_viewport
            .visible_range(original.code_points().len())
    }

    pub fn follow_selection(&mut self, original: &TextAnalysis) {
        let selected = self.original_selection(original);
        if !selected.is_empty() {
            self.original_viewport
                .ensure_visible(selected.end - 1, original.code_points().len());
            self.original_viewport
                .ensure_visible(selected.start, original.code_points().len());
        }
    }

    pub fn resize_original_viewport(&mut self, original: &TextAnalysis, height: usize) {
        let selected = self.original_selection(original);
        self.original_viewport
            .resize(height, selected.start, original.code_points().len());
        self.follow_selection(original);
    }
}

#[derive(Debug, Clone)]
pub struct NormalizationState {
    selected: usize,
    metrics: [TextMetrics; 4],
    comparisons: [Option<NormalizationComparison>; 4],
}

impl NormalizationState {
    pub fn new(original: &TextAnalysis) -> Self {
        let metrics = NormalizationForm::ALL.map(|form| {
            let text = original.normalized_text(form);
            TextMetrics {
                code_points: original.normalization().get(form).code_point_count(),
                bytes: text.len(),
                graphemes: text.graphemes(true).count(),
            }
        });
        let mut state = Self {
            selected: 0,
            metrics,
            comparisons: std::array::from_fn(|_| None),
        };
        state.prepare_result(original);
        state
    }

    pub fn form(&self) -> NormalizationForm {
        NormalizationForm::ALL[self.selected]
    }

    pub fn metrics(&self, form: NormalizationForm) -> TextMetrics {
        self.metrics[form as usize]
    }

    pub fn comparison(&self) -> &NormalizationComparison {
        self.comparisons[self.selected]
            .as_ref()
            .expect("the selected comparison is prepared")
    }

    pub fn comparison_mut(&mut self) -> &mut NormalizationComparison {
        self.comparisons[self.selected]
            .as_mut()
            .expect("the selected comparison is prepared")
    }

    pub fn move_selection(&mut self, movement: NormalizationMove, original: &TextAnalysis) {
        self.selected = match movement {
            NormalizationMove::Previous => self.selected.saturating_sub(1),
            NormalizationMove::Next => (self.selected + 1).min(3),
            NormalizationMove::First => 0,
            NormalizationMove::Last => 3,
        };
        self.prepare_result(original);
    }

    fn prepare_result(&mut self, original: &TextAnalysis) {
        let form = self.form();
        self.comparisons[self.selected].get_or_insert_with(|| {
            let result = SequenceState::new(original.normalized_text(form).to_owned());
            let diff = NormalizationDiff::new(original, result.analysis(), form);
            NormalizationComparison {
                result,
                diff,
                original_viewport: ListViewport::default(),
            }
        });
    }
}

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

    #[test]
    fn reference_tracks_expanded_scalars_and_adjacent_changes_separately() {
        let original = TextAnalysis::new("ffi①".to_owned());
        let mut state = NormalizationState::new(&original);
        state.move_selection(NormalizationMove::Next, &original);
        state.move_selection(NormalizationMove::Next, &original);
        let comparison = state.comparison_mut();
        for expected in [0..1, 0..1, 0..1, 1..2] {
            assert_eq!(comparison.original_selection(&original), expected);
            comparison
                .result
                .move_selection(crate::sequence::SequenceMove::Next);
        }
    }

    #[test]
    fn lazily_prepares_forms_and_preserves_the_original() {
        let original = TextAnalysis::new("A\u{0301} ①👩‍💻".to_owned());
        let saved = original.clone();
        let mut state = NormalizationState::new(&original);
        assert_eq!(state.form(), NormalizationForm::Nfc);
        assert_eq!(state.comparison().result.analysis().source(), "Á ①👩‍💻");
        assert_eq!(
            state
                .comparisons
                .iter()
                .filter(|value| value.is_some())
                .count(),
            1
        );
        state.move_selection(NormalizationMove::Last, &original);
        assert_eq!(state.form(), NormalizationForm::Nfkd);
        assert_eq!(
            state.metrics(NormalizationForm::Nfkc),
            TextMetrics {
                code_points: 6,
                bytes: 15,
                graphemes: 4
            }
        );
        assert_eq!(
            state
                .comparisons
                .iter()
                .filter(|value| value.is_some())
                .count(),
            2
        );
        assert_eq!(original, saved);
        state.move_selection(NormalizationMove::Next, &original);
        assert_eq!(state.form(), NormalizationForm::Nfkd);
        state.move_selection(NormalizationMove::First, &original);
        state.move_selection(NormalizationMove::Previous, &original);
        assert_eq!(state.form(), NormalizationForm::Nfc);
    }
}