"""The reader's note editor and the list of a book's bookmarks, highlights and notes.""" from __future__ import annotations from dataclasses import dataclass from rich.text import Text from textual.binding import Binding from textual.containers import Container from textual.screen import ModalScreen from textual.widgets import Input, Label, ListItem, ListView, Static from . import annotations, blocks class NoteScreen(ModalScreen[str]): """Type a note; Enter keeps it, Escape leaves it as it was (dismisses with None).""" BINDINGS = [Binding("escape", "cancel", "Abbrechen")] def __init__(self, title: str, text: str = "") -> None: super().__init__() self._title = title self._text = text def compose(self): with Container(id="note-body"): yield Label(self._title, id="note-title") yield Input(value=self._text, placeholder="Notiz …", id="note-input") yield Label("Enter: speichern Escape: abbrechen", id="note-hint") def on_mount(self) -> None: self.query_one(Input).focus() def on_input_submitted(self, event: Input.Submitted) -> None: self.dismiss(event.value.strip()) def action_cancel(self) -> None: self.dismiss(None) @dataclass class Entry: kind: str # 'bookmarks' | 'highlights' item: dict block: int # where it belongs, for the jump percent: int def entries(book: blocks.FlatBook, ann: annotations.BookAnnotations, mark_index=None) -> list[Entry]: out: list[Entry] = [] for bm in ann.bookmarks: block = bookmark_block(book, bm) out.append(Entry("bookmarks", bm, block, round(blocks.fraction_for_anchor(book, block, 0.0) * 100))) for h in sorted(ann.highlights, key=annotations.sort_key): a = h.get("anchor") or {} block = a.get("startBlockIndex") if isinstance(a.get("startBlockIndex"), int) else None if block is None and mark_index is not None: # an old anchor: where its quote is found = mark_index._find_quote(a) block = found[0] if found else None block = min(max(block or 0, 0), len(book.blocks) - 1) out.append(Entry("highlights", h, block, round(blocks.fraction_for_anchor(book, block, 0.0) * 100))) return out def bookmark_block(book: blocks.FlatBook, bm: dict) -> int: """The block a bookmark points at: its chapter's first block; else its fraction of the text.""" src = bm.get("anchor") or "" if src in book.chapter_srcs: return book.chapter_start_block[book.chapter_srcs.index(src)] target = float(bm.get("scrollFraction") or 0) * book.total_chars return next((i for i, b in enumerate(book.blocks) if b.char_start + b.char_len >= target), 0) class AnnotationsScreen(ModalScreen[int]): """Lesezeichen, then Markierungen and Notizen in the order of the book. Enter jumps (dismisses with the block), d deletes, e edits a note; Escape closes.""" BINDINGS = [ Binding("escape,q,a", "close", "Schließen"), Binding("d", "delete", "Löschen"), Binding("e", "edit", "Notiz"), ] def __init__(self, book: blocks.FlatBook, store: annotations.AnnotationStore, key: str, on_change, mark_index=None) -> None: super().__init__() self.book = book self.store = store self.key = key self.mark_index = mark_index self.on_change = on_change # called after a delete or an edit: marks and the queue follow self.rows: list[Entry] = [] def compose(self): with Container(id="ann-body"): yield Label("Lesezeichen, Markierungen und Notizen", id="ann-title") yield ListView(id="ann-list") yield Static("", id="ann-detail") yield Label("Enter: hinspringen d: löschen e: Notiz bearbeiten Escape: schließen", id="ann-hint") def on_mount(self) -> None: self._fill() def _fill(self, keep: int = 0) -> None: self.rows = entries(self.book, self.store.get(self.key), self.mark_index) lv = self.query_one(ListView) lv.clear() if not self.rows: lv.append(ListItem(Label("Noch nichts. b: Lesezeichen, v: Text markieren, im Lesen."))) for r in self.rows: lv.append(ListItem(Label(Text(self._label(r))))) if self.rows: lv.index = min(keep, len(self.rows) - 1) lv.focus() self._detail() @staticmethod def _label(r: Entry) -> str: if r.kind == "bookmarks": return f"★ {r.item.get('label') or 'Lesezeichen'} {r.percent} %" h = r.item quote = " ".join(((h.get("anchor") or {}).get("quote") or "").split()) quote = quote if len(quote) <= 60 else quote[:59] + "…" if h.get("type") == "note": return f"✎ {h.get('note') or ''} {r.percent} %" mark = {"yellow": "▌", "green": "▌", "blue": "▌", "red": "▌"}.get(h.get("color") or "yellow", "▌") note = f" ✎ {h['note']}" if h.get("note") else "" return f"{mark} „{quote}“{note} {r.percent} %" def _detail(self) -> None: lv = self.query_one(ListView) detail = self.query_one("#ann-detail", Static) if not self.rows or lv.index is None or lv.index >= len(self.rows): detail.update("") return h = self.rows[lv.index].item text = "" if self.rows[lv.index].kind == "highlights": text = " ".join(((h.get("anchor") or {}).get("quote") or "").split()) if h.get("note"): text += "\n✎ " + h["note"] detail.update(Text(text)) def on_list_view_highlighted(self, event: ListView.Highlighted) -> None: self._detail() def on_list_view_selected(self, event: ListView.Selected) -> None: lv = self.query_one(ListView) if self.rows and lv.index is not None and lv.index < len(self.rows): self.dismiss(self.rows[lv.index].block) def _current(self) -> Entry | None: lv = self.query_one(ListView) return self.rows[lv.index] if self.rows and lv.index is not None and lv.index < len(self.rows) else None def action_close(self) -> None: self.dismiss(None) def action_delete(self) -> None: r = self._current() if r is None: return idx = self.query_one(ListView).index or 0 self.store.delete(self.key, r.kind, r.item["id"]) self.on_change() self._fill(idx) def action_edit(self) -> None: r = self._current() if r is None or r.kind != "highlights": return idx = self.query_one(ListView).index or 0 def edited(text: str | None) -> None: if text is not None and self.store.set_note(self.key, r.item["id"], text): self.on_change() self._fill(idx) self.app.push_screen(NoteScreen("Notiz", r.item.get("note") or ""), edited)