Compare commits
2 commits
b1a04d2a65
...
e9683fbdbd
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e9683fbdbd | ||
|
|
20d04361bc |
7 changed files with 106 additions and 5 deletions
|
|
@ -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`
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
44
tui/diora_tui/statusbar.py
Normal file
44
tui/diora_tui/statusbar.py
Normal 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)}%"
|
||||||
|
|
@ -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]
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue