diora-web/tui/diora_tui/annotation_screens.py

180 lines
6.8 KiB
Python
Raw Normal View History

"""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)