mirror of
https://github.com/extrawurst/gitui.git
synced 2024-12-29 12:06:26 +03:00
Stateful paragraph (#729)
* introduce stateful paragraph * limit scroll * show focus clearly
This commit is contained in:
parent
7ac40f73eb
commit
9b6dd60fe0
1
Cargo.lock
generated
1
Cargo.lock
generated
@ -504,6 +504,7 @@ dependencies = [
|
||||
"syntect",
|
||||
"textwrap 0.13.4",
|
||||
"tui",
|
||||
"unicode-segmentation",
|
||||
"unicode-truncate",
|
||||
"unicode-width",
|
||||
"which",
|
||||
|
@ -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"
|
||||
|
@ -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)?;
|
||||
|
@ -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<AsyncSyntaxJob, AsyncNotification>,
|
||||
key_config: SharedKeyConfig,
|
||||
scroll_top: Cell<u16>,
|
||||
focused: bool,
|
||||
theme: SharedTheme,
|
||||
}
|
||||
|
||||
impl SyntaxTextComponent {
|
||||
@ -37,6 +42,7 @@ impl SyntaxTextComponent {
|
||||
pub fn new(
|
||||
sender: &Sender<AsyncNotification>,
|
||||
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
|
||||
}
|
||||
}
|
||||
|
@ -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};
|
||||
|
||||
|
663
src/ui/reflow.rs
Normal file
663
src/ui/reflow.rs
Normal file
@ -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<Item = StyledGrapheme<'a>>,
|
||||
max_line_width: u16,
|
||||
current_line: Vec<StyledGrapheme<'a>>,
|
||||
next_line: Vec<StyledGrapheme<'a>>,
|
||||
/// Removes the leading whitespace from lines
|
||||
trim: bool,
|
||||
}
|
||||
|
||||
impl<'a, 'b> WordWrapper<'a, 'b> {
|
||||
pub fn new(
|
||||
symbols: &'b mut dyn Iterator<Item = StyledGrapheme<'a>>,
|
||||
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::<u16>::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::<u16>::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<Item = StyledGrapheme<'a>>,
|
||||
max_line_width: u16,
|
||||
current_line: Vec<StyledGrapheme<'a>>,
|
||||
/// Record the offet to skip render
|
||||
horizontal_offset: u16,
|
||||
}
|
||||
|
||||
impl<'a, 'b> LineTruncator<'a, 'b> {
|
||||
pub fn new(
|
||||
symbols: &'b mut dyn Iterator<Item = StyledGrapheme<'a>>,
|
||||
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::<u16>::cast(symbol.width()) > self.max_line_width
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// Break on newline and discard it.
|
||||
if symbol == "\n" {
|
||||
break;
|
||||
}
|
||||
|
||||
if current_line_width + Cast::<u16>::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::<u16>::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<String>, Vec<u16>) {
|
||||
let style = Default::default();
|
||||
let mut styled = UnicodeSegmentation::graphemes(text, true)
|
||||
.map(|g| StyledGrapheme { symbol: g, style });
|
||||
let mut composer: Box<dyn LineComposer> = 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::<String>();
|
||||
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!"
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
206
src/ui/stateful_paragraph.rs
Normal file
206
src/ui/stateful_paragraph.rs
Normal file
@ -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<Block<'a>>,
|
||||
/// Widget style
|
||||
style: Style,
|
||||
/// How to wrap the text
|
||||
wrap: Option<Wrap>,
|
||||
/// 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<T>(text: T) -> Self
|
||||
where
|
||||
T: Into<Text<'a>>,
|
||||
{
|
||||
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<dyn LineComposer> =
|
||||
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::<u16>::cast(symbol.width());
|
||||
}
|
||||
}
|
||||
y += 1;
|
||||
if y >= text_area.height + state.scroll.y {
|
||||
end_reached = true;
|
||||
}
|
||||
}
|
||||
|
||||
state.lines = y;
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue
Block a user