binocular-cli 0.2.3

Not exactly a telescope, but it's useful sometimes. TUI to search/navigate through files and workspaces.
Documentation
use std::ops::Range;

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TextBuffer {
    content: String,
    line_ranges: Vec<(usize, usize)>,
}

impl TextBuffer {
    pub fn new(content: String) -> Self {
        let line_ranges = build_line_ranges(&content);
        Self {
            content,
            line_ranges,
        }
    }

    pub fn as_str(&self) -> &str {
        &self.content
    }

    pub fn len_bytes(&self) -> usize {
        self.content.len()
    }

    pub fn line_ranges(&self) -> &[(usize, usize)] {
        &self.line_ranges
    }

    pub fn line_range(&self, line_idx: usize) -> Option<(usize, usize)> {
        self.line_ranges.get(line_idx).copied()
    }

    pub fn line_count(&self) -> usize {
        self.line_ranges.len()
    }

    pub fn line_slice(&self, line_idx: usize) -> Option<&str> {
        let (start, end) = self.line_range(line_idx)?;
        Some(&self.content[start..end])
    }

    pub fn byte_index(&self, line: usize, char_idx: usize) -> usize {
        let Some((start, end)) = self.line_range(line) else {
            return self.content.len();
        };

        let line_text = &self.content[start..end];
        let mut byte_offset = 0;
        let mut current_char = 0;

        for (idx, c) in line_text.char_indices() {
            if current_char == char_idx {
                return start + idx;
            }
            current_char += 1;
            byte_offset = idx + c.len_utf8();
        }

        if current_char == char_idx {
            start + byte_offset
        } else {
            end
        }
    }

    pub fn line_char_from_byte(&self, byte_idx: usize) -> (usize, usize) {
        let line_idx = self
            .line_ranges
            .iter()
            .position(|(start, end)| byte_idx >= *start && byte_idx <= *end)
            .unwrap_or_else(|| self.line_ranges.len().saturating_sub(1));

        let Some((start, end)) = self.line_range(line_idx) else {
            return (0, 0);
        };
        let clamped = byte_idx.clamp(start, end);
        let char_idx = self.content[start..clamped].chars().count();
        (line_idx, char_idx)
    }

    pub fn line_len_chars(&self, line_idx: usize) -> usize {
        self.line_slice(line_idx)
            .map(|line| {
                line.trim_end_matches('\n')
                    .trim_end_matches('\r')
                    .chars()
                    .count()
            })
            .unwrap_or(0)
    }

    pub fn char_at_byte(&self, byte_idx: usize) -> Option<char> {
        self.content.get(byte_idx..)?.chars().next()
    }

    pub fn char_before_byte(&self, byte_idx: usize) -> Option<(usize, char)> {
        self.content.get(..byte_idx)?.char_indices().last()
    }

    pub fn apply_edit(&mut self, edit: &TextEdit) -> bool {
        match edit.kind {
            TextEditKind::Insert => self.insert(edit.byte_idx, &edit.text),
            TextEditKind::Delete => {
                let end = edit.byte_idx + edit.text.len();
                if self.content.get(edit.byte_idx..end) != Some(edit.text.as_str()) {
                    return false;
                }
                self.delete_range(edit.byte_idx..end).is_some()
            }
        }
    }

    pub fn insert_char(&mut self, byte_idx: usize, c: char) -> Option<TextEdit> {
        let mut text = String::new();
        text.push(c);
        self.insert_text(byte_idx, text)
    }

    pub fn insert_text(&mut self, byte_idx: usize, text: String) -> Option<TextEdit> {
        if !self.insert(byte_idx, &text) {
            return None;
        }
        Some(TextEdit::insert(byte_idx, text))
    }

    pub fn delete_char_before(&mut self, byte_idx: usize) -> Option<TextEdit> {
        let (start, c) = self.char_before_byte(byte_idx)?;
        let end = start + c.len_utf8();
        let deleted = self.delete_range(start..end)?;
        Some(TextEdit::delete(start, deleted))
    }

    pub fn delete_char_at(&mut self, byte_idx: usize) -> Option<TextEdit> {
        let c = self.char_at_byte(byte_idx)?;
        let end = byte_idx + c.len_utf8();
        let deleted = self.delete_range(byte_idx..end)?;
        Some(TextEdit::delete(byte_idx, deleted))
    }

