clankerdiff-ratatui 0.1.8

Embeddable Ratatui diff review widget
Documentation
use crate::{
    annotation::{AnnotationBox, AnnotationKind},
    annotation_layout::{AnnotationLayout, AnnotationRow},
    text::{FitCursor, FitOptions, FitPosition},
};
use clankerdiff_core::{
    CommentDraft, DiffPresentation, DiffSide, Layout, PresentedCell, ReviewComment, ReviewSession,
    RowKind,
};
use std::{collections::BTreeMap, ops::Range, sync::Arc};

const MIN_GUTTER_WIDTH: u16 = 6;

#[derive(Debug, Clone, Copy)]
pub(crate) struct PatchPaneGeometry {
    pub(crate) width: u16,
    pub(crate) gutter_width: u16,
    pub(crate) content_width: usize,
}

#[derive(Debug, Clone, Copy)]
pub(crate) struct PatchRowGeometry {
    pub(crate) split: bool,
    pub(crate) left: PatchPaneGeometry,
    pub(crate) right: PatchPaneGeometry,
}

impl PatchRowGeometry {
    pub(crate) fn new(width: u16, layout: Layout, number_digits: usize) -> Self {
        let gutter_width = MIN_GUTTER_WIDTH
            .max(u16::try_from(number_digits.saturating_add(2)).unwrap_or(u16::MAX));
        let split = layout.is_split();
        let (left_width, right_width) = if split {
            let left = width.saturating_sub(1) / 2;
            (left, width.saturating_sub(left.saturating_add(1)))
        } else {
            (0, width)
        };
        let pane = |pane_width| PatchPaneGeometry {
            width: pane_width,
            gutter_width: gutter_width.min(pane_width),
            content_width: usize::from(pane_width.saturating_sub(gutter_width)),
        };
        Self {
            split,
            left: pane(left_width),
            right: pane(right_width),
        }
    }
}

#[derive(Debug)]
struct PatchContentRow {
    source: usize,
    range: Range<usize>,
    left: Vec<FitPosition>,
    right: Vec<FitPosition>,
}

#[derive(Debug)]
pub(crate) struct PatchContentLayout {
    rows: Vec<PatchContentRow>,
    source_range: Range<usize>,
    len: usize,
    geometry: PatchRowGeometry,
}

impl PatchContentLayout {
    pub(crate) fn new(
        presentation: &DiffPresentation,
        range: Range<usize>,
        width: u16,
        tab_width: u16,
    ) -> Self {
        let number_digits = presentation
            .rows(range.clone())
            .iter()
            .flat_map(clankerdiff_core::PresentedRow::cells)
            .filter_map(PresentedCell::line_number)
            .max()
            .map_or(1, |number| number.to_string().len());
        let geometry = PatchRowGeometry::new(width, presentation.layout(), number_digits);
        let mut rows = Vec::with_capacity(range.len());
        let mut offset: usize = 0;
        for (index, row) in presentation.rows(range.clone()).iter().enumerate() {
            let source = range.start + index;
            let wrapping = matches!(row.kind, RowKind::Code | RowKind::ExpandedContext);
            let (left, right) = if wrapping && geometry.split {
                (
                    measure_cell(row.left.as_ref(), geometry.left.content_width, tab_width),
                    measure_cell(row.right.as_ref(), geometry.right.content_width, tab_width),
                )
            } else if wrapping {
                (
                    Vec::new(),
                    measure_cell(row.primary_cell(), geometry.right.content_width, tab_width),
                )
            } else {
                (Vec::new(), Vec::new())
            };
            let height = left.len().max(right.len()).max(1);
            let visual_range = offset..offset.saturating_add(height);
            offset = visual_range.end;
            rows.push(PatchContentRow {
                source,
                range: visual_range,
                left,
                right,
            });
        }
        Self {
            rows,
            source_range: range,
            len: offset,
            geometry,
        }
    }

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

    pub(crate) const fn geometry(&self) -> PatchRowGeometry {
        self.geometry
    }

    pub(crate) fn content_range_for_source(&self, source: usize) -> Option<Range<usize>> {
        let index = source.checked_sub(self.source_range.start)?;
        self.rows.get(index).map(|row| row.range.clone())
    }

    pub(crate) fn source_at_content_row(&self, content: usize) -> Option<(usize, usize)> {
        let index = self.rows.partition_point(|row| row.range.end <= content);
        let row = self.rows.get(index)?;
        Some((row.source, content - row.range.start))
    }

