verso-reader 0.1.0

A terminal EPUB reader with vim navigation, a Kindle-style library, and Markdown highlight export
Documentation
use crate::store::library_view::Row as LibRow;
use ratatui::{
    layout::{Constraint, Rect},
    style::{Color, Modifier, Style},
    widgets::{Block, Borders, Row, Table, TableState},
    Frame,
};

pub struct LibraryView<'a> {
    pub rows: &'a [LibRow],
    pub selected: usize,
    pub sort_label: &'a str,
    pub filter_label: &'a str,
}

impl<'a> LibraryView<'a> {
    pub fn render(&self, f: &mut Frame, area: Rect) {
        let header = Row::new(vec!["Title", "Author", "Pages", "Progress", "Left", "Last"])
            .style(Style::default().add_modifier(Modifier::BOLD));
        let body: Vec<Row> = self
            .rows
            .iter()
            .map(|r| {
                let pct = r.progress_pct.unwrap_or(0.0);
                let bar = render_bar(pct, 6);
                Row::new(vec![
                    r.title.clone(),
                    r.author.clone().unwrap_or_default(),
                    r.pages.map(|p| p.to_string()).unwrap_or_default(),
                    format!("{bar} {pct:>3.0}%"),
                    format_time_left(r.time_left_s),
                    r.last_read_at.clone().unwrap_or_else(|| "".into()),
                ])
            })
            .collect();

        let widths = [
            Constraint::Min(20),
            Constraint::Length(16),
            Constraint::Length(6),
            Constraint::Length(13),
            Constraint::Length(6),
            Constraint::Length(10),
        ];
        let title = format!(
            " verso · Library · {} books · {} ",
            self.rows.len(),
            reading_count(self.rows)
        );
        let block = Block::default().title(title).borders(Borders::ALL);
        let mut state = TableState::default();
        state.select(Some(self.selected));
        let table = Table::new(body, widths)
            .header(header)
            .block(block)
            .highlight_style(Style::default().bg(Color::DarkGray));
        f.render_stateful_widget(table, area, &mut state);
    }
}

fn reading_count(rows: &[LibRow]) -> usize {
    rows.iter()
        .filter(|r| r.finished_at.is_none() && r.progress_pct.unwrap_or(0.0) > 0.0)
        .count()
}

fn render_bar(pct: f32, width: u16) -> String {
    let filled = (pct / 100.0 * width as f32).round() as usize;
    let empty = (width as usize).saturating_sub(filled);
    "".repeat(filled) + &"".repeat(empty)
}

fn format_time_left(s: Option<u64>) -> String {
    match s {
        None => "".into(),
        Some(secs) if secs < 3600 => format!("{}m", secs / 60),
        Some(secs) => format!("{}h", secs / 3600),
    }
}