mirror of
https://github.com/ratatui-org/ratatui
synced 2024-11-21 20:23:11 +00:00
feat(widgets/paragraph): improve scrolling
Add optional callback to `Wrap` to compute the scroll offsets given the wrapped lines. This let users compute the offsets dynamically.
This commit is contained in:
parent
ecb482f297
commit
54b841fab6
6 changed files with 184 additions and 43 deletions
|
@ -274,7 +274,7 @@ where
|
|||
.fg(Color::Magenta)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
));
|
||||
let paragraph = Paragraph::new(text).block(block).wrap(Wrap { trim: true });
|
||||
let paragraph = Paragraph::new(text).block(block).wrap(Wrap::default());
|
||||
f.render_widget(paragraph, area);
|
||||
}
|
||||
|
||||
|
|
|
@ -81,20 +81,20 @@ fn main() -> Result<(), Box<dyn Error>> {
|
|||
.style(Style::default().bg(Color::White).fg(Color::Black))
|
||||
.block(create_block("Left, wrap"))
|
||||
.alignment(Alignment::Left)
|
||||
.wrap(Wrap { trim: true });
|
||||
.wrap(Wrap::default());
|
||||
f.render_widget(paragraph, chunks[1]);
|
||||
let paragraph = Paragraph::new(text.clone())
|
||||
.style(Style::default().bg(Color::White).fg(Color::Black))
|
||||
.block(create_block("Center, wrap"))
|
||||
.alignment(Alignment::Center)
|
||||
.wrap(Wrap { trim: true })
|
||||
.wrap(Wrap::default())
|
||||
.scroll((scroll, 0));
|
||||
f.render_widget(paragraph, chunks[2]);
|
||||
let paragraph = Paragraph::new(text)
|
||||
.style(Style::default().bg(Color::White).fg(Color::Black))
|
||||
.block(create_block("Right, wrap"))
|
||||
.alignment(Alignment::Right)
|
||||
.wrap(Wrap { trim: true });
|
||||
.wrap(Wrap::default());
|
||||
f.render_widget(paragraph, chunks[3]);
|
||||
})?;
|
||||
|
||||
|
|
81
examples/paragraph2.rs
Normal file
81
examples/paragraph2.rs
Normal file
|
@ -0,0 +1,81 @@
|
|||
#[allow(dead_code)]
|
||||
mod util;
|
||||
|
||||
use crate::util::event::{Event, Events};
|
||||
use std::{error::Error, io};
|
||||
use termion::{event::Key, input::MouseTerminal, raw::IntoRawMode, screen::AlternateScreen};
|
||||
use tui::{
|
||||
backend::TermionBackend,
|
||||
layout::{Alignment, Margin},
|
||||
style::{Color, Style},
|
||||
text::{Span, Spans},
|
||||
widgets::{Block, Borders, Paragraph, Wrap},
|
||||
Terminal,
|
||||
};
|
||||
|
||||
const LOREM_IPSUM: &str = "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua";
|
||||
|
||||
fn main() -> Result<(), Box<dyn Error>> {
|
||||
// Terminal initialization
|
||||
let stdout = io::stdout().into_raw_mode()?;
|
||||
let stdout = MouseTerminal::from(stdout);
|
||||
let stdout = AlternateScreen::from(stdout);
|
||||
let backend = TermionBackend::new(stdout);
|
||||
let mut terminal = Terminal::new(backend)?;
|
||||
|
||||
let events = Events::new();
|
||||
|
||||
let mut i = 0;
|
||||
let mut lines = Vec::with_capacity(100);
|
||||
while i < 100 {
|
||||
lines.push((i, format!("{}: {}", i, LOREM_IPSUM)));
|
||||
i += 1;
|
||||
}
|
||||
loop {
|
||||
terminal.draw(|f| {
|
||||
let size = f.size();
|
||||
let text: Vec<Spans> = lines
|
||||
.iter()
|
||||
.cloned()
|
||||
.map(|(j, l)| {
|
||||
let span = if i == j + 1 {
|
||||
Span::styled(l, Style::default().bg(Color::Yellow))
|
||||
} else {
|
||||
Span::raw(l)
|
||||
};
|
||||
Spans::from(span)
|
||||
})
|
||||
.collect();
|
||||
let mut wrap = Wrap::default();
|
||||
wrap.scroll_callback = Some(Box::new(|text_area, lines| {
|
||||
let len = lines.len() as u16;
|
||||
(len.saturating_sub(text_area.height), 0)
|
||||
}));
|
||||
let paragraph = Paragraph::new(text)
|
||||
.block(Block::default().borders(Borders::ALL))
|
||||
.wrap(wrap)
|
||||
.alignment(Alignment::Left);
|
||||
f.render_widget(
|
||||
paragraph,
|
||||
size.inner(&Margin {
|
||||
vertical: 2,
|
||||
horizontal: 2,
|
||||
}),
|
||||
);
|
||||
})?;
|
||||
|
||||
match events.next()? {
|
||||
Event::Tick => {
|
||||
lines.push((i, format!("{}: {}", i, LOREM_IPSUM)));
|
||||
lines.remove(0);
|
||||
i += 1;
|
||||
}
|
||||
Event::Input(key) => {
|
||||
if key == Key::Char('q') {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
|
@ -81,12 +81,12 @@ fn main() -> Result<(), Box<dyn Error>> {
|
|||
|
||||
let paragraph = Paragraph::new(text.clone())
|
||||
.block(Block::default().title("Left Block").borders(Borders::ALL))
|
||||
.alignment(Alignment::Left).wrap(Wrap { trim: true });
|
||||
.alignment(Alignment::Left).wrap(Wrap::default());
|
||||
f.render_widget(paragraph, chunks[0]);
|
||||
|
||||
let paragraph = Paragraph::new(text)
|
||||
.block(Block::default().title("Right Block").borders(Borders::ALL))
|
||||
.alignment(Alignment::Left).wrap(Wrap { trim: true });
|
||||
.alignment(Alignment::Left).wrap(Wrap::default());
|
||||
f.render_widget(paragraph, chunks[1]);
|
||||
|
||||
let block = Block::default().title("Popup").borders(Borders::ALL);
|
||||
|
|
|
@ -40,9 +40,8 @@ fn get_line_offset(line_width: u16, text_area_width: u16, alignment: Alignment)
|
|||
/// .block(Block::default().title("Paragraph").borders(Borders::ALL))
|
||||
/// .style(Style::default().fg(Color::White).bg(Color::Black))
|
||||
/// .alignment(Alignment::Center)
|
||||
/// .wrap(Wrap { trim: true });
|
||||
/// .wrap(Wrap::default());
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Paragraph<'a> {
|
||||
/// A block to wrap the widget in
|
||||
block: Option<Block<'a>>,
|
||||
|
@ -70,7 +69,7 @@ pub struct Paragraph<'a> {
|
|||
/// - Here is another point that is long enough to wrap"#);
|
||||
///
|
||||
/// // With leading spaces trimmed (window width of 30 chars):
|
||||
/// Paragraph::new(bullet_points.clone()).wrap(Wrap { trim: true });
|
||||
/// Paragraph::new(bullet_points.clone()).wrap(Wrap::default());
|
||||
/// // Some indented points:
|
||||
/// // - First thing goes here and is
|
||||
/// // long so that it wraps
|
||||
|
@ -78,19 +77,30 @@ pub struct Paragraph<'a> {
|
|||
/// // is long enough to wrap
|
||||
///
|
||||
/// // But without trimming, indentation is preserved:
|
||||
/// Paragraph::new(bullet_points).wrap(Wrap { trim: false });
|
||||
/// Paragraph::new(bullet_points).wrap(Wrap { trim: false, ..Wrap::default() });
|
||||
/// // Some indented points:
|
||||
/// // - First thing goes here
|
||||
/// // and is long so that it wraps
|
||||
/// // - Here is another point
|
||||
/// // that is long enough to wrap
|
||||
/// ```
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct Wrap {
|
||||
/// Should leading whitespace be trimmed
|
||||
pub trim: bool,
|
||||
pub scroll_callback: Option<Box<ScrollCallback>>,
|
||||
}
|
||||
|
||||
impl Default for Wrap {
|
||||
fn default() -> Wrap {
|
||||
Wrap {
|
||||
trim: true,
|
||||
scroll_callback: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub type ScrollCallback = dyn FnOnce(Rect, &[(Vec<StyledGrapheme<'_>>, u16)]) -> (u16, u16);
|
||||
|
||||
impl<'a> Paragraph<'a> {
|
||||
pub fn new<T>(text: T) -> Paragraph<'a>
|
||||
where
|
||||
|
@ -130,6 +140,42 @@ impl<'a> Paragraph<'a> {
|
|||
self.alignment = alignment;
|
||||
self
|
||||
}
|
||||
|
||||
fn draw_lines<'b, T>(
|
||||
&self,
|
||||
text_area: Rect,
|
||||
buf: &mut Buffer,
|
||||
mut line_composer: T,
|
||||
scroll: (u16, u16),
|
||||
) where
|
||||
T: LineComposer<'b>,
|
||||
{
|
||||
let mut y = 0;
|
||||
let mut i = 0;
|
||||
while let Some((current_line, current_line_width)) = line_composer.next_line() {
|
||||
if i >= scroll.0 {
|
||||
let cell_y = text_area.top().saturating_add(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, cell_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 += symbol.width() as u16;
|
||||
}
|
||||
y += 1;
|
||||
}
|
||||
i += 1;
|
||||
if y >= text_area.height {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> Widget for Paragraph<'a> {
|
||||
|
@ -158,40 +204,54 @@ impl<'a> Widget for Paragraph<'a> {
|
|||
// composers to operate on lines instead of a stream of graphemes.
|
||||
.chain(iter::once(StyledGrapheme {
|
||||
symbol: "\n",
|
||||
style: self.style,
|
||||
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(self.scroll.1);
|
||||
}
|
||||
line_composer
|
||||
};
|
||||
let mut y = 0;
|
||||
while let Some((current_line, current_line_width)) = line_composer.next_line() {
|
||||
if y >= self.scroll.0 {
|
||||
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 - self.scroll.0)
|
||||
.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 += symbol.width() as u16;
|
||||
match self.wrap {
|
||||
None => {
|
||||
let mut line_composer = LineTruncator::new(&mut styled, text_area.width);
|
||||
if let Alignment::Left = self.alignment {
|
||||
line_composer.set_horizontal_offset(self.scroll.1);
|
||||
}
|
||||
self.draw_lines(text_area, buf, line_composer, self.scroll);
|
||||
}
|
||||
y += 1;
|
||||
if y >= text_area.height + self.scroll.0 {
|
||||
break;
|
||||
Some(Wrap {
|
||||
trim,
|
||||
scroll_callback: None,
|
||||
}) => {
|
||||
let line_composer = WordWrapper::new(&mut styled, text_area.width, trim);
|
||||
self.draw_lines(text_area, buf, line_composer, self.scroll);
|
||||
}
|
||||
}
|
||||
Some(Wrap {
|
||||
trim,
|
||||
ref mut scroll_callback,
|
||||
}) => {
|
||||
let mut line_composer = WordWrapper::new(&mut styled, text_area.width, trim);
|
||||
let mut lines = Vec::new();
|
||||
while let Some((current_line, current_line_width)) = line_composer.next_line() {
|
||||
lines.push((Vec::from(current_line), current_line_width));
|
||||
}
|
||||
let f = scroll_callback.take().unwrap();
|
||||
let scroll = f(text_area, lines.as_ref());
|
||||
self.draw_lines(text_area, buf, WrappedLines { lines, index: 0 }, scroll);
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
struct WrappedLines<'a> {
|
||||
lines: Vec<(Vec<StyledGrapheme<'a>>, u16)>,
|
||||
index: usize,
|
||||
}
|
||||
|
||||
impl<'a> LineComposer<'a> for WrappedLines<'a> {
|
||||
fn next_line(&mut self) -> Option<(&[StyledGrapheme<'a>], u16)> {
|
||||
if self.index >= self.lines.len() {
|
||||
return None;
|
||||
}
|
||||
let (line, width) = &self.lines[self.index];
|
||||
self.index += 1;
|
||||
Some((&line, *width))
|
||||
}
|
||||
}
|
||||
|
|
|
@ -25,7 +25,7 @@ fn widgets_paragraph_can_wrap_its_content() {
|
|||
let paragraph = Paragraph::new(text)
|
||||
.block(Block::default().borders(Borders::ALL))
|
||||
.alignment(alignment)
|
||||
.wrap(Wrap { trim: true });
|
||||
.wrap(Wrap::default());
|
||||
f.render_widget(paragraph, size);
|
||||
})
|
||||
.unwrap();
|
||||
|
@ -91,7 +91,7 @@ fn widgets_paragraph_renders_double_width_graphemes() {
|
|||
let text = vec![Spans::from(s)];
|
||||
let paragraph = Paragraph::new(text)
|
||||
.block(Block::default().borders(Borders::ALL))
|
||||
.wrap(Wrap { trim: true });
|
||||
.wrap(Wrap::default());
|
||||
f.render_widget(paragraph, size);
|
||||
})
|
||||
.unwrap();
|
||||
|
@ -123,7 +123,7 @@ fn widgets_paragraph_renders_mixed_width_graphemes() {
|
|||
let text = vec![Spans::from(s)];
|
||||
let paragraph = Paragraph::new(text)
|
||||
.block(Block::default().borders(Borders::ALL))
|
||||
.wrap(Wrap { trim: true });
|
||||
.wrap(Wrap::default());
|
||||
f.render_widget(paragraph, size);
|
||||
})
|
||||
.unwrap();
|
||||
|
|
Loading…
Reference in a new issue