use std::{cell::Cell, io};
use crossterm::event::{KeyCode, KeyEvent};
use ratatui::{
Frame,
layout::{Constraint, Direction, Layout, Rect},
text::{Line, Span},
widgets::Paragraph,
};
use super::{Link, StyleSheet};
use crate::{
chrome,
layout::centered_fraction,
modal::ModalSignal,
nav,
overlay::{self, PopupFlow, popup},
scroll, style,
terminal::Tui,
text::truncate,
theme::Skin,
};
pub struct MarkdownView {
source: String,
sheet: Option<StyleSheet>,
offset: Cell<usize>,
total: Cell<usize>,
viewport: Cell<usize>,
links: Vec<Link>,
active_link: usize,
decor: Option<chrome::BoxDecor>,
}
impl MarkdownView {
pub fn new(source: impl Into<String>) -> Self {
let source = source.into();
let links = super::links(&source);
Self {
source,
sheet: None,
offset: Cell::new(0),
total: Cell::new(0),
viewport: Cell::new(1),
links,
active_link: 0,
decor: None,
}
}
#[must_use]
pub fn boxed(mut self, decor: chrome::BoxDecor) -> Self {
self.decor = Some(decor);
self
}
#[must_use]
pub fn with_stylesheet(mut self, sheet: StyleSheet) -> Self {
self.sheet = Some(sheet);
self
}
pub fn set_source(&mut self, source: impl Into<String>) {
self.source = source.into();
self.links = super::links(&self.source);
self.active_link = 0;
self.offset.set(0);
}
pub fn links(&self) -> &[Link] {
&self.links
}
pub fn selected_link(&self) -> Option<&Link> {
self.links.get(self.active_link)
}
pub fn handle_key(&mut self, key: KeyEvent) -> bool {
let page = self.viewport.get().max(1);
let offset = self.offset.get();
match key.code {
KeyCode::Up | KeyCode::Char('k') => {
self.scroll_to(offset.saturating_sub(1));
}
KeyCode::Down | KeyCode::Char('j') => {
self.scroll_to(offset + 1);
}
KeyCode::PageUp => self.scroll_to(offset.saturating_sub(page)),
KeyCode::PageDown => self.scroll_to(offset + page),
KeyCode::Home => self.scroll_to(0),
KeyCode::End => self.scroll_to(usize::MAX),
KeyCode::Tab => self.cycle_link(1),
KeyCode::BackTab => self.cycle_link(-1),
_ => return false,
}
true
}
pub fn render(&self, frame: &mut Frame, area: Rect, skin: &Skin) {
let inner = match &self.decor {
Some(decor) => chrome::framed_decor(frame, area, skin, decor, ""),
None => area,
};
let width = (inner.width as usize).max(1);
let viewport = (inner.height as usize).max(1);
let sheet = self
.sheet
.clone()
.unwrap_or_else(|| StyleSheet::from_skin(skin));
let lines = super::render_block(&self.source, width, &sheet);
let total = lines.len();
self.total.set(total);
self.viewport.set(viewport);
let offset = self.offset.get().min(total.saturating_sub(viewport));
self.offset.set(offset);
let visible: Vec<Line> =
lines.into_iter().skip(offset).take(viewport).collect();
frame.render_widget(Paragraph::new(visible), inner);
scroll::render_scrollbar(
frame,
inner,
skin,
nav::ScrollView {
total,
offset,
viewport,
},
);
if let Some(decor) = &self.decor
&& matches!(decor.badge, chrome::Badge::Auto)
{
chrome::render_badge(frame, area, skin, &self.percent_badge());
}
}
fn percent_badge(&self) -> String {
let percent = nav::scroll_percent(nav::ScrollView {
total: self.total.get(),
offset: self.offset.get(),
viewport: self.viewport.get(),
});
format!("{percent}%")
}
fn scroll_to(&self, offset: usize) {
let max_offset = self.total.get().saturating_sub(self.viewport.get());
self.offset.set(offset.min(max_offset));
}
fn cycle_link(&mut self, delta: isize) {
if !self.links.is_empty() {
self.active_link =
nav::cycle(self.active_link, self.links.len(), delta);
}
}
}
pub fn viewer(
tui: &mut Tui,
skin: &Skin,
title: &str,
source: &str,
render_bg: impl Fn(&mut Frame),
) -> io::Result<ModalSignal<Link>> {
let mut view = MarkdownView::new(source);
popup(
tui,
&mut view,
|area, _| centered_fraction(area, 3, 4, 40, 8),
|frame, _| render_bg(frame),
|frame, rect, view: &MarkdownView| {
let inner = overlay::framed(frame, rect, skin, title);
render_viewer_body(frame, inner, skin, view);
chrome::render_badge(frame, rect, skin, &view.percent_badge());
},
|view, key| match key.code {
KeyCode::Esc => PopupFlow::Cancelled,
KeyCode::Enter | KeyCode::Char('o') => match view.selected_link() {
Some(link) => PopupFlow::Done(link.clone()),
None => PopupFlow::Continue,
},
_ => {
view.handle_key(key);
PopupFlow::Continue
}
},
)
}
fn render_viewer_body(
frame: &mut Frame,
inner: Rect,
skin: &Skin,
view: &MarkdownView,
) {
let footer = u16::from(!view.links().is_empty());
let rows = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Min(1), Constraint::Length(footer)])
.split(inner);
view.render(frame, rows[0], skin);
if footer > 0 {
let width = rows[1].width as usize;
frame.render_widget(
Paragraph::new(link_hint_line(view, skin, width)),
rows[1],
);
}
}
fn link_hint_line(
view: &MarkdownView,
skin: &Skin,
width: usize,
) -> Line<'static> {
match view.selected_link() {
Some(link) => {
let text = truncate(
&format!(" \u{203a} {} \u{b7} {} ", link.text, link.url),
width,
);
Line::from(Span::styled(text, style::secondary(&skin.palette)))
}
None => Line::from(""),
}
}
#[cfg(test)]
mod tests {
use crossterm::event::KeyModifiers;
use super::*;
fn press(code: KeyCode) -> KeyEvent {
KeyEvent::new(code, KeyModifiers::NONE)
}
#[test]
fn scroll_is_clamped_to_the_content() {
let mut view = MarkdownView::new("a\n\nb\n\nc");
view.total.set(3);
view.viewport.set(2);
assert!(view.handle_key(press(KeyCode::Down)));
assert_eq!(view.offset.get(), 1);
view.handle_key(press(KeyCode::End));
assert_eq!(view.offset.get(), 1);
view.handle_key(press(KeyCode::Home));
assert_eq!(view.offset.get(), 0);
}
#[test]
fn tab_cycles_the_links() {
let mut view = MarkdownView::new("[one](http://a) and [two](http://b)");
assert_eq!(view.links().len(), 2);
assert_eq!(
view.selected_link().map(|l| l.url.as_str()),
Some("http://a")
);
view.handle_key(press(KeyCode::Tab));
assert_eq!(
view.selected_link().map(|l| l.url.as_str()),
Some("http://b")
);
view.handle_key(press(KeyCode::Tab));
assert_eq!(
view.selected_link().map(|l| l.url.as_str()),
Some("http://a")
);
}
#[test]
fn set_source_rebuilds_links_and_resets_scroll() {
let mut view = MarkdownView::new("no links here");
view.offset.set(5);
view.set_source("see [x](http://x)");
assert_eq!(view.offset.get(), 0);
assert_eq!(view.links().len(), 1);
}
}