TUI: Zeichen am rechten Rand bei schmalen Terminals nicht mehr abgeschnitten

ContinuousBookViews vertikale Scrollbar reserviert 2 Spalten, die vorher
nicht aus der Wrap-Breite herausgerechnet wurden — der Text wurde also
2 Zeichen breiter gewrappt, als tatsächlich sichtbar war, wodurch die
Scrollbar die letzten 1-2 Buchstaben jeder Zeile überdeckt hat. Bei
schmalen Terminals (großer Font, wenig Spalten) war das besonders
auffällig, betraf strukturell aber jede Breite.

Fix: overflow-x: hidden (eine ungewollte horizontale Scrollbar hat
zusätzlich eine Zeile unten geklaut) + overflow-y: scroll (hält die
Scrollbar-Breite von Anfang an konstant, kein Rätselraten je nach
Inhaltsgröße) in book_view.py. reader_screen.py misst die Wrap-Breite
jetzt am Container statt an book_view selbst (vermeidet einen
Miss-nach-Einschränken-Zirkelbezug bei wiederholten Resizes) und rechnet
die feste Scrollbar-Breite (SCROLLBAR_GUTTER=2) heraus; beim Anwenden
des Layouts wird sie wieder daraufgerechnet, damit book_view.styles.width
weiterhin fürs Zentrieren passt.

Getestet: content_region-Breite stimmt jetzt exakt mit der Wrap-Breite
über sechs verschiedene Terminalbreiten (60-200 Spalten, inkl. des
120-Zeichen-Cap-Bereichs) überein — vorher lag sie durchgehend 2 Spalten
darunter. Voller Regressionstest (Scroll, Fußnote, Resize) läuft weiter
fehlerfrei.
This commit is contained in:
marwin 2026-08-15 21:07:41 +02:00
parent 20d04361bc
commit e9683fbdbd
2 changed files with 45 additions and 5 deletions

View file

@ -15,6 +15,18 @@ from .layout import BookLayout
class ContinuousBookView(ScrollView): class ContinuousBookView(ScrollView):
# We always wrap text to fit exactly, so a horizontal scrollbar should
# never be needed — and "scroll" (not "auto") for the vertical one keeps
# its gutter reserved from the very first (still-empty) layout pass, so
# the width we wrap text at later never has to guess whether a scrollbar
# will appear and steal columns out from under already-wrapped lines.
DEFAULT_CSS = """
ContinuousBookView {
overflow-x: hidden;
overflow-y: scroll;
}
"""
def __init__(self, book_layout: BookLayout) -> None: def __init__(self, book_layout: BookLayout) -> None:
super().__init__() super().__init__()
self.book_layout = book_layout self.book_layout = book_layout
@ -24,6 +36,7 @@ class ContinuousBookView(ScrollView):
_scroll_x, scroll_y = self.scroll_offset _scroll_x, scroll_y = self.scroll_offset
row = scroll_y + y row = scroll_y + y
strips = self.book_layout.row_strips strips = self.book_layout.row_strips
width = self.scrollable_content_region.width
if row < 0 or row >= len(strips): if row < 0 or row >= len(strips):
return Strip.blank(self.size.width, self.rich_style) return Strip.blank(width, self.rich_style)
return strips[row].crop_extend(0, self.size.width, self.rich_style) return strips[row].crop_extend(0, width, self.rich_style)

View file

@ -29,6 +29,11 @@ from .statusbar import StatusBar
MAX_LINE_WIDTH = 120 MAX_LINE_WIDTH = 120
AUTOSAVE_INTERVAL = 5.0 AUTOSAVE_INTERVAL = 5.0
# 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
_EMPTY_LAYOUT = layout.BookLayout(starts=[], heights=[], row_strips=[], total_rows=0, width=1) _EMPTY_LAYOUT = layout.BookLayout(starts=[], heights=[], row_strips=[], total_rows=0, width=1)
@ -81,7 +86,8 @@ class ReaderScreen(Screen):
def compose(self): def compose(self):
yield Header() yield Header()
yield LoadingIndicator(id="reader-loading") yield LoadingIndicator(id="reader-loading")
with Container(id="reader-scroll"): with Container(id="reader-scroll") as scroll_container:
self._scroll_container = scroll_container
# Stays visible (empty) from the start rather than toggling display # Stays visible (empty) from the start rather than toggling display
# on once loaded — a widget that's just been switched from hidden # 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` # to visible hasn't been through a layout pass yet, so its `.size`
@ -93,6 +99,17 @@ class ReaderScreen(Screen):
yield Footer() yield Footer()
def on_mount(self) -> None: 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()) self._load_book(self._content_width())
@work(thread=True) @work(thread=True)
@ -116,11 +133,21 @@ class ReaderScreen(Screen):
self.set_interval(AUTOSAVE_INTERVAL, self._autosave) self.set_interval(AUTOSAVE_INTERVAL, self._autosave)
def _content_width(self) -> int: def _content_width(self) -> int:
return max(20, min(MAX_LINE_WIDTH, self.size.width - 4)) # 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: def _apply_layout(self, book_layout: layout.BookLayout, width: int) -> None:
self.book_layout = book_layout self.book_layout = book_layout
self._book_view.styles.width = width # 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.book_layout = book_layout
self._book_view.virtual_size = Size(width, book_layout.total_rows) self._book_view.virtual_size = Size(width, book_layout.total_rows)
self._book_view.refresh() self._book_view.refresh()