Compare commits

...

2 commits

Author SHA1 Message Date
marwin
e9683fbdbd 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.
2026-08-15 21:07:41 +02:00
marwin
20d04361bc TUI: Statusleiste mit Akkustand und Uhrzeit
Neues diora_tui/statusbar.py: StatusBar-Widget, unten rechts auf Bibliotheks-
und Reader-Screen, zeigt Akkustand (psutil.sensors_battery(), degradiert
sauber zu reiner Uhrzeit auf Geräten ohne Akku) + aktuelle Uhrzeit,
sekündlich aktualisiert.

Getestet: Anzeige und Aktualisierung auf beiden Screens verifiziert.
2026-08-15 20:36:54 +02:00
7 changed files with 106 additions and 5 deletions

View file

@ -44,6 +44,9 @@ Tastenkürzel:
Der Fließtext ist auf 120 Zeichen Breite begrenzt und horizontal zentriert (lesbarer als Der Fließtext ist auf 120 Zeichen Breite begrenzt und horizontal zentriert (lesbarer als
volle Terminalbreite bei breiten Fenstern). volle Terminalbreite bei breiten Fenstern).
Unten rechts zeigt eine Statusleiste Akkustand (falls vorhanden, via `psutil`) und Uhrzeit,
sekündlich aktualisiert, auf Bibliotheks- und Reader-Ansicht.
Die Bibliotheksansicht ist nach zuletzt geöffnetem Buch sortiert (neueste zuerst; anhand Die Bibliotheksansicht ist nach zuletzt geöffnetem Buch sortiert (neueste zuerst; anhand
des Zeitstempels der zuletzt gespeicherten Position), und blendet gelesene Bücher des Zeitstempels der zuletzt gespeicherten Position), und blendet gelesene Bücher
standardmäßig aus (`EBook.is_read` aus dem Sync-Snapshot, lokal in `library.json` standardmäßig aus (`EBook.is_read` aus dem Sync-Snapshot, lokal in `library.json`

View file

@ -17,6 +17,7 @@ from textual.widgets import Footer, Header, Label, ListItem, ListView
from . import config as config_mod from . import config as config_mod
from . import crypto, epub, library_meta, progress, remote from . import crypto, epub, library_meta, progress, remote
from .reader_screen import ReaderScreen from .reader_screen import ReaderScreen
from .statusbar import StatusBar
DEFAULT_LIBRARY = Path.home() / "Books" DEFAULT_LIBRARY = Path.home() / "Books"
@ -38,6 +39,7 @@ class LibraryScreen(Screen):
def compose(self) -> ComposeResult: def compose(self) -> ComposeResult:
yield Header() yield Header()
yield ListView(id="library-list") yield ListView(id="library-list")
yield StatusBar()
yield Footer() yield Footer()
def on_mount(self) -> None: def on_mount(self) -> None:

View file

@ -2,6 +2,15 @@
height: 1fr; height: 1fr;
} }
StatusBar {
dock: bottom;
height: 1;
padding: 0 1;
background: $panel;
color: $text-muted;
text-align: right;
}
#reader-scroll { #reader-scroll {
height: 1fr; height: 1fr;
padding: 1 2; padding: 1 2;

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

@ -25,9 +25,15 @@ from textual.widgets import Footer, Header, Label, LoadingIndicator, Static
from . import api, blocks, cache, config as config_mod, layout, progress from . import api, blocks, cache, config as config_mod, layout, progress
from .book_view import ContinuousBookView from .book_view import ContinuousBookView
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)
@ -80,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`
@ -88,9 +95,21 @@ class ReaderScreen(Screen):
# nothing to clamp against and silently resets to 0. # nothing to clamp against and silently resets to 0.
self._book_view = ContinuousBookView(_EMPTY_LAYOUT) self._book_view = ContinuousBookView(_EMPTY_LAYOUT)
yield self._book_view yield self._book_view
yield StatusBar()
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)
@ -114,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()

View file

@ -0,0 +1,44 @@
"""Bottom status bar: battery level + current time, refreshed every second.
Battery is read via psutil (cross-platform); on a desktop machine with no
battery, psutil.sensors_battery() returns None and the bar just shows time.
"""
from __future__ import annotations
from datetime import datetime
from textual.widgets import Static
try:
import psutil
except ImportError:
psutil = None # battery display degrades gracefully to time-only
class StatusBar(Static):
def on_mount(self) -> None:
self._refresh()
self.set_interval(1.0, self._refresh)
def _refresh(self) -> None:
self.update(self._render_text())
def _render_text(self) -> str:
parts = []
battery = self._battery_text()
if battery:
parts.append(battery)
parts.append(datetime.now().strftime("%H:%M:%S"))
return " ".join(parts)
def _battery_text(self) -> str | None:
if psutil is None:
return None
try:
battery = psutil.sensors_battery()
except Exception:
return None
if battery is None:
return None
state = "lädt" if battery.power_plugged else "Akku"
return f"{state} {round(battery.percent)}%"

View file

@ -11,6 +11,7 @@ dependencies = [
"platformdirs>=4.0", "platformdirs>=4.0",
"requests>=2.31", "requests>=2.31",
"cryptography>=42.0", "cryptography>=42.0",
"psutil>=5.9",
] ]
[project.scripts] [project.scripts]