    pub fn delete_range(&mut self, range: Range<usize>) -> Option<String> {
        if range.start > range.end || range.end > self.content.len() {
            return None;
        }
        let removed = self.content.get(range.clone())?.to_string();
        self.content.replace_range(range, "");
        self.rebuild_line_ranges();
        Some(removed)
    }

    fn insert(&mut self, byte_idx: usize, text: &str) -> bool {
        if byte_idx > self.content.len() || !self.content.is_char_boundary(byte_idx) {
            return false;
        }
        self.content.insert_str(byte_idx, text);
        self.rebuild_line_ranges();
        true
    }

    fn rebuild_line_ranges(&mut self) {
        self.line_ranges = build_line_ranges(&self.content);
    }
}

fn build_line_ranges(content: &str) -> Vec<(usize, usize)> {
    let mut ranges = Vec::new();
    let mut start = 0;

    for (idx, byte) in content.as_bytes().iter().enumerate() {
        if *byte == b'\n' {
            ranges.push((start, idx + 1));
            start = idx + 1;
        }
    }

    if start < content.len() {
        ranges.push((start, content.len()));
    } else if start == content.len() && start > 0 {
        ranges.push((start, start));
    }

    if ranges.is_empty() {
        ranges.push((0, 0));
    }

    ranges
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TextEditKind {
    Insert,
    Delete,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TextEdit {
    pub kind: TextEditKind,
    pub byte_idx: usize,
    pub text: String,
}

impl TextEdit {
    pub fn insert(byte_idx: usize, text: String) -> Self {
        Self {
            kind: TextEditKind::Insert,
            byte_idx,
            text,
        }
    }

    pub fn delete(byte_idx: usize, text: String) -> Self {
        Self {
            kind: TextEditKind::Delete,
            byte_idx,
            text,
        }
    }
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TextUndoFrame {
    pub undo_edit: TextEdit,
    pub redo_edit: TextEdit,
    pub before_cursor: (usize, usize),
    pub after_cursor: (usize, usize),
}

impl TextUndoFrame {
    pub fn from_forward_edit(
        edit: TextEdit,
        before_cursor: (usize, usize),
        after_cursor: (usize, usize),
    ) -> Self {
        let inverse = match edit.kind {
            TextEditKind::Insert => TextEdit::delete(edit.byte_idx, edit.text.clone()),
            TextEditKind::Delete => TextEdit::insert(edit.byte_idx, edit.text.clone()),
        };
        Self {
            undo_edit: inverse,
            redo_edit: edit,
            before_cursor,
            after_cursor,
        }
    }
}

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

    #[test]
    fn insert_and_delete_update_line_ranges() {
        let mut buffer = TextBuffer::new("abc\ndef".to_string());
        buffer.insert_char(3, '\n');
        assert_eq!(buffer.line_count(), 3);
        assert_eq!(buffer.line_slice(1), Some("\n"));

        let deleted = buffer.delete_char_before(4).unwrap();
        assert_eq!(deleted.text, "\n");
        assert_eq!(buffer.as_str(), "abc\ndef");
        assert_eq!(buffer.line_count(), 2);
    }

    #[test]
    fn delete_range_returns_removed_text() {
        let mut buffer = TextBuffer::new("hello world".to_string());
        let removed = buffer.delete_range(5..11).unwrap();
        assert_eq!(removed, " world");
        assert_eq!(buffer.as_str(), "hello");
    }

    #[test]
    fn byte_index_uses_line_context() {
        let buffer = TextBuffer::new("ab\ncd".to_string());
        assert_eq!(buffer.byte_index(1, 1), 4);
        assert_eq!(buffer.line_char_from_byte(4), (1, 1));
    }

    #[test]
    fn delete_range_handles_multi_line_edits() {
        let mut buffer = TextBuffer::new("one\ntwo\nthree".to_string());
        let start = buffer.byte_index(0, 2);
        let end = buffer.byte_index(1, 2);
        let removed = buffer.delete_range(start..end).unwrap();

        assert_eq!(removed, "e\ntw");
        assert_eq!(buffer.as_str(), "ono\nthree");
        assert_eq!(buffer.line_count(), 2);
    }
}