diff --git a/src/textarea.rs b/src/textarea.rs index b8c77ca..580119c 100644 --- a/src/textarea.rs +++ b/src/textarea.rs @@ -37,6 +37,7 @@ use tui::widgets::{Block, Widget}; pub struct TextArea<'a> { lines: Vec, block: Option>, + wrap: bool, style: Style, cursor: (usize, usize), // 0-base tab_len: u8, @@ -134,6 +135,7 @@ impl<'a> TextArea<'a> { Self { lines, block: None, + wrap: false, style: Style::default(), cursor: (0, 0), tab_len: 4, @@ -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; diff --git a/src/util.rs b/src/util.rs index f0e90e0..a0dde21 100644 --- a/src/util.rs +++ b/src/util.rs @@ -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( + "\"editor", + 58, + true, + 100, + 4, + ) + } +} diff --git a/src/widget.rs b/src/widget.rs index c71d71a..766551c 100644 --- a/src/widget.rs +++ b/src/widget.rs @@ -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 @@ -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 { + 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 @@ -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 { + 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::() + + 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);