diora-web/tui/diora_tui/reader_screen.py
mrwnslz e246a32212 TUI: Markierungen, Notizen und Lesezeichen (anzeigen, anlegen, löschen, abgleichen)
Dieselben verschlüsselten Listen wie der Web-Reader (annotations.py). Die Anker des Webs
(Block-Index plus Zeichenposition im Rohtext) werden auf den angezeigten Text umgerechnet
(marks.py, blocks.collapse_map), alte XPath-Anker über das Zitat gefunden. Im Reader: v markiert
(Cursor je Wort, Anfang/Ende, Farbe oder Notiz), n legt eine Notiz an, b ein Lesezeichen,
a öffnet die Liste (springen, Notiz ändern, löschen).

Der Server ersetzt die Liste als Ganzes; die TUI legt ihre Änderungen vor dem Schreiben
dreiwege je Eintrag-ID auf die aktuelle Liste des Servers, offline bleiben sie vorgemerkt.
Geprüft: die echten resolveAnchorRange des Webs lösen 81 TUI-Anker exakt auf
(tools/parity/web_anchors.cjs), 34 von 34 echten Web-Markierungen werden angezeigt, der
Server-Smoke schreibt und mischt zwei Geräte. 75 Tests.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-26 16:42:32 +01:00

703 lines
30 KiB
Python

