pub mod content;
pub use content::{StyledBlock, StyledContent, StyledInline};
use ratatui::prelude::*;
use ratatui::widgets::{Block, Borders, Paragraph, Wrap};
use super::{Component, EventContext, RenderContext};
use crate::input::{Event, Key};
#[derive(Clone, Debug, PartialEq)]
pub enum StyledTextMessage {
ScrollUp,
ScrollDown,
PageUp(usize),
PageDown(usize),
Home,
End,
SetContent(StyledContent),
}
#[derive(Clone, Debug, PartialEq, Eq)]
#[cfg_attr(
feature = "serialization",
derive(serde::Serialize, serde::Deserialize)
)]
pub enum StyledTextOutput {
ScrollChanged(usize),
}
#[derive(Clone, Debug, PartialEq)]
#[cfg_attr(
feature = "serialization",
derive(serde::Serialize, serde::Deserialize)
)]
pub struct StyledTextState {
#[cfg_attr(feature = "serialization", serde(skip))]
content: StyledContent,
scroll_offset: usize,
title: Option<String>,
show_border: bool,
}
impl Default for StyledTextState {
fn default() -> Self {
Self {
content: StyledContent::default(),
scroll_offset: 0,
title: None,
show_border: true,
}
}
}
impl StyledTextState {
pub fn new() -> Self {
Self::default()
}
pub fn with_content(mut self, content: StyledContent) -> Self {
self.content = content;
self
}
pub fn with_title(mut self, title: impl Into<String>) -> Self {
self.title = Some(title.into());
self
}
pub fn with_show_border(mut self, show: bool) -> Self {
self.show_border = show;
self
}
pub fn content(&self) -> &StyledContent {
&self.content
}
pub fn set_content(&mut self, content: StyledContent) {
self.content = content;
self.scroll_offset = 0;
}
pub fn title(&self) -> Option<&str> {
self.title.as_deref()
}
pub fn set_title(&mut self, title: impl Into<String>) {
self.title = Some(title.into());
}
pub fn show_border(&self) -> bool {
self.show_border
}
pub fn set_show_border(&mut self, show: bool) {
self.show_border = show;
}
pub fn scroll_offset(&self) -> usize {
self.scroll_offset
}
pub fn update(&mut self, msg: StyledTextMessage) -> Option<StyledTextOutput> {
StyledText::update(self, msg)
}
}
pub struct StyledText;
impl Component for StyledText {
type State = StyledTextState;
type Message = StyledTextMessage;
type Output = StyledTextOutput;
fn init() -> Self::State {
StyledTextState::default()
}
fn handle_event(
_state: &Self::State,
event: &Event,
ctx: &EventContext,
) -> Option<Self::Message> {
if !ctx.focused || ctx.disabled {
return None;
}
let key = event.as_key()?;
let ctrl = key.modifiers.ctrl();
match key.code {
Key::Up | Key::Char('k') if !ctrl => Some(StyledTextMessage::ScrollUp),
Key::Down | Key::Char('j') if !ctrl => Some(StyledTextMessage::ScrollDown),
Key::PageUp => Some(StyledTextMessage::PageUp(10)),
Key::PageDown => Some(StyledTextMessage::PageDown(10)),
Key::Char('u') if ctrl => Some(StyledTextMessage::PageUp(10)),
Key::Char('d') if ctrl => Some(StyledTextMessage::PageDown(10)),
Key::Char('g') if key.modifiers.shift() => Some(StyledTextMessage::End),
Key::Home | Key::Char('g') => Some(StyledTextMessage::Home),
Key::End => Some(StyledTextMessage::End),
_ => None,
}
}
fn update(state: &mut Self::State, msg: Self::Message) -> Option<Self::Output> {
match msg {
StyledTextMessage::ScrollUp => {
if state.scroll_offset > 0 {
state.scroll_offset -= 1;
Some(StyledTextOutput::ScrollChanged(state.scroll_offset))
} else {
None
}
}
StyledTextMessage::ScrollDown => {
state.scroll_offset += 1;
Some(StyledTextOutput::ScrollChanged(state.scroll_offset))
}
StyledTextMessage::PageUp(n) => {
let old = state.scroll_offset;
state.scroll_offset = state.scroll_offset.saturating_sub(n);
if state.scroll_offset != old {
Some(StyledTextOutput::ScrollChanged(state.scroll_offset))
} else {
None
}
}
StyledTextMessage::PageDown(n) => {
state.scroll_offset += n;
Some(StyledTextOutput::ScrollChanged(state.scroll_offset))
}
StyledTextMessage::Home => {
if state.scroll_offset > 0 {
state.scroll_offset = 0;
Some(StyledTextOutput::ScrollChanged(0))
} else {
None
}
}
StyledTextMessage::End => {
state.scroll_offset = usize::MAX;
Some(StyledTextOutput::ScrollChanged(state.scroll_offset))
}
StyledTextMessage::SetContent(content) => {
state.content = content;
state.scroll_offset = 0;
None
}
}
}
fn view(state: &Self::State, ctx: &mut RenderContext<'_, '_>) {
crate::annotation::with_registry(|reg| {
reg.register(
ctx.area,
crate::annotation::Annotation::new(crate::annotation::WidgetType::StyledText)
.with_id("styled_text")
.with_focus(ctx.focused)
.with_disabled(ctx.disabled),
);
});
let border_style = if ctx.disabled {
ctx.theme.disabled_style()
} else if ctx.focused {
ctx.theme.focused_border_style()
} else {
ctx.theme.border_style()
};
let (inner, render_area) = if state.show_border {
let mut block = Block::default()
.borders(Borders::ALL)
.border_style(border_style);
if let Some(title) = &state.title {
block = block.title(title.as_str());
}
let inner = block.inner(ctx.area);
ctx.frame.render_widget(block, ctx.area);
(inner, inner)
} else {
(ctx.area, ctx.area)
};
if inner.height == 0 || inner.width == 0 {
return;
}
let rendered_lines = state.content.render_lines(inner.width, ctx.theme);
let total_visual_rows = visual_row_count(&rendered_lines, inner.width as usize);
let visible_lines = inner.height as usize;
let max_scroll = total_visual_rows.saturating_sub(visible_lines);
let effective_scroll = state.scroll_offset.min(max_scroll);
let text = Text::from(rendered_lines);
let paragraph = Paragraph::new(text)
.wrap(Wrap { trim: false })
.scroll((effective_scroll as u16, 0));
ctx.frame.render_widget(paragraph, render_area);
}
}
fn visual_row_count(lines: &[Line<'static>], width: usize) -> usize {
if width == 0 {
return lines.len();
}
lines
.iter()
.map(|line| {
let line_width = line.width();
if line_width == 0 {
1
} else {
line_width.div_ceil(width)
}
})
.sum()
}
#[cfg(test)]
mod tests;