use crate::core::buffer::{Buffer, Cell};
use crate::core::color::Color;
use crate::core::rect::Rect;
use crate::sanitize::char_display_width;
use crate::style::{Modifier, Style};
use crate::widgets::Widget;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Alignment {
Left,
Center,
Right,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum WrapMode {
None,
Word { trim: bool },
Character { trim: bool },
}
#[derive(Debug, Clone, PartialEq)]
pub struct Span {
pub content: String,
pub style: Style,
}
impl Span {
pub fn raw(content: &str) -> Self {
Self {
content: content.to_string(),
style: Style::new(),
}
}
pub fn styled(content: &str, style: Style) -> Self {
Self {
content: content.to_string(),
style,
}
}
pub fn width(&self) -> usize {
display_width(&self.content)
}
}
impl From<&str> for Span {
fn from(value: &str) -> Self {
Span::raw(value)
}
}
impl From<String> for Span {
fn from(value: String) -> Self {
Self {
content: value,
style: Style::new(),
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct Line {
pub spans: Vec<Span>,
pub alignment: Alignment,
}
impl Line {
pub fn new(spans: Vec<Span>) -> Self {
Self {
spans,
alignment: Alignment::Left,
}
}
pub fn raw(content: &str) -> Self {
Self::new(vec![Span::raw(content)])
}
pub fn styled(content: &str, style: Style) -> Self {
Self::new(vec![Span::styled(content, style)])
}
pub fn with_alignment(mut self, alignment: Alignment) -> Self {
self.alignment = alignment;
self
}
pub fn width(&self) -> usize {
self.spans.iter().map(Span::width).sum()
}
fn is_blank(&self) -> bool {
self.spans.is_empty() || self.spans.iter().all(|span| span.content.is_empty())
}
}
impl From<&str> for Line {
fn from(value: &str) -> Self {
Line::raw(value)
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct Text {
pub lines: Vec<Line>,
}
impl Text {
pub fn new(lines: Vec<Line>) -> Self {
Self { lines }
}
pub fn raw(content: &str) -> Self {
let lines = content.split('\n').map(Line::raw).collect();
Self { lines }
}
pub fn push_line(&mut self, line: Line) {
self.lines.push(line);
}
}
impl From<&str> for Text {
fn from(value: &str) -> Self {
Text::raw(value)
}
}
#[derive(Debug, Clone)]
pub struct Paragraph {
pub text: Text,
pub style: Style,
pub wrap: WrapMode,
pub scroll: (u16, u16),
pub alignment: Alignment,
}
impl Paragraph {
pub fn new(text: &str) -> Self {
Self::from_text(Text::raw(text))
}
pub fn from_text(text: Text) -> Self {
Self {
text,
style: Style::new(),
wrap: WrapMode::None,
scroll: (0, 0),
alignment: Alignment::Left,
}
}
pub fn with_style(mut self, style: Style) -> Self {
self.style = style;
self
}
pub fn with_word_wrap(mut self, wrap: bool) -> Self {
self.wrap = if wrap {
WrapMode::Word { trim: true }
} else {
WrapMode::None
};
self
}
pub fn wrap(mut self, mode: WrapMode) -> Self {
self.wrap = mode;
self
}
pub fn scroll(mut self, x: u16, y: u16) -> Self {
self.scroll = (x, y);
self
}
pub fn with_scroll(self, x: u16, y: u16) -> Self {
self.scroll(x, y)
}
pub fn alignment(mut self, alignment: Alignment) -> Self {
self.alignment = alignment;
self
}
pub fn wrapped_rows(&self, width: u16) -> Vec<Line> {
let width = width as usize;
let mut rows = Vec::new();
for line in &self.text.lines {
let mut line = line.clone();
for span in &mut line.spans {
span.style = self.style.merge(&span.style);
}
let alignment = if line.alignment == Alignment::Left {
self.alignment
} else {
line.alignment
};
if width == 0 || line.is_blank() {
rows.push(Line::new(Vec::new()).with_alignment(alignment));
continue;
}
match self.wrap {
WrapMode::None => rows.push(line.with_alignment(alignment)),
WrapMode::Character { trim } => {
rows.extend(wrap_character(&line, width, trim, alignment));
}
WrapMode::Word { trim } => rows.extend(wrap_word(&line, width, trim, alignment)),
}
}
rows
}
pub fn rendered_height(&self, width: u16) -> usize {
self.wrapped_rows(width).len()
}
}
impl Widget for Paragraph {
fn render(&self, buffer: &mut Buffer, area: Rect) {
if area.width == 0 || area.height == 0 {
return;
}
let rows = self.wrapped_rows(area.width);
let start_y = self.scroll.1 as usize;
for (screen_row, row) in rows
.iter()
.skip(start_y)
.take(area.height as usize)
.enumerate()
{
let y = area.y as usize + screen_row;
render_line(buffer, row, area, y, self.scroll.0 as usize);
}
}
}
fn wrap_character(line: &Line, width: usize, trim: bool, alignment: Alignment) -> Vec<Line> {
let cells = styled_cells(line, trim);
rows_from_cells(&cells, width, alignment)
}
fn wrap_word(line: &Line, width: usize, trim: bool, alignment: Alignment) -> Vec<Line> {
let cells = styled_cells(line, trim);
if cells.is_empty() {
return vec![Line::new(Vec::new()).with_alignment(alignment)];
}
let mut rows = Vec::new();
let mut row = Vec::new();
let mut row_width = 0usize;
let mut word = Vec::new();
let mut word_width = 0usize;
for cell in cells {
let is_space = cell.ch == ' ' || cell.ch == '\t';
if is_space {
if !word.is_empty() {
push_word(
&mut rows,
&mut row,
&mut row_width,
&mut word,
&mut word_width,
width,
alignment,
);
}
if row_width + 1 > width {
rows.push(line_from_cells(std::mem::take(&mut row), alignment));
row_width = 0;
} else if !trim || row_width > 0 {
row.push(cell);
row_width += 1;
}
} else {
let w = char_display_width(cell.ch).max(1);
word_width += w;
word.push(cell);
}
}
if !word.is_empty() {
push_word(
&mut rows,
&mut row,
&mut row_width,
&mut word,
&mut word_width,
width,
alignment,
);
}
if !row.is_empty() || rows.is_empty() {
rows.push(line_from_cells(row, alignment));
}
rows
}
fn push_word(
rows: &mut Vec<Line>,
row: &mut Vec<StyledCell>,
row_width: &mut usize,
word: &mut Vec<StyledCell>,
word_width: &mut usize,
width: usize,
alignment: Alignment,
) {
if *word_width > width {
if !row.is_empty() {
rows.push(line_from_cells(std::mem::take(row), alignment));
*row_width = 0;
}
let chunks = split_cells_to_width(word, width);
let last_index = chunks.len().saturating_sub(1);
for chunk in chunks.iter().take(last_index) {
rows.push(line_from_cells(chunk.clone(), alignment));
}
*row = chunks.last().cloned().unwrap_or_default();
*row_width = row
.iter()
.map(|cell| char_display_width(cell.ch).max(1))
.sum();
word.clear();
} else if *row_width + *word_width > width && !row.is_empty() {
rows.push(line_from_cells(std::mem::take(row), alignment));
*row = std::mem::take(word);
*row_width = *word_width;
} else {
*row_width += *word_width;
row.append(word);
}
*word_width = 0;
}
fn split_cells_to_width(cells: &[StyledCell], width: usize) -> Vec<Vec<StyledCell>> {
let mut chunks = Vec::new();
let mut chunk = Vec::new();
let mut chunk_width = 0usize;
for cell in cells {
let w = char_display_width(cell.ch).max(1);
if chunk_width > 0 && chunk_width + w > width {
chunks.push(std::mem::take(&mut chunk));
chunk_width = 0;
}
chunk.push(cell.clone());
chunk_width += w;
}
if !chunk.is_empty() || chunks.is_empty() {
chunks.push(chunk);
}
chunks
}
#[derive(Debug, Clone, PartialEq)]
struct StyledCell {
ch: char,
style: Style,
}
fn styled_cells(line: &Line, trim: bool) -> Vec<StyledCell> {
let mut cells = Vec::new();
for span in &line.spans {
for ch in span.content.chars() {
if ch == '\n' || ch == '\r' {
continue;
}
cells.push(StyledCell {
ch: if ch.is_control() && ch != '\t' {
' '
} else {
ch
},
style: span.style,
});
}
}
if trim {
while cells.first().map(|c| c.ch.is_whitespace()).unwrap_or(false) {
cells.remove(0);
}
while cells.last().map(|c| c.ch.is_whitespace()).unwrap_or(false) {
cells.pop();
}
}
cells
}
fn rows_from_cells(cells: &[StyledCell], width: usize, alignment: Alignment) -> Vec<Line> {
if width == 0 {
return vec![Line::new(Vec::new()).with_alignment(alignment)];
}
let mut rows = Vec::new();
let mut row = Vec::new();
let mut row_width = 0usize;
for cell in cells {
let w = char_display_width(cell.ch).max(1);
if row_width > 0 && row_width + w > width {
rows.push(line_from_cells(std::mem::take(&mut row), alignment));
row_width = 0;
}
row.push(cell.clone());
row_width += w;
}
if !row.is_empty() || rows.is_empty() {
rows.push(line_from_cells(row, alignment));
}
rows
}
fn line_from_cells(cells: Vec<StyledCell>, alignment: Alignment) -> Line {
let mut spans: Vec<Span> = Vec::new();
for cell in cells {
if let Some(last) = spans.last_mut() {
if last.style == cell.style {
last.content.push(cell.ch);
continue;
}
}
spans.push(Span::styled(&cell.ch.to_string(), cell.style));
}
Line::new(spans).with_alignment(alignment)
}
fn render_line(buffer: &mut Buffer, line: &Line, area: Rect, y: usize, scroll_x: usize) {
let line_width = line.width();
let align_offset = match line.alignment {
Alignment::Left => 0,
Alignment::Center => (area.width as usize).saturating_sub(line_width) / 2,
Alignment::Right => (area.width as usize).saturating_sub(line_width),
};
let mut col = align_offset;
let right = area.width as usize;
for span in &line.spans {
for ch in span.content.chars() {
let w = char_display_width(ch).max(1);
if col + w <= scroll_x {
col += w;
continue;
}
if col.saturating_sub(scroll_x) >= right {
return;
}
let x = area.x as usize + col.saturating_sub(scroll_x);
let cell = cell_from_style(ch, span.style);
buffer.set(x, y, cell);
col += w;
}
}
}
fn cell_from_style(ch: char, style: Style) -> Cell {
let mut fg = style.fg_or_default();
if style.dim || style.add_modifier.contains(Modifier::DIM) {
fg = fg.dim(0.45);
}
let mut bg = style.bg;
if style.add_modifier.contains(Modifier::REVERSED) {
let old_fg = fg;
fg = bg.unwrap_or(Color::BLACK);
bg = Some(old_fg);
}
Cell {
ch,
fg,
bg,
bold: style.bold || style.add_modifier.contains(Modifier::BOLD),
italic: style.italic || style.add_modifier.contains(Modifier::ITALIC),
underlined: style.underlined || style.add_modifier.contains(Modifier::UNDERLINED),
}
}
fn display_width(s: &str) -> usize {
s.chars().map(char_display_width).sum()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn paragraph_renders_multiple_spans() {
let red = Color::rgb(255, 0, 0);
let blue = Color::rgb(0, 0, 255);
let text = Text::new(vec![Line::new(vec![
Span::styled("red", Style::new().fg(red)),
Span::styled("blue", Style::new().fg(blue)),
])]);
let mut buffer = Buffer::new(10, 1);
Paragraph::from_text(text).render(&mut buffer, Rect::new(0, 0, 10, 1));
assert_eq!(buffer.get(0, 0).unwrap().fg, red);
assert_eq!(buffer.get(3, 0).unwrap().fg, blue);
}
#[test]
fn paragraph_wrap_preserves_leading_spaces_when_not_trimmed() {
let p = Paragraph::new(" alpha beta").wrap(WrapMode::Word { trim: false });
let rows = p.wrapped_rows(8);
assert_eq!(rows[0].spans[0].content, " alpha ");
}
#[test]
fn paragraph_scroll_uses_wrapped_rows() {
let p = Paragraph::new("one two three four")
.wrap(WrapMode::Word { trim: true })
.scroll(0, 1);
let mut buffer = Buffer::new(8, 1);
p.render(&mut buffer, Rect::new(0, 0, 8, 1));
assert!(buffer.to_plain_string().starts_with("three"));
}
}