use crossterm::event::{KeyCode, KeyModifiers};
use edlearn_client::content::ContentPayload;
use log::debug;
use ratatui::{
prelude::{Margin, Rect},
style::{Color, Modifier, Style, Stylize},
text::{Line, Text},
widgets::{Paragraph, Scrollbar, ScrollbarOrientation, ScrollbarState, Wrap},
Frame,
};
use crate::{
event::Event,
store::{ContentIdx, DownloadState, Store},
styles::error_text,
};
use super::{Action, Pane};
#[derive(Default)]
pub enum Document {
#[default]
Welcome,
Downloads,
Content(ContentIdx),
}
#[derive(Default)]
pub struct Viewer {
show: Document,
y_offset: u16,
jump_y_offset: u16,
cached_render: Option<Paragraph<'static>>,
displayed_links: Vec<String>,
link_idx_max_digits: usize,
link_entry_acc: usize,
link_entry_digits: Option<usize>,
}
impl Viewer {
pub fn show(&mut self, d: Document) {
self.show = d;
self.y_offset = 0;
self.cached_render = None;
self.set_displayed_links(vec![]);
}
fn render(&mut self, store: &Store) -> Paragraph<'static> {
if let Some(p) = self.cached_render.clone() {
return p;
}
match self.show {
Document::Content(idx) => {
if let Some(p) = self.render_content(store, idx) {
self.cached_render = Some(p.clone());
p
} else {
Paragraph::new("Loading...")
}
}
Document::Welcome => {
let p = welcome_message();
self.cached_render = Some(p.clone());
p
}
Document::Downloads => Paragraph::new(
store
.download_queue()
.flat_map(|(req, state)| {
vec![
vec![
req.orig_filename.to_string().blue(),
match &state {
DownloadState::Queued => " - Queued".gray(),
DownloadState::InProgress(p) => {
format!(" - {:.2}%", p * 100.0).blue()
}
DownloadState::Completed => " - Completed".green(),
DownloadState::Errored(e) => format!(" - {e}").red(),
},
]
.into(),
vec![req.dest.to_string().gray()].into(),
]
})
.collect::<Vec<Line>>(),
),
}
}
fn render_content(
&mut self,
store: &Store,
content_idx: ContentIdx,
) -> Option<Paragraph<'static>> {
let content = store.content(content_idx);
match &content.payload {
ContentPayload::Page => {
let Some(text) = store.page_text(content_idx) else {
store.request_page_text(content_idx);
return None;
};
let (text, links) = bbml::render(text);
self.set_displayed_links(links);
Some(text)
}
ContentPayload::Link(l) => Some(Paragraph::new(format!("Link to {}. Open with b", l))),
ContentPayload::Folder => Some(Paragraph::new("Folder")),
ContentPayload::File {
file_name,
mime_type,
..
} => Some(Paragraph::new(vec![
Line::styled(
file_name.clone(),
Style::new().fg(Color::Blue).add_modifier(Modifier::BOLD),
),
Line::raw(mime_type.clone()),
Line::raw("Open with b"),
])),
ContentPayload::Other => Some(Paragraph::new(vec![
Line::styled(
"Unknown content type.",
Style::new().fg(Color::Red).add_modifier(Modifier::BOLD),
),
Line::raw("File an issue, and in the meantime open in your browser with b."),
])),
}
}
fn set_displayed_links(&mut self, links: Vec<String>) {
self.link_idx_max_digits = if !links.is_empty() {
links.len().ilog10() as usize + 1
} else {
0
};
self.displayed_links = links;
self.link_entry_acc = 0;
self.link_entry_digits = None;
debug!(
"displaying {} links (max digits = {})",
self.displayed_links.len(),
self.link_idx_max_digits
);
}
fn open_referenced_link(&mut self) -> Action {
let Some(href) = self.displayed_links.get(self.link_entry_acc) else {
return Action::Flash(error_text("No link found".to_string()));
};
if let Err(e) = open::that(href) {
return Action::Flash(error_text(format!("Error opening in browser: {e}")));
}
self.link_entry_acc = 0;
self.link_entry_digits = None;
Action::Flash(format!("Opened {href} in browser").into())
}
}
impl Pane for Viewer {
fn draw(&mut self, store: &Store, frame: &mut Frame, area: Rect) {
let rendered = self.render(store);
let line_count = rendered.line_count(area.width);
self.jump_y_offset = area.height / 2;
let max_y_offset = (line_count as u16).saturating_sub(area.height);
self.y_offset = self.y_offset.min(max_y_offset);
let scrollbar = Scrollbar::default()
.orientation(ScrollbarOrientation::VerticalRight)
.begin_symbol(Some("↑"))
.end_symbol(Some("↓"));
let mut scrollbar_state =
ScrollbarState::new(max_y_offset as usize).position(self.y_offset as usize);
frame.render_widget(
rendered.scroll((self.y_offset, 0)),
area.inner(&Margin {
vertical: 0,
horizontal: 1,
}),
);
frame.render_stateful_widget(scrollbar, area, &mut scrollbar_state);
}
fn handle_event(&mut self, store: &mut Store, event: Event) -> Action {
let Event::Key(key) = event else {
return Action::None;
};
match key.code {
KeyCode::Char('q') | KeyCode::Esc => {
self.link_entry_digits = None;
return Action::FocusNavigation;
}
KeyCode::Char('g') => self.y_offset = 0,
KeyCode::Char('G') => self.y_offset = u16::MAX,
KeyCode::Char('j') => self.y_offset += 1,
KeyCode::Char('k') => self.y_offset = self.y_offset.saturating_sub(1),
KeyCode::Char('u') | KeyCode::Char('U')
if key.modifiers.contains(KeyModifiers::CONTROL) =>
{
self.y_offset = self.y_offset.saturating_sub(self.jump_y_offset)
}
KeyCode::Char('d') | KeyCode::Char('D')
if key.modifiers.contains(KeyModifiers::CONTROL) =>
{
self.y_offset += self.jump_y_offset
}
KeyCode::Char('b') => {
self.link_entry_digits = None;
if let Document::Content(content_idx) = self.show {
let content = store.content(content_idx);
if let Err(e) = open::that(content.browser_link()) {
return Action::Flash(error_text(format!("Error opening in browser: {e}")));
}
};
}
KeyCode::Char('d') => {
if let Document::Content(content_idx) = self.show {
store.download_content(content_idx);
return Action::Flash("Queued for download".into());
};
}
KeyCode::Char('f') => {
if self.link_idx_max_digits > 0 {
self.link_entry_acc = 0;
self.link_entry_digits = Some(0);
return Action::Flash(
"Go to... (type the number after the link)"
.to_string()
.into(),
);
}
}
KeyCode::Enter if self.link_entry_digits.is_some() => {
return self.open_referenced_link();
}
KeyCode::Char(n) if n.is_ascii_digit() => {
if let Some(idx) = self.link_entry_digits.as_mut() {
self.link_entry_acc *= 10;
self.link_entry_acc += n.to_digit(10).unwrap() as usize;
*idx += 1;
debug!(
"entered {idx} digits / {}. acc = {}",
self.link_idx_max_digits, self.link_entry_acc
);
if *idx == self.link_idx_max_digits {
return self.open_referenced_link();
} else {
return Action::Flash(
format!(
"Go to... {} (RET to open, or keep typing numbers)",
self.link_entry_acc
)
.into(),
);
}
}
}
_ => (),
};
self.link_entry_digits = None;
Action::None
}
}
fn welcome_message() -> Paragraph<'static> {
Paragraph::new(Into::<Text>::into(vec![
vec!["Welcome to learn-tui!\n".blue().bold()].into(),
vec![
"Use ".into(),
"j/k or ↓/↑".blue(),
" to navigate up and down, then ".into(),
"Enter".blue(),
" to select an item.".into(),
]
.into(),
vec![
"When an item is selected, you can scroll the viewer pane using ".into(),
"j/k ↓/↑ g/G PgUp/PgDn".blue(),
" and go back to the navigation pane with ".into(),
"q".blue(),
".".into(),
]
.into(),
vec![
"Links have ".into(),
"blue".blue(),
" text and a number after them. Hit ".into(),
"f".blue(),
" then type the number to open them.".into(),
]
.into(),
vec![
"At any point, use ".into(),
"b".blue(),
" to try to open the selected item in your browser, or ".into(),
"d".blue(),
" to try to download it.".into(),
]
.into(),
vec!["Use ".into(), "Ctrl-C".blue(), " to quit.".into()].into(),
]))
.wrap(Wrap { trim: false })
}