use anyhow::Result;
use crossterm::{
event::{self, Event, KeyCode, KeyModifiers},
execute,
terminal::{self, EnterAlternateScreen, LeaveAlternateScreen},
};
use ratatui::{
backend::CrosstermBackend,
layout::{Constraint, Direction, Layout},
Frame, Terminal,
};
use stillo_core::document::BrowsePage;
use url::Url;
use crate::widgets::{
content_view::ContentView,
link_bar::{render_hint_bar, render_input_bar},
status_bar::render_status_bar,
};
pub enum TuiResult {
Navigate(Url),
Dump,
Quit,
}
enum BrowserMode {
Normal,
SearchInput(String),
UrlInput(String),
}
pub struct TuiBrowser {
page: BrowsePage,
view: ContentView,
mode: BrowserMode,
search_matches: Vec<usize>,
search_cursor: usize,
history: Vec<(BrowsePage, usize)>,
}
impl TuiBrowser {
pub fn new(page: BrowsePage) -> Self {
let view = ContentView::from_document(&page.doc, &page.links);
Self {
page,
view,
mode: BrowserMode::Normal,
search_matches: Vec::new(),
search_cursor: 0,
history: Vec::new(),
}
}
pub fn load_page(&mut self, page: BrowsePage) {
let offset = self.view.scroll_offset;
let old_page = std::mem::replace(&mut self.page, page);
self.history.push((old_page, offset));
self.view = ContentView::from_document(&self.page.doc, &self.page.links);
self.mode = BrowserMode::Normal;
self.search_matches.clear();
self.search_cursor = 0;
}
pub fn markdown(&self) -> &str {
&self.page.markdown
}
pub fn run(&mut self) -> Result<TuiResult> {
terminal::enable_raw_mode()?;
let mut stdout = std::io::stdout();
execute!(stdout, EnterAlternateScreen)?;
let backend = CrosstermBackend::new(stdout);
let mut terminal = Terminal::new(backend)?;
let result = self.event_loop(&mut terminal);
terminal::disable_raw_mode()?;
execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
terminal.show_cursor()?;
result
}
fn event_loop(
&mut self,
terminal: &mut Terminal<CrosstermBackend<std::io::Stdout>>,
) -> Result<TuiResult> {
loop {
let viewport_height = terminal.size()?.height.saturating_sub(2) as usize;
terminal.draw(|f| self.render(f))?;
if let Event::Key(key) = event::read()? {
if let Some(result) = self.handle_key(key.code, key.modifiers, viewport_height) {
return Ok(result);
}
}
}
}
fn handle_key(
&mut self,
code: KeyCode,
modifiers: KeyModifiers,
viewport_height: usize,
) -> Option<TuiResult> {
match &self.mode {
BrowserMode::Normal => self.handle_normal(code, modifiers, viewport_height),
BrowserMode::SearchInput(_) => self.handle_search_input(code),
BrowserMode::UrlInput(_) => self.handle_url_input(code),
}
}
fn handle_normal(
&mut self,
code: KeyCode,
modifiers: KeyModifiers,
viewport_height: usize,
) -> Option<TuiResult> {
match code {
KeyCode::Char('q') | KeyCode::Esc => return Some(TuiResult::Quit),
KeyCode::Char('j') | KeyCode::Down => self.view.scroll_down(1, viewport_height),
KeyCode::Char('k') | KeyCode::Up => self.view.scroll_up(1),
KeyCode::Char('d') if modifiers == KeyModifiers::CONTROL => {
self.view.scroll_down(viewport_height / 2, viewport_height);
}
KeyCode::Char('u') if modifiers == KeyModifiers::CONTROL => {
self.view.scroll_up(viewport_height / 2);
}
KeyCode::PageDown => self.view.scroll_down(viewport_height, viewport_height),
KeyCode::PageUp => self.view.scroll_up(viewport_height),
KeyCode::Char('g') | KeyCode::Home => self.view.scroll_to_top(),
KeyCode::Char('G') | KeyCode::End => self.view.scroll_to_bottom(viewport_height),
KeyCode::Tab => self.view.next_link(),
KeyCode::BackTab => self.view.prev_link(),
KeyCode::Enter => {
if let Some(url) = self.view.selected_link_url(&self.page.links) {
return Some(TuiResult::Navigate(url.clone()));
}
}
KeyCode::Char('B') => {
if let Some((prev_page, prev_offset)) = self.history.pop() {
let mut prev_view = ContentView::from_document(&prev_page.doc, &prev_page.links);
prev_view.scroll_offset = prev_offset;
self.page = prev_page;
self.view = prev_view;
self.search_matches.clear();
}
}
KeyCode::Char('U') => {
self.mode = BrowserMode::UrlInput(String::new());
}
KeyCode::Char('/') => {
self.mode = BrowserMode::SearchInput(String::new());
}
KeyCode::Char('n') => {
if !self.search_matches.is_empty() {
self.search_cursor =
(self.search_cursor + 1) % self.search_matches.len();
self.view.scroll_offset = self.search_matches[self.search_cursor];
}
}
KeyCode::Char('d') => return Some(TuiResult::Dump),
_ => {}
}
None
}
fn handle_search_input(&mut self, code: KeyCode) -> Option<TuiResult> {
match code {
KeyCode::Esc => {
self.mode = BrowserMode::Normal;
}
KeyCode::Enter => {
let query = match &self.mode {
BrowserMode::SearchInput(q) => q.clone(),
_ => unreachable!(),
};
self.search_matches = self.view.search(&query);
self.search_cursor = 0;
if let Some(&line_idx) = self.search_matches.first() {
self.view.scroll_offset = line_idx;
}
self.mode = BrowserMode::Normal;
}
KeyCode::Backspace => {
if let BrowserMode::SearchInput(ref mut q) = self.mode {
q.pop();
}
}
KeyCode::Char(c) => {
if let BrowserMode::SearchInput(ref mut q) = self.mode {
q.push(c);
}
}
_ => {}
}
None
}
fn handle_url_input(&mut self, code: KeyCode) -> Option<TuiResult> {
match code {
KeyCode::Esc => {
self.mode = BrowserMode::Normal;
}
KeyCode::Enter => {
let input = match &self.mode {
BrowserMode::UrlInput(s) => s.clone(),
_ => unreachable!(),
};
self.mode = BrowserMode::Normal;
if let Ok(url) = input.parse::<Url>() {
return Some(TuiResult::Navigate(url));
}
if let Ok(url) = format!("https://{}", input).parse::<Url>() {
return Some(TuiResult::Navigate(url));
}
}
KeyCode::Backspace => {
if let BrowserMode::UrlInput(ref mut s) = self.mode {
s.pop();
}
}
KeyCode::Char(c) => {
if let BrowserMode::UrlInput(ref mut s) = self.mode {
s.push(c);
}
}
_ => {}
}
None
}
fn render(&self, f: &mut Frame) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(1), Constraint::Min(0), Constraint::Length(1), ])
.split(f.area());
render_status_bar(f, chunks[0], &self.page.title, self.page.url.as_str());
let viewport_height = chunks[1].height as usize;
let visible_lines: Vec<_> = self
.view
.lines
.iter()
.skip(self.view.scroll_offset)
.take(viewport_height)
.cloned()
.collect();
let content_widget = ratatui::widgets::Paragraph::new(visible_lines)
.style(ratatui::style::Style::default());
f.render_widget(content_widget, chunks[1]);
match &self.mode {
BrowserMode::Normal => {
render_hint_bar(
f,
chunks[2],
self.view.link_positions.len(),
self.view.selected_link,
);
}
BrowserMode::SearchInput(q) => {
render_input_bar(f, chunks[2], "/", q);
}
BrowserMode::UrlInput(s) => {
render_input_bar(f, chunks[2], "URL: ", s);
}
}
}
}