1use std::cell::RefCell;
2
3use crate::core::buffer::Buffer;
4use crate::core::rect::Rect;
5use crate::interaction::{
6 HitRegion, InteractionLayer, ScrollRowHit, SelectableSpan, SelectionGroup, TextRange,
7 WidgetAction, WidgetId, WidgetRole,
8};
9use crate::sanitize;
10use crate::scroll_state::ScrollState;
11use crate::style::Style;
12use crate::theme::ThemeTokens;
13use crate::widgets::paragraph::{render_line, Alignment, Line, Paragraph, Text, WrapMode};
14use crate::widgets::Widget;
15
16#[derive(Debug, Clone, PartialEq)]
17struct WrappedTextCache {
18 width: u16,
19 text: Text,
20 style: Style,
21 wrap: WrapMode,
22 alignment: Alignment,
23 rows: Vec<Line>,
24}
25
26#[derive(Debug, Clone)]
27pub struct ScrollableText {
28 pub text: Text,
29 pub style: Style,
30 pub wrap: WrapMode,
31 pub scroll: ScrollState,
32 pub alignment: Alignment,
33 pub selectable: bool,
34 pub region_id: Option<WidgetId>,
35 cache: RefCell<Option<WrappedTextCache>>,
36}
37
38impl ScrollableText {
39 pub fn new(text: Text) -> Self {
40 Self {
41 text,
42 style: Style::new(),
43 wrap: WrapMode::Word { trim: true },
44 scroll: ScrollState::new(),
45 alignment: Alignment::Left,
46 selectable: false,
47 region_id: None,
48 cache: RefCell::new(None),
49 }
50 }
51
52 pub fn raw(content: &str) -> Self {
53 Self::new(Text::raw(content))
54 }
55
56 pub fn with_style(mut self, style: Style) -> Self {
57 self.style = style;
58 self.invalidate();
59 self
60 }
61
62 pub fn with_theme_tokens(self, tokens: ThemeTokens) -> Self {
63 self.with_style(tokens.text_style())
64 }
65
66 pub fn wrap(mut self, mode: WrapMode) -> Self {
67 self.wrap = mode;
68 self.invalidate();
69 self
70 }
71
72 pub fn with_scroll(mut self, scroll: ScrollState) -> Self {
73 self.scroll = scroll;
74 self
75 }
76
77 pub fn alignment(mut self, alignment: Alignment) -> Self {
78 self.alignment = alignment;
79 self.invalidate();
80 self
81 }
82
83 pub fn with_selectable(mut self, selectable: bool) -> Self {
84 self.selectable = selectable;
85 self
86 }
87
88 pub fn with_region_id(mut self, id: impl Into<WidgetId>) -> Self {
89 self.region_id = Some(id.into());
90 self
91 }
92
93 pub fn set_text(&mut self, text: Text) {
94 self.text = text;
95 self.invalidate();
96 }
97
98 pub fn push_line(&mut self, line: Line) {
99 self.text.push_line(line);
100 self.invalidate();
101 }
102
103 pub fn invalidate(&self) {
104 *self.cache.borrow_mut() = None;
105 }
106
107 pub fn rows_for_width(&self, width: u16) -> Vec<Line> {
108 self.with_rows_for_width(width, |rows| rows.to_vec())
109 }
110
111 pub fn with_rows_for_width<R>(&self, width: u16, f: impl FnOnce(&[Line]) -> R) -> R {
112 let needs_rebuild = match self.cache.borrow().as_ref() {
113 Some(cached) => {
114 cached.width != width
115 || cached.text != self.text
116 || cached.style != self.style
117 || cached.wrap != self.wrap
118 || cached.alignment != self.alignment
119 }
120 None => true,
121 };
122 if needs_rebuild {
123 let rows = Paragraph::from_text(self.text.clone())
124 .with_style(self.style)
125 .wrap(self.wrap)
126 .alignment(self.alignment)
127 .wrapped_rows(width);
128 *self.cache.borrow_mut() = Some(WrappedTextCache {
129 width,
130 text: self.text.clone(),
131 style: self.style,
132 wrap: self.wrap,
133 alignment: self.alignment,
134 rows,
135 });
136 }
137 let cache = self.cache.borrow();
138 let rows = &cache.as_ref().expect("scroll text cache must exist").rows;
139 f(rows)
140 }
141
142 pub fn rendered_height(&self, width: u16) -> usize {
143 self.with_rows_for_width(width, |rows| rows.len())
144 }
145
146 pub fn render_with_interaction(
147 &self,
148 buffer: &mut Buffer,
149 area: Rect,
150 layer: &mut InteractionLayer,
151 ) {
152 self.render(buffer, area);
153 if area.is_empty() {
154 return;
155 }
156
157 let region_id = self
158 .region_id
159 .clone()
160 .unwrap_or_else(|| WidgetId::new("scrollable-text"));
161 layer.push_region(
162 HitRegion::new(region_id.clone(), area)
163 .with_role(WidgetRole::Text)
164 .with_label("scrollable text"),
165 );
166
167 self.with_rows_for_width(area.width, |rows| {
168 let mut scroll = self.scroll;
169 scroll.set_bounds(rows.len(), area.height as usize);
170 let selection_group = SelectionGroup::new(format!("{}:text", region_id.as_ref()));
171 let mut row_hits = Vec::new();
172 for (screen_row, row_idx) in scroll.visible_range().enumerate() {
173 let y = area.y as usize + screen_row;
174 if y >= area.bottom() as usize {
175 break;
176 }
177 let row_id = WidgetId::new(format!("{}:row:{}", region_id.as_ref(), row_idx));
178 let span_id = format!("{}:span:{}", region_id.as_ref(), row_idx);
179 let row_area = Rect::new(area.x, y as u16, area.width, 1);
180 let text = line_plain_text(&rows[row_idx]);
181 layer.push_region(
182 HitRegion::new(row_id.clone(), row_area)
183 .with_role(WidgetRole::TextSpan)
184 .with_label(text.clone())
185 .with_action(WidgetAction::Select)
186 .with_row(row_idx)
187 .with_selection_group(selection_group.clone())
188 .with_z_index(1),
189 );
190 row_hits.push(
191 ScrollRowHit::new(row_id.clone(), row_idx)
192 .with_span_id(span_id.clone())
193 .with_item_id(row_id)
194 .with_wrapped_continuation(row_idx > 0),
195 );
196 if self.selectable {
197 let display = sanitize::sanitize_str(&text, area.width as usize);
198 let width = sanitize::str_display_width(&display).min(area.width as usize);
199 layer.push_selectable_span(
200 SelectableSpan::new(
201 span_id,
202 display.clone(),
203 0..display.len(),
204 Rect::new(area.x, y as u16, width as u16, 1),
205 )
206 .with_source_id(region_id.clone())
207 .with_group(selection_group.clone())
208 .with_logical_range(TextRange::new(
209 row_idx,
210 0,
211 sanitize::str_display_width(&display),
212 )),
213 );
214 }
215 }
216 layer.push_scroll_region(region_id, area, scroll.offset, row_hits);
217 });
218 }
219}
220
221fn line_plain_text(line: &Line) -> String {
222 line.spans
223 .iter()
224 .map(|span| span.content.as_str())
225 .collect::<Vec<_>>()
226 .join("")
227}
228
229impl From<Text> for ScrollableText {
230 fn from(value: Text) -> Self {
231 Self::new(value)
232 }
233}
234
235impl From<&str> for ScrollableText {
236 fn from(value: &str) -> Self {
237 Self::raw(value)
238 }
239}
240
241impl Widget for ScrollableText {
242 fn render(&self, buffer: &mut Buffer, area: Rect) {
243 if area.is_empty() {
244 return;
245 }
246 self.with_rows_for_width(area.width, |rows| {
247 let mut scroll = self.scroll;
248 scroll.set_bounds(rows.len(), area.height as usize);
249 for (screen_row, row_idx) in scroll.visible_range().enumerate() {
250 let y = area.y as usize + screen_row;
251 if y >= area.bottom() as usize {
252 break;
253 }
254 render_line(buffer, &rows[row_idx], area, y, 0);
255 }
256 });
257 }
258}
259
260#[cfg(test)]
261mod tests {
262 use super::*;
263 use crate::core::color::Color;
264
265 #[test]
266 fn scrollable_text_caches_wrapped_rows_by_width() {
267 let text = ScrollableText::raw("alpha beta gamma").wrap(WrapMode::Word { trim: true });
268 assert_eq!(text.rendered_height(8), 3);
269 assert_eq!(text.rendered_height(80), 1);
270 }
271
272 #[test]
273 fn scrollable_text_renders_styled_lines() {
274 let text = Text::new(vec![Line::styled("hello", Style::new().fg(Color::CYAN))]);
275 let widget = ScrollableText::new(text);
276 let mut buffer = Buffer::new(8, 1);
277 widget.render(&mut buffer, Rect::new(0, 0, 8, 1));
278 assert_eq!(buffer.get(0, 0).unwrap().ch, 'h');
279 assert_eq!(buffer.get(0, 0).unwrap().fg, Color::CYAN);
280 }
281
282 #[test]
283 fn scrollable_text_registers_scroll_hits_and_spans() {
284 let widget = ScrollableText::raw("alpha beta gamma")
285 .wrap(WrapMode::Word { trim: true })
286 .with_selectable(true)
287 .with_region_id("transcript:body");
288 let mut buffer = Buffer::new(8, 3);
289 let mut layer = InteractionLayer::new();
290
291 widget.render_with_interaction(&mut buffer, Rect::new(0, 0, 8, 3), &mut layer);
292
293 let hit = layer.scroll_hit_test(1, 1).unwrap();
294 assert_eq!(hit.region_id.as_ref(), "transcript:body");
295 assert_eq!(hit.logical_row, 1);
296 assert!(layer.selectable_at(1, 0).is_some());
297 }
298}