use bevy::prelude::Resource;
pub const DEFAULT_VISIBLE_LINE_COUNT: usize = 32;
#[derive(Clone, Debug, Eq, PartialEq, Resource)]
pub struct TextViewport {
first_line_start: usize,
visible_line_count: usize,
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 {
#[must_use]
pub const fn byte_range(&self) -> VisibleByteRange {
self.byte_range
}
#[must_use]
pub const fn visible_line_count(&self) -> usize {
self.visible_line_count
}
pub fn position_cursor_top(&mut self, text: &str, cursor_byte_index: usize) -> bool {
self.position_cursor(text, cursor_byte_index, CursorViewportPosition::Top)
}
pub fn position_cursor_center(&mut self, text: &str, cursor_byte_index: usize) -> bool {
self.position_cursor(text, cursor_byte_index, CursorViewportPosition::Center)
}
pub fn position_cursor_bottom(&mut self, text: &str, cursor_byte_index: usize) -> bool {
self.position_cursor(text, cursor_byte_index, CursorViewportPosition::Bottom)
}
#[must_use]
pub const fn contains_cursor(&self, cursor_byte_index: usize) -> bool {
self.byte_range.contains_cursor(cursor_byte_index)
}
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
}
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
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum CursorViewportPosition {
Top,
Center,
Bottom,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct VisibleByteRange {
start: usize,
end: usize,
}
impl VisibleByteRange {
const fn empty() -> Self {
Self { start: 0, end: 0 }
}
const fn new(start: usize, end: usize) -> Self {
Self { start, end }
}
pub const fn slice_range(self) -> std::ops::Range<usize> {
self.start..self.end
}
#[cfg(test)]
const fn cursor_range(self) -> std::ops::RangeInclusive<usize> {
self.start..=self.end
}
const fn contains_cursor(self, cursor_byte_index: usize) -> bool {
self.start <= cursor_byte_index && cursor_byte_index <= self.end
}
}
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())
}
fn line_number_at(text: &str, line_start: usize) -> usize {
text[..line_start.min(text.len())]
.chars()
.filter(|character| *character == '\n')
.count()
}
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()
}
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())
}
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());
}
}
}