From e9683fbdbd1792c4456192e644aee89e4f3935cc Mon Sep 17 00:00:00 2001 From: marwin Date: Sat, 15 Aug 2026 21:07:41 +0200 Subject: [PATCH] TUI: Zeichen am rechten Rand bei schmalen Terminals nicht mehr abgeschnitten MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- tui/diora_tui/book_view.py | 17 +++++++++++++++-- tui/diora_tui/reader_screen.py | 33 ++++++++++++++++++++++++++++++--- 2 files changed, 45 insertions(+), 5 deletions(-) diff --git a/tui/diora_tui/book_view.py b/tui/diora_tui/book_view.py index f92a5b6..9ccc14e 100644 --- a/tui/diora_tui/book_view.py +++ b/tui/diora_tui/book_view.py @@ -15,6 +15,18 @@ from .layout import BookLayout 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: super().__init__() self.book_layout = book_layout @@ -24,6 +36,7 @@ class ContinuousBookView(ScrollView): _scroll_x, scroll_y = self.scroll_offset row = scroll_y + y strips = self.book_layout.row_strips + width = self.scrollable_content_region.width if row < 0 or row >= len(strips): - return Strip.blank(self.size.width, self.rich_style) - return strips[row].crop_extend(0, self.size.width, self.rich_style) + return Strip.blank(width, self.rich_style) + return strips[row].crop_extend(0, width, self.rich_style) diff --git a/tui/diora_tui/reader_screen.py b/tui/diora_tui/reader_screen.py index f820a0f..6d4392b 100644 --- a/tui/diora_tui/reader_screen.py +++ b/tui/diora_tui/reader_screen.py @@ -29,6 +29,11 @@ from .statusbar import StatusBar MAX_LINE_WIDTH = 120 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) @@ -81,7 +86,8 @@ class ReaderScreen(Screen): def compose(self): yield Header() 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 # 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` @@ -93,6 +99,17 @@ class ReaderScreen(Screen): 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) @@ -116,11 +133,21 @@ class ReaderScreen(Screen): self.set_interval(AUTOSAVE_INTERVAL, self._autosave) 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: 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.virtual_size = Size(width, book_layout.total_rows) self._book_view.refresh()