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#[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
92pub 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
106pub 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 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
177fn 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
197fn terminal_height() -> Result<u16> {
200 let (_w, h) = crossterm::terminal::size()?;
201 Ok(h)
202}
203
204fn 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 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 if app.modal.is_some() {
357 handle_modal_key(app, k)?;
358 continue;
359 }
360
361 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 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 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 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 }
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 }
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 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}