Skip to main content

scrin/widgets/
scroll_text.rs

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}