alma 0.1.0

A Bevy-native modal text editor with Vim-style navigation.
Documentation
//! Byte-range viewport tracking for managed text rendering.

use bevy::prelude::Resource;

/// Number of physical text lines rendered in the editor viewport.
pub const DEFAULT_VISIBLE_LINE_COUNT: usize = 32;

/// Visible byte span for the text viewport.
#[derive(Clone, Debug, Eq, PartialEq, Resource)]
pub struct TextViewport {
    /// First visible line start byte index.
    first_line_start: usize,
    /// Maximum number of physical lines to render.
    visible_line_count: usize,
    /// Last computed visible byte range.
    byte_range: VisibleByteRange,
}

impl Default for TextViewport {
    fn default() -> Self {
        Self {
            first_line_start: 0,
            visible_line_count: DEFAULT_VISIBLE_LINE_COUNT,
            byte_range: VisibleByteRange::empty(),
        }
    }
}

impl TextViewport {
    /// Reads the current visible byte range.
    #[must_use]
    pub const fn byte_range(&self) -> VisibleByteRange {
        self.byte_range
    }

    /// Returns the configured visible physical line count.
    #[must_use]
    pub const fn visible_line_count(&self) -> usize {
        self.visible_line_count
    }

    /// Repositions the viewport so the cursor line appears near the top.
    pub fn position_cursor_top(&mut self, text: &str, cursor_byte_index: usize) -> bool {
        self.position_cursor(text, cursor_byte_index, CursorViewportPosition::Top)
    }

    /// Repositions the viewport so the cursor line appears near the center.
    pub fn position_cursor_center(&mut self, text: &str, cursor_byte_index: usize) -> bool {
        self.position_cursor(text, cursor_byte_index, CursorViewportPosition::Center)
    }

    /// Repositions the viewport so the cursor line appears near the bottom.
    pub fn position_cursor_bottom(&mut self, text: &str, cursor_byte_index: usize) -> bool {
        self.position_cursor(text, cursor_byte_index, CursorViewportPosition::Bottom)
    }

    /// Returns whether the cursor byte index is visible in the current viewport.
    #[must_use]
    pub const fn contains_cursor(&self, cursor_byte_index: usize) -> bool {
        self.byte_range.contains_cursor(cursor_byte_index)
    }

    /// Keeps `cursor_byte_index` visible and returns whether the range changed.
    pub fn follow_cursor(&mut self, text: &str, cursor_byte_index: usize) -> bool {
        if !(self.byte_range.start == 0 && self.byte_range.end == 0 && !text.is_empty())
            && self.byte_range.contains_cursor(cursor_byte_index)
            && self.byte_range.end <= text.len()
            && self.first_line_start <= self.byte_range.start
        {
            return false;
        }

        let cursor_line_start = line_start_at_or_before(text, cursor_byte_index);
        let cursor_line_number = line_number_at(text, cursor_line_start);
        let first_line_number = line_number_at(text, self.first_line_start);

        if cursor_line_number < first_line_number {
            self.first_line_start = cursor_line_start;
        } else if cursor_line_number >= first_line_number + self.visible_line_count {
            let target_line = cursor_line_number + 1 - self.visible_line_count;
            self.first_line_start = line_start_for_number(text, target_line);
        }

        let next_range = visible_range_from(text, self.first_line_start, self.visible_line_count);
        debug_assert!(next_range.contains_cursor(cursor_byte_index));
        let changed = self.byte_range != next_range;
        self.byte_range = next_range;
        changed
    }

    /// Repositions the viewport around `cursor_byte_index` without changing the cursor.
    fn position_cursor(
        &mut self,
        text: &str,
        cursor_byte_index: usize,
        position: CursorViewportPosition,
    ) -> bool {
        let cursor_line_start = line_start_at_or_before(text, cursor_byte_index);
        let cursor_line_number = line_number_at(text, cursor_line_start);
        let target_line = match position {
            CursorViewportPosition::Top => cursor_line_number,
            CursorViewportPosition::Center => {
                cursor_line_number.saturating_sub(self.visible_line_count / 2)
            }
            CursorViewportPosition::Bottom => {
                cursor_line_number.saturating_sub(self.visible_line_count.saturating_sub(1))
            }
        };

        self.first_line_start = line_start_for_number(text, target_line);
        let next_range = visible_range_from(text, self.first_line_start, self.visible_line_count);
        debug_assert!(next_range.contains_cursor(cursor_byte_index) || text.is_empty());
        let changed = self.byte_range != next_range;
        self.byte_range = next_range;
        changed
    }
}

/// Desired cursor-line placement in the viewport.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum CursorViewportPosition {
    /// Cursor line is the first visible line.
    Top,
    /// Cursor line is centered when enough preceding lines exist.
    Center,
    /// Cursor line is the last visible line when enough preceding lines exist.
    Bottom,
}

/// Half-open visible byte range with inclusive cursor containment at the trailing edge.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct VisibleByteRange {
    /// First visible byte index.
    start: usize,
    /// One-past-last visible byte index.
    end: usize,
}

impl VisibleByteRange {
    /// Creates an empty visible range.
    const fn empty() -> Self {
        Self { start: 0, end: 0 }
    }

    /// Creates a visible range.
    const fn new(start: usize, end: usize) -> Self {
        Self { start, end }
    }

    /// Converts to the half-open range used for string slicing.
    pub const fn slice_range(self) -> std::ops::Range<usize> {
        self.start..self.end
    }

    /// Converts to an inclusive range for invariant checks and tests.
    #[cfg(test)]
    const fn cursor_range(self) -> std::ops::RangeInclusive<usize> {
        self.start..=self.end
    }