    pub(crate) fn checkpoint(
        &self,
        source: usize,
        side: DiffSide,
        segment: usize,
    ) -> Option<FitPosition> {
        let index = source.checked_sub(self.source_range.start)?;
        let row = self.rows.get(index)?;
        let checkpoints = match side {
            DiffSide::Old => &row.left,
            DiffSide::New => &row.right,
        };
        checkpoints.get(segment).copied()
    }
}

#[derive(Debug, Clone, Copy)]
pub(crate) enum PatchVisualRow<'a> {
    Source {
        index: usize,
        segment: usize,
    },
    Annotation {
        source: usize,
        annotation: &'a AnnotationBox,
        line: usize,
    },
}

#[derive(Debug)]
pub(crate) struct PatchVisualLayout {
    content: Arc<PatchContentLayout>,
    layout: AnnotationLayout,
}

impl PatchVisualLayout {
    pub(crate) fn new(
        session: &ReviewSession,
        content: Arc<PatchContentLayout>,
        width: u16,
    ) -> Self {
        let mut anchored: BTreeMap<usize, Vec<AnnotationBox>> = BTreeMap::new();
        for comment in session.review().comments() {
            if let Some(source) = session.presentation().row_showing_anchor(&comment.anchor)
                && let Some(range) = content.content_range_for_source(source)
            {
                anchored
                    .entry(range.end - 1)
                    .or_default()
                    .push(comment_box(comment, width));
            }
        }
        if let Some(draft) = session.draft()
            && let Some(source) = session.presentation().row_showing_anchor(draft.anchor())
            && let Some(range) = content.content_range_for_source(source)
        {
            anchored
                .entry(range.end - 1)
                .or_default()
                .push(draft_box(draft, width));
        }
        let layout = AnnotationLayout::new(0..content.len(), anchored);
        Self { content, layout }
    }

    pub(crate) const fn len(&self) -> usize {
        self.layout.len()
    }

    pub(crate) const fn is_empty(&self) -> bool {
        self.layout.is_empty()
    }

    pub(crate) fn content(&self) -> &PatchContentLayout {
        &self.content
    }

    pub(crate) fn focused_visual_row(&self, source: usize, draft: bool) -> Option<usize> {
        let range = self.content.content_range_for_source(source)?;
        let content = if draft { range.end - 1 } else { range.start };
        self.layout.focused_visual_row(content, draft)
    }

    pub(crate) fn row(&self, visual: usize) -> Option<PatchVisualRow<'_>> {
        match self.layout.row(visual)? {
            AnnotationRow::Source(content) => {
                let (index, segment) = self.content.source_at_content_row(content)?;
                Some(PatchVisualRow::Source { index, segment })
            }
            AnnotationRow::Annotation {
                source: content,
                annotation,
                line,
            } => {
                let (source, _) = self.content.source_at_content_row(content)?;
                Some(PatchVisualRow::Annotation {
                    source,
                    annotation,
                    line,
                })
            }
        }
    }
}

fn measure_cell(cell: Option<&PresentedCell>, width: usize, tab_width: u16) -> Vec<FitPosition> {
    let Some(cell) = cell else {
        return Vec::new();
    };
    let mut cursor = FitCursor::new(
        FitOptions {
            width,
            wrap: true,
            tab_width: usize::from(tab_width),
            continuation: "",
        },
        FitPosition::default(),
    );
    let mut checkpoints = Vec::new();
    loop {
        let start = cursor.position();
        if cursor.next_row(&cell.text, |_, _| {}).is_none() {
            break;
        }
        checkpoints.push(start);
    }
    checkpoints
}

fn comment_box(comment: &ReviewComment, width: u16) -> AnnotationBox {
    let kind = if comment.outdated {
        AnnotationKind::Outdated
    } else {
        AnnotationKind::Comment
    };
    let title = if comment.outdated {
        "Outdated comment"
    } else {
        "Comment"
    };
    AnnotationBox::new(kind, title, &comment.body, None, width)
}

fn draft_box(draft: &CommentDraft, width: u16) -> AnnotationBox {
    AnnotationBox::new(
        AnnotationKind::Draft,
        "Draft",
        draft.body(),
        Some(draft.cursor()),
        width,
    )
}