use std::iter;
use std::ops::Range;
use unicode_width::UnicodeWidthChar;
pub struct VisualLine {
pub range: Range<usize>,
pub hard_continuation: bool,
}
impl VisualLine {
fn shifted(mut self, offset: usize) -> Self {
self.range = offset + self.range.start..offset + self.range.end;
self
}
}
pub fn offset_at_column(text: &str, column: usize) -> usize {
let mut columns = 0;
for (offset, character) in text.char_indices() {
columns += character.width().unwrap_or(0);
if columns > column {
return offset;
}
}
text.len()
}
pub trait WrapPolicy {
fn wrap_logical_line(&self, line: &str, width: u16, offset: usize) -> Vec<VisualLine>;
fn wrap(&self, text: &str, width: u16) -> Vec<VisualLine> {
let mut lines = Vec::new();
let mut offset = 0;
for logical in text.split('\n') {
lines.extend(self.wrap_logical_line(logical, width, offset));
offset += logical.len() + 1;
}
lines
}
}
pub trait TextLayout {
type Wrapping: Default + WrapPolicy;
fn text(&self) -> &str;
fn lines(&self, width: u16) -> Vec<VisualLine> {
Self::Wrapping::default().wrap(self.text(), width)
}
}
struct Projection {
source: Range<usize>,
hard_continuation: bool,
}
pub struct WrappedText {
text: String,
projections: Vec<Projection>,
}
impl WrappedText {
pub fn word(source: &str, width: u16) -> Self {
Self::from_lines(source, WordWrap.wrap(source, width))
}
pub fn code(source: &str, width: u16, spans: &[Range<usize>]) -> Self {
Self::from_lines(source, SpanWrap(spans).wrap(source, width))
}
fn from_lines(source: &str, lines: Vec<VisualLine>) -> Self {
let mut text = String::new();
let mut projections = Vec::with_capacity(lines.len());
for (index, line) in lines.into_iter().enumerate() {
if index > 0 {
text.push('\n');
}
text.push_str(&source[line.range.clone()]);
projections.push(Projection {
source: line.range,
hard_continuation: line.hard_continuation,
});
}
Self { text, projections }
}
pub fn text(&self) -> &str {
&self.text
}
pub fn height(&self) -> u16 {
self.projections.len().try_into().unwrap_or(u16::MAX)
}
pub fn source_lines(&self) -> impl Iterator<Item = (&Range<usize>, bool)> {
self.projections
.iter()
.map(|projection| (&projection.source, projection.hard_continuation))
}
}
#[derive(Default)]
pub struct CaretWrap;
impl WrapPolicy for CaretWrap {
fn wrap_logical_line(&self, line: &str, width: u16, offset: usize) -> Vec<VisualLine> {
let mut rows = WordWrap.wrap_logical_line(line, width, offset);
let ends_on_width = rows.last().is_some_and(|row| {
let columns: usize = line[row.range.start - offset..row.range.end - offset]
.chars()
.map(|character| character.width().unwrap_or(0))
.sum();
width > 0 && columns == usize::from(width)
});
if ends_on_width {
let end = offset + line.len();
rows.push(VisualLine {
range: end..end,
hard_continuation: true,
});
}
rows
}
}
#[derive(Default)]
pub struct WordWrap;
impl WrapPolicy for WordWrap {
fn wrap_logical_line(&self, line: &str, width: u16, offset: usize) -> Vec<VisualLine> {
let width = width as usize;
if width == 0 || line.is_empty() {
return iter::once(VisualLine {
range: 0..line.len(),
hard_continuation: false,
})
.map(|line| line.shifted(offset))
.collect();
}
let characters: Vec<(usize, char)> = line.char_indices().collect();
let count = characters.len();
let byte_at = |index: usize| characters.get(index).map_or(line.len(), |&(byte, _)| byte);
let mut rows = Vec::new();
let mut start = 0;
let mut hard_continuation = false;
while start < count {
let mut hard_end = start;
let mut columns = 0;
while hard_end < count {
let cells = characters[hard_end].1.width().unwrap_or(0);
if hard_end > start && columns + cells > width {
break;
}
columns += cells;
hard_end += 1;
}
if hard_end == count {
rows.push(VisualLine {
range: byte_at(start)..line.len(),
hard_continuation,
});
break;
}
let (end, next, hard_break) = if let Some(index) = (start + 1..hard_end)
.rev()
.find(|&index| characters[index].1 == ' ')
{
(index, index + 1, false)
} else if let Some(index) = (start + 1..hard_end)
.rev()
.find(|&index| !is_word_character(characters[index].1))
{
(index, index, false)
} else {
(hard_end, hard_end, true)
};
rows.push(VisualLine {
range: byte_at(start)..byte_at(end),
hard_continuation,
});
start = next;
hard_continuation = hard_break;
}
rows.into_iter().map(|line| line.shifted(offset)).collect()
}
}
struct SpanWrap<'a>(pub &'a [Range<usize>]);
impl WrapPolicy for SpanWrap<'_> {
fn wrap_logical_line(
&self,
logical: &str,
width: u16,
logical_start: usize,
) -> Vec<VisualLine> {
let width = width as usize;
let mut lines = Vec::new();
let characters: Vec<(usize, char)> = logical.char_indices().collect();
let byte_at = |index: usize| {
characters
.get(index)
.map_or(logical.len(), |&(byte, _)| byte)
};
let mut start = 0;
let mut hard_continuation = false;
if width == 0 || characters.is_empty() {
lines.push(VisualLine {
range: logical_start..logical_start + logical.len(),
hard_continuation: false,
});
return lines;
}
while characters.len() - start > width {
let limit = start + width;
let whitespace = (start + 1..=limit)
.rev()
.find(|&index| characters[index].1.is_whitespace());
if let Some(end) = whitespace {
lines.push(VisualLine {
range: logical_start + byte_at(start)..logical_start + byte_at(end),
hard_continuation,
});
start = end + 1;
hard_continuation = false;
continue;
}
let start_byte = logical_start + byte_at(start);
let limit_byte = logical_start + byte_at(limit);
if let Some(end) = self
.0
.iter()
.rev()
.map(|span| span.end)
.find(|&end| start_byte < end && end <= limit_byte)
{
lines.push(VisualLine {
range: start_byte..end,
hard_continuation,
});
start = characters.partition_point(|&(byte, _)| logical_start + byte < end);
hard_continuation = false;
continue;
}
lines.push(VisualLine {
range: start_byte..limit_byte,
hard_continuation,
});
start = limit;
hard_continuation = true;
}
lines.push(VisualLine {
range: logical_start + byte_at(start)..logical_start + logical.len(),
hard_continuation,
});
lines
}
}
fn is_word_character(character: char) -> bool {
character.is_alphanumeric() || character == '\'' || character == '$'
}
#[cfg(test)]
mod tests {
use super::*;
fn width_is_stable(glyph: char) -> bool {
use unicode_width::UnicodeWidthChar;
glyph.width() == glyph.width_cjk()
}
fn screen(candidates: &str) -> Vec<(char, Option<usize>, Option<usize>)> {
use unicode_width::UnicodeWidthChar;
candidates
.chars()
.map(|glyph| (glyph, glyph.width(), glyph.width_cjk()))
.collect()
}
#[test]
fn rendered_value_glyphs_keep_one_width_everywhere() {
for glyph in "ℤℚℝℂ⋯⋅∖⟵⟶⟼⎧⎨⎩⎰⎱⎪⎫⎬⎭⎛⎝⎞⎠".chars()
{
assert!(width_is_stable(glyph), "{glyph} is ambiguous");
}
}
#[test]
fn the_ambiguous_glyphs_this_shell_already_draws_are_known() {
let ambiguous: String = screen("─⇒²³│╭╮╰╯┈×…")
.into_iter()
.filter(|(glyph, ..)| !width_is_stable(*glyph))
.map(|(glyph, ..)| glyph)
.collect();
assert_eq!(ambiguous, "─⇒²³│╭╮╰╯┈×…");
}
#[test]
fn token_wrapping_prefers_whitespace_then_token_boundaries() {
let whitespace = SpanWrap(&[0..2, 3..7]).wrap("aa bbbb", 4);
assert_eq!(whitespace[0].range, 0..2);
assert_eq!(whitespace[1].range, 3..7);
assert!(!whitespace[1].hard_continuation);
let tokens = SpanWrap(&[0..3, 3..4, 4..7]).wrap("abc+def", 5);
assert_eq!(tokens[0].range, 0..4);
assert_eq!(tokens[1].range, 4..7);
assert!(!tokens[1].hard_continuation);
}
#[test]
fn a_single_long_token_marks_its_continuation() {
let span = 0..8;
let lines = SpanWrap(std::slice::from_ref(&span)).wrap("abcdefgh", 4);
assert_eq!(lines[0].range, 0..4);
assert!(!lines[0].hard_continuation);
assert_eq!(lines[1].range, 4..8);
assert!(lines[1].hard_continuation);
}
#[test]
fn edited_text_breaks_between_words_and_leaves_the_caret_a_row() {
let text = "abc defg";
let rows: Vec<_> = CaretWrap
.wrap(text, 4)
.into_iter()
.map(|row| &text[row.range])
.collect();
assert_eq!(rows, ["abc", "defg", ""]);
}
#[test]
fn word_wrap_measures_wide_characters_by_their_columns() {
let text = "ααα 中中中";
for row in WordWrap.wrap(text, 4) {
let columns: usize = text[row.range]
.chars()
.map(|character| character.width().unwrap_or(0))
.sum();
assert!(columns <= 4);
}
}
#[test]
fn shared_wrapper_reports_hard_word_continuations() {
let wrapped = WrappedText::word("abcdefgh", 4);
assert_eq!(wrapped.text(), "abcd\nefgh");
assert_eq!(
wrapped
.source_lines()
.filter_map(|(range, hard)| hard.then_some(range.start))
.collect::<Vec<_>>(),
[4]
);
}
}