    /// Returns whether `cursor_byte_index` can be represented by this viewport.
    const fn contains_cursor(self, cursor_byte_index: usize) -> bool {
        self.start <= cursor_byte_index && cursor_byte_index <= self.end
    }
}

/// Returns the line start at or before `index`.
fn line_start_at_or_before(text: &str, index: usize) -> usize {
    let index = clamp_to_boundary(text, index);

    text[..index]
        .rfind('\n')
        .map_or(0, |newline_index| newline_index + '\n'.len_utf8())
}

/// Returns a zero-based physical line number for `line_start`.
fn line_number_at(text: &str, line_start: usize) -> usize {
    text[..line_start.min(text.len())]
        .chars()
        .filter(|character| *character == '\n')
        .count()
}

/// Returns the start byte index for a zero-based physical line number.
fn line_start_for_number(text: &str, target_line: usize) -> usize {
    if target_line == 0 {
        return 0;
    }

    let mut line_number = 0;

    for (byte_index, character) in text.char_indices() {
        if character == '\n' {
            line_number += 1;

            if line_number == target_line {
                return byte_index + character.len_utf8();
            }
        }
    }

    text.len()
}

/// Computes the visible byte range from `first_line_start`.
fn visible_range_from(
    text: &str,
    first_line_start: usize,
    visible_line_count: usize,
) -> VisibleByteRange {
    if text.is_empty() {
        return VisibleByteRange::empty();
    }

    let mut rendered_lines = 0;

    for (offset, character) in text[first_line_start..].char_indices() {
        if character == '\n' {
            rendered_lines += 1;

            if rendered_lines >= visible_line_count {
                let end = first_line_start + offset + character.len_utf8();
                return VisibleByteRange::new(first_line_start, end);
            }
        }
    }

    VisibleByteRange::new(first_line_start, text.len())
}

/// Returns the nearest UTF-8 boundary at or before `index`.
fn clamp_to_boundary(text: &str, index: usize) -> usize {
    let mut boundary = text.len().min(index);

    while !text.is_char_boundary(boundary) {
        boundary -= 1;
    }

    boundary
}

#[cfg(test)]
mod tests {
    use super::{TextViewport, VisibleByteRange};
    use proptest::prelude::*;

    #[test]
    fn viewport_scrolls_to_keep_cursor_line_visible() {
        let text = "0\n1\n2\n3\n4\n";
        let mut viewport = TextViewport {
            first_line_start: 0,
            visible_line_count: 3,
            byte_range: VisibleByteRange::empty(),
        };

        assert!(viewport.follow_cursor(text, "0\n1\n2\n3".len()));
        assert_eq!(
            viewport.byte_range().slice_range(),
            "0\n".len().."0\n1\n2\n3\n".len()
        );
        assert!(viewport.contains_cursor("0\n1\n2\n3".len()));
    }

    #[test]
    fn viewport_accepts_blank_final_line() {
        let text = "one\n";
        let mut viewport = TextViewport {
            first_line_start: 0,
            visible_line_count: 2,
            byte_range: VisibleByteRange::empty(),
        };

        assert!(viewport.follow_cursor(text, text.len()));
        assert_eq!(viewport.byte_range().slice_range(), 0..text.len());
        assert_eq!(viewport.byte_range().cursor_range(), 0..=text.len());
        assert!(viewport.contains_cursor(text.len()));
    }

    #[test]
    fn viewport_positions_cursor_line_at_top_center_and_bottom() {
        let text = "0\n1\n2\n3\n4\n5\n6\n7\n8\n9\n";
        let cursor = "0\n1\n2\n3\n4\n".len();
        let mut viewport = TextViewport {
            first_line_start: 0,
            visible_line_count: 5,
            byte_range: VisibleByteRange::empty(),
        };

        assert!(viewport.position_cursor_top(text, cursor));
        assert_eq!(viewport.byte_range().slice_range(), cursor..text.len());

        assert!(viewport.position_cursor_center(text, cursor));
        assert_eq!(
            viewport.byte_range().slice_range(),
            "0\n1\n2\n".len().."0\n1\n2\n3\n4\n5\n6\n7\n".len()
        );

        assert!(viewport.position_cursor_bottom(text, cursor));
        assert_eq!(
            viewport.byte_range().slice_range(),
            "0\n".len().."0\n1\n2\n3\n4\n5\n".len()
        );
    }

    #[test]
    fn viewport_positioning_clamps_near_file_start() {
        let text = "0\n1\n2\n";
        let cursor = "0\n".len();
        let mut viewport = TextViewport {
            first_line_start: 0,
            visible_line_count: 5,
            byte_range: VisibleByteRange::empty(),
        };

        assert!(viewport.position_cursor_center(text, cursor));
        assert_eq!(viewport.byte_range().slice_range(), 0..text.len());
        assert!(viewport.contains_cursor(cursor));
    }

    proptest! {
        #[test]
        fn followed_viewport_ranges_are_utf8_slices_containing_cursor(
            prefix in any::<String>(),
            suffix in any::<String>(),
        ) {
            let text = format!("{prefix}{suffix}");
            let cursor = prefix.len();
            let mut viewport = TextViewport::default();

            let _changed = viewport.follow_cursor(&text, cursor);
            let range = viewport.byte_range().slice_range();

            prop_assert!(range.start <= range.end);
            prop_assert!(range.end <= text.len());
            prop_assert!(text.is_char_boundary(range.start));
            prop_assert!(text.is_char_boundary(range.end));
            prop_assert!(viewport.contains_cursor(cursor) || text.is_empty());
        }
    }
}