pub mod render;
use ratatui::prelude::*;
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)]
#[cfg_attr(
feature = "serialization",
derive(serde::Serialize, serde::Deserialize)
)]
pub enum MarkdownRendererMessage {
ScrollUp,
ScrollDown,
PageUp(usize),
PageDown(usize),
Home,
End,
SetSource(String),
ToggleSource,
}
#[derive(Clone, Debug, Default, PartialEq)]
#[cfg_attr(
feature = "serialization",
derive(serde::Serialize, serde::Deserialize)
)]
pub struct MarkdownRendererState {
source: String,
scroll: ScrollState,
title: Option<String>,
show_source: bool,
}
impl MarkdownRendererState {
pub fn new() -> Self {
Self::default()
}
pub fn with_source(mut self, source: impl Into<String>) -> Self {
self.source = source.into();
self.scroll
.set_content_length(self.source.lines().count().max(1));
self
}
pub fn with_title(mut self, title: impl Into<String>) -> Self {
self.title = Some(title.into());
self
}
pub fn with_show_source(mut self, show: bool) -> Self {
self.show_source = show;
self
}
pub fn source(&self) -> &str {
&self.source
}
pub fn set_source(&mut self, source: impl Into<String>) {
self.source = source.into();
self.scroll = ScrollState::new(self.source.lines().count().max(1));
}
pub fn title(&self) -> Option<&str> {
self.title.as_deref()
}
pub fn set_title(&mut self, title: Option<String>) {
self.title = title;
}
pub fn show_source(&self) -> bool {
self.show_source
}
pub fn set_show_source(&mut self, show: bool) {
self.show_source = show;
}
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 update(&mut self, msg: MarkdownRendererMessage) {
MarkdownRenderer::update(self, msg);
}
}
pub struct MarkdownRenderer;
impl Component for MarkdownRenderer {
type State = MarkdownRendererState;
type Message = MarkdownRendererMessage;
type Output = ();
fn init() -> Self::State {
MarkdownRendererState::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();
let shift = key.modifiers.shift();
match key.code {
Key::Up | Key::Char('k') if !ctrl => Some(MarkdownRendererMessage::ScrollUp),
Key::Down | Key::Char('j') if !ctrl => Some(MarkdownRendererMessage::ScrollDown),
Key::PageUp => Some(MarkdownRendererMessage::PageUp(10)),
Key::PageDown => Some(MarkdownRendererMessage::PageDown(10)),
Key::Char('u') if ctrl => Some(MarkdownRendererMessage::PageUp(10)),
Key::Char('d') if ctrl => Some(MarkdownRendererMessage::PageDown(10)),
Key::Char('g') if key.modifiers.shift() => Some(MarkdownRendererMessage::End),
Key::Home | Key::Char('g') => Some(MarkdownRendererMessage::Home),
Key::End => Some(MarkdownRendererMessage::End),
Key::Char('s') if !ctrl && !shift => Some(MarkdownRendererMessage::ToggleSource),
_ => None,
}
}
fn update(state: &mut Self::State, msg: Self::Message) -> Option<Self::Output> {
match msg {
MarkdownRendererMessage::ScrollUp => {
state.scroll.scroll_up();
}
MarkdownRendererMessage::ScrollDown => {
state.scroll.scroll_down();
}
MarkdownRendererMessage::PageUp(n) => {
state.scroll.page_up(n);
}
MarkdownRendererMessage::PageDown(n) => {
state.scroll.page_down(n);
}
MarkdownRendererMessage::Home => {
state.scroll.scroll_to_start();
}
MarkdownRendererMessage::End => {
state.scroll.scroll_to_end();
}
MarkdownRendererMessage::SetSource(source) => {
state.source = source;
state.scroll = ScrollState::new(state.source.lines().count().max(1));
}
MarkdownRendererMessage::ToggleSource => {
state.show_source = !state.show_source;
state.scroll.set_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::Custom(
"MarkdownRenderer".to_string(),
))
.with_id("markdown_renderer")
.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 mut block = Block::default()
.borders(Borders::ALL)
.border_style(border_style);
if let Some(title) = &state.title {
let suffix = if state.show_source { " [source]" } else { "" };
block = block.title(format!("{}{}", title, suffix));
}
let inner = block.inner(ctx.area);
ctx.frame.render_widget(block, ctx.area);
if inner.height == 0 || inner.width == 0 {
return;
}
if state.show_source {
let text_style = if ctx.disabled {
ctx.theme.disabled_style()
} else {
ctx.theme.normal_style()
};
let total_lines = crate::util::wrapped_line_count(&state.source, inner.width as usize);
let visible = inner.height as usize;
let max_scroll = total_lines.saturating_sub(visible);
let effective_scroll = state.scroll.offset().min(max_scroll);
let paragraph = Paragraph::new(state.source.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 {
let mut bar_scroll = ScrollState::new(total_lines);
bar_scroll.set_viewport_height(visible);
bar_scroll.set_offset(effective_scroll);
crate::scroll::render_scrollbar_inside_border(
&bar_scroll,
ctx.frame,
ctx.area,
ctx.theme,
);
}
} else {
let rendered_lines = render::render_markdown(&state.source, inner.width, ctx.theme);
let total_lines = rendered_lines.len();
let visible = inner.height as usize;
let max_scroll = total_lines.saturating_sub(visible);
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, inner);
if total_lines > visible {
let mut bar_scroll = ScrollState::new(total_lines);
bar_scroll.set_viewport_height(visible);
bar_scroll.set_offset(effective_scroll);
crate::scroll::render_scrollbar_inside_border(
&bar_scroll,
ctx.frame,
ctx.area,
ctx.theme,
);
}
}
}
}
#[cfg(test)]
mod tests;