Skip to content
12 changes: 12 additions & 0 deletions src/textarea.rs
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ use tui::widgets::{Block, Widget};
pub struct TextArea<'a> {
lines: Vec<String>,
block: Option<Block<'a>>,
wrap: bool,
style: Style,
cursor: (usize, usize), // 0-base
tab_len: u8,
Expand Down Expand Up @@ -134,6 +135,7 @@ impl<'a> TextArea<'a> {
Self {
lines,
block: None,
wrap: false,
style: Style::default(),
cursor: (0, 0),
tab_len: 4,
Expand Down Expand Up @@ -1042,6 +1044,16 @@ impl<'a> TextArea<'a> {
self.style
}

/// Get current wrap setting of textarea.
pub fn get_wrap(&self) -> bool {
self.wrap
}

/// Set text wrapping. By default, wrap is false.
pub fn set_wrap(&mut self, wrap: bool) {
self.wrap = wrap
}

/// Set the block of textarea. By default, no block is set.
/// ```
/// use tui_textarea::TextArea;
Expand Down
200 changes: 200 additions & 0 deletions src/util.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,203 @@ pub fn spaces(size: u8) -> &'static str {
pub fn num_digits(i: usize) -> u8 {
f64::log10(i as f64) as u8 + 1
}

/// Calculate number of rows for a wrapped line
pub fn line_rows(line: &String, wrap_width: u16, has_lnum: bool, num_lines: usize) -> u16 {
let lnum_span_len = if has_lnum {
// Longest line number plus space on each side
num_digits(num_lines) + 2
} else {
0
};

let mut curr_line_len = lnum_span_len;
let mut wraps = 0;
let mut in_whitespace = false;
let mut word_len = 0;

// Return new cur_line_len and wraps resulting from word
fn add_word_to_line(word_len: u8, mut curr_line_len: u8, width: u8) -> (u8, u8) {
let mut wraps = 0;

// Overflow case: Word cannot fit on a single line
// It is guaranteed to start on next line, and will wrap a known number of times
if word_len > width {
// Add one to round up, and one for initial wrap
wraps += (word_len / width) + 1 + 1;
curr_line_len = word_len % width;
return (curr_line_len, wraps);
}

if curr_line_len + word_len > width {
wraps += 1;
curr_line_len = word_len;
} else {
curr_line_len += word_len;
}

(curr_line_len, wraps)
}

for c in line.chars() {
if c.is_whitespace() {
// Add last complete word
if !in_whitespace && word_len > 0 {
let added_wraps;
(curr_line_len, added_wraps) =
add_word_to_line(word_len, curr_line_len, wrap_width as u8);
wraps += added_wraps;
word_len = 0;
in_whitespace = true;
}
if c == '\t' {
// FIXME: Count tabs properly
}
curr_line_len += 1;
} else {
if in_whitespace {
word_len = 0;
in_whitespace = false;
}
// FIXME: Unicode grapheme clusters are counted individually instead of visible char
word_len += 1;
}
}

// Add 1 to account for the last line
(wraps + 1).max(1) as u16
}

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

fn run_line_rows_test(
line: &str,
wrap_width: u16,
has_lnum: bool,
num_lines: usize,
expected: u16,
) {
let line = line.to_string();
let result = line_rows(&line, wrap_width, has_lnum, num_lines);
assert_eq!(
result, expected,
"with string: '{}', width: {}, lnum: {}, num_lines: {}",
line, wrap_width, has_lnum, num_lines
);
}

#[test]
fn test_empty_line() {
run_line_rows_test("", 1, false, 1, 1);
run_line_rows_test("", 10, true, 10, 1);
}

#[test]
fn test_no_wrapping() {
run_line_rows_test("Hello, world!", 13, false, 1, 1);
// _10_Hello, world!
run_line_rows_test("Hello, world!", 17, true, 10, 1);
}

#[test]
fn test_wrapping() {
run_line_rows_test(
"A long line that should wrap at least once.",
20,
false,
1,
3,
);
run_line_rows_test(
"A long line that should wrap at least once.",
20,
true,
10,
3,
);
}

#[test]
fn test_long_word_wrapping() {
// This line
// has a
// longwordth
// atwraps.
run_line_rows_test("This line has a longwordthatwraps.", 10, false, 1, 4);
// _10_This
// line has a
// longwordth
// atwraps.
run_line_rows_test("This line has a longwordthatwraps.", 10, true, 10, 4);
// _10_This
// line has a
// longwordth
// atwrapsmor
// e.
run_line_rows_test("This line has a longwordthatwrapsmore.", 10, true, 10, 5);
}

#[test]
fn test_long_word_overflow() {
// This line
// has a
// longwordth
// atoverflow
// s.
run_line_rows_test("This line has a longwordthatoverflows.", 10, false, 1, 5);
// _100_ This
// line has a
// longwordth
// atoverflow
// s.
run_line_rows_test("This line has a longwordthatoverflows.", 10, true, 100, 5);
// _100000_
// This line
// has a
// longwordth
// atoverflow
// s.
run_line_rows_test(
"This line has a longwordthatoverflows.",
10,
true,
100000,
6,
);
}

#[test]
fn test_line_numbers() {
// _1_Word
// Word Word
// Word
run_line_rows_test("Word Word Word Word", 10, true, 1, 3);
// _1000_Word
// Word Word
// Word
run_line_rows_test("Word Word Word Word", 10, true, 1000, 3);
// _10000_
// Word Word
// Word Word
run_line_rows_test("Word Word Word Word", 10, true, 10000, 3);

// _1_ Longer
run_line_rows_test("Longer", 10, true, 1, 1);
// _10_
// Longer
run_line_rows_test("Longer", 10, true, 10, 2);
}

#[test]
fn test_regression() {
run_line_rows_test(
"<img src=\"https://raw.githubusercontent.com/rhysd/ss/master/tui-textarea/editor.gif\" width=560 height=236 alt=\"editor example\">",
58,
true,
100,
4,
)
}
}
79 changes: 74 additions & 5 deletions src/widget.rs
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
use crate::textarea::TextArea;
use crate::util::num_digits;
use crate::util::{line_rows, num_digits};
use std::cmp;
use std::sync::atomic::{AtomicU64, Ordering};
use tui::buffer::Buffer;
use tui::layout::Rect;
use tui::text::Text;
use tui::widgets::{Paragraph, Widget};
use tui::widgets::{Paragraph, Widget, Wrap};

// &mut 'a (u16, u16, u16, u16) is not available since Renderer instance totally takes over the ownership of TextArea
// instance. In the case, the TextArea instance cannot be accessed from any other objects since it is mutablly
Expand Down Expand Up @@ -104,6 +104,17 @@ impl<'a> Widget for Renderer<'a> {
area
};

// Transform lines into array of row count for each line
fn wrapped_rows(lines: &[String], wrap_width: u16, has_lnum: bool) -> Vec<u16> {
let num_lines = lines.len();
lines
.iter()
.map(|line| line_rows(&line, wrap_width, has_lnum, num_lines))
.collect()
}

// Move scroll top if cursor moves outside of viewport
// Cursor position is relative to text lines, not viewport.
fn next_scroll_top(prev_top: u16, cursor: u16, length: u16) -> u16 {
if cursor < prev_top {
cursor
Expand All @@ -114,21 +125,79 @@ impl<'a> Widget for Renderer<'a> {
}
}

fn next_scroll_row_wrapped(
prev_top_row: u16,
cursor_row: u16,
viewport_height: u16,
wrapped_rows: &Vec<u16>,
) -> u16 {
if cursor_row < prev_top_row {
return cursor_row;
} else {
// Calculate the number of wrap rows between the top row and the cursor row
// TODO: Clarify why +1 is needed
let rows_from_top_to_cursor = wrapped_rows
[prev_top_row as usize..cursor_row as usize]
.iter()
.sum::<u16>()
+ 1;
let cursor_row_wraps = wrapped_rows[cursor_row as usize] - 1;
let cursor_line_on_screen =
rows_from_top_to_cursor + cursor_row_wraps <= viewport_height;
let rows_to_move =
(rows_from_top_to_cursor + cursor_row_wraps).saturating_sub(viewport_height);

if !cursor_line_on_screen {
// Count how many lines add up to enough rows to get entire cursor line on screen again
let lines_to_move = wrapped_rows[prev_top_row as usize..cursor_row as usize]
.iter()
.scan(0, |acc, &row| {
// Sum wrap rows to this line
*acc += row;
Some(*acc)
})
// Return index of line where acc exceeds rows_to_move
.position(|sum| sum >= rows_to_move)
.unwrap_or(0) as u16;
let lines_to_move = lines_to_move + 1; // Convert from index

// Never move below cursor row in case terminal can't fit it
return (prev_top_row + lines_to_move).min(cursor_row);
} else {
return prev_top_row;
}
};
}

let cursor = self.0.cursor();
let (top_row, top_col) = self.0.viewport.scroll_top();
let top_row = next_scroll_top(top_row, cursor.0 as u16, height);
let top_col = next_scroll_top(top_col, cursor.1 as u16, width);
let (mut top_row, mut top_col) = self.0.viewport.scroll_top();
let wrap = self.0.get_wrap();
if wrap {
let wrapped_rows =
wrapped_rows(&self.0.lines(), width, self.0.line_number_style().is_some());
top_row = next_scroll_row_wrapped(top_row, cursor.0 as u16, height, &wrapped_rows);
// Column for scoll should never change with wrapping (no horiz scroll)
// FIXME: Edge case where line can't fit in screen and overflows?
} else {
top_row = next_scroll_top(top_row, cursor.0 as u16, height);
top_col = next_scroll_top(top_col, cursor.1 as u16, width);
}
let (top_row, top_col) = (top_row, top_col);

let text = self.text(top_row as usize, height as usize);
let mut inner = Paragraph::new(text)
.style(self.0.style())
.alignment(self.0.alignment());
if wrap {
inner = inner.wrap(Wrap { trim: false });
}
if let Some(b) = self.0.block() {
inner = inner.block(b.clone());
}
if top_col != 0 {
inner = inner.scroll((0, top_col));
}
// TODO: Vertical scroll to position top edge in middle of wrapped line

// Store scroll top position for rendering on the next tick
self.0.viewport.store(top_row, top_col, width, height);
Expand Down