use std::cell::RefCell;
use crate::core::buffer::Buffer;
use crate::core::rect::Rect;
use crate::interaction::{
HitRegion, InteractionLayer, ScrollRowHit, SelectableSpan, SelectionGroup, TextRange,
WidgetAction, WidgetId, WidgetRole,
};
use crate::sanitize;
use crate::scroll_state::ScrollState;
use crate::style::Style;
use crate::theme::ThemeTokens;
use crate::widgets::paragraph::{render_line, Alignment, Line, Paragraph, Text, WrapMode};
use crate::widgets::Widget;
#[derive(Debug, Clone, PartialEq)]
struct WrappedTextCache {
width: u16,
text: Text,
style: Style,
wrap: WrapMode,
alignment: Alignment,
rows: Vec<Line>,
}
#[derive(Debug, Clone)]
pub struct ScrollableText {
pub text: Text,
pub style: Style,
pub wrap: WrapMode,
pub scroll: ScrollState,
pub alignment: Alignment,
pub selectable: bool,
pub region_id: Option<WidgetId>,
cache: RefCell<Option<WrappedTextCache>>,
}
impl ScrollableText {
pub fn new(text: Text) -> Self {
Self {
text,
style: Style::new(),
wrap: WrapMode::Word { trim: true },
scroll: ScrollState::new(),
alignment: Alignment::Left,
selectable: false,
region_id: None,
cache: RefCell::new(None),
}
}
pub fn raw(content: &str) -> Self {
Self::new(Text::raw(content))
}
pub fn with_style(mut self, style: Style) -> Self {
self.style = style;
self.invalidate();
self
}
pub fn with_theme_tokens(self, tokens: ThemeTokens) -> Self {
self.with_style(tokens.text_style())
}
pub fn wrap(mut self, mode: WrapMode) -> Self {
self.wrap = mode;
self.invalidate();
self
}
pub fn with_scroll(mut self, scroll: ScrollState) -> Self {
self.scroll = scroll;
self
}
pub fn alignment(mut self, alignment: Alignment) -> Self {
self.alignment = alignment;
self.invalidate();
self
}
pub fn with_selectable(mut self, selectable: bool) -> Self {
self.selectable = selectable;
self
}
pub fn with_region_id(mut self, id: impl Into<WidgetId>) -> Self {
self.region_id = Some(id.into());
self
}
pub fn set_text(&mut self, text: Text) {
self.text = text;
self.invalidate();
}
pub fn push_line(&mut self, line: Line) {
self.text.push_line(line);
self.invalidate();
}
pub fn invalidate(&self) {
*self.cache.borrow_mut() = None;
}
pub fn rows_for_width(&self, width: u16) -> Vec<Line> {
self.with_rows_for_width(width, |rows| rows.to_vec())
}
pub fn with_rows_for_width<R>(&self, width: u16, f: impl FnOnce(&[Line]) -> R) -> R {
let needs_rebuild = match self.cache.borrow().as_ref() {
Some(cached) => {
cached.width != width
|| cached.text != self.text
|| cached.style != self.style
|| cached.wrap != self.wrap
|| cached.alignment != self.alignment
}
None => true,
};
if needs_rebuild {
let rows = Paragraph::from_text(self.text.clone())
.with_style(self.style)
.wrap(self.wrap)
.alignment(self.alignment)
.wrapped_rows(width);
*self.cache.borrow_mut() = Some(WrappedTextCache {
width,
text: self.text.clone(),
style: self.style,
wrap: self.wrap,
alignment: self.alignment,
rows,
});
}
let cache = self.cache.borrow();
let rows = &cache.as_ref().expect("scroll text cache must exist").rows;
f(rows)
}
pub fn rendered_height(&self, width: u16) -> usize {
self.with_rows_for_width(width, |rows| rows.len())
}
pub fn render_with_interaction(
&self,
buffer: &mut Buffer,
area: Rect,
layer: &mut InteractionLayer,
) {
self.render(buffer, area);
if area.is_empty() {
return;
}
let region_id = self
.region_id
.clone()
.unwrap_or_else(|| WidgetId::new("scrollable-text"));
layer.push_region(
HitRegion::new(region_id.clone(), area)
.with_role(WidgetRole::Text)
.with_label("scrollable text"),
);
self.with_rows_for_width(area.width, |rows| {
let mut scroll = self.scroll;
scroll.set_bounds(rows.len(), area.height as usize);
let selection_group = SelectionGroup::new(format!("{}:text", region_id.as_ref()));
let mut row_hits = Vec::new();
for (screen_row, row_idx) in scroll.visible_range().enumerate() {
let y = area.y as usize + screen_row;
if y >= area.bottom() as usize {
break;
}
let row_id = WidgetId::new(format!("{}:row:{}", region_id.as_ref(), row_idx));
let span_id = format!("{}:span:{}", region_id.as_ref(), row_idx);
let row_area = Rect::new(area.x, y as u16, area.width, 1);
let text = line_plain_text(&rows[row_idx]);
layer.push_region(
HitRegion::new(row_id.clone(), row_area)
.with_role(WidgetRole::TextSpan)
.with_label(text.clone())
.with_action(WidgetAction::Select)
.with_row(row_idx)
.with_selection_group(selection_group.clone())
.with_z_index(1),
);
row_hits.push(
ScrollRowHit::new(row_id.clone(), row_idx)
.with_span_id(span_id.clone())
.with_item_id(row_id)
.with_wrapped_continuation(row_idx > 0),
);
if self.selectable {
let display = sanitize::sanitize_str(&text, area.width as usize);
let width = sanitize::str_display_width(&display).min(area.width as usize);
layer.push_selectable_span(
SelectableSpan::new(
span_id,
display.clone(),
0..display.len(),
Rect::new(area.x, y as u16, width as u16, 1),
)
.with_source_id(region_id.clone())
.with_group(selection_group.clone())
.with_logical_range(TextRange::new(
row_idx,
0,
sanitize::str_display_width(&display),
)),
);
}
}
layer.push_scroll_region(region_id, area, scroll.offset, row_hits);
});
}
}
fn line_plain_text(line: &Line) -> String {
line.spans
.iter()
.map(|span| span.content.as_str())
.collect::<Vec<_>>()
.join("")
}
impl From<Text> for ScrollableText {
fn from(value: Text) -> Self {
Self::new(value)
}
}
impl From<&str> for ScrollableText {
fn from(value: &str) -> Self {
Self::raw(value)
}
}
impl Widget for ScrollableText {
fn render(&self, buffer: &mut Buffer, area: Rect) {
if area.is_empty() {
return;
}
self.with_rows_for_width(area.width, |rows| {
let mut scroll = self.scroll;
scroll.set_bounds(rows.len(), area.height as usize);
for (screen_row, row_idx) in scroll.visible_range().enumerate() {
let y = area.y as usize + screen_row;
if y >= area.bottom() as usize {
break;
}
render_line(buffer, &rows[row_idx], area, y, 0);
}
});
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::core::color::Color;
#[test]
fn scrollable_text_caches_wrapped_rows_by_width() {
let text = ScrollableText::raw("alpha beta gamma").wrap(WrapMode::Word { trim: true });
assert_eq!(text.rendered_height(8), 3);
assert_eq!(text.rendered_height(80), 1);
}
#[test]
fn scrollable_text_renders_styled_lines() {
let text = Text::new(vec![Line::styled("hello", Style::new().fg(Color::CYAN))]);
let widget = ScrollableText::new(text);
let mut buffer = Buffer::new(8, 1);
widget.render(&mut buffer, Rect::new(0, 0, 8, 1));
assert_eq!(buffer.get(0, 0).unwrap().ch, 'h');
assert_eq!(buffer.get(0, 0).unwrap().fg, Color::CYAN);
}
#[test]
fn scrollable_text_registers_scroll_hits_and_spans() {
let widget = ScrollableText::raw("alpha beta gamma")
.wrap(WrapMode::Word { trim: true })
.with_selectable(true)
.with_region_id("transcript:body");
let mut buffer = Buffer::new(8, 3);
let mut layer = InteractionLayer::new();
widget.render_with_interaction(&mut buffer, Rect::new(0, 0, 8, 3), &mut layer);
let hit = layer.scroll_hit_test(1, 1).unwrap();
assert_eq!(hit.region_id.as_ref(), "transcript:body");
assert_eq!(hit.logical_row, 1);
assert!(layer.selectable_at(1, 0).is_some());
}
}