Skip to main content

verso/ui/
library_app.rs

1use crate::{
2    library::{
3        scan,
4        watch::{self, LibraryEvent},
5    },
6    store::{
7        db::Db,
8        library_view::{list_rows, Filter, Row, Sort},
9    },
10    ui::{
11        library_view::LibraryView,
12        reader_app,
13        terminal::{self, Tui},
14    },
15};
16use anyhow::Result;
17use crossterm::event::{self, Event, KeyCode};
18use ratatui::layout::Rect;
19use std::collections::BTreeMap;
20use std::time::Duration;
21
22pub fn run(
23    db: &Db,
24    library_path: &std::path::Path,
25    keymap_overrides: &BTreeMap<String, Vec<String>>,
26) -> Result<()> {
27    let (watch_rx, _watcher_handle) = watch::spawn_watcher(library_path)?;
28
29    let mut term = terminal::enter()?;
30    let mut selected = 0usize;
31    let mut sort = Sort::LastRead;
32    let mut filter = Filter::All;
33
34    let res = loop_body(
35        &mut term,
36        db,
37        library_path,
38        &mut selected,
39        &mut sort,
40        &mut filter,
41        &watch_rx,
42        keymap_overrides,
43    );
44    terminal::leave(&mut term)?;
45    res
46}
47
48struct Details {
49    path: String,
50    added_at: String,
51    finished_at: Option<String>,
52    parse_error: Option<String>,
53    highlights_count: i64,
54    bookmarks_count: i64,
55}
56
57fn fetch_details(db: &Db, book_id: i64) -> Result<Details> {
58    let conn = db.conn()?;
59    let (path, added_at, finished_at, parse_error): (
60        String,
61        String,
62        Option<String>,
63        Option<String>,
64    ) = conn.query_row(
65        "SELECT path, added_at, finished_at, parse_error FROM books WHERE id = ?",
66        rusqlite::params![book_id],
67        |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?)),
68    )?;
69    let (highlights_count, bookmarks_count): (i64, i64) = conn.query_row(
70        "SELECT (SELECT COUNT(*) FROM highlights WHERE book_id = ?),
71                (SELECT COUNT(*) FROM bookmarks  WHERE book_id = ?)",
72        rusqlite::params![book_id, book_id],
73        |r| Ok((r.get(0)?, r.get(1)?)),
74    )?;
75    Ok(Details {
76        path,
77        added_at,
78        finished_at,
79        parse_error,
80        highlights_count,
81        bookmarks_count,
82    })
83}
84
85fn build_details_text(row: &Row, d: &Details) -> String {
86    let mut lines = Vec::<String>::new();
87    lines.push(format!("Title:       {}", row.title));
88    lines.push(format!(
89        "Author:      {}",
90        row.author.clone().unwrap_or_else(|| "—".into())
91    ));
92    lines.push(format!("Path:        {}", d.path));
93    lines.push(format!("Added:       {}", d.added_at));
94    lines.push(format!(
95        "Finished:    {}",
96        d.finished_at.clone().unwrap_or_else(|| "—".into())
97    ));
98    lines.push(format!("Highlights:  {}", d.highlights_count));
99    lines.push(format!("Bookmarks:   {}", d.bookmarks_count));
100    if let Some(e) = &d.parse_error {
101        lines.push(String::new());
102        lines.push(format!("Parse error: {e}"));
103    }
104    lines.push(String::new());
105    lines.push("[d / Esc to close]".into());
106    lines.join("\n")
107}
108
109#[allow(clippy::too_many_arguments)]
110fn loop_body(
111    term: &mut Tui,
112    db: &Db,
113    library_path: &std::path::Path,
114    selected: &mut usize,
115    sort: &mut Sort,
116    filter: &mut Filter,
117    watch_rx: &crossbeam_channel::Receiver<LibraryEvent>,
118    keymap_overrides: &BTreeMap<String, Vec<String>>,
119) -> Result<()> {
120    let mut details_open = false;
121    loop {
122        let rows: Vec<Row> = list_rows(&db.conn()?, *sort, *filter)?;
123        if !rows.is_empty() {
124            *selected = (*selected).min(rows.len() - 1);
125        }
126
127        let details: Option<Details> = if details_open {
128            rows.get(*selected)
129                .and_then(|r| fetch_details(db, r.book_id).ok())
130        } else {
131            None
132        };
133
134        term.draw(|f| {
135            let area = f.size();
136            LibraryView {
137                rows: &rows,
138                selected: *selected,
139                sort_label: "last-read",
140                filter_label: "all",
141            }
142            .render(f, area);
143
144            if let (true, Some(row), Some(d)) =
145                (details_open, rows.get(*selected), details.as_ref())
146            {
147                let panel = Rect {
148                    x: area.x + area.width / 5,
149                    y: area.y + area.height / 5,
150                    width: (area.width * 3 / 5).max(40),
151                    height: (area.height * 3 / 5).max(10),
152                };
153                let details_text = build_details_text(row, d);
154                f.render_widget(ratatui::widgets::Clear, panel);
155                let block = ratatui::widgets::Block::default()
156                    .title(" Details ")
157                    .borders(ratatui::widgets::Borders::ALL);
158                let para = ratatui::widgets::Paragraph::new(details_text).block(block);
159                f.render_widget(para, panel);
160            }
161        })?;
162
163        let mut needs_rescan = false;
164        while let Ok(_ev) = watch_rx.try_recv() {
165            needs_rescan = true;
166        }
167        if needs_rescan {
168            let _ = scan::scan_folder(library_path, db);
169        }
170
171        if event::poll(Duration::from_millis(200))? {
172            if let Event::Key(k) = event::read()? {
173                if details_open {
174                    match k.code {
175                        KeyCode::Char('d') | KeyCode::Esc => details_open = false,
176                        KeyCode::Char('j') | KeyCode::Down if *selected + 1 < rows.len() => {
177                            *selected += 1
178                        }
179                        KeyCode::Char('k') | KeyCode::Up if *selected > 0 => *selected -= 1,
180                        _ => {}
181                    }
182                } else {
183                    match k.code {
184                        KeyCode::Char('q') => return Ok(()),
185                        KeyCode::Char('j') | KeyCode::Down if *selected + 1 < rows.len() => {
186                            *selected += 1
187                        }
188                        KeyCode::Char('k') | KeyCode::Up if *selected > 0 => *selected -= 1,
189                        KeyCode::Char('s') => *sort = cycle_sort(*sort),
190                        KeyCode::Char('f') => *filter = cycle_filter(*filter),
191                        KeyCode::Char('d') => details_open = true,
192                        KeyCode::Esc => {}
193                        KeyCode::Enter => {
194                            if let Some(row) = rows.get(*selected) {
195                                let path: String = db.conn()?.query_row(
196                                    "SELECT path FROM books WHERE id = ?",
197                                    rusqlite::params![row.book_id],
198                                    |r| r.get(0),
199                                )?;
200                                terminal::leave(term)?;
201                                let reader_db = Db::open(db.location())?;
202                                reader_app::run_with_epub_and_db(
203                                    std::path::Path::new(&path),
204                                    &row.title,
205                                    Some(reader_db),
206                                    Some(row.book_id),
207                                    Some(keymap_overrides),
208                                )?;
209                                *term = terminal::enter()?;
210                            }
211                        }
212                        _ => {}
213                    }
214                }
215            }
216        }
217    }
218}
219
220fn cycle_sort(s: Sort) -> Sort {
221    use Sort::*;
222    match s {
223        LastRead => Title,
224        Title => Author,
225        Author => Progress,
226        Progress => Added,
227        Added => LastRead,
228    }
229}
230fn cycle_filter(f: Filter) -> Filter {
231    use Filter::*;
232    match f {
233        All => Reading,
234        Reading => Unread,
235        Unread => Finished,
236        Finished => Broken,
237        Broken => All,
238    }
239}