From 9b6dd60fe078caf81c07c9dcab6ed50ffd4eb0a4 Mon Sep 17 00:00:00 2001 From: Stephan Dilly Date: Sun, 23 May 2021 23:42:49 +0200 Subject: [PATCH] Stateful paragraph (#729) * introduce stateful paragraph * limit scroll * show focus clearly --- Cargo.lock | 1 + Cargo.toml | 1 + src/components/revision_files.rs | 21 +- src/components/syntax_text.rs | 50 ++- src/ui/mod.rs | 5 + src/ui/reflow.rs | 663 +++++++++++++++++++++++++++++++ src/ui/stateful_paragraph.rs | 206 ++++++++++ 7 files changed, 935 insertions(+), 12 deletions(-) create mode 100644 src/ui/reflow.rs create mode 100644 src/ui/stateful_paragraph.rs diff --git a/Cargo.lock b/Cargo.lock index d47fa05e..27c8c00f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -504,6 +504,7 @@ dependencies = [ "syntect", "textwrap 0.13.4", "tui", + "unicode-segmentation", "unicode-truncate", "unicode-width", "which", diff --git a/Cargo.toml b/Cargo.toml index 2de78b34..13a6ebde 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -43,6 +43,7 @@ anyhow = "1.0" unicode-width = "0.1" textwrap = "0.13" unicode-truncate = "0.2" +unicode-segmentation = "1.7" easy-cast = "0.4" bugreport = "0.4" lazy_static = "1.4" diff --git a/src/components/revision_files.rs b/src/components/revision_files.rs index 9e4e3d0b..8003dc5e 100644 --- a/src/components/revision_files.rs +++ b/src/components/revision_files.rs @@ -68,6 +68,7 @@ impl RevisionFilesComponent { current_file: SyntaxTextComponent::new( sender, key_config.clone(), + theme.clone(), ), theme, files: Vec::new(), @@ -90,7 +91,7 @@ impl RevisionFilesComponent { self.tree.collapse_but_root(); self.revision = Some(commit); self.title = format!( - "File Tree at [{}]", + "Files at [{}]", self.revision .map(|c| c.get_short_string()) .unwrap_or_default() @@ -221,21 +222,23 @@ impl DrawableComponent for RevisionFilesComponent { f.render_widget(Clear, area); f.render_widget( Block::default() - .borders(Borders::ALL) + .borders(Borders::TOP) .title(Span::styled( - &self.title, + format!(" {}", self.title), self.theme.title(true), )) .border_style(self.theme.block(true)), area, ); + let is_tree_focused = matches!(self.focus, Focus::Tree); + ui::draw_list_block( f, chunks[0], Block::default() - .borders(Borders::RIGHT) - .border_style(self.theme.block(true)), + .borders(Borders::ALL) + .border_style(self.theme.block(is_tree_focused)), items, ); @@ -298,8 +301,14 @@ impl Component for RevisionFilesComponent { } else if key == self.key_config.move_right { if is_tree_focused { self.focus = Focus::File; - } else { + self.current_file.focus(true); + self.focus(true); + } + } else if key == self.key_config.move_left { + if !is_tree_focused { self.focus = Focus::Tree; + self.current_file.focus(false); + self.focus(false); } } else if !is_tree_focused { self.current_file.event(event)?; diff --git a/src/components/syntax_text.rs b/src/components/syntax_text.rs index fb0fdb3d..55c22bd0 100644 --- a/src/components/syntax_text.rs +++ b/src/components/syntax_text.rs @@ -4,7 +4,10 @@ use super::{ }; use crate::{ keys::SharedKeyConfig, - ui::{self, AsyncSyntaxJob}, + ui::{ + self, style::SharedTheme, AsyncSyntaxJob, ParagraphState, + ScrollPos, StatefulParagraph, + }, }; use anyhow::Result; use async_utils::AsyncSingleJob; @@ -20,7 +23,7 @@ use tui::{ backend::Backend, layout::Rect, text::Text, - widgets::{Paragraph, Wrap}, + widgets::{Block, Borders, Wrap}, Frame, }; @@ -30,6 +33,8 @@ pub struct SyntaxTextComponent { AsyncSingleJob, key_config: SharedKeyConfig, scroll_top: Cell, + focused: bool, + theme: SharedTheme, } impl SyntaxTextComponent { @@ -37,6 +42,7 @@ impl SyntaxTextComponent { pub fn new( sender: &Sender, key_config: SharedKeyConfig, + theme: SharedTheme, ) -> Self { Self { async_highlighting: AsyncSingleJob::new( @@ -45,7 +51,9 @@ impl SyntaxTextComponent { ), current_file: None, scroll_top: Cell::new(0), + focused: false, key_config, + theme, } } @@ -126,10 +134,30 @@ impl DrawableComponent for SyntaxTextComponent { }, ); - let content = Paragraph::new(text) - .scroll((self.scroll_top.get(), 0)) - .wrap(Wrap { trim: false }); - f.render_widget(content, area); + let content = StatefulParagraph::new(text) + .wrap(Wrap { trim: false }) + .block( + Block::default() + .title( + self.current_file + .as_ref() + .map(|(name, _)| name.clone()) + .unwrap_or_default(), + ) + .borders(Borders::ALL) + .border_style(self.theme.title(self.focused())), + ); + + let mut state = ParagraphState::default(); + state.set_scroll(ScrollPos::new(0, self.scroll_top.get())); + + f.render_stateful_widget(content, area, &mut state); + + self.scroll_top.set( + self.scroll_top + .get() + .min(state.lines().saturating_sub(area.height)), + ); Ok(()) } @@ -161,4 +189,14 @@ impl Component for SyntaxTextComponent { Ok(EventState::NotConsumed) } + + /// + fn focused(&self) -> bool { + self.focused + } + + /// focus/unfocus this component depending on param + fn focus(&mut self, focus: bool) { + self.focused = focus + } } diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 4734f8f3..2b4646cd 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -1,10 +1,15 @@ +mod reflow; mod scrollbar; mod scrolllist; +mod stateful_paragraph; pub mod style; mod syntax_text; pub use scrollbar::draw_scrollbar; pub use scrolllist::{draw_list, draw_list_block}; +pub use stateful_paragraph::{ + ParagraphState, ScrollPos, StatefulParagraph, +}; pub use syntax_text::{AsyncSyntaxJob, SyntaxText}; use tui::layout::{Constraint, Direction, Layout, Rect}; diff --git a/src/ui/reflow.rs b/src/ui/reflow.rs new file mode 100644 index 00000000..3d469c52 --- /dev/null +++ b/src/ui/reflow.rs @@ -0,0 +1,663 @@ +use easy_cast::Cast; +use tui::text::StyledGrapheme; +use unicode_segmentation::UnicodeSegmentation; +use unicode_width::UnicodeWidthStr; + +const NBSP: &str = "\u{00a0}"; + +/// A state machine to pack styled symbols into lines. +/// Cannot implement it as Iterator since it yields slices of the internal buffer (need streaming +/// iterators for that). +pub trait LineComposer<'a> { + fn next_line(&mut self) -> Option<(&[StyledGrapheme<'a>], u16)>; +} + +/// A state machine that wraps lines on word boundaries. +pub struct WordWrapper<'a, 'b> { + symbols: &'b mut dyn Iterator>, + max_line_width: u16, + current_line: Vec>, + next_line: Vec>, + /// Removes the leading whitespace from lines + trim: bool, +} + +impl<'a, 'b> WordWrapper<'a, 'b> { + pub fn new( + symbols: &'b mut dyn Iterator>, + max_line_width: u16, + trim: bool, + ) -> WordWrapper<'a, 'b> { + WordWrapper { + symbols, + max_line_width, + current_line: vec![], + next_line: vec![], + trim, + } + } +} + +impl<'a, 'b> LineComposer<'a> for WordWrapper<'a, 'b> { + fn next_line(&mut self) -> Option<(&[StyledGrapheme<'a>], u16)> { + if self.max_line_width == 0 { + return None; + } + std::mem::swap(&mut self.current_line, &mut self.next_line); + self.next_line.truncate(0); + + let mut current_line_width = self + .current_line + .iter() + .map(|StyledGrapheme { symbol, .. }| -> u16 { + symbol.width().cast() + }) + .sum(); + + let mut symbols_to_last_word_end: usize = 0; + let mut width_to_last_word_end: u16 = 0; + let mut prev_whitespace = false; + let mut symbols_exhausted = true; + for StyledGrapheme { symbol, style } in &mut self.symbols { + symbols_exhausted = false; + let symbol_whitespace = + symbol.chars().all(&char::is_whitespace) + && symbol != NBSP; + + // Ignore characters wider that the total max width. + if Cast::::cast(symbol.width()) > self.max_line_width + // Skip leading whitespace when trim is enabled. + || self.trim && symbol_whitespace && symbol != "\n" && current_line_width == 0 + { + continue; + } + + // Break on newline and discard it. + if symbol == "\n" { + if prev_whitespace { + current_line_width = width_to_last_word_end; + self.current_line + .truncate(symbols_to_last_word_end); + } + break; + } + + // Mark the previous symbol as word end. + if symbol_whitespace && !prev_whitespace { + symbols_to_last_word_end = self.current_line.len(); + width_to_last_word_end = current_line_width; + } + + self.current_line.push(StyledGrapheme { symbol, style }); + current_line_width += Cast::::cast(symbol.width()); + + if current_line_width > self.max_line_width { + // If there was no word break in the text, wrap at the end of the line. + let (truncate_at, truncated_width) = + if symbols_to_last_word_end == 0 { + ( + self.current_line.len() - 1, + self.max_line_width, + ) + } else { + ( + symbols_to_last_word_end, + width_to_last_word_end, + ) + }; + + // Push the remainder to the next line but strip leading whitespace: + { + let remainder = &self.current_line[truncate_at..]; + if let Some(remainder_nonwhite) = + remainder.iter().position( + |StyledGrapheme { symbol, .. }| { + !symbol + .chars() + .all(&char::is_whitespace) + }, + ) + { + self.next_line.extend_from_slice( + &remainder[remainder_nonwhite..], + ); + } + } + self.current_line.truncate(truncate_at); + current_line_width = truncated_width; + break; + } + + prev_whitespace = symbol_whitespace; + } + + // Even if the iterator is exhausted, pass the previous remainder. + if symbols_exhausted && self.current_line.is_empty() { + None + } else { + Some((&self.current_line[..], current_line_width)) + } + } +} + +/// A state machine that truncates overhanging lines. +pub struct LineTruncator<'a, 'b> { + symbols: &'b mut dyn Iterator>, + max_line_width: u16, + current_line: Vec>, + /// Record the offet to skip render + horizontal_offset: u16, +} + +impl<'a, 'b> LineTruncator<'a, 'b> { + pub fn new( + symbols: &'b mut dyn Iterator>, + max_line_width: u16, + ) -> LineTruncator<'a, 'b> { + LineTruncator { + symbols, + max_line_width, + horizontal_offset: 0, + current_line: vec![], + } + } + + pub fn set_horizontal_offset(&mut self, horizontal_offset: u16) { + self.horizontal_offset = horizontal_offset; + } +} + +impl<'a, 'b> LineComposer<'a> for LineTruncator<'a, 'b> { + fn next_line(&mut self) -> Option<(&[StyledGrapheme<'a>], u16)> { + if self.max_line_width == 0 { + return None; + } + + self.current_line.truncate(0); + let mut current_line_width = 0; + + let mut skip_rest = false; + let mut symbols_exhausted = true; + let mut horizontal_offset = self.horizontal_offset as usize; + for StyledGrapheme { symbol, style } in &mut self.symbols { + symbols_exhausted = false; + + // Ignore characters wider that the total max width. + if Cast::::cast(symbol.width()) > self.max_line_width + { + continue; + } + + // Break on newline and discard it. + if symbol == "\n" { + break; + } + + if current_line_width + Cast::::cast(symbol.width()) + > self.max_line_width + { + // Exhaust the remainder of the line. + skip_rest = true; + break; + } + + let symbol = if horizontal_offset == 0 { + symbol + } else { + let w = symbol.width(); + if w > horizontal_offset { + let t = trim_offset(symbol, horizontal_offset); + horizontal_offset = 0; + t + } else { + horizontal_offset -= w; + "" + } + }; + current_line_width += Cast::::cast(symbol.width()); + self.current_line.push(StyledGrapheme { symbol, style }); + } + + if skip_rest { + for StyledGrapheme { symbol, .. } in &mut self.symbols { + if symbol == "\n" { + break; + } + } + } + + if symbols_exhausted && self.current_line.is_empty() { + None + } else { + Some((&self.current_line[..], current_line_width)) + } + } +} + +/// This function will return a str slice which start at specified offset. +/// As src is a unicode str, start offset has to be calculated with each character. +fn trim_offset(src: &str, mut offset: usize) -> &str { + let mut start = 0; + for c in UnicodeSegmentation::graphemes(src, true) { + let w = c.width(); + if w <= offset { + offset -= w; + start += c.len(); + } else { + break; + } + } + &src[start..] +} + +#[cfg(test)] +mod test { + use super::*; + use unicode_segmentation::UnicodeSegmentation; + + enum Composer { + WordWrapper { trim: bool }, + LineTruncator, + } + + fn run_composer( + which: Composer, + text: &str, + text_area_width: u16, + ) -> (Vec, Vec) { + let style = Default::default(); + let mut styled = UnicodeSegmentation::graphemes(text, true) + .map(|g| StyledGrapheme { symbol: g, style }); + let mut composer: Box = match which { + Composer::WordWrapper { trim } => Box::new( + WordWrapper::new(&mut styled, text_area_width, trim), + ), + Composer::LineTruncator => Box::new(LineTruncator::new( + &mut styled, + text_area_width, + )), + }; + let mut lines = vec![]; + let mut widths = vec![]; + while let Some((styled, width)) = composer.next_line() { + let line = styled + .iter() + .map(|StyledGrapheme { symbol, .. }| *symbol) + .collect::(); + assert!(width <= text_area_width); + lines.push(line); + widths.push(width); + } + (lines, widths) + } + + #[test] + fn line_composer_one_line() { + let width = 40; + for i in 1..width { + let text = "a".repeat(i); + let (word_wrapper, _) = run_composer( + Composer::WordWrapper { trim: true }, + &text, + width as u16, + ); + let (line_truncator, _) = run_composer( + Composer::LineTruncator, + &text, + width as u16, + ); + let expected = vec![text]; + assert_eq!(word_wrapper, expected); + assert_eq!(line_truncator, expected); + } + } + + #[test] + fn line_composer_short_lines() { + let width = 20; + let text = + "abcdefg\nhijklmno\npabcdefg\nhijklmn\nopabcdefghijk\nlmnopabcd\n\n\nefghijklmno"; + let (word_wrapper, _) = run_composer( + Composer::WordWrapper { trim: true }, + text, + width, + ); + let (line_truncator, _) = + run_composer(Composer::LineTruncator, text, width); + + let wrapped: Vec<&str> = text.split('\n').collect(); + assert_eq!(word_wrapper, wrapped); + assert_eq!(line_truncator, wrapped); + } + + #[test] + fn line_composer_long_word() { + let width = 20; + let text = "abcdefghijklmnopabcdefghijklmnopabcdefghijklmnopabcdefghijklmno"; + let (word_wrapper, _) = run_composer( + Composer::WordWrapper { trim: true }, + text, + width as u16, + ); + let (line_truncator, _) = + run_composer(Composer::LineTruncator, text, width as u16); + + let wrapped = vec![ + &text[..width], + &text[width..width * 2], + &text[width * 2..width * 3], + &text[width * 3..], + ]; + assert_eq!( + word_wrapper, wrapped, + "WordWrapper should detect the line cannot be broken on word boundary and \ + break it at line width limit." + ); + assert_eq!(line_truncator, vec![&text[..width]]); + } + + #[test] + fn line_composer_long_sentence() { + let width = 20; + let text = + "abcd efghij klmnopabcd efgh ijklmnopabcdefg hijkl mnopab c d e f g h i j k l m n o"; + let text_multi_space = + "abcd efghij klmnopabcd efgh ijklmnopabcdefg hijkl mnopab c d e f g h i j k l \ + m n o"; + let (word_wrapper_single_space, _) = run_composer( + Composer::WordWrapper { trim: true }, + text, + width as u16, + ); + let (word_wrapper_multi_space, _) = run_composer( + Composer::WordWrapper { trim: true }, + text_multi_space, + width as u16, + ); + let (line_truncator, _) = + run_composer(Composer::LineTruncator, text, width as u16); + + let word_wrapped = vec![ + "abcd efghij", + "klmnopabcd efgh", + "ijklmnopabcdefg", + "hijkl mnopab c d e f", + "g h i j k l m n o", + ]; + assert_eq!(word_wrapper_single_space, word_wrapped); + assert_eq!(word_wrapper_multi_space, word_wrapped); + + assert_eq!(line_truncator, vec![&text[..width]]); + } + + #[test] + fn line_composer_zero_width() { + let width = 0; + let text = "abcd efghij klmnopabcd efgh ijklmnopabcdefg hijkl mnopab "; + let (word_wrapper, _) = run_composer( + Composer::WordWrapper { trim: true }, + text, + width, + ); + let (line_truncator, _) = + run_composer(Composer::LineTruncator, text, width); + + let expected: Vec<&str> = Vec::new(); + assert_eq!(word_wrapper, expected); + assert_eq!(line_truncator, expected); + } + + #[test] + fn line_composer_max_line_width_of_1() { + let width = 1; + let text = "abcd efghij klmnopabcd efgh ijklmnopabcdefg hijkl mnopab "; + let (word_wrapper, _) = run_composer( + Composer::WordWrapper { trim: true }, + text, + width, + ); + let (line_truncator, _) = + run_composer(Composer::LineTruncator, text, width); + + let expected: Vec<&str> = + UnicodeSegmentation::graphemes(text, true) + .filter(|g| g.chars().any(|c| !c.is_whitespace())) + .collect(); + assert_eq!(word_wrapper, expected); + assert_eq!(line_truncator, vec!["a"]); + } + + #[test] + fn line_composer_max_line_width_of_1_double_width_characters() { + let width = 1; + let text = "コンピュータ上で文字を扱う場合、典型的には文字\naaaによる通信を行う場合にその\ + 両端点では、"; + let (word_wrapper, _) = run_composer( + Composer::WordWrapper { trim: true }, + text, + width, + ); + let (line_truncator, _) = + run_composer(Composer::LineTruncator, text, width); + assert_eq!(word_wrapper, vec!["", "a", "a", "a"]); + assert_eq!(line_truncator, vec!["", "a"]); + } + + /// Tests WordWrapper with words some of which exceed line length and some not. + #[test] + fn line_composer_word_wrapper_mixed_length() { + let width = 20; + let text = "abcd efghij klmnopabcdefghijklmnopabcdefghijkl mnopab cdefghi j klmno"; + let (word_wrapper, _) = run_composer( + Composer::WordWrapper { trim: true }, + text, + width, + ); + assert_eq!( + word_wrapper, + vec![ + "abcd efghij", + "klmnopabcdefghijklmn", + "opabcdefghijkl", + "mnopab cdefghi j", + "klmno", + ] + ) + } + + #[test] + fn line_composer_double_width_chars() { + let width = 20; + let text = "コンピュータ上で文字を扱う場合、典型的には文字による通信を行う場合にその両端点\ + では、"; + let (word_wrapper, word_wrapper_width) = run_composer( + Composer::WordWrapper { trim: true }, + &text, + width, + ); + let (line_truncator, _) = + run_composer(Composer::LineTruncator, &text, width); + assert_eq!(line_truncator, vec!["コンピュータ上で文字"]); + let wrapped = vec![ + "コンピュータ上で文字", + "を扱う場合、典型的に", + "は文字による通信を行", + "う場合にその両端点で", + "は、", + ]; + assert_eq!(word_wrapper, wrapped); + assert_eq!( + word_wrapper_width, + vec![width, width, width, width, 4] + ); + } + + #[test] + fn line_composer_leading_whitespace_removal() { + let width = 20; + let text = "AAAAAAAAAAAAAAAAAAAA AAA"; + let (word_wrapper, _) = run_composer( + Composer::WordWrapper { trim: true }, + text, + width, + ); + let (line_truncator, _) = + run_composer(Composer::LineTruncator, text, width); + assert_eq!( + word_wrapper, + vec!["AAAAAAAAAAAAAAAAAAAA", "AAA",] + ); + assert_eq!(line_truncator, vec!["AAAAAAAAAAAAAAAAAAAA"]); + } + + /// Tests truncation of leading whitespace. + #[test] + fn line_composer_lots_of_spaces() { + let width = 20; + let text = " "; + let (word_wrapper, _) = run_composer( + Composer::WordWrapper { trim: true }, + text, + width, + ); + let (line_truncator, _) = + run_composer(Composer::LineTruncator, text, width); + assert_eq!(word_wrapper, vec![""]); + assert_eq!(line_truncator, vec![" "]); + } + + /// Tests an input starting with a letter, folowed by spaces - some of the behaviour is + /// incidental. + #[test] + fn line_composer_char_plus_lots_of_spaces() { + let width = 20; + let text = "a "; + let (word_wrapper, _) = run_composer( + Composer::WordWrapper { trim: true }, + text, + width, + ); + let (line_truncator, _) = + run_composer(Composer::LineTruncator, text, width); + // What's happening below is: the first line gets consumed, trailing spaces discarded, + // after 20 of which a word break occurs (probably shouldn't). The second line break + // discards all whitespace. The result should probably be vec!["a"] but it doesn't matter + // that much. + assert_eq!(word_wrapper, vec!["a", ""]); + assert_eq!(line_truncator, vec!["a "]); + } + + #[test] + fn line_composer_word_wrapper_double_width_chars_mixed_with_spaces( + ) { + let width = 20; + // Japanese seems not to use spaces but we should break on spaces anyway... We're using it + // to test double-width chars. + // You are more than welcome to add word boundary detection based of alterations of + // hiragana and katakana... + // This happens to also be a test case for mixed width because regular spaces are single width. + let text = "コンピュ ータ上で文字を扱う場合、 典型的には文 字による 通信を行 う場合にその両端点では、"; + let (word_wrapper, word_wrapper_width) = run_composer( + Composer::WordWrapper { trim: true }, + text, + width, + ); + assert_eq!( + word_wrapper, + vec![ + "コンピュ", + "ータ上で文字を扱う場", + "合、 典型的には文", + "字による 通信を行", + "う場合にその両端点で", + "は、", + ] + ); + // Odd-sized lines have a space in them. + assert_eq!(word_wrapper_width, vec![8, 20, 17, 17, 20, 4]); + } + + /// Ensure words separated by nbsp are wrapped as if they were a single one. + #[test] + fn line_composer_word_wrapper_nbsp() { + let width = 20; + let text = "AAAAAAAAAAAAAAA AAAA\u{00a0}AAA"; + let (word_wrapper, _) = run_composer( + Composer::WordWrapper { trim: true }, + text, + width, + ); + assert_eq!( + word_wrapper, + vec!["AAAAAAAAAAAAAAA", "AAAA\u{00a0}AAA",] + ); + + // Ensure that if the character was a regular space, it would be wrapped differently. + let text_space = text.replace("\u{00a0}", " "); + let (word_wrapper_space, _) = run_composer( + Composer::WordWrapper { trim: true }, + &text_space, + width, + ); + assert_eq!( + word_wrapper_space, + vec!["AAAAAAAAAAAAAAA AAAA", "AAA",] + ); + } + + #[test] + fn line_composer_word_wrapper_preserve_indentation() { + let width = 20; + let text = "AAAAAAAAAAAAAAAAAAAA AAA"; + let (word_wrapper, _) = run_composer( + Composer::WordWrapper { trim: false }, + text, + width, + ); + assert_eq!( + word_wrapper, + vec!["AAAAAAAAAAAAAAAAAAAA", " AAA",] + ); + } + + #[test] + fn line_composer_word_wrapper_preserve_indentation_with_wrap() { + let width = 10; + let text = "AAA AAA AAAAA AA AAAAAA\n B\n C\n D"; + let (word_wrapper, _) = run_composer( + Composer::WordWrapper { trim: false }, + text, + width, + ); + assert_eq!( + word_wrapper, + vec![ + "AAA AAA", "AAAAA AA", "AAAAAA", " B", " C", " D" + ] + ); + } + + #[test] + fn line_composer_word_wrapper_preserve_indentation_lots_of_whitespace( + ) { + let width = 10; + let text = + " 4 Indent\n must wrap!"; + let (word_wrapper, _) = run_composer( + Composer::WordWrapper { trim: false }, + text, + width, + ); + assert_eq!( + word_wrapper, + vec![ + " ", + " 4", + "Indent", + " ", + " must", + "wrap!" + ] + ); + } +} diff --git a/src/ui/stateful_paragraph.rs b/src/ui/stateful_paragraph.rs new file mode 100644 index 00000000..089a7c4e --- /dev/null +++ b/src/ui/stateful_paragraph.rs @@ -0,0 +1,206 @@ +#![allow(dead_code)] + +use easy_cast::Cast; +use std::iter; +use tui::{ + buffer::Buffer, + layout::{Alignment, Rect}, + style::Style, + text::{StyledGrapheme, Text}, + widgets::{Block, StatefulWidget, Widget, Wrap}, +}; +use unicode_width::UnicodeWidthStr; + +use super::reflow::{LineComposer, LineTruncator, WordWrapper}; + +const fn get_line_offset( + line_width: u16, + text_area_width: u16, + alignment: Alignment, +) -> u16 { + match alignment { + Alignment::Center => { + (text_area_width / 2).saturating_sub(line_width / 2) + } + Alignment::Right => { + text_area_width.saturating_sub(line_width) + } + Alignment::Left => 0, + } +} + +#[derive(Debug, Clone)] +pub struct StatefulParagraph<'a> { + /// A block to wrap the widget in + block: Option>, + /// Widget style + style: Style, + /// How to wrap the text + wrap: Option, + /// The text to display + text: Text<'a>, + /// Alignment of the text + alignment: Alignment, +} + +#[derive(Debug, Default, Clone, Copy)] +pub struct ScrollPos { + x: u16, + y: u16, +} + +impl ScrollPos { + pub const fn new(x: u16, y: u16) -> Self { + Self { x, y } + } +} + +#[derive(Debug, Copy, Clone, Default)] +pub struct ParagraphState { + /// Scroll + scroll: ScrollPos, + /// after all wrapping this is the amount of lines + lines: u16, +} + +impl ParagraphState { + pub const fn lines(self) -> u16 { + self.lines + } + + pub const fn scroll(self) -> ScrollPos { + self.scroll + } + + pub fn set_scroll(&mut self, scroll: ScrollPos) { + self.scroll = scroll; + } +} + +impl<'a> StatefulParagraph<'a> { + pub fn new(text: T) -> Self + where + T: Into>, + { + Self { + block: None, + style: Style::default(), + wrap: None, + text: text.into(), + alignment: Alignment::Left, + } + } + + #[allow(clippy::missing_const_for_fn)] + pub fn block(mut self, block: Block<'a>) -> Self { + self.block = Some(block); + self + } + + pub const fn style(mut self, style: Style) -> Self { + self.style = style; + self + } + + pub const fn wrap(mut self, wrap: Wrap) -> Self { + self.wrap = Some(wrap); + self + } + + pub const fn alignment(mut self, alignment: Alignment) -> Self { + self.alignment = alignment; + self + } +} + +impl<'a> StatefulWidget for StatefulParagraph<'a> { + type State = ParagraphState; + + fn render( + mut self, + area: Rect, + buf: &mut Buffer, + state: &mut Self::State, + ) { + buf.set_style(area, self.style); + let text_area = match self.block.take() { + Some(b) => { + let inner_area = b.inner(area); + b.render(area, buf); + inner_area + } + None => area, + }; + + if text_area.height < 1 { + return; + } + + let style = self.style; + let mut styled = self.text.lines.iter().flat_map(|spans| { + spans + .0 + .iter() + .flat_map(|span| span.styled_graphemes(style)) + // Required given the way composers work but might be refactored out if we change + // composers to operate on lines instead of a stream of graphemes. + .chain(iter::once(StyledGrapheme { + symbol: "\n", + style: self.style, + })) + }); + + let mut line_composer: Box = + if let Some(Wrap { trim }) = self.wrap { + Box::new(WordWrapper::new( + &mut styled, + text_area.width, + trim, + )) + } else { + let mut line_composer = Box::new(LineTruncator::new( + &mut styled, + text_area.width, + )); + if let Alignment::Left = self.alignment { + line_composer + .set_horizontal_offset(state.scroll.x); + } + line_composer + }; + let mut y = 0; + let mut end_reached = false; + while let Some((current_line, current_line_width)) = + line_composer.next_line() + { + if !end_reached && y >= state.scroll.y { + let mut x = get_line_offset( + current_line_width, + text_area.width, + self.alignment, + ); + for StyledGrapheme { symbol, style } in current_line { + buf.get_mut( + text_area.left() + x, + text_area.top() + y - state.scroll.y, + ) + .set_symbol(if symbol.is_empty() { + // If the symbol is empty, the last char which rendered last time will + // leave on the line. It's a quick fix. + " " + } else { + symbol + }) + .set_style(*style); + x += Cast::::cast(symbol.width()); + } + } + y += 1; + if y >= text_area.height + state.scroll.y { + end_reached = true; + } + } + + state.lines = y; + } +}