Skip to main content

envision/component/styled_text/
mod.rs

1//! A rich text display component with semantic block elements and inline styling.
2//!
3//! [`StyledText`] renders structured content composed of headings, paragraphs,
4//! lists, code blocks, and horizontal rules with scrolling support. State is
5//! stored in [`StyledTextState`], updated via [`StyledTextMessage`], and
6//! produces [`StyledTextOutput`]. Content is built with [`StyledContent`],
7//! [`StyledBlock`], and [`StyledInline`].
8//!
9//!
10//! See also [`ScrollableText`](super::ScrollableText) for plain text display.
11//!
12//! # Example
13//!
14//! ```rust
15//! use envision::component::{
16//!     StyledText, StyledTextMessage, StyledTextState, Component,
17//!     styled_text::{StyledContent, StyledInline},
18//! };
19//!
20//! let content = StyledContent::new()
21//!     .heading(1, "Welcome")
22//!     .text("This is a styled paragraph.")
23//!     .bullet_list(vec![
24//!         vec![StyledInline::Bold("Important".to_string())],
25//!         vec![StyledInline::Plain("Normal item".to_string())],
26//!     ]);
27//!
28//! let mut state = StyledTextState::new()
29//!     .with_content(content);
30//!
31//! // Scroll down
32//! StyledText::update(&mut state, StyledTextMessage::ScrollDown);
33//! ```
34
35pub mod content;
36
37pub use content::{StyledBlock, StyledContent, StyledInline};
38
39use ratatui::prelude::*;
40use ratatui::widgets::{Block, Borders, Paragraph, Wrap};
41
42use super::{Component, ViewContext};
43use crate::input::{Event, KeyCode, KeyModifiers};
44use crate::theme::Theme;
45
46/// Messages that can be sent to a StyledText component.
47#[derive(Clone, Debug, PartialEq)]
48pub enum StyledTextMessage {
49    /// Scroll up by one line.
50    ScrollUp,
51    /// Scroll down by one line.
52    ScrollDown,
53    /// Scroll up by a page (given number of lines).
54    PageUp(usize),
55    /// Scroll down by a page (given number of lines).
56    PageDown(usize),
57    /// Scroll to the top.
58    Home,
59    /// Scroll to the bottom.
60    End,
61    /// Replace the content.
62    SetContent(StyledContent),
63}
64
65/// Output messages from a StyledText component.
66#[derive(Clone, Debug, PartialEq, Eq)]
67#[cfg_attr(
68    feature = "serialization",
69    derive(serde::Serialize, serde::Deserialize)
70)]
71pub enum StyledTextOutput {
72    /// The scroll position changed.
73    ScrollChanged(usize),
74}
75
76/// State for a StyledText component.
77///
78/// Contains the styled content, scroll position, and display options.
79///
80/// # Example
81///
82/// ```rust
83/// use envision::component::styled_text::{StyledContent, StyledInline};
84/// use envision::component::StyledTextState;
85///
86/// let content = StyledContent::new()
87///     .heading(1, "Title")
88///     .text("Body text");
89///
90/// let state = StyledTextState::new()
91///     .with_content(content)
92///     .with_title("Preview");
93///
94/// assert_eq!(state.title(), Some("Preview"));
95/// assert_eq!(state.scroll_offset(), 0);
96/// ```
97#[derive(Clone, Debug, PartialEq)]
98#[cfg_attr(
99    feature = "serialization",
100    derive(serde::Serialize, serde::Deserialize)
101)]
102pub struct StyledTextState {
103    #[cfg_attr(feature = "serialization", serde(skip))]
104    content: StyledContent,
105    scroll_offset: usize,
106    title: Option<String>,
107    show_border: bool,
108}
109
110impl Default for StyledTextState {
111    /// Creates a default styled text state with border enabled.
112    ///
113    /// # Example
114    ///
115    /// ```rust
116    /// use envision::component::StyledTextState;
117    ///
118    /// let state = StyledTextState::default();
119    /// assert_eq!(state.scroll_offset(), 0);
120    /// assert!(state.show_border());
121    /// ```
122    fn default() -> Self {
123        Self {
124            content: StyledContent::default(),
125            scroll_offset: 0,
126            title: None,
127            show_border: true,
128        }
129    }
130}
131
132impl StyledTextState {
133    /// Creates a new empty styled text state with a border.
134    ///
135    /// # Example
136    ///
137    /// ```rust
138    /// use envision::component::StyledTextState;
139    ///
140    /// let state = StyledTextState::new();
141    /// assert_eq!(state.scroll_offset(), 0);
142    /// assert!(state.show_border());
143    /// ```
144    pub fn new() -> Self {
145        Self::default()
146    }
147
148    /// Sets the content (builder pattern).
149    ///
150    /// # Example
151    ///
152    /// ```rust
153    /// use envision::component::styled_text::StyledContent;
154    /// use envision::component::StyledTextState;
155    ///
156    /// let content = StyledContent::new().text("Hello");
157    /// let state = StyledTextState::new().with_content(content);
158    /// assert!(!state.content().is_empty());
159    /// ```
160    pub fn with_content(mut self, content: StyledContent) -> Self {
161        self.content = content;
162        self
163    }
164
165    /// Sets the title (builder pattern).
166    ///
167    /// # Example
168    ///
169    /// ```rust
170    /// use envision::component::StyledTextState;
171    ///
172    /// let state = StyledTextState::new().with_title("Preview");
173    /// assert_eq!(state.title(), Some("Preview"));
174    /// ```
175    pub fn with_title(mut self, title: impl Into<String>) -> Self {
176        self.title = Some(title.into());
177        self
178    }
179
180    /// Sets whether to show the border (builder pattern).
181    ///
182    /// # Example
183    ///
184    /// ```rust
185    /// use envision::component::StyledTextState;
186    ///
187    /// let state = StyledTextState::new().with_show_border(false);
188    /// assert!(!state.show_border());
189    /// ```
190    pub fn with_show_border(mut self, show: bool) -> Self {
191        self.show_border = show;
192        self
193    }
194
195    // ---- Content accessors ----
196
197    /// Returns the styled content.
198    ///
199    /// # Example
200    ///
201    /// ```rust
202    /// use envision::component::StyledTextState;
203    /// use envision::component::styled_text::StyledContent;
204    ///
205    /// let content = StyledContent::new().text("Hello");
206    /// let state = StyledTextState::new().with_content(content);
207    /// assert!(!state.content().is_empty());
208    /// ```
209    pub fn content(&self) -> &StyledContent {
210        &self.content
211    }
212
213    /// Sets the styled content and resets scroll to top.
214    ///
215    /// # Example
216    ///
217    /// ```rust
218    /// use envision::component::StyledTextState;
219    /// use envision::component::styled_text::StyledContent;
220    ///
221    /// let mut state = StyledTextState::new();
222    /// state.set_content(StyledContent::new().text("New content"));
223    /// assert_eq!(state.scroll_offset(), 0);
224    /// assert!(!state.content().is_empty());
225    /// ```
226    pub fn set_content(&mut self, content: StyledContent) {
227        self.content = content;
228        self.scroll_offset = 0;
229    }
230
231    /// Returns the title.
232    ///
233    /// # Example
234    ///
235    /// ```rust
236    /// use envision::component::StyledTextState;
237    ///
238    /// let state = StyledTextState::new().with_title("Readme");
239    /// assert_eq!(state.title(), Some("Readme"));
240    ///
241    /// let state2 = StyledTextState::new();
242    /// assert_eq!(state2.title(), None);
243    /// ```
244    pub fn title(&self) -> Option<&str> {
245        self.title.as_deref()
246    }
247
248    /// Sets the title.
249    ///
250    /// # Example
251    ///
252    /// ```rust
253    /// use envision::component::StyledTextState;
254    ///
255    /// let mut state = StyledTextState::new();
256    /// state.set_title("Preview");
257    /// assert_eq!(state.title(), Some("Preview"));
258    /// ```
259    pub fn set_title(&mut self, title: impl Into<String>) {
260        self.title = Some(title.into());
261    }
262
263    /// Returns whether the border is shown.
264    ///
265    /// # Example
266    ///
267    /// ```rust
268    /// use envision::component::StyledTextState;
269    ///
270    /// let state = StyledTextState::new();
271    /// assert!(state.show_border());
272    /// ```
273    pub fn show_border(&self) -> bool {
274        self.show_border
275    }
276
277    /// Sets whether the border is shown.
278    ///
279    /// # Example
280    ///
281    /// ```rust
282    /// use envision::component::StyledTextState;
283    ///
284    /// let mut state = StyledTextState::new();
285    /// state.set_show_border(false);
286    /// assert!(!state.show_border());
287    /// ```
288    pub fn set_show_border(&mut self, show: bool) {
289        self.show_border = show;
290    }
291
292    // ---- Scroll accessors ----
293
294    /// Returns the current scroll offset.
295    ///
296    /// # Example
297    ///
298    /// ```rust
299    /// use envision::component::{StyledTextState, StyledTextMessage};
300    ///
301    /// let mut state = StyledTextState::new();
302    /// assert_eq!(state.scroll_offset(), 0);
303    /// state.update(StyledTextMessage::ScrollDown);
304    /// assert_eq!(state.scroll_offset(), 1);
305    /// ```
306    pub fn scroll_offset(&self) -> usize {
307        self.scroll_offset
308    }
309
310    // ---- State accessors ----
311
312    // ---- Instance methods ----
313
314    /// Updates the state with a message, returning any output.
315    ///
316    /// # Example
317    ///
318    /// ```rust
319    /// use envision::component::{StyledTextState, StyledTextMessage, StyledTextOutput};
320    ///
321    /// let mut state = StyledTextState::new();
322    /// let output = state.update(StyledTextMessage::ScrollDown);
323    /// assert_eq!(output, Some(StyledTextOutput::ScrollChanged(1)));
324    /// assert_eq!(state.scroll_offset(), 1);
325    /// ```
326    pub fn update(&mut self, msg: StyledTextMessage) -> Option<StyledTextOutput> {
327        StyledText::update(self, msg)
328    }
329}
330
331/// A rich text display component with semantic styling.
332///
333/// `StyledText` renders [`StyledContent`] with proper formatting for headings,
334/// paragraphs, lists, code blocks, and other semantic elements.
335///
336/// # Key Bindings
337///
338/// - `Up` / `k` — Scroll up one line
339/// - `Down` / `j` — Scroll down one line
340/// - `PageUp` / `Ctrl+u` — Scroll up half a page
341/// - `PageDown` / `Ctrl+d` — Scroll down half a page
342/// - `Home` / `g` — Scroll to top
343/// - `End` / `G` — Scroll to bottom
344///
345/// # Example
346///
347/// ```rust
348/// use envision::component::{
349///     StyledText, StyledTextMessage, StyledTextState, Component,
350///     styled_text::StyledContent,
351/// };
352///
353/// let content = StyledContent::new()
354///     .heading(1, "Title")
355///     .text("Hello, world!");
356///
357/// let mut state = StyledTextState::new()
358///     .with_content(content);
359///
360/// StyledText::update(&mut state, StyledTextMessage::ScrollDown);
361/// assert_eq!(state.scroll_offset(), 1);
362/// ```
363pub struct StyledText;
364
365impl Component for StyledText {
366    type State = StyledTextState;
367    type Message = StyledTextMessage;
368    type Output = StyledTextOutput;
369
370    fn init() -> Self::State {
371        StyledTextState::default()
372    }
373
374    fn handle_event(
375        _state: &Self::State,
376        event: &Event,
377        ctx: &ViewContext,
378    ) -> Option<Self::Message> {
379        if !ctx.focused || ctx.disabled {
380            return None;
381        }
382
383        let key = event.as_key()?;
384        let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
385        let shift = key.modifiers.contains(KeyModifiers::SHIFT);
386
387        match key.code {
388            KeyCode::Up | KeyCode::Char('k') if !ctrl => Some(StyledTextMessage::ScrollUp),
389            KeyCode::Down | KeyCode::Char('j') if !ctrl => Some(StyledTextMessage::ScrollDown),
390            KeyCode::PageUp => Some(StyledTextMessage::PageUp(10)),
391            KeyCode::PageDown => Some(StyledTextMessage::PageDown(10)),
392            KeyCode::Char('u') if ctrl => Some(StyledTextMessage::PageUp(10)),
393            KeyCode::Char('d') if ctrl => Some(StyledTextMessage::PageDown(10)),
394            KeyCode::Home | KeyCode::Char('g') if !shift => Some(StyledTextMessage::Home),
395            KeyCode::End | KeyCode::Char('G') if shift || key.code == KeyCode::End => {
396                Some(StyledTextMessage::End)
397            }
398            _ => None,
399        }
400    }
401
402    fn update(state: &mut Self::State, msg: Self::Message) -> Option<Self::Output> {
403        match msg {
404            StyledTextMessage::ScrollUp => {
405                if state.scroll_offset > 0 {
406                    state.scroll_offset -= 1;
407                    Some(StyledTextOutput::ScrollChanged(state.scroll_offset))
408                } else {
409                    None
410                }
411            }
412            StyledTextMessage::ScrollDown => {
413                state.scroll_offset += 1;
414                Some(StyledTextOutput::ScrollChanged(state.scroll_offset))
415            }
416            StyledTextMessage::PageUp(n) => {
417                let old = state.scroll_offset;
418                state.scroll_offset = state.scroll_offset.saturating_sub(n);
419                if state.scroll_offset != old {
420                    Some(StyledTextOutput::ScrollChanged(state.scroll_offset))
421                } else {
422                    None
423                }
424            }
425            StyledTextMessage::PageDown(n) => {
426                state.scroll_offset += n;
427                Some(StyledTextOutput::ScrollChanged(state.scroll_offset))
428            }
429            StyledTextMessage::Home => {
430                if state.scroll_offset > 0 {
431                    state.scroll_offset = 0;
432                    Some(StyledTextOutput::ScrollChanged(0))
433                } else {
434                    None
435                }
436            }
437            StyledTextMessage::End => {
438                state.scroll_offset = usize::MAX;
439                Some(StyledTextOutput::ScrollChanged(state.scroll_offset))
440            }
441            StyledTextMessage::SetContent(content) => {
442                state.content = content;
443                state.scroll_offset = 0;
444                None
445            }
446        }
447    }
448
449    fn view(state: &Self::State, frame: &mut Frame, area: Rect, theme: &Theme, ctx: &ViewContext) {
450        crate::annotation::with_registry(|reg| {
451            reg.register(
452                area,
453                crate::annotation::Annotation::new(crate::annotation::WidgetType::StyledText)
454                    .with_id("styled_text")
455                    .with_focus(ctx.focused)
456                    .with_disabled(ctx.disabled),
457            );
458        });
459
460        let border_style = if ctx.disabled {
461            theme.disabled_style()
462        } else if ctx.focused {
463            theme.focused_border_style()
464        } else {
465            theme.border_style()
466        };
467
468        let (inner, render_area) = if state.show_border {
469            let mut block = Block::default()
470                .borders(Borders::ALL)
471                .border_style(border_style);
472
473            if let Some(title) = &state.title {
474                block = block.title(title.as_str());
475            }
476
477            let inner = block.inner(area);
478            frame.render_widget(block, area);
479            (inner, inner)
480        } else {
481            (area, area)
482        };
483
484        if inner.height == 0 || inner.width == 0 {
485            return;
486        }
487
488        let rendered_lines = state.content.render_lines(inner.width, theme);
489        let total_visual_rows = visual_row_count(&rendered_lines, inner.width as usize);
490        let visible_lines = inner.height as usize;
491        let max_scroll = total_visual_rows.saturating_sub(visible_lines);
492        let effective_scroll = state.scroll_offset.min(max_scroll);
493
494        let text = Text::from(rendered_lines);
495        let paragraph = Paragraph::new(text)
496            .wrap(Wrap { trim: false })
497            .scroll((effective_scroll as u16, 0));
498
499        frame.render_widget(paragraph, render_area);
500    }
501}
502
503/// Counts the total visual rows that a set of rendered lines will occupy
504/// when word-wrapped at the given width.
505fn visual_row_count(lines: &[Line<'static>], width: usize) -> usize {
506    if width == 0 {
507        return lines.len();
508    }
509    lines
510        .iter()
511        .map(|line| {
512            let line_width = line.width();
513            if line_width == 0 {
514                1
515            } else {
516                line_width.div_ceil(width)
517            }
518        })
519        .sum()
520}
521
522#[cfg(test)]
523mod tests;