use ratatui::style::Style;
use ratatui::text::Span;
use unicode_segmentation::UnicodeSegmentation;
use unicode_width::UnicodeWidthStr;
use crate::document::str_byte_index;
pub fn text_field_spans(
value: &str,
cursor: usize,
visible: bool,
value_style: Style,
cursor_style: Style,
) -> [Span<'static>; 3] {
let (pre, rest) = split_at_char(value, cursor);
let (under, post) = match rest.graphemes(true).next() {
Some(g) => (g.to_owned(), rest[g.len()..].to_owned()),
None => (" ".to_owned(), String::new()),
};
let cell_style = if visible { cursor_style } else { value_style };
[
Span::styled(pre, value_style),
Span::styled(under, cell_style),
Span::styled(post, value_style),
]
}
pub fn scroll_field(value: &str, cursor: usize, scroll: usize, width: usize) -> (usize, String) {
let mut units: Vec<(usize, usize, &str)> = Vec::new();
let mut len = 0;
for g in value.graphemes(true) {
units.push((len, g.width(), g));
len += g.chars().count();
}
let unit_of = |ch: usize| -> usize {
if ch >= len {
units.len()
} else {
units.partition_point(|u| u.0 <= ch).saturating_sub(1)
}
};
let cells = |from: usize, to: usize| -> usize { units[from..to].iter().map(|u| u.1).sum() };
let cursor = unit_of(cursor);
let cursor_cell = units.get(cursor).map_or(1, |u| u.1.max(1));
let mut scroll = unit_of(scroll).min(cursor);
while scroll < cursor && cells(scroll, cursor) + cursor_cell > width {
scroll += 1;
}
while scroll > 0 && cells(scroll - 1, units.len()) < width {
scroll -= 1;
}
let mut used = 0;
let visible = units[scroll..]
.iter()
.take_while(|u| {
used += u.1;
used <= width
})
.map(|u| u.2)
.collect();
(units.get(scroll).map_or(len, |u| u.0), visible)
}
pub fn scrolled_field_spans(
value: &str,
cursor: usize,
scroll: &mut usize,
width: usize,
visible: bool,
value_style: Style,
cursor_style: Style,
) -> Vec<Span<'static>> {
let cursor = cursor.min(value.chars().count());
let (new_scroll, shown) = scroll_field(value, cursor, *scroll, width);
*scroll = new_scroll;
let mut spans = Vec::from(text_field_spans(
&shown,
cursor - new_scroll,
visible,
value_style,
cursor_style,
));
let used: usize = spans.iter().map(Span::width).sum();
let pad = width.saturating_sub(used);
if pad > 0 {
spans.push(Span::styled(" ".repeat(pad), value_style));
}
spans
}
pub fn insert_char_at(s: &mut String, cursor: usize, ch: char) {
s.insert(str_byte_index(s, cursor), ch);
}
pub fn pop_grapheme(s: &mut String) -> bool {
match s.grapheme_indices(true).next_back() {
Some((byte_idx, _)) => {
s.truncate(byte_idx);
true
}
None => false,
}
}
fn split_at_char(s: &str, cursor: usize) -> (String, String) {
let byte_idx = str_byte_index(s, cursor);
(s[..byte_idx].to_owned(), s[byte_idx..].to_owned())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn split_at_char_mid_and_past_end() {
assert_eq!(split_at_char("hello", 2), ("he".into(), "llo".into()));
assert_eq!(split_at_char("hi", 5), ("hi".into(), String::new()));
}
#[test]
fn split_at_char_respects_char_boundaries() {
assert_eq!(split_at_char("é!", 1), ("é".into(), "!".into()));
}
#[test]
fn text_field_slot_is_constant_width_across_blink() {
let vis = text_field_spans("note", 2, true, Style::default(), Style::default());
let hid = text_field_spans("note", 2, false, Style::default(), Style::default());
let width = |spans: &[Span<'static>]| -> usize {
spans.iter().map(|s| s.content.chars().count()).sum()
};
assert_eq!(width(&vis), width(&hid));
assert_eq!(vis[1].content.as_ref(), "t");
assert_eq!(hid[1].content.as_ref(), "t");
}
#[test]
fn text_field_cursor_past_end_is_a_space_cell() {
let spans = text_field_spans("hi", 2, true, Style::default(), Style::default());
let text: String = spans.iter().map(|s| s.content.as_ref()).collect();
assert_eq!(text, "hi ");
assert_eq!(spans[1].content.as_ref(), " ");
assert!(spans[2].content.is_empty());
}
#[test]
fn text_field_cursor_covers_the_whole_cluster() {
let s = "e\u{0301}👨\u{200D}👩x";
let spans = text_field_spans(s, 0, true, Style::default(), Style::default());
assert_eq!(spans[1].content.as_ref(), "e\u{0301}");
assert_eq!(spans[2].content.as_ref(), "👨\u{200D}👩x");
let spans = text_field_spans(s, 2, true, Style::default(), Style::default());
assert_eq!(spans[1].content.as_ref(), "👨\u{200D}👩");
assert_eq!(spans[2].content.as_ref(), "x");
}
#[test]
fn scroll_field_leaves_a_fitting_value_unscrolled() {
assert_eq!(scroll_field("hello", 5, 0, 10), (0, "hello".into()));
assert_eq!(scroll_field("hello", 0, 0, 10), (0, "hello".into()));
}
#[test]
fn scroll_field_keeps_the_end_of_text_cursor_cell_on_screen() {
assert_eq!(scroll_field("abcdefgh", 8, 0, 5), (4, "efgh".into()));
}
#[test]
fn scroll_field_moves_only_as_far_as_the_cursor_forces() {
assert_eq!(scroll_field("abcdefgh", 5, 4, 5), (4, "efgh".into()));
assert_eq!(scroll_field("abcdefgh", 2, 4, 5), (2, "cdefg".into()));
}
#[test]
fn scroll_field_slides_back_when_the_text_shrinks() {
assert_eq!(scroll_field("abcdef", 6, 5, 5), (2, "cdef".into()));
}
#[test]
fn scroll_field_counts_wide_chars_as_two_cells() {
assert_eq!(scroll_field("日本語", 3, 0, 5), (1, "本語".into()));
}
#[test]
fn scroll_field_never_splits_a_cluster() {
let s = "e\u{0301}".repeat(4);
assert_eq!(scroll_field(&s, 8, 0, 4), (2, "e\u{0301}".repeat(3)));
assert_eq!(scroll_field(&s, 8, 3, 4), (2, "e\u{0301}".repeat(3)));
assert_eq!(
scroll_field("👍\u{1F3FD}ab", 4, 0, 5),
(0, "👍\u{1F3FD}ab".into())
);
}
#[test]
fn insert_char_at_respects_char_boundaries() {
let mut s = "é!".to_owned();
insert_char_at(&mut s, 1, 'x');
assert_eq!(s, "éx!");
insert_char_at(&mut s, 9, '?');
assert_eq!(s, "éx!?");
}
#[test]
fn pop_grapheme_removes_the_last_cluster() {
let mut s = "a👍\u{1F3FD}".to_owned();
assert!(pop_grapheme(&mut s));
assert_eq!(s, "a");
assert!(pop_grapheme(&mut s));
assert!(!pop_grapheme(&mut s));
}
#[test]
fn scrolled_field_spans_pad_to_the_full_width() {
let width = |spans: &[Span<'static>]| -> usize { spans.iter().map(Span::width).sum() };
let mut scroll = 0;
let short = scrolled_field_spans(
"hi",
2,
&mut scroll,
10,
true,
Style::default(),
Style::default(),
);
assert_eq!(width(&short), 10);
let long = scrolled_field_spans(
"abcdefghijkl",
12,
&mut scroll,
10,
true,
Style::default(),
Style::default(),
);
assert_eq!(width(&long), 10);
assert_eq!(scroll, 3, "the window followed the cursor to the end");
let wide = scrolled_field_spans(
"日本",
1,
&mut 0,
6,
false,
Style::default(),
Style::default(),
);
assert_eq!(width(&wide), 6);
}
}