"""Continuous whole-book reader screen — mirrors the web reader's single
scrollable view (see blocks.py's module docstring) instead of the old
per-chapter pagination, so reading position is expressed in the same
"blockIndex:innerFraction" anchor space as the server and the web client.
Uses book_view.ContinuousBookView (a Line-API widget) rather than dumping the
whole book into one Static: for large books (tens of thousands of blocks) a
single giant renderable made Textual's layout/paint pass take upwards of a
minute, whereas the Line API only ever renders the rows on screen.
Reading position (docs/progress-sync.md, rules in progress.py): the place last read wins.
The reader saves a position only when the *reader* moved it (keys, wheel), stamped with the
moment of arriving; restoring, re-wrapping on resize and jumping on an offer change nothing.
While a book is open the server is asked now and then; a newer position from another device
is offered ("Auf X weitergelesen — springen?"), never applied by itself.
"""
from __future__ import annotations
import bisect
import re
import threading
from pathlib import Path
from rich.console import Console
from textual import work
from textual.binding import Binding
from textual.containers import Container, VerticalScroll
from textual.geometry import Size
from textual.screen import ModalScreen, Screen
from textual.widgets import Footer, Header, Label, LoadingIndicator, Static
from . import annotations, blocks, cache, config as config_mod, epub, layout, library, library_meta, marks, progress, remote
from .annotation_screens import AnnotationsScreen, NoteScreen
from .book_view import ContinuousBookView
from .statusbar import StatusBar
MAX_LINE_WIDTH = 120
AUTOSAVE_INTERVAL = 5.0
# How often the server is asked for a newer position while the book is open (the apps pull
# at most every 2 minutes).
REMOTE_CHECK_INTERVAL = 120.0
# "Weiteste Stelle" is offered when the furthest place is this much of the book ahead.
FURTHEST_OFFER_SHARE = 0.01
# ContinuousBookView forces its vertical scrollbar always-on (see book_view.py)
# so this stays constant — measuring the container's width and subtracting
# this fixed amount avoids a measure-after-constrain race against book_view's
# own (possibly already-constrained-from-a-previous-layout) width.
SCROLLBAR_GUTTER = 2
# A selection may span this many paragraphs (each is drawn again at every cursor move).
MAX_MARK_BLOCKS = 200
_EMPTY_LAYOUT = layout.BookLayout(starts=[], heights=[], row_strips=[], total_rows=0, width=1)
class FootnoteScreen(ModalScreen[None]):
BINDINGS = [Binding("escape,f,q", "dismiss_self", "Schließen")]
def __init__(self, notes: list[str]) -> None:
super().__init__()
self._notes = notes
def compose(self):
with VerticalScroll(id="footnote-body"):
for note in self._notes:
yield Static(note, classes="footnote-note")
yield Label("Escape/f zum Schließen", id="footnote-hint")
def action_dismiss_self(self) -> None:
self.dismiss(None)
class JumpOfferScreen(ModalScreen[bool]):
"""A question the reader answers with a key; nothing moves unless the answer is yes."""
BINDINGS = [
Binding("y,enter", "answer(True)", "Springen"),
Binding("n,escape,q", "answer(False)", "Hier bleiben"),
]
def __init__(self, text: str) -> None:
super().__init__()
self._text = text
def compose(self):
with Container(id="offer-body"):
yield Static(self._text)
yield Label("y/Enter: springen n/Escape: hier bleiben", id="offer-hint")
def action_answer(self, yes: bool) -> None:
self.dismiss(yes)
class ReaderScreen(Screen):
BINDINGS = [
Binding("j,down", "scroll_down_line", "Runter", show=False),
Binding("k,up", "scroll_up_line", "Hoch", show=False),
Binding("n", "next_chapter", "Nächstes Kapitel"),
Binding("p", "prev_chapter", "Vorheriges Kapitel"),
Binding("f", "peek_footnote", "Fußnote"),
Binding("w", "goto_furthest", "Weiteste Stelle"),
Binding("v", "select", "Markieren"),
Binding("b", "bookmark", "Lesezeichen"),
Binding("a", "annotations", "Markierungen"),
Binding("h,left", "word_back", "Wort zurück", show=False),
Binding("l,right", "word_forward", "Wort vor", show=False),
Binding("enter,space", "confirm", "Weiter", show=False),
Binding("q,escape", "back", "Zurück zur Bibliothek"),
]
def __init__(self, path: Path) -> None:
super().__init__()
self.path = path
self.book: blocks.FlatBook | None = None
self.book_layout: layout.BookLayout = _EMPTY_LAYOUT
self._key = epub._book_id(path)
self._server_id = epub.server_id_of(path)
self._cfg = config_mod.load() if self._server_id is not None else None
self._store = progress.default()
# The position the view is on, as last saved or restored, and when the reader last
# moved it (this machine's clock). A change of the anchor is a reading position only
# when there is such a move.
self._last_anchor: str | None = None
self._last_move_ms: int | None = None
# read_at (server clock) of the state this view is based on: a newer one from the
# server is what gets offered.
self._held_read_at: int | None = None
self._offer_open = False
self._marks: marks.MarkIndex | None = None
self._ann = annotations.default()
self._styled: set[int] = set() # blocks drawn with marks at the moment
self._sel: dict | None = None # the selection being made: phase, cursor, start
self._ann_timer = None
def compose(self):
yield Header()
yield LoadingIndicator(id="reader-loading")
with Container(id="reader-scroll") as scroll_container:
self._scroll_container = scroll_container
# Stays visible (empty) from the start rather than toggling display
# on once loaded — a widget that's just been switched from hidden
# to visible hasn't been through a layout pass yet, so its `.size`
# is still (0, 0) and an immediate scroll_to() right after has
# nothing to clamp against and silently resets to 0.
self._book_view = ContinuousBookView(_EMPTY_LAYOUT, on_read_scroll=self._on_read_scroll)
yield self._book_view
yield StatusBar()
yield Footer()
def on_mount(self) -> None:
# Deferred rather than read synchronously here: right after mount the
# container hasn't been through a layout pass yet, so its size isn't
# reliable — same lesson as the scroll-restore race below.
self.call_after_refresh(self._start_loading)
def _start_loading(self) -> None:
if self._scroll_container.size.width == 0:
# Not sized yet after all — keep deferring instead of guessing a
# fixed number of refresh cycles (mirrors _restore_position below).
self.call_after_refresh(self._start_loading)
return
self._load_book(self._content_width())
@work(thread=True)
def _load_book(self, width: int) -> None:
cached = cache.load(self.path, width)
if cached is not None:
book, book_layout = cached
else:
book = blocks.load_flat_book(self.path)
console = Console(width=width)
book_layout = layout.build_layout([b.text for b in book.blocks], width, console)
cache.save(self.path, width, book, book_layout)
self.app.call_from_thread(self._on_book_loaded, book, book_layout, width)
def _on_book_loaded(self, book: blocks.FlatBook, book_layout: layout.BookLayout, width: int) -> None:
self.book = book
self.sub_title = book.title
self._apply_layout(book_layout, width)
self.query_one("#reader-loading", LoadingIndicator).display = False
library_meta.set_local_pages(self._key, library.pages_of_chars(book.total_chars))
st = self._store.get(self._key)
self._held_read_at = st.read_at if st else None
self._marks = marks.MarkIndex(book)
self._apply_marks()
if self._cfg is not None:
self._pull_annotations()
self.call_after_refresh(self._restore_position)
self.set_interval(AUTOSAVE_INTERVAL, self._autosave)
if self._cfg is not None:
self.set_interval(REMOTE_CHECK_INTERVAL, self._check_remote)
def _content_width(self) -> int:
# Measuring the *container* rather than book_view's own size avoids a
# measure-after-constrain race: once a layout has been applied,
# book_view's width is pinned to a previous value via styles.width
# (below), so re-measuring book_view itself on a later resize would
# just read that stale pinned width back instead of the new
# available space. The container's width is unaffected by that.
available = self._scroll_container.size.width - SCROLLBAR_GUTTER
return max(20, min(MAX_LINE_WIDTH, available))
def _apply_layout(self, book_layout: layout.BookLayout, width: int) -> None:
self.book_layout = book_layout
# Pin book_view's outer width to content-width-plus-scrollbar so its
# *inner* content region (what render_line actually draws into) ends
# up exactly `width` — matching what block texts were wrapped at.
self._book_view.styles.width = width + SCROLLBAR_GUTTER
self._book_view.book_layout = book_layout
self._book_view.virtual_size = Size(width, book_layout.total_rows)
self._book_view.refresh()
def _build_layout(self) -> None:
assert self.book is not None
width = self._content_width()
console = Console(width=width)
block_texts = [b.text for b in self.book.blocks]
new_layout = layout.build_layout(block_texts, width, console)
cache.save(self.path, width, self.book, new_layout)
self._apply_layout(new_layout, width)
self._styled = set() # a fresh layout is drawn without marks
self._apply_marks()
def on_resize(self) -> None:
if self.book is None:
return
old_y = self._book_view.scroll_y
anchor_block, anchor_frac = layout.get_position_anchor(self.book_layout, old_y)
self._build_layout()
new_y = layout.scroll_y_for_anchor(self.book_layout, anchor_block, anchor_frac)
with self._book_view.program_scroll():
self._book_view.scroll_to(y=new_y, animate=False, immediate=True)
def _scroll_to_anchor(self, anchor: str) -> bool:
parsed = blocks.parse_anchor(anchor)
if parsed is None:
return False
y = layout.scroll_y_for_anchor(self.book_layout, *parsed)
with self._book_view.program_scroll():
self._book_view.scroll_to(y=y, animate=False, immediate=True)
return y > 0
def _restore_position(self, attempt: int = 0) -> None:
if self.book is None:
return
st = self._store.get(self._key)
if st is None or not st.anchor or blocks.parse_anchor(st.anchor) is None:
self._settle()
return
parsed = blocks.parse_anchor(st.anchor)
y = layout.scroll_y_for_anchor(self.book_layout, *parsed)
if y <= 0:
self._settle()
return
with self._book_view.program_scroll():
self._book_view.scroll_to(y=y, animate=False, immediate=True)
# A widget that's only just become part of the layout doesn't always
# honor an immediate scroll on the first attempt (its own size/scroll
# bounds can still be mid-update) — verify it actually landed and
# retry a bounded number of times rather than guessing a fixed delay.
if attempt < 20 and abs(self._book_view.scroll_y - y) > 1:
self.call_after_refresh(lambda: self._restore_position(attempt + 1))
return
self._settle()
self._offer_furthest(st)
def _settle(self) -> None:
"""The view is where the saved position put it: from here a changed anchor with a move
by the reader is a reading position."""
self._last_anchor = self._current_anchor_str()
self._last_move_ms = None
def _current_anchor_str(self) -> str:
block_index, inner_fraction = layout.get_position_anchor(self.book_layout, self._book_view.scroll_y)
return blocks.format_anchor(block_index, inner_fraction)
# ---- saving
def _on_read_scroll(self) -> None:
self._last_move_ms = progress.now_ms()
def _autosave(self) -> None:
self._save_progress()
def _save_progress(self) -> None:
if self.book is None or not self.book_layout.heights or self._last_anchor is None:
return
anchor = self._current_anchor_str()
if anchor == self._last_anchor:
self._last_move_ms = None # moved and came back: nothing new
return
moved_at = self._last_move_ms
self._last_anchor = anchor
self._last_move_ms = None
if moved_at is None:
return # the layout moved it, not the reader
block_index, inner = blocks.parse_anchor(anchor)
fraction = blocks.fraction_for_anchor(self.book, block_index, inner)
self._store.write(self._key, anchor, fraction, moved_at, queue=self._cfg is not None)
st = self._store.get(self._key)
if st is not None:
self._held_read_at = st.read_at
if self._cfg is not None:
self._push()
@work(thread=True, exclusive=True, group="progress-push")
def _push(self) -> None:
try:
remote.flush_pending(self._cfg)
except Exception:
return # best-effort — the position is saved locally and stays queued
self.app.call_from_thread(self._after_push)
def _after_push(self) -> None:
# What the server stored of ours is no news; a newer position of another device in its
# answer (our write was refused) is.
st = self._store.get(self._key)
if st is not None and st.echo_read_at is not None and st.echo_read_at >= (self._held_read_at or 0):
self._held_read_at = st.echo_read_at
self._offer_newer()
def on_unmount(self) -> None:
self._save_progress()
if self._cfg is not None and (self._store.pending_keys() or self._ann.dirty_keys()):
# The screen's workers die with it; the queue still has to go out.
threading.Thread(target=self._flush_quietly, args=(self._cfg,), daemon=False).start()
@staticmethod
def _flush_quietly(cfg) -> None:
for send in (remote.flush_pending, remote.push_annotations):
try:
send(cfg)
except Exception:
pass
# ---- the server's newer positions
@work(thread=True, exclusive=True, group="progress-check")
def _check_remote(self) -> None:
try:
remote.sync_library(self.path.parent, self._cfg)
except Exception:
return # offline etc.: ask again later
self.app.call_from_thread(self._offer_newer)
def _offer_newer(self) -> None:
if self.book is None or self._offer_open or self._last_anchor is None:
return
st = self._store.get(self._key)
if st is None or not st.anchor or st.read_at is None:
return
if self._held_read_at is not None and st.read_at <= self._held_read_at:
return
parsed = blocks.parse_anchor(st.anchor)
if parsed is None:
return
shown_block, _ = layout.get_position_anchor(self.book_layout, self._book_view.scroll_y)
self._held_read_at = st.read_at # asked once per newer state
if parsed[0] == shown_block:
return
pct = round(blocks.fraction_for_anchor(self.book, *parsed) * 100)
who = st.device or "einem anderen Gerät"
self._ask(f"Auf {who} weitergelesen — bei {pct} %.\nSpringen?", st.anchor)
def _offer_furthest(self, st: progress.BookState) -> None:
if self.book is None or not st.furthest_anchor:
return
here, there = blocks.parse_anchor(st.anchor), blocks.parse_anchor(st.furthest_anchor)
if there is None:
return
share = blocks.fraction_for_anchor(self.book, *there) - (blocks.fraction_for_anchor(self.book, *here) if here else 0.0)
if share >= FURTHEST_OFFER_SHARE:
pct = round(blocks.fraction_for_anchor(self.book, *there) * 100)
self.notify(f"Weiteste Stelle: {pct} % — mit w springen.", timeout=6)
def _ask(self, text: str, anchor: str) -> None:
self._offer_open = True
def answered(yes: bool | None) -> None:
self._offer_open = False
if yes:
self._scroll_to_anchor(anchor)
self._last_anchor = self._current_anchor_str()
self._last_move_ms = None
self.app.push_screen(JumpOfferScreen(text), answered)
def action_goto_furthest(self) -> None:
st = self._store.get(self._key)
if self.book is None or st is None or not st.furthest_anchor:
self.notify("Keine weiteste Stelle bekannt.", timeout=3)
return
if blocks.parse_anchor(st.furthest_anchor) is None:
return
self._scroll_to_anchor(st.furthest_anchor)
# A jump, not reading: nothing is saved until the reader carries on from there.
self._last_anchor = self._current_anchor_str()
self._last_move_ms = None
# ---- navigation
def action_scroll_down_line(self) -> None:
if self._sel is not None:
self._move_block(1)
return
self._book_view.scroll_relative(y=1, animate=False)
def action_scroll_up_line(self) -> None:
if self._sel is not None:
self._move_block(-1)
return
self._book_view.scroll_relative(y=-1, animate=False)
def _current_chapter_index(self) -> int:
if self.book is None:
return 0
block_index, _ = layout.get_position_anchor(self.book_layout, self._book_view.scroll_y)
return bisect.bisect_right(self.book.chapter_start_block, block_index) - 1
def _jump_to_block(self, block_index: int) -> None:
y = layout.scroll_y_for_anchor(self.book_layout, block_index, 0.0)
self._book_view.scroll_to(y=y, animate=False, immediate=True)
def action_next_chapter(self) -> None:
if self._sel is not None:
self._selection_note() # n: a note, while marking
return
if self.book is None:
return
chapter = self._current_chapter_index()
if chapter + 1 < len(self.book.chapter_start_block):
self._jump_to_block(self.book.chapter_start_block[chapter + 1])
self._save_progress()
def action_prev_chapter(self) -> None:
if self.book is None:
return
chapter = self._current_chapter_index()
if chapter > 0:
self._jump_to_block(self.book.chapter_start_block[chapter - 1])
self._save_progress()
def action_peek_footnote(self) -> None:
if self.book is None or not self.book_layout.heights:
return
current_block, _ = layout.get_position_anchor(self.book_layout, self._book_view.scroll_y)
forward = [(i, b) for i, b in enumerate(self.book.blocks) if i >= current_block and b.footnotes]
backward = [(i, b) for i, b in enumerate(self.book.blocks) if i < current_block and b.footnotes]
candidate = forward[0] if forward else (backward[-1] if backward else None)
if candidate is None:
self.notify("Keine Fußnote in diesem Buch gefunden.", timeout=3)
return
_, block = candidate
notes: list[str] = []
for ref in block.footnotes:
target_idx = self.book.footnote_targets.get(ref.target_id)
if target_idx is None:
continue
notes.append(self.book.blocks[target_idx].text)
if not notes:
self.notify("Fußnote konnte nicht aufgelöst werden.", timeout=3)
return
self.app.push_screen(FootnoteScreen(notes))
def action_back(self) -> None:
if self._sel is not None:
self._end_selection()
return
self.app.pop_screen()
# ---- marks on screen
def _marks_by_block(self) -> dict[int, list]:
spans: dict[int, list] = {}
if self._marks is not None:
for bi, lst in self._marks.spans(self._ann.get(self._key).highlights).items():
spans.setdefault(bi, []).extend(lst)
for bi, lst in self._selection_spans().items():
spans.setdefault(bi, []).extend(lst)
return spans
def _apply_marks(self) -> None:
"""Draw the highlights (and the selection) in place; blocks that lost theirs are drawn plain."""
if self.book is None or not self.book_layout.heights:
return
spans = self._marks_by_block()
console = Console(width=self.book_layout.width)
for bi in sorted(self._styled | set(spans)):
if 0 <= bi < len(self.book.blocks):
layout.restyle_block(self.book_layout, bi, self.book.blocks[bi].text, console, spans.get(bi, ()))
self._styled = set(spans)
self._book_view.refresh()
@work(thread=True, exclusive=True, group="ann-pull")
def _pull_annotations(self) -> None:
if remote.pull_annotations(self._cfg, self._server_id, self._key):
self.app.call_from_thread(self._apply_marks)
def _annotations_changed(self) -> None:
"""A highlight, note or bookmark was made, changed or deleted: draw it, and send it soon."""
self._apply_marks()
if self._cfg is None:
return
if self._ann_timer is not None:
self._ann_timer.stop()
self._ann_timer = self.set_timer(2.0, self._push_annotations) # like the web's debounce
@work(thread=True, exclusive=True, group="ann-push")
def _push_annotations(self) -> None:
try:
remote.push_annotations(self._cfg)
except Exception:
pass # stays queued
# ---- bookmarks and the list
def action_bookmark(self) -> None:
if self.book is None or not self.book_layout.heights or self._sel is not None:
return
block, inner = layout.get_position_anchor(self.book_layout, self._book_view.scroll_y)
chapter = max(0, self._current_chapter_index())
src = self.book.chapter_srcs[chapter] if chapter < len(self.book.chapter_srcs) else ""
label = re.sub(r"\.x?html?$", "", src.rsplit("/", 1)[-1], flags=re.I) or "Lesezeichen" # as the web names it
bm = annotations.new_bookmark(label, src, blocks.fraction_for_anchor(self.book, block, inner))
self._ann.add(self._key, "bookmarks", bm)
self.notify(f"★ Lesezeichen: {label}", timeout=2.5)
self._annotations_changed()
def action_annotations(self) -> None:
if self.book is None or self._sel is not None:
return
def picked(block: int | None) -> None:
if block is not None:
self._jump_quiet(block)
self.app.push_screen(AnnotationsScreen(self.book, self._ann, self._key, self._annotations_changed, self._marks), picked)
def _jump_quiet(self, block: int) -> None:
"""Go to a block as a jump, not as reading: nothing is saved until the reader moves on."""
y = layout.scroll_y_for_anchor(self.book_layout, block, 0.0)
with self._book_view.program_scroll():
self._book_view.scroll_to(y=y, animate=False, immediate=True)
self._last_anchor = self._current_anchor_str()
self._last_move_ms = None
# ---- selecting text (v): a cursor by word, Enter sets the start, Enter again the end
def _first_block_in_view(self) -> int:
y = self._book_view.scroll_y
i = bisect.bisect_left(self.book_layout.starts, y)
return min(i, len(self.book.blocks) - 1)
def _word_block(self, block: int, step: int) -> int | None:
"""The nearest block from `block` (inclusive) in direction `step` that has a word."""
while 0 <= block < len(self.book.blocks):
if marks.word_starts(self.book.blocks[block].text):
return block
block += step
return None
def action_select(self) -> None:
if self.book is None or not self.book_layout.heights:
return
if self._sel is not None:
self.action_confirm()
return
block = self._word_block(self._first_block_in_view(), 1)
if block is None:
return
self._sel = {"phase": "start", "cursor": (block, marks.word_starts(self.book.blocks[block].text)[0]), "start": None}
self.notify("Markieren: h/l Wort, j/k Absatz, Enter = Anfang, n = Notiz, Esc = Abbrechen", timeout=6)
self._selection_moved()
def _end_selection(self) -> None:
self._sel = None
self._apply_marks()
def _selection_moved(self) -> None:
self._apply_marks()
block, char = self._sel["cursor"]
text = self.book.blocks[block].text
row = self.book_layout.starts[block] + int(char / max(1, len(text)) * self.book_layout.heights[block])
top, height = self._book_view.scroll_y, self._book_view.size.height
if not (top <= row < top + height - 1):
with self._book_view.program_scroll():
self._book_view.scroll_to(y=max(0, row - height // 3), animate=False, immediate=True)
self._last_anchor = self._current_anchor_str()
def action_word_forward(self) -> None:
self._move_word(1)
def action_word_back(self) -> None:
self._move_word(-1)
def _move_word(self, step: int) -> None:
if self._sel is None:
return
block, char = self._sel["cursor"]
text = self.book.blocks[block].text
target = marks.next_word(text, char) if step > 0 else marks.prev_word(text, char)
if target is not None:
self._sel["cursor"] = (block, target)
else:
nb = self._word_block(block + step, step)
if nb is None:
return
ws = marks.word_starts(self.book.blocks[nb].text)
self._sel["cursor"] = (nb, ws[0] if step > 0 else ws[-1])
self._selection_moved()
def _move_block(self, step: int) -> None:
nb = self._word_block(self._sel["cursor"][0] + step, step)
if nb is not None:
self._sel["cursor"] = (nb, marks.word_starts(self.book.blocks[nb].text)[0])
self._selection_moved()
def _cursor_word(self) -> tuple[int, int, int]:
block, char = self._sel["cursor"]
w = marks.word_at(self.book.blocks[block].text, char) or (char, char)
return block, w[0], w[1]
def _selection_range(self) -> tuple[tuple[int, int], tuple[int, int]]:
"""(start, end) of what is selected, end exclusive, whichever way the cursor went."""
sb, sc = self._sel["start"]
sw = marks.word_at(self.book.blocks[sb].text, sc) or (sc, sc)
cb, cs, ce = self._cursor_word()
if (cb, cs) >= (sb, sw[0]):
return (sb, sw[0]), (cb, ce)
return (cb, cs), (sb, sw[1])
def _selection_spans(self) -> dict[int, list]:
if self._sel is None or self.book is None:
return {}
if self._sel["phase"] == "start":
block, s, e = self._cursor_word()
return {block: [(s, e, marks.SELECTION_STYLE)]}
(sb, sc), (eb, ec) = self._selection_range()
out: dict[int, list] = {}
for bi in range(sb, eb + 1):
text_len = len(self.book.blocks[bi].text)
out[bi] = [(sc if bi == sb else 0, ec if bi == eb else text_len, marks.SELECTION_STYLE)]
return out
def action_confirm(self) -> None:
if self._sel is None:
return
if self._sel["phase"] == "start":
block, s, _e = self._cursor_word()
self._sel["start"] = (block, s)
self._sel["phase"] = "end"
self.notify("Ende wählen (h/l/j/k), Enter = fertig.", timeout=4)
self._selection_moved()
return
(sb, sc), (eb, ec) = self._selection_range()
if eb - sb > MAX_MARK_BLOCKS:
self.notify(f"Zu lang zum Markieren (mehr als {MAX_MARK_BLOCKS} Absätze).", severity="warning")
return
anchor = self._marks.anchor((sb, sc), (eb, ec))
if anchor is None:
self.notify("Diese Stelle lässt sich nicht genau zuordnen (Fußnotenmarker mit Leerraum).", severity="warning")
return
names = [annotations.COLOR_NAMES[c] for c in annotations.COLORS] + ["Gelb mit Notiz …"]
colors = list(annotations.COLORS)
def picked(i: int | None) -> None:
if i is None:
return # the selection stays: pick again or Esc
if i < len(colors):
self._add_highlight(anchor, colors[i], "")
else:
self.app.push_screen(NoteScreen("Notiz zur Markierung"), lambda text: self._add_highlight(anchor, "yellow", text) if text is not None else None)
from .library_screen import ChoiceScreen
self.app.push_screen(ChoiceScreen("Markieren als", names, 0), picked)
def _add_highlight(self, anchor: dict, color: str, note: str) -> None:
self._ann.add(self._key, "highlights", annotations.new_highlight(anchor, color, note))
self._sel = None
self._annotations_changed()
def _selection_note(self) -> None:
"""n while marking: a freeform note at the cursor's word (phase 1) or on the selection."""
if self._sel["phase"] == "start":
block, s, e = self._cursor_word()
start, end = (block, s), (block, e)
else:
start, end = self._selection_range()
anchor = self._marks.anchor(start, end)
if anchor is None:
self.notify("Diese Stelle lässt sich nicht genau zuordnen.", severity="warning")
return
def noted(text: str | None) -> None:
if text:
self._ann.add(self._key, "highlights", annotations.new_note(anchor, text))
self._sel = None
self._annotations_changed()
self.app.push_screen(NoteScreen("Notiz"), noted)