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;