use ratatui::widgets::{Block, Borders, Paragraph, Wrap};
use super::{Component, EventContext, RenderContext};
use crate::input::{Event, Key};
use crate::scroll::ScrollState;
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum ScrollableTextMessage {
ScrollUp,
ScrollDown,
PageUp(usize),
PageDown(usize),
Home,
End,
SetContent(String),
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum ScrollableTextOutput {
ScrollChanged(usize),
}
#[derive(Clone, Debug, Default, PartialEq)]
#[cfg_attr(
feature = "serialization",
derive(serde::Serialize, serde::Deserialize)
)]
pub struct ScrollableTextState {
content: String,
scroll: ScrollState,
title: Option<String>,
}
impl ScrollableTextState {
pub fn new() -> Self {
Self::default()
}
pub fn with_content(mut self, content: impl Into<String>) -> Self {
self.content = content.into();
self.scroll
.set_content_length(self.content.lines().count().max(1));
self
}
pub fn with_title(mut self, title: impl Into<String>) -> Self {
self.title = Some(title.into());
self
}
pub fn content(&self) -> &str {
&self.content
}
pub fn set_content(&mut self, content: impl Into<String>) {
self.content = content.into();
self.scroll = ScrollState::new(self.content.lines().count().max(1));
}
pub fn append(&mut self, text: &str) {
self.content.push_str(text);
}
pub fn title(&self) -> Option<&str> {
self.title.as_deref()
}
pub fn set_title(&mut self, title: Option<String>) {
self.title = title;
}
pub fn scroll_offset(&self) -> usize {
self.scroll.offset()
}
pub fn set_scroll_offset(&mut self, offset: usize) {
self.scroll.set_offset(offset);
}
pub fn line_count(&self, width: usize) -> usize {
crate::util::wrapped_line_count(&self.content, width)
}
pub fn update(&mut self, msg: ScrollableTextMessage) -> Option<ScrollableTextOutput> {
ScrollableText::update(self, msg)
}
}
pub struct ScrollableText;
impl Component for ScrollableText {
type State = ScrollableTextState;
type Message = ScrollableTextMessage;
type Output = ScrollableTextOutput;
fn init() -> Self::State {
ScrollableTextState::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(ScrollableTextMessage::ScrollUp),
Key::Down | Key::Char('j') if !ctrl => Some(ScrollableTextMessage::ScrollDown),
Key::PageUp => Some(ScrollableTextMessage::PageUp(10)),
Key::PageDown => Some(ScrollableTextMessage::PageDown(10)),
Key::Char('u') if ctrl => Some(ScrollableTextMessage::PageUp(10)),
Key::Char('d') if ctrl => Some(ScrollableTextMessage::PageDown(10)),
Key::Char('g') if key.modifiers.shift() => Some(ScrollableTextMessage::End),
Key::Home | Key::Char('g') => Some(ScrollableTextMessage::Home),
Key::End => Some(ScrollableTextMessage::End),
_ => None,
}
}
fn update(state: &mut Self::State, msg: Self::Message) -> Option<Self::Output> {
match msg {
ScrollableTextMessage::ScrollUp => {
if state.scroll.scroll_up() {
Some(ScrollableTextOutput::ScrollChanged(state.scroll.offset()))
} else {
None
}
}
ScrollableTextMessage::ScrollDown => {
if state.scroll.scroll_down() {
Some(ScrollableTextOutput::ScrollChanged(state.scroll.offset()))
} else {
None
}
}
ScrollableTextMessage::PageUp(n) => {
if state.scroll.page_up(n) {
Some(ScrollableTextOutput::ScrollChanged(state.scroll.offset()))
} else {
None
}
}
ScrollableTextMessage::PageDown(n) => {
if state.scroll.page_down(n) {
Some(ScrollableTextOutput::ScrollChanged(state.scroll.offset()))
} else {
None
}
}
ScrollableTextMessage::Home => {
if state.scroll.scroll_to_start() {
Some(ScrollableTextOutput::ScrollChanged(0))
} else {
None
}
}
ScrollableTextMessage::End => {
if state.scroll.scroll_to_end() {
Some(ScrollableTextOutput::ScrollChanged(state.scroll.offset()))
} else {
None
}
}
ScrollableTextMessage::SetContent(content) => {
state.content = content;
state.scroll = ScrollState::new(state.content.lines().count().max(1));
None
}
}
}
fn view(state: &Self::State, ctx: &mut RenderContext<'_, '_>) {
crate::annotation::with_registry(|reg| {
reg.register(
ctx.area,
crate::annotation::Annotation::scrollable_text("scrollable_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 text_style = if ctx.disabled {
ctx.theme.disabled_style()
} else if ctx.focused {
ctx.theme.focused_style()
} else {
ctx.theme.normal_style()
};
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);
if inner.height == 0 || inner.width == 0 {
return;
}
let total_lines = state.line_count(inner.width as usize);
let visible_lines = inner.height as usize;
let max_scroll = total_lines.saturating_sub(visible_lines);
let effective_scroll = state.scroll.offset().min(max_scroll);
let paragraph = Paragraph::new(state.content.as_str())
.style(text_style)
.wrap(Wrap { trim: false })
.scroll((effective_scroll as u16, 0));
ctx.frame.render_widget(paragraph, inner);
if total_lines > visible_lines {
let mut bar_scroll = ScrollState::new(total_lines);
bar_scroll.set_viewport_height(visible_lines);
bar_scroll.set_offset(effective_scroll);
crate::scroll::render_scrollbar_inside_border(
&bar_scroll,
ctx.frame,
ctx.area,
ctx.theme,
);
}
}
}
#[cfg(test)]
mod tests;