Skip to main content

verso/ui/
reader_app.rs

1use anyhow::Result;
2use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyModifiers};
3use ratatui::layout::{Constraint, Direction, Layout, Rect};
4use rbook::Ebook;
5use std::path::{Path, PathBuf};
6use std::time::{Duration, Instant};
7
8use crate::{
9    library::epub_meta,
10    reader::{
11        anchor,
12        book::{self as readerbook, SpineData},
13        page::Page,
14        search::{self, SearchDirection},
15    },
16    store::{
17        bookmarks::{self, Bookmark},
18        db::Db,
19        highlights::{self, AnchorStatus, Highlight},
20        progress::{self, ProgressRow},
21    },
22    ui::{
23        chrome::{Chrome, ChromeState},
24        keymap::{
25            defaults,
26            table::{Dispatch, Keymap},
27            Action,
28        },
29        reader_view::ReaderView,
30        terminal::{self, Tui},
31    },
32};
33
34#[derive(Debug, Clone, Copy)]
35enum MarkMode {
36    Set,
37    Jump,
38}
39
40#[derive(Debug, Clone, Copy)]
41pub enum Mode {
42    Normal,
43    Visual { anchor_char_offset: usize },
44}
45
46/// Active modal overlay, if any.
47#[derive(Debug, Clone)]
48enum Modal {
49    Toc {
50        selected: usize,
51    },
52    Highlights {
53        items: Vec<Highlight>,
54        selected: usize,
55    },
56}
57
58pub struct ReaderApp {
59    pub pages: Vec<Page>,
60    pub page_idx: usize,
61    pub row_idx: usize,
62    pub column_width: u16,
63    pub theme: String,
64    pub chrome: Chrome,
65    pub title: String,
66    pub keymap: Keymap,
67    pub spine_hrefs: Vec<String>,
68    pub chapter_titles: Vec<String>,
69    pub current_spine: u32,
70    pub total_spines: u32,
71    pub epub_path: PathBuf,
72    pub book_id: Option<i64>,
73    pub db: Option<Db>,
74    pub mode: Mode,
75    pending_mark: Option<MarkMode>,
76    plain_text: String,
77    plain_text_chars: usize,
78    last_persist: Instant,
79    search_buffer: String,
80    search_mode: Option<SearchDirection>,
81    search_matches: Vec<usize>,
82    search_cursor: usize,
83    cmd_mode: Option<String>,
84    toast: Option<(String, Instant)>,
85    modal: Option<Modal>,
86    should_quit: bool,
87}
88
89const PROGRESS_PERSIST_INTERVAL: Duration = Duration::from_secs(5);
90const TOAST_TTL: Duration = Duration::from_secs(3);
91
92/// Simple wrapper for the `verso open <path>` CLI flow: opens an EPUB
93/// without a database/book-id and with default keymap bindings.
94pub fn run_with_epub(path: &Path) -> Result<()> {
95    let title = epub_meta::extract(path)
96        .map(|m| m.title)
97        .unwrap_or_else(|_| {
98            path.file_stem()
99                .and_then(|s| s.to_str())
100                .unwrap_or("Untitled")
101                .to_string()
102        });
103    run_with_epub_and_db(path, &title, None, None, None)
104}
105
106/// Primary reader entry point. Opens the EPUB at `path`, manages spine
107/// navigation internally, and persists progress/bookmarks/highlights if a
108/// database is supplied.
109pub fn run_with_epub_and_db(
110    path: &Path,
111    title: &str,
112    db: Option<Db>,
113    book_id: Option<i64>,
114    keymap_overrides: Option<&std::collections::BTreeMap<String, Vec<String>>>,
115) -> Result<()> {
116    let book = rbook::Epub::new(path)?;
117    let spine_hrefs = readerbook::spine_hrefs(&book)?;
118    let chapter_titles = readerbook::chapter_titles_from_book(&book);
119    let total_spines = spine_hrefs.len() as u32;
120
121    let entries = match keymap_overrides {
122        Some(user) => defaults::merge_with_user(user),
123        None => defaults::default_entries(),
124    };
125    let keymap = Keymap::from_config(&entries)?;
126
127    let mut term = terminal::enter()?;
128    let size = term.size()?;
129    let col = 68u16.min(size.width);
130
131    // Load spine 0 initially; `restore_progress` may reload a different spine.
132    let initial = if total_spines == 0 {
133        readerbook::load_spine_from_html("", col, size.height)
134    } else {
135        let html = book.read_file(&spine_hrefs[0])?;
136        readerbook::load_spine_from_html(&html, col, size.height)
137    };
138
139    let mut app = ReaderApp {
140        pages: initial.pages,
141        page_idx: 0,
142        row_idx: 0,
143        column_width: col,
144        theme: "dark".into(),
145        chrome: Chrome::new(Duration::from_millis(3000)),
146        title: title.to_string(),
147        keymap,
148        spine_hrefs,
149        chapter_titles,
150        current_spine: 0,
151        total_spines,
152        epub_path: path.to_path_buf(),
153        book_id,
154        db,
155        mode: Mode::Normal,
156        pending_mark: None,
157        plain_text: initial.plain_text,
158        plain_text_chars: initial.plain_text_chars,
159        last_persist: Instant::now(),
160        search_buffer: String::new(),
161        search_mode: None,
162        search_matches: Vec::new(),
163        search_cursor: 0,
164        cmd_mode: None,
165        toast: None,
166        modal: None,
167        should_quit: false,
168    };
169
170    restore_progress(&mut app);
171
172    let res = event_loop(&mut term, &mut app);
173    terminal::leave(&mut term)?;
174    res
175}
176
177/// Reopen the EPUB, load spine `idx`, repaginate, and reset page/search state.
178/// The caller is responsible for deciding what to do with `page_idx` afterwards
179/// (we reset to 0 here so chapter navigation lands on the first page).
180fn load_spine(app: &mut ReaderApp, idx: u32) -> Result<()> {
181    if idx >= app.total_spines {
182        return Ok(());
183    }
184    let book = rbook::Epub::new(&app.epub_path)?;
185    let data: SpineData =
186        readerbook::load_spine_data(&book, idx as usize, app.column_width, terminal_height()?)?;
187    app.pages = data.pages;
188    app.plain_text = data.plain_text;
189    app.plain_text_chars = data.plain_text_chars;
190    app.page_idx = 0;
191    app.current_spine = idx;
192    app.search_matches.clear();
193    app.search_cursor = 0;
194    Ok(())
195}
196
197/// Query the terminal for its current height. Used during `load_spine`
198/// to keep pagination in sync if the window has resized since open.
199fn terminal_height() -> Result<u16> {
200    let (_w, h) = crossterm::terminal::size()?;
201    Ok(h)
202}
203
204/// Restore progress: jump to the stored spine first, then page-seek.
205/// Clamps `spine_idx` into range if the EPUB was replaced with a shorter edition.
206fn restore_progress(app: &mut ReaderApp) {
207    let (Some(db), Some(book_id)) = (app.db.as_ref(), app.book_id) else {
208        return;
209    };
210    let Ok(conn) = db.conn() else {
211        return;
212    };
213    let Ok(Some(row)) = progress::load(&conn, book_id) else {
214        return;
215    };
216    drop(conn);
217
218    let clamped = if app.total_spines == 0 {
219        0
220    } else {
221        row.spine_idx.min(app.total_spines - 1)
222    };
223    if clamped != app.current_spine {
224        if let Err(e) = load_spine(app, clamped) {
225            tracing::warn!(error = %e, "restore_progress: load_spine failed");
226            return;
227        }
228    }
229
230    let target = row.char_offset as usize;
231    let mut best: Option<usize> = None;
232    for (idx, page) in app.pages.iter().enumerate() {
233        let Some(first) = page.rows.first() else {
234            continue;
235        };
236        if first.char_offset <= target {
237            best = Some(idx);
238        } else {
239            break;
240        }
241    }
242    if let Some(idx) = best {
243        app.page_idx = idx;
244    }
245}
246
247fn save_progress(app: &mut ReaderApp) {
248    let (Some(db), Some(book_id)) = (app.db.as_ref(), app.book_id) else {
249        return;
250    };
251    let Ok(mut conn) = db.conn() else {
252        return;
253    };
254    let char_offset = current_char_offset(app);
255    let percent = if app.plain_text_chars == 0 {
256        0.0
257    } else {
258        (char_offset as f32 / app.plain_text_chars as f32 * 100.0).clamp(0.0, 100.0)
259    };
260    let anchor_hash = anchor::anchor_hash(&app.plain_text, char_offset as usize);
261    let row = ProgressRow {
262        book_id,
263        spine_idx: app.current_spine,
264        char_offset,
265        anchor_hash,
266        percent,
267        time_read_s: 0,
268        words_read: 0,
269    };
270    let _ = progress::upsert(&mut conn, &row);
271    app.last_persist = Instant::now();
272}
273
274fn save_auto_bookmark(app: &mut ReaderApp) -> anyhow::Result<()> {
275    let Some(db) = app.db.as_ref() else {
276        return Ok(());
277    };
278    let Some(book_id) = app.book_id else {
279        return Ok(());
280    };
281    let co = current_char_offset(app);
282    let bm = Bookmark {
283        book_id,
284        mark: "\"".into(),
285        spine_idx: app.current_spine,
286        char_offset: co,
287        anchor_hash: anchor::anchor_hash(&app.plain_text, co as usize),
288    };
289    let mut conn = db.conn()?;
290    bookmarks::set_bookmark(&mut conn, &bm)?;
291    Ok(())
292}
293
294fn current_char_offset(app: &ReaderApp) -> u64 {
295    app.pages
296        .get(app.page_idx)
297        .and_then(|p| p.rows.first())
298        .map(|r| r.char_offset as u64)
299        .unwrap_or(0)
300}
301
302fn seek_to_offset(app: &mut ReaderApp, target: usize) {
303    let best = app
304        .pages
305        .iter()
306        .position(|p| p.rows.iter().any(|r| r.char_offset >= target))
307        .unwrap_or(app.page_idx);
308    app.page_idx = best;
309}
310
311fn set_toast(app: &mut ReaderApp, msg: impl Into<String>) {
312    app.toast = Some((msg.into(), Instant::now()));
313}
314
315fn event_loop(term: &mut Tui, app: &mut ReaderApp) -> Result<()> {
316    loop {
317        if app.should_quit {
318            break;
319        }
320        let now = Instant::now();
321        if now.duration_since(app.last_persist) >= PROGRESS_PERSIST_INTERVAL {
322            save_progress(app);
323        }
324        // Expire stale toasts before rendering.
325        if let Some((_, t)) = &app.toast {
326            if now.duration_since(*t) >= TOAST_TTL {
327                app.toast = None;
328            }
329        }
330
331        term.draw(|f| {
332            let area = f.size();
333            let _show_chrome = matches!(app.chrome.state(now), ChromeState::Visible);
334            let chunks = Layout::default()
335                .direction(Direction::Vertical)
336                .constraints([Constraint::Min(1), Constraint::Length(1)])
337                .split(area);
338            ReaderView {
339                page: app.pages.get(app.page_idx),
340                column_width: app.column_width,
341                theme: &app.theme,
342            }
343            .render(f, chunks[0]);
344            render_status(f, chunks[1], app);
345
346            if let Some(modal) = app.modal.clone() {
347                render_modal(f, area, app, &modal);
348            }
349        })?;
350
351        if event::poll(Duration::from_millis(100))? {
352            if let Event::Key(k) = event::read()? {
353                app.chrome.touch(Instant::now());
354
355                // Modals swallow input before anything else.
356                if app.modal.is_some() {
357                    handle_modal_key(app, k)?;
358                    continue;
359                }
360
361                // Handle pending mark follow-up letter (before keymap).
362                if let Some(mode) = app.pending_mark {
363                    if let KeyCode::Char(letter) = k.code {
364                        if letter.is_ascii_alphabetic() {
365                            handle_mark(mode, letter, app)?;
366                            app.pending_mark = None;
367                            continue;
368                        }
369                    }
370                    app.pending_mark = None;
371                    continue;
372                }
373
374                // Intercept search-prompt keys.
375                if let Some(dir) = app.search_mode {
376                    match k.code {
377                        KeyCode::Char(c) => {
378                            app.search_buffer.push(c);
379                        }
380                        KeyCode::Backspace => {
381                            app.search_buffer.pop();
382                        }
383                        KeyCode::Enter => {
384                            app.search_matches =
385                                search::find_matches(&app.plain_text, &app.search_buffer, dir);
386                            if !app.search_matches.is_empty() {
387                                let cur = current_char_offset(app) as usize;
388                                let idx = match dir {
389                                    SearchDirection::Forward => app
390                                        .search_matches
391                                        .iter()
392                                        .position(|&m| m >= cur)
393                                        .unwrap_or(0),
394                                    SearchDirection::Backward => app
395                                        .search_matches
396                                        .iter()
397                                        .rposition(|&m| m <= cur)
398                                        .unwrap_or(app.search_matches.len() - 1),
399                                };
400                                app.search_cursor = idx;
401                                let target = app.search_matches[idx];
402                                seek_to_offset(app, target);
403                            }
404                            app.search_mode = None;
405                        }
406                        KeyCode::Esc => {
407                            app.search_mode = None;
408                        }
409                        _ => {}
410                    }
411                    continue;
412                }
413
414                // Intercept command-prompt keys.
415                if app.cmd_mode.is_some() {
416                    handle_cmd_key(app, k)?;
417                    continue;
418                }
419
420                match app.keymap.feed(&key_to_raw(k)) {
421                    Dispatch::Fire(Action::MoveDown)
422                    | Dispatch::Fire(Action::PageDown)
423                    | Dispatch::Fire(Action::HalfPageDown) => {
424                        app.page_idx = (app.page_idx + 1).min(app.pages.len().saturating_sub(1));
425                    }
426                    Dispatch::Fire(Action::MoveUp)
427                    | Dispatch::Fire(Action::PageUp)
428                    | Dispatch::Fire(Action::HalfPageUp) => {
429                        app.page_idx = app.page_idx.saturating_sub(1);
430                    }
431                    Dispatch::Fire(Action::GotoTop) => app.page_idx = 0,
432                    Dispatch::Fire(Action::GotoBottom) => {
433                        app.page_idx = app.pages.len().saturating_sub(1)
434                    }
435                    Dispatch::Fire(Action::NextChapter)
436                        if app.current_spine + 1 < app.total_spines =>
437                    {
438                        if let Err(e) = load_spine(app, app.current_spine + 1) {
439                            set_toast(app, format!("chapter load failed: {e}"));
440                        }
441                    }
442                    Dispatch::Fire(Action::PrevChapter) if app.current_spine > 0 => {
443                        if let Err(e) = load_spine(app, app.current_spine - 1) {
444                            set_toast(app, format!("chapter load failed: {e}"));
445                        }
446                    }
447                    Dispatch::Fire(Action::QuitToLibrary) => match app.mode {
448                        Mode::Visual { .. } => app.mode = Mode::Normal,
449                        Mode::Normal => {
450                            save_progress(app);
451                            save_auto_bookmark(app)?;
452                            break;
453                        }
454                    },
455                    Dispatch::Fire(Action::VisualSelect) => {
456                        let off = current_char_offset(app) as usize;
457                        app.mode = Mode::Visual {
458                            anchor_char_offset: off,
459                        };
460                    }
461                    Dispatch::Fire(Action::YankHighlight) => {
462                        if let Mode::Visual { anchor_char_offset } = app.mode {
463                            let cur = current_char_offset(app) as usize;
464                            let (start, end) = if cur >= anchor_char_offset {
465                                (anchor_char_offset, cur)
466                            } else {
467                                (cur, anchor_char_offset)
468                            };
469                            save_highlight(app, start, end)?;
470                            app.mode = Mode::Normal;
471                        }
472                    }
473                    Dispatch::Fire(Action::ToggleTheme) => {
474                        app.theme = match app.theme.as_str() {
475                            "dark" => "sepia".into(),
476                            "sepia" => "light".into(),
477                            _ => "dark".into(),
478                        };
479                    }
480                    Dispatch::Fire(Action::MarkSetPrompt) => {
481                        app.pending_mark = Some(MarkMode::Set);
482                    }
483                    Dispatch::Fire(Action::MarkJumpPrompt) => {
484                        app.pending_mark = Some(MarkMode::Jump);
485                    }
486                    Dispatch::Fire(Action::BeginSearchFwd) => {
487                        app.search_mode = Some(SearchDirection::Forward);
488                        app.search_buffer.clear();
489                    }
490                    Dispatch::Fire(Action::BeginSearchBack) => {
491                        app.search_mode = Some(SearchDirection::Backward);
492                        app.search_buffer.clear();
493                    }
494                    Dispatch::Fire(Action::SearchNext) if !app.search_matches.is_empty() => {
495                        app.search_cursor = (app.search_cursor + 1) % app.search_matches.len();
496                        let target = app.search_matches[app.search_cursor];
497                        seek_to_offset(app, target);
498                    }
499                    Dispatch::Fire(Action::SearchPrev) if !app.search_matches.is_empty() => {
500                        let len = app.search_matches.len();
501                        app.search_cursor = (app.search_cursor + len - 1) % len;
502                        let target = app.search_matches[app.search_cursor];
503                        seek_to_offset(app, target);
504                    }
505                    Dispatch::Fire(Action::BeginCmd) => {
506                        app.cmd_mode = Some(String::new());
507                    }
508                    Dispatch::Fire(Action::ListHighlights) => {
509                        open_highlights_modal(app);
510                    }
511                    _ => {}
512                }
513            }
514        }
515    }
516    Ok(())
517}
518
519fn render_status(f: &mut ratatui::Frame, area: Rect, app: &ReaderApp) {
520    if app.search_mode.is_some() {
521        let prefix = match app.search_mode {
522            Some(SearchDirection::Backward) => "?",
523            _ => "/",
524        };
525        let status = format!("{prefix}{}", app.search_buffer);
526        f.render_widget(ratatui::widgets::Paragraph::new(status), area);
527        return;
528    }
529    if let Some(buf) = &app.cmd_mode {
530        let status = format!(":{buf}");
531        f.render_widget(ratatui::widgets::Paragraph::new(status), area);
532        return;
533    }
534    if let Some((msg, _)) = &app.toast {
535        f.render_widget(ratatui::widgets::Paragraph::new(msg.clone()), area);
536        return;
537    }
538    let mode_str = match app.mode {
539        Mode::Visual { .. } => " [VIS] ",
540        Mode::Normal => "",
541    };
542    let ch = chapter_label(app);
543    let status = format!(
544        "{} {} · {} · page {}/{} ",
545        mode_str,
546        app.title,
547        ch,
548        app.page_idx + 1,
549        app.pages.len()
550    );
551    f.render_widget(ratatui::widgets::Paragraph::new(status), area);
552}
553
554fn chapter_label(app: &ReaderApp) -> String {
555    let idx = app.current_spine as usize;
556    app.chapter_titles
557        .get(idx)
558        .cloned()
559        .unwrap_or_else(|| format!("Chapter {}", idx + 1))
560}
561
562fn handle_cmd_key(app: &mut ReaderApp, k: KeyEvent) -> Result<()> {
563    // Safe to unwrap: caller guards `app.cmd_mode.is_some()`.
564    match k.code {
565        KeyCode::Char(c) => {
566            if let Some(buf) = app.cmd_mode.as_mut() {
567                buf.push(c);
568            }
569        }
570        KeyCode::Backspace => {
571            if let Some(buf) = app.cmd_mode.as_mut() {
572                buf.pop();
573            }
574        }
575        KeyCode::Esc => {
576            app.cmd_mode = None;
577        }
578        KeyCode::Enter => {
579            let cmd = app.cmd_mode.take().unwrap_or_default();
580            dispatch_command(app, cmd.trim())?;
581        }
582        _ => {}
583    }
584    Ok(())
585}
586
587fn dispatch_command(app: &mut ReaderApp, cmd: &str) -> Result<()> {
588    match cmd {
589        "" => {}
590        "toc" => {
591            app.modal = Some(Modal::Toc {
592                selected: app.current_spine as usize,
593            });
594        }
595        "hl" => {
596            open_highlights_modal(app);
597        }
598        "export" => {
599            run_export(app);
600        }
601        "w" => match force_save_progress(app) {
602            Ok(()) => {}
603            Err(e) => set_toast(app, format!("write failed: {e}")),
604        },
605        "q" => {
606            save_progress(app);
607            save_auto_bookmark(app)?;
608            app.should_quit = true;
609        }
610        other => set_toast(app, format!("unknown command: {other}")),
611    }
612    Ok(())
613}
614
615fn force_save_progress(app: &mut ReaderApp) -> anyhow::Result<()> {
616    let (Some(db), Some(book_id)) = (app.db.as_ref(), app.book_id) else {
617        return Ok(());
618    };
619    let mut conn = db.conn()?;
620    let char_offset = current_char_offset(app);
621    let percent = if app.plain_text_chars == 0 {
622        0.0
623    } else {
624        (char_offset as f32 / app.plain_text_chars as f32 * 100.0).clamp(0.0, 100.0)
625    };
626    let anchor_hash = anchor::anchor_hash(&app.plain_text, char_offset as usize);
627    let row = ProgressRow {
628        book_id,
629        spine_idx: app.current_spine,
630        char_offset,
631        anchor_hash,
632        percent,
633        time_read_s: 0,
634        words_read: 0,
635    };
636    progress::upsert(&mut conn, &row)?;
637    app.last_persist = Instant::now();
638    Ok(())
639}
640
641fn run_export(app: &mut ReaderApp) {
642    let (Some(db), Some(book_id)) = (app.db.as_ref(), app.book_id) else {
643        return;
644    };
645    match export_highlights(db, book_id, app) {
646        Ok(path) => set_toast(app, format!("exported: {}", path.display())),
647        Err(e) => set_toast(app, format!("export failed: {e}")),
648    }
649}
650
651fn export_highlights(db: &Db, book_id: i64, app: &ReaderApp) -> anyhow::Result<PathBuf> {
652    let conn = db.conn()?;
653    let highs = highlights::list(&conn, book_id)?;
654    let meta = epub_meta::extract(&app.epub_path)?;
655    let now = time::OffsetDateTime::now_utc()
656        .format(&time::format_description::well_known::Iso8601::DEFAULT)?;
657    let ctx = crate::export::markdown::BookContext {
658        title: meta.title.clone(),
659        author: meta.author.clone(),
660        published: meta.published_at.clone(),
661        progress_pct: None,
662        source_path: app.epub_path.display().to_string(),
663        tags: vec![],
664        exported_at: now,
665    };
666    let md = crate::export::markdown::render(&ctx, &highs);
667    let export_dir = dirs_home_books_highlights();
668    let slug = crate::export::writer::slug_from_title(&meta.title);
669    crate::export::writer::write_export(&export_dir, &slug, &md)
670}
671
672fn dirs_home_books_highlights() -> PathBuf {
673    let tilde = shellexpand::tilde("~/Books/highlights").to_string();
674    PathBuf::from(tilde)
675}
676
677fn open_highlights_modal(app: &mut ReaderApp) {
678    let (Some(db), Some(book_id)) = (app.db.as_ref(), app.book_id) else {
679        set_toast(app, "no database — highlights unavailable");
680        return;
681    };
682    let conn = match db.conn() {
683        Ok(c) => c,
684        Err(e) => {
685            set_toast(app, format!("db open failed: {e}"));
686            return;
687        }
688    };
689    let items = match highlights::list(&conn, book_id) {
690        Ok(v) => v,
691        Err(e) => {
692            set_toast(app, format!("list failed: {e}"));
693            return;
694        }
695    };
696    app.modal = Some(Modal::Highlights { items, selected: 0 });
697}
698
699fn handle_modal_key(app: &mut ReaderApp, k: KeyEvent) -> Result<()> {
700    let modal = match app.modal.take() {
701        Some(m) => m,
702        None => return Ok(()),
703    };
704    match modal {
705        Modal::Toc { mut selected } => match k.code {
706            KeyCode::Esc | KeyCode::Char('q') => {
707                // close
708            }
709            KeyCode::Char('j') | KeyCode::Down => {
710                if selected + 1 < app.chapter_titles.len() {
711                    selected += 1;
712                }
713                app.modal = Some(Modal::Toc { selected });
714            }
715            KeyCode::Char('k') | KeyCode::Up => {
716                selected = selected.saturating_sub(1);
717                app.modal = Some(Modal::Toc { selected });
718            }
719            KeyCode::Enter => {
720                if let Err(e) = load_spine(app, selected as u32) {
721                    set_toast(app, format!("load failed: {e}"));
722                }
723            }
724            _ => {
725                app.modal = Some(Modal::Toc { selected });
726            }
727        },
728        Modal::Highlights {
729            mut items,
730            mut selected,
731        } => match k.code {
732            KeyCode::Esc | KeyCode::Char('q') => {
733                // close
734            }
735            KeyCode::Char('j') | KeyCode::Down => {
736                if selected + 1 < items.len() {
737                    selected += 1;
738                }
739                app.modal = Some(Modal::Highlights { items, selected });
740            }
741            KeyCode::Char('k') | KeyCode::Up => {
742                selected = selected.saturating_sub(1);
743                app.modal = Some(Modal::Highlights { items, selected });
744            }
745            KeyCode::Enter => {
746                if let Some(h) = items.get(selected).cloned() {
747                    if let Err(e) = load_spine(app, h.spine_idx) {
748                        set_toast(app, format!("load failed: {e}"));
749                    } else {
750                        seek_to_offset(app, h.char_offset_start as usize);
751                    }
752                }
753            }
754            KeyCode::Char('d') => {
755                if let (Some(db), Some(h)) = (app.db.as_ref(), items.get(selected).cloned()) {
756                    match db.conn() {
757                        Ok(mut conn) => match highlights::delete(&mut conn, h.id) {
758                            Ok(()) => {
759                                items.remove(selected);
760                                if selected >= items.len() && selected > 0 {
761                                    selected -= 1;
762                                }
763                            }
764                            Err(e) => set_toast(app, format!("delete failed: {e}")),
765                        },
766                        Err(e) => set_toast(app, format!("db open failed: {e}")),
767                    }
768                }
769                app.modal = Some(Modal::Highlights { items, selected });
770            }
771            // Inline note editing is deferred to v1.1. Swallow the key
772            // silently so it doesn't feel broken.
773            KeyCode::Char('e') => {
774                app.modal = Some(Modal::Highlights { items, selected });
775            }
776            _ => {
777                app.modal = Some(Modal::Highlights { items, selected });
778            }
779        },
780    }
781    Ok(())
782}
783
784fn render_modal(f: &mut ratatui::Frame, area: Rect, app: &ReaderApp, modal: &Modal) {
785    let panel = Rect {
786        x: area.x + area.width / 6,
787        y: area.y + area.height / 6,
788        width: (area.width * 2 / 3).max(40),
789        height: (area.height * 2 / 3).max(10),
790    };
791    f.render_widget(ratatui::widgets::Clear, panel);
792    match modal {
793        Modal::Toc { selected } => {
794            let items: Vec<ratatui::widgets::ListItem> = app
795                .chapter_titles
796                .iter()
797                .enumerate()
798                .map(|(i, t)| {
799                    let marker = if i == app.current_spine as usize {
800                        "▸"
801                    } else {
802                        " "
803                    };
804                    let line = format!("{marker} {t}");
805                    let mut item = ratatui::widgets::ListItem::new(line);
806                    if i == *selected {
807                        item = item.style(
808                            ratatui::style::Style::default()
809                                .add_modifier(ratatui::style::Modifier::REVERSED),
810                        );
811                    }
812                    item
813                })
814                .collect();
815            let block = ratatui::widgets::Block::default()
816                .title(" Table of contents ")
817                .borders(ratatui::widgets::Borders::ALL);
818            let list = ratatui::widgets::List::new(items).block(block);
819            f.render_widget(list, panel);
820        }
821        Modal::Highlights { items, selected } => {
822            let rows: Vec<ratatui::widgets::ListItem> = items
823                .iter()
824                .enumerate()
825                .map(|(i, h)| {
826                    let ch_label = app
827                        .chapter_titles
828                        .get(h.spine_idx as usize)
829                        .cloned()
830                        .unwrap_or_else(|| format!("Chapter {}", h.spine_idx + 1));
831                    let preview = snippet(&h.text, 60);
832                    let status = match h.anchor_status {
833                        AnchorStatus::Ok => "ok",
834                        AnchorStatus::Drifted => "drifted",
835                        AnchorStatus::Lost => "lost",
836                    };
837                    let line = format!("{ch_label}  \"{preview}\"  [{status}]");
838                    let mut item = ratatui::widgets::ListItem::new(line);
839                    if i == *selected {
840                        item = item.style(
841                            ratatui::style::Style::default()
842                                .add_modifier(ratatui::style::Modifier::REVERSED),
843                        );
844                    }
845                    item
846                })
847                .collect();
848            let title = format!(" Highlights ({}) ", items.len());
849            let block = ratatui::widgets::Block::default()
850                .title(title)
851                .borders(ratatui::widgets::Borders::ALL);
852            let list = ratatui::widgets::List::new(rows).block(block);
853            f.render_widget(list, panel);
854        }
855    }
856}
857
858fn snippet(s: &str, max: usize) -> String {
859    let trimmed: String = s.chars().map(|c| if c == '\n' { ' ' } else { c }).collect();
860    if trimmed.chars().count() <= max {
861        trimmed
862    } else {
863        let shortened: String = trimmed.chars().take(max.saturating_sub(1)).collect();
864        format!("{shortened}…")
865    }
866}
867
868fn handle_mark(mode: MarkMode, letter: char, app: &mut ReaderApp) -> Result<()> {
869    let Some(db) = app.db.as_ref() else {
870        return Ok(());
871    };
872    let Some(book_id) = app.book_id else {
873        return Ok(());
874    };
875    let mark = letter.to_string();
876    match mode {
877        MarkMode::Set => {
878            let co = current_char_offset(app);
879            let bm = Bookmark {
880                book_id,
881                mark,
882                spine_idx: app.current_spine,
883                char_offset: co,
884                anchor_hash: anchor::anchor_hash(&app.plain_text, co as usize),
885            };
886            let mut conn = db.conn()?;
887            bookmarks::set_bookmark(&mut conn, &bm)?;
888        }
889        MarkMode::Jump => {
890            let conn = db.conn()?;
891            if let Some(bm) = bookmarks::get_bookmark(&conn, book_id, &mark)? {
892                drop(conn);
893                if bm.spine_idx != app.current_spine {
894                    if let Err(e) = load_spine(app, bm.spine_idx) {
895                        set_toast(app, format!("mark load failed: {e}"));
896                        return Ok(());
897                    }
898                }
899                let target = bm.char_offset as usize;
900                seek_to_offset(app, target);
901            }
902        }
903    }
904    Ok(())
905}
906
907fn save_highlight(app: &mut ReaderApp, start: usize, end: usize) -> anyhow::Result<()> {
908    let Some(db) = app.db.as_ref() else {
909        return Ok(());
910    };
911    let Some(book_id) = app.book_id else {
912        return Ok(());
913    };
914
915    let chars: Vec<char> = app.plain_text.chars().collect();
916    if end <= start || end > chars.len() {
917        return Ok(());
918    }
919
920    let text: String = chars[start..end].iter().collect();
921    let ctx_before_start = start.saturating_sub(80);
922    let ctx_after_end = (end + 80).min(chars.len());
923    let context_before: String = chars[ctx_before_start..start].iter().collect();
924    let context_after: String = chars[end..ctx_after_end].iter().collect();
925
926    let chapter_title = app.chapter_titles.get(app.current_spine as usize).cloned();
927
928    let h = Highlight {
929        id: 0,
930        book_id,
931        spine_idx: app.current_spine,
932        chapter_title,
933        char_offset_start: start as u64,
934        char_offset_end: end as u64,
935        text,
936        context_before: Some(context_before),
937        context_after: Some(context_after),
938        note: None,
939        anchor_status: AnchorStatus::Ok,
940    };
941
942    let mut conn = db.conn()?;
943    highlights::insert(&mut conn, &h)?;
944    Ok(())
945}
946
947fn key_to_raw(k: KeyEvent) -> String {
948    match k.code {
949        KeyCode::Char(c) if k.modifiers.contains(KeyModifiers::CONTROL) => format!("<C-{c}>"),
950        KeyCode::Char(c) => c.to_string(),
951        KeyCode::Up => "<Up>".into(),
952        KeyCode::Down => "<Down>".into(),
953        KeyCode::Enter => "<Enter>".into(),
954        KeyCode::Esc => "<Esc>".into(),
955        KeyCode::F(n) => format!("<F{n}>"),
956        _ => String::new(),
957    }
958}