rsvpreader 1.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. rsvpreader-1.1.0/.gitignore +19 -0
  2. rsvpreader-1.1.0/LICENSE +21 -0
  3. rsvpreader-1.1.0/PKG-INFO +149 -0
  4. rsvpreader-1.1.0/README.md +122 -0
  5. rsvpreader-1.1.0/pyproject.toml +70 -0
  6. rsvpreader-1.1.0/src/rsvpreader/__init__.py +8 -0
  7. rsvpreader-1.1.0/src/rsvpreader/__main__.py +6 -0
  8. rsvpreader-1.1.0/src/rsvpreader/app.py +234 -0
  9. rsvpreader-1.1.0/src/rsvpreader/autoplay.py +52 -0
  10. rsvpreader-1.1.0/src/rsvpreader/browser_view.py +154 -0
  11. rsvpreader-1.1.0/src/rsvpreader/cli.py +104 -0
  12. rsvpreader-1.1.0/src/rsvpreader/colors.py +24 -0
  13. rsvpreader-1.1.0/src/rsvpreader/layout.py +74 -0
  14. rsvpreader-1.1.0/src/rsvpreader/library.py +43 -0
  15. rsvpreader-1.1.0/src/rsvpreader/paths.py +57 -0
  16. rsvpreader-1.1.0/src/rsvpreader/pivot.py +65 -0
  17. rsvpreader-1.1.0/src/rsvpreader/progress.py +54 -0
  18. rsvpreader-1.1.0/src/rsvpreader/reader_view.py +323 -0
  19. rsvpreader-1.1.0/src/rsvpreader/session.py +85 -0
  20. rsvpreader-1.1.0/src/rsvpreader/settings.py +96 -0
  21. rsvpreader-1.1.0/src/rsvpreader/settings_panel.py +150 -0
  22. rsvpreader-1.1.0/src/rsvpreader/storage.py +38 -0
  23. rsvpreader-1.1.0/src/rsvpreader/text.py +194 -0
  24. rsvpreader-1.1.0/src/rsvpreader/theme.py +60 -0
  25. rsvpreader-1.1.0/tests/test_autoplay.py +40 -0
  26. rsvpreader-1.1.0/tests/test_gui_smoke.py +237 -0
  27. rsvpreader-1.1.0/tests/test_layout.py +42 -0
  28. rsvpreader-1.1.0/tests/test_library.py +32 -0
  29. rsvpreader-1.1.0/tests/test_paths.py +40 -0
  30. rsvpreader-1.1.0/tests/test_pivot.py +60 -0
  31. rsvpreader-1.1.0/tests/test_progress.py +53 -0
  32. rsvpreader-1.1.0/tests/test_session.py +61 -0
  33. rsvpreader-1.1.0/tests/test_settings.py +80 -0
  34. rsvpreader-1.1.0/tests/test_text.py +190 -0
@@ -0,0 +1,19 @@
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ .venv/
5
+ .pytest_cache/
6
+ .ruff_cache/
7
+ build/
8
+ dist/
9
+ *.egg-info/
10
+
11
+ # Personal content: books and the legacy single-script settings file
12
+ book/
13
+ rsvp_settings.json
14
+
15
+ # Local task tracking (tasks-axi)
16
+ backlog.md
17
+
18
+ # macOS
19
+ .DS_Store
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Shreejit Verma
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,149 @@
1
+ Metadata-Version: 2.5
2
+ Name: rsvpreader
3
+ Version: 1.1.0
4
+ Summary: Spritz-style rapid serial visual presentation reader for plain-text books, with a fixed pivot letter and per-book resume.
5
+ Project-URL: Homepage, https://github.com/shreejitverma/rsvpreader
6
+ Project-URL: Repository, https://github.com/shreejitverma/rsvpreader
7
+ Project-URL: Issues, https://github.com/shreejitverma/rsvpreader/issues
8
+ Project-URL: Releases, https://github.com/shreejitverma/rsvpreader/releases
9
+ Author: Shreejit Verma
10
+ License-Expression: MIT
11
+ License-File: LICENSE
12
+ Keywords: reader,rsvp,speed-reading,spritz,tkinter
13
+ Classifier: Environment :: MacOS X
14
+ Classifier: Environment :: Win32 (MS Windows)
15
+ Classifier: Environment :: X11 Applications
16
+ Classifier: Intended Audience :: End Users/Desktop
17
+ Classifier: Operating System :: OS Independent
18
+ Classifier: Programming Language :: Python :: 3
19
+ Classifier: Programming Language :: Python :: 3.10
20
+ Classifier: Programming Language :: Python :: 3.11
21
+ Classifier: Programming Language :: Python :: 3.12
22
+ Classifier: Programming Language :: Python :: 3.13
23
+ Classifier: Programming Language :: Python :: 3.14
24
+ Classifier: Topic :: Text Processing
25
+ Requires-Python: >=3.10
26
+ Description-Content-Type: text/markdown
27
+
28
+ # rsvpreader
29
+
30
+ A Spritz-style rapid serial visual presentation reader for plain-text books.
31
+ It shows one chunk of one to three words at a time with a fixed pivot letter pinned to the center of the screen, or one full sentence at a time, and remembers where you stopped in every book.
32
+
33
+ [![CI](https://github.com/shreejitverma/rsvpreader/actions/workflows/ci.yml/badge.svg)](https://github.com/shreejitverma/rsvpreader/actions/workflows/ci.yml)
34
+ [![PyPI](https://img.shields.io/pypi/v/rsvpreader)](https://pypi.org/project/rsvpreader/)
35
+
36
+ ## Install and run
37
+
38
+ Requires Python 3.10 or newer with Tk.
39
+ On macOS, Homebrew's Python ships without Tk; `uv` installs a Python that has it.
40
+
41
+ From PyPI:
42
+
43
+ ```sh
44
+ uv tool install rsvpreader # or: pipx install rsvpreader, pip install rsvpreader
45
+ rsvpreader # fullscreen, reads ./book
46
+ rsvpreader --windowed # normal window
47
+ rsvpreader ~/Books # a different book folder
48
+ ```
49
+
50
+ From a checkout:
51
+
52
+ ```sh
53
+ uv sync
54
+ uv run rsvpreader --windowed
55
+ ```
56
+
57
+ `python rsvpreader.py` works as a launcher from a checkout without installing.
58
+
59
+ Put `.txt` files, in any folder structure, under `book/` next to where you run the command, or point `RSVPREADER_BOOK_ROOT` or the positional argument at another folder.
60
+
61
+ ## Controls
62
+
63
+ | Where | Input | Action |
64
+ |---|---|---|
65
+ | Browser | Click, Enter, Space, Right | Open folder or book |
66
+ | Browser | Up, Down | Move selection |
67
+ | Browser | Backspace, Left | Back one folder |
68
+ | Reader | Click, Right, Space | Next chunk or sentence |
69
+ | Reader | Secondary click, Left | Previous chunk or sentence |
70
+ | Reader | Hold click 2 s | Autoplay while held, ramping up to the max WPM over 5 s |
71
+ | Reader | p | Toggle autoplay (same ramp) |
72
+ | Reader | Up, Down | Max WPM plus or minus 10 |
73
+ | Reader | Home, End | First or last unit |
74
+ | Reader | Hold secondary click 3 s | Open settings |
75
+ | Anywhere | s or the gear icon | Toggle settings |
76
+ | Anywhere | Esc | Close settings, else back one level, else leave fullscreen |
77
+ | Anywhere | q | Quit |
78
+
79
+ Autoplay speed is true words per minute: a three-word chunk is shown three times as long as a single word, and a sentence for as long as its word count implies.
80
+
81
+ ## Settings
82
+
83
+ Chunk size (1, 2 or 3 words), max autoplay WPM, font family and size, brightness, text color (native color picker) and sentence mode.
84
+ Changes apply live, rebuild the current book without losing your place, and persist to `settings.json` in the user config directory:
85
+
86
+ | Platform | Location |
87
+ |---|---|
88
+ | macOS | `~/Library/Application Support/rsvpreader/` |
89
+ | Linux | `$XDG_CONFIG_HOME/rsvpreader/` or `~/.config/rsvpreader/` |
90
+ | Windows | `%APPDATA%\rsvpreader\` |
91
+
92
+ Override with `--config-dir` or `RSVPREADER_CONFIG_DIR`.
93
+ On first run, settings and reading positions are imported from the directory the 1.0.x releases used (`RSVP_READER_CONFIG_DIR` if set, otherwise `rsvp-reader` in the same locations), then from a legacy `rsvp_settings.json` in the working directory.
94
+ Reading positions live in `progress.json` in the same directory, keyed by the book's absolute path.
95
+
96
+ ## How text is chunked
97
+
98
+ 1. Regular punctuation (comma, semicolon, colon, apostrophe, dashes) attaches to its word and never forces a break.
99
+ 2. A sentence terminator (`.` `!` `?`) closes the chunk on the word carrying it.
100
+ 3. An opening double quote (straight or curly) closes the previous chunk, so the quoted word starts a new one.
101
+ 4. A closing double quote closes the chunk on the word carrying it.
102
+
103
+ Text pasted without spaces is repaired first: `forth,The` becomes `forth, The` and `it."Believe` becomes `it. "Believe`, while `0.99365`, `1,000`, `e.g.` and `7.25p.m.` stay intact.
104
+ Straight double quotes alternate open and close by parity.
105
+ A token with no letters or digits, such as a spaced en dash, glues to the previous word so it never occupies a chunk of its own.
106
+
107
+ The pivot word is the middle word of a three-word chunk, the word with more letters in a two-word chunk (tie goes to the first), or the only word.
108
+ The pivot letter is the middle letter counting letters only, rounding left for an even count (`Bull` highlights `u`); a word with no letters falls back to its digits.
109
+
110
+ To inspect the units for any file without opening the GUI:
111
+
112
+ ```sh
113
+ uv run rsvpreader --dump-chunks "book/selfhelp/some book.txt" --chunk-size 3
114
+ uv run rsvpreader --dump-chunks "book/selfhelp/some book.txt" --sentences
115
+ ```
116
+
117
+ ## Development
118
+
119
+ ```sh
120
+ uv sync
121
+ uv run pytest # unit tests plus Tk smoke tests (skipped without a display)
122
+ uv run ruff check src tests
123
+ uv run ruff format src tests
124
+ uv build # sdist and wheel under dist/
125
+ ```
126
+
127
+ CI runs lint and tests on Linux, macOS and Windows for every push and pull request, and builds the wheel on Linux.
128
+
129
+ ### Releasing
130
+
131
+ The package version lives only in `__version__` in `src/rsvpreader/__init__.py`.
132
+ To release: bump it, merge, then publish a GitHub release whose tag is `v<version>` (for example `v1.0.1`).
133
+ The publish workflow builds the distributions, checks that the tag matches the version, and uploads to PyPI through trusted publishing; no API token is stored anywhere.
134
+
135
+ Layout under `src/rsvpreader/`:
136
+
137
+ | Module | Role |
138
+ |---|---|
139
+ | `text.py` | Normalization, tokenization, chunk and sentence building, file decoding |
140
+ | `pivot.py` | Pivot word and letter selection |
141
+ | `session.py` | Reading cursor over units; position survives mode and chunk-size changes |
142
+ | `autoplay.py` | WPM ramp and due-time logic driven by an external clock |
143
+ | `layout.py` | Pivot box and flow-text geometry |
144
+ | `settings.py`, `progress.py`, `storage.py`, `paths.py`, `colors.py` | Validated settings, per-book resume, atomic JSON, config locations, hex color helpers |
145
+ | `library.py` | Folder listing |
146
+ | `app.py`, `browser_view.py`, `reader_view.py`, `settings_panel.py`, `theme.py` | Tk shell and views |
147
+ | `cli.py` | Argument parsing and entry point |
148
+
149
+ Everything outside the Tk modules is pure and unit tested; the Tk smoke tests drive a real window and assert the rendered pivot geometry from Tk's own bounding boxes.
@@ -0,0 +1,122 @@
1
+ # rsvpreader
2
+
3
+ A Spritz-style rapid serial visual presentation reader for plain-text books.
4
+ It shows one chunk of one to three words at a time with a fixed pivot letter pinned to the center of the screen, or one full sentence at a time, and remembers where you stopped in every book.
5
+
6
+ [![CI](https://github.com/shreejitverma/rsvpreader/actions/workflows/ci.yml/badge.svg)](https://github.com/shreejitverma/rsvpreader/actions/workflows/ci.yml)
7
+ [![PyPI](https://img.shields.io/pypi/v/rsvpreader)](https://pypi.org/project/rsvpreader/)
8
+
9
+ ## Install and run
10
+
11
+ Requires Python 3.10 or newer with Tk.
12
+ On macOS, Homebrew's Python ships without Tk; `uv` installs a Python that has it.
13
+
14
+ From PyPI:
15
+
16
+ ```sh
17
+ uv tool install rsvpreader # or: pipx install rsvpreader, pip install rsvpreader
18
+ rsvpreader # fullscreen, reads ./book
19
+ rsvpreader --windowed # normal window
20
+ rsvpreader ~/Books # a different book folder
21
+ ```
22
+
23
+ From a checkout:
24
+
25
+ ```sh
26
+ uv sync
27
+ uv run rsvpreader --windowed
28
+ ```
29
+
30
+ `python rsvpreader.py` works as a launcher from a checkout without installing.
31
+
32
+ Put `.txt` files, in any folder structure, under `book/` next to where you run the command, or point `RSVPREADER_BOOK_ROOT` or the positional argument at another folder.
33
+
34
+ ## Controls
35
+
36
+ | Where | Input | Action |
37
+ |---|---|---|
38
+ | Browser | Click, Enter, Space, Right | Open folder or book |
39
+ | Browser | Up, Down | Move selection |
40
+ | Browser | Backspace, Left | Back one folder |
41
+ | Reader | Click, Right, Space | Next chunk or sentence |
42
+ | Reader | Secondary click, Left | Previous chunk or sentence |
43
+ | Reader | Hold click 2 s | Autoplay while held, ramping up to the max WPM over 5 s |
44
+ | Reader | p | Toggle autoplay (same ramp) |
45
+ | Reader | Up, Down | Max WPM plus or minus 10 |
46
+ | Reader | Home, End | First or last unit |
47
+ | Reader | Hold secondary click 3 s | Open settings |
48
+ | Anywhere | s or the gear icon | Toggle settings |
49
+ | Anywhere | Esc | Close settings, else back one level, else leave fullscreen |
50
+ | Anywhere | q | Quit |
51
+
52
+ Autoplay speed is true words per minute: a three-word chunk is shown three times as long as a single word, and a sentence for as long as its word count implies.
53
+
54
+ ## Settings
55
+
56
+ Chunk size (1, 2 or 3 words), max autoplay WPM, font family and size, brightness, text color (native color picker) and sentence mode.
57
+ Changes apply live, rebuild the current book without losing your place, and persist to `settings.json` in the user config directory:
58
+
59
+ | Platform | Location |
60
+ |---|---|
61
+ | macOS | `~/Library/Application Support/rsvpreader/` |
62
+ | Linux | `$XDG_CONFIG_HOME/rsvpreader/` or `~/.config/rsvpreader/` |
63
+ | Windows | `%APPDATA%\rsvpreader\` |
64
+
65
+ Override with `--config-dir` or `RSVPREADER_CONFIG_DIR`.
66
+ On first run, settings and reading positions are imported from the directory the 1.0.x releases used (`RSVP_READER_CONFIG_DIR` if set, otherwise `rsvp-reader` in the same locations), then from a legacy `rsvp_settings.json` in the working directory.
67
+ Reading positions live in `progress.json` in the same directory, keyed by the book's absolute path.
68
+
69
+ ## How text is chunked
70
+
71
+ 1. Regular punctuation (comma, semicolon, colon, apostrophe, dashes) attaches to its word and never forces a break.
72
+ 2. A sentence terminator (`.` `!` `?`) closes the chunk on the word carrying it.
73
+ 3. An opening double quote (straight or curly) closes the previous chunk, so the quoted word starts a new one.
74
+ 4. A closing double quote closes the chunk on the word carrying it.
75
+
76
+ Text pasted without spaces is repaired first: `forth,The` becomes `forth, The` and `it."Believe` becomes `it. "Believe`, while `0.99365`, `1,000`, `e.g.` and `7.25p.m.` stay intact.
77
+ Straight double quotes alternate open and close by parity.
78
+ A token with no letters or digits, such as a spaced en dash, glues to the previous word so it never occupies a chunk of its own.
79
+
80
+ The pivot word is the middle word of a three-word chunk, the word with more letters in a two-word chunk (tie goes to the first), or the only word.
81
+ The pivot letter is the middle letter counting letters only, rounding left for an even count (`Bull` highlights `u`); a word with no letters falls back to its digits.
82
+
83
+ To inspect the units for any file without opening the GUI:
84
+
85
+ ```sh
86
+ uv run rsvpreader --dump-chunks "book/selfhelp/some book.txt" --chunk-size 3
87
+ uv run rsvpreader --dump-chunks "book/selfhelp/some book.txt" --sentences
88
+ ```
89
+
90
+ ## Development
91
+
92
+ ```sh
93
+ uv sync
94
+ uv run pytest # unit tests plus Tk smoke tests (skipped without a display)
95
+ uv run ruff check src tests
96
+ uv run ruff format src tests
97
+ uv build # sdist and wheel under dist/
98
+ ```
99
+
100
+ CI runs lint and tests on Linux, macOS and Windows for every push and pull request, and builds the wheel on Linux.
101
+
102
+ ### Releasing
103
+
104
+ The package version lives only in `__version__` in `src/rsvpreader/__init__.py`.
105
+ To release: bump it, merge, then publish a GitHub release whose tag is `v<version>` (for example `v1.0.1`).
106
+ The publish workflow builds the distributions, checks that the tag matches the version, and uploads to PyPI through trusted publishing; no API token is stored anywhere.
107
+
108
+ Layout under `src/rsvpreader/`:
109
+
110
+ | Module | Role |
111
+ |---|---|
112
+ | `text.py` | Normalization, tokenization, chunk and sentence building, file decoding |
113
+ | `pivot.py` | Pivot word and letter selection |
114
+ | `session.py` | Reading cursor over units; position survives mode and chunk-size changes |
115
+ | `autoplay.py` | WPM ramp and due-time logic driven by an external clock |
116
+ | `layout.py` | Pivot box and flow-text geometry |
117
+ | `settings.py`, `progress.py`, `storage.py`, `paths.py`, `colors.py` | Validated settings, per-book resume, atomic JSON, config locations, hex color helpers |
118
+ | `library.py` | Folder listing |
119
+ | `app.py`, `browser_view.py`, `reader_view.py`, `settings_panel.py`, `theme.py` | Tk shell and views |
120
+ | `cli.py` | Argument parsing and entry point |
121
+
122
+ Everything outside the Tk modules is pure and unit tested; the Tk smoke tests drive a real window and assert the rendered pivot geometry from Tk's own bounding boxes.
@@ -0,0 +1,70 @@
1
+ [project]
2
+ name = "rsvpreader"
3
+ dynamic = ["version"]
4
+ description = "Spritz-style rapid serial visual presentation reader for plain-text books, with a fixed pivot letter and per-book resume."
5
+ readme = "README.md"
6
+ requires-python = ">=3.10"
7
+ license = "MIT"
8
+ license-files = ["LICENSE"]
9
+ authors = [{ name = "Shreejit Verma" }]
10
+ keywords = ["rsvp", "speed-reading", "spritz", "tkinter", "reader"]
11
+ classifiers = [
12
+ "Environment :: X11 Applications",
13
+ "Environment :: MacOS X",
14
+ "Environment :: Win32 (MS Windows)",
15
+ "Intended Audience :: End Users/Desktop",
16
+ "Operating System :: OS Independent",
17
+ "Programming Language :: Python :: 3",
18
+ "Programming Language :: Python :: 3.10",
19
+ "Programming Language :: Python :: 3.11",
20
+ "Programming Language :: Python :: 3.12",
21
+ "Programming Language :: Python :: 3.13",
22
+ "Programming Language :: Python :: 3.14",
23
+ "Topic :: Text Processing",
24
+ ]
25
+ dependencies = []
26
+
27
+ [project.scripts]
28
+ rsvpreader = "rsvpreader.cli:main"
29
+
30
+ [project.urls]
31
+ Homepage = "https://github.com/shreejitverma/rsvpreader"
32
+ Repository = "https://github.com/shreejitverma/rsvpreader"
33
+ Issues = "https://github.com/shreejitverma/rsvpreader/issues"
34
+ Releases = "https://github.com/shreejitverma/rsvpreader/releases"
35
+
36
+ [build-system]
37
+ requires = ["hatchling>=1.27"]
38
+ build-backend = "hatchling.build"
39
+
40
+ [tool.hatch.version]
41
+ path = "src/rsvpreader/__init__.py"
42
+
43
+ [tool.hatch.build.targets.wheel]
44
+ packages = ["src/rsvpreader"]
45
+
46
+ [tool.hatch.build.targets.sdist]
47
+ only-include = ["src/rsvpreader", "tests"]
48
+
49
+ [dependency-groups]
50
+ dev = ["pytest>=8", "ruff>=0.6"]
51
+
52
+ [tool.pytest.ini_options]
53
+ testpaths = ["tests"]
54
+ addopts = "-q"
55
+
56
+ [tool.ruff]
57
+ line-length = 100
58
+ target-version = "py310"
59
+ src = ["src", "tests"]
60
+
61
+ [tool.ruff.lint]
62
+ select = ["E", "F", "I", "B", "UP", "SIM", "RUF", "N", "PL"]
63
+ ignore = [
64
+ "PLR0913", # many keyword args are fine for layout/geometry helpers
65
+ "PLR2004", # magic numbers in tests and geometry are self-explanatory
66
+ "RUF001", "RUF002", "RUF003", # typographic quotes and dashes are this project's domain
67
+ ]
68
+
69
+ [tool.ruff.lint.per-file-ignores]
70
+ "tests/*" = ["PLR0915"]
@@ -0,0 +1,8 @@
1
+ """rsvpreader: a Spritz-style rapid serial visual presentation reader for plain-text books.
2
+
3
+ The package is split into pure, Tk-free modules (text, pivot, session, autoplay, layout,
4
+ settings, progress, library) and thin Tk views (app, browser_view, reader_view,
5
+ settings_panel). Everything under the pure modules is unit tested without a display.
6
+ """
7
+
8
+ __version__ = "1.1.0"
@@ -0,0 +1,6 @@
1
+ """Allow `python -m rsvpreader`."""
2
+
3
+ from rsvpreader.cli import main
4
+
5
+ if __name__ == "__main__":
6
+ raise SystemExit(main())
@@ -0,0 +1,234 @@
1
+ """Application shell: window, navigation stack, settings ownership, key dispatch."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+ import tkinter as tk
7
+ import tkinter.font as tkfont
8
+ from pathlib import Path
9
+
10
+ from rsvpreader.browser_view import FolderView
11
+ from rsvpreader.colors import apply_brightness
12
+ from rsvpreader.progress import ProgressStore
13
+ from rsvpreader.reader_view import ReaderView
14
+ from rsvpreader.settings import Settings, SettingsStore, platform_default_font_family
15
+ from rsvpreader.settings_panel import SettingsPanel
16
+ from rsvpreader.theme import (
17
+ BG,
18
+ STATUS_BRIGHTNESS_FACTOR,
19
+ UI_FONT_CANDIDATES,
20
+ Theme,
21
+ resolve_font_family,
22
+ )
23
+
24
+ WINDOWED_GEOMETRY = "1100x700"
25
+ SETTINGS_SAVE_DEBOUNCE_MS = 300
26
+ GEAR_GLYPH = "⚙"
27
+ REBUILD_KEYS = frozenset({"chunk_size", "sentence_mode"})
28
+
29
+
30
+ class App:
31
+ def __init__(
32
+ self,
33
+ root: tk.Tk,
34
+ *,
35
+ book_root: Path,
36
+ settings_store: SettingsStore,
37
+ progress_store: ProgressStore,
38
+ fullscreen: bool = True,
39
+ ) -> None:
40
+ self.root = root
41
+ self.book_root = Path(book_root)
42
+ self.settings_store = settings_store
43
+ self.progress = progress_store
44
+ self.installed_fonts = frozenset(tkfont.families(root))
45
+ self.theme = Theme(resolve_font_family("", self.installed_fonts, UI_FONT_CANDIDATES))
46
+ self._fonts: dict[tuple[str, int, str], tkfont.Font] = {}
47
+ self._settings_save_job: str | None = None
48
+
49
+ loaded = settings_store.load()
50
+ self.settings: Settings = loaded.with_changes(
51
+ font_family=resolve_font_family(
52
+ loaded.font_family,
53
+ self.installed_fonts,
54
+ (platform_default_font_family(), *UI_FONT_CANDIDATES),
55
+ )
56
+ )
57
+
58
+ root.title("rsvpreader")
59
+ root.configure(bg=BG)
60
+ self.is_fullscreen = fullscreen
61
+ if fullscreen:
62
+ root.attributes("-fullscreen", True)
63
+ else:
64
+ root.geometry(WINDOWED_GEOMETRY)
65
+
66
+ self.container = tk.Frame(root, bg=BG)
67
+ self.container.pack(fill="both", expand=True)
68
+
69
+ self.nav_stack: list[tuple[str, Path]] = []
70
+ self.view: FolderView | ReaderView | None = None
71
+ self.settings_panel: SettingsPanel | None = None
72
+ self._gear: tk.Label | None = None
73
+
74
+ root.bind("<Escape>", lambda e: self.on_escape())
75
+ root.bind("<Key>", self._on_key)
76
+ root.protocol("WM_DELETE_WINDOW", self.quit)
77
+
78
+ self.push_folder(self.book_root)
79
+
80
+ # -- appearance -----------------------------------------------------------------
81
+ def accent(self) -> str:
82
+ return apply_brightness(self.settings.font_color, self.settings.brightness)
83
+
84
+ def dim_accent(self) -> str:
85
+ return apply_brightness(
86
+ self.settings.font_color, self.settings.brightness * STATUS_BRIGHTNESS_FACTOR
87
+ )
88
+
89
+ def font(self, family: str, size: int, weight: str = "normal") -> tkfont.Font:
90
+ """Cached named fonts; creating one per redraw would leak Tk font objects."""
91
+ key = (family, size, weight)
92
+ if key not in self._fonts:
93
+ self._fonts[key] = tkfont.Font(root=self.root, family=family, size=size, weight=weight)
94
+ return self._fonts[key]
95
+
96
+ def reader_font(self) -> tkfont.Font:
97
+ return self.font(self.settings.font_family, self.settings.font_size, "bold")
98
+
99
+ # -- navigation --------------------------------------------------------------------
100
+ def push_folder(self, folder: Path) -> None:
101
+ self.nav_stack.append(("folder", Path(folder)))
102
+ self._show_top()
103
+
104
+ def open_book(self, book_path: Path) -> None:
105
+ self.nav_stack.append(("reader", Path(book_path)))
106
+ self._show_top()
107
+
108
+ def _show_top(self, focus: Path | None = None) -> None:
109
+ """Render the top of the stack; `focus` preselects that entry in a folder view."""
110
+ self.close_settings_panel()
111
+ if self.view is not None:
112
+ self.view.destroy()
113
+ self.view = None
114
+ kind, path = self.nav_stack[-1]
115
+ if kind == "folder":
116
+ self.view = FolderView(self, self.container, path, focus=focus)
117
+ else:
118
+ self.view = ReaderView(self, self.container, path)
119
+ self._add_gear()
120
+
121
+ def on_escape(self) -> None:
122
+ if self.settings_panel is not None:
123
+ self.close_settings_panel()
124
+ return
125
+ if len(self.nav_stack) > 1:
126
+ _, left = self.nav_stack.pop()
127
+ self._show_top(focus=left)
128
+ return
129
+ if self.is_fullscreen:
130
+ self.root.attributes("-fullscreen", False)
131
+ self.root.geometry(WINDOWED_GEOMETRY)
132
+ self.is_fullscreen = False
133
+
134
+ def quit(self) -> None:
135
+ if self.view is not None:
136
+ self.view.destroy() # the reader view saves its position on destroy
137
+ self.view = None
138
+ self._flush_settings_save()
139
+ self.progress.save()
140
+ self.root.destroy()
141
+
142
+ def _on_key(self, event: tk.Event) -> None:
143
+ keysym = event.keysym
144
+ if keysym == "Escape":
145
+ return # handled by the dedicated <Escape> binding
146
+ if event.char == "q":
147
+ self.quit()
148
+ return
149
+ if event.char == "s":
150
+ self.toggle_settings_panel()
151
+ return
152
+ if self.settings_panel is not None:
153
+ return # keys belong to the panel's widgets while it is open
154
+ if self.view is not None:
155
+ self.view.on_key(event)
156
+
157
+ # -- gear icon + settings panel -------------------------------------------------
158
+ def _add_gear(self) -> None:
159
+ self._gear = tk.Label(
160
+ self.container,
161
+ text=GEAR_GLYPH,
162
+ font=self.theme.gear,
163
+ fg=self.accent(),
164
+ bg=BG,
165
+ cursor="hand2",
166
+ )
167
+ self._gear.place(relx=1.0, rely=0.0, x=-16, y=10, anchor="ne")
168
+ self._gear.bind("<Button-1>", lambda e: self.toggle_settings_panel())
169
+
170
+ def toggle_settings_panel(self) -> None:
171
+ if self.settings_panel is None:
172
+ self.open_settings_panel()
173
+ else:
174
+ self.close_settings_panel()
175
+
176
+ def open_settings_panel(self) -> None:
177
+ if self.settings_panel is None:
178
+ self.settings_panel = SettingsPanel(self, self.container)
179
+
180
+ def close_settings_panel(self) -> None:
181
+ if self.settings_panel is not None:
182
+ self.settings_panel.destroy()
183
+ self.settings_panel = None
184
+
185
+ # -- settings ---------------------------------------------------------------------
186
+ def update_settings(self, **changes: object) -> None:
187
+ new = self.settings.with_changes(**changes)
188
+ if new == self.settings:
189
+ return
190
+ rebuild = any(getattr(new, k) != getattr(self.settings, k) for k in REBUILD_KEYS)
191
+ self.settings = new
192
+ self._schedule_settings_save()
193
+ if self.view is not None:
194
+ self.view.on_settings_changed(rebuild=rebuild)
195
+ if self._gear is not None:
196
+ self._gear.configure(fg=self.accent())
197
+ if self.settings_panel is not None:
198
+ self.settings_panel.refresh()
199
+
200
+ def _schedule_settings_save(self) -> None:
201
+ if self._settings_save_job is not None:
202
+ self.root.after_cancel(self._settings_save_job)
203
+ self._settings_save_job = self.root.after(
204
+ SETTINGS_SAVE_DEBOUNCE_MS, self._flush_settings_save
205
+ )
206
+
207
+ def _flush_settings_save(self) -> None:
208
+ if self._settings_save_job is not None:
209
+ self.root.after_cancel(self._settings_save_job)
210
+ self._settings_save_job = None
211
+ self.settings_store.save(self.settings)
212
+
213
+
214
+ def run_app(
215
+ *,
216
+ book_root: Path,
217
+ settings_store: SettingsStore,
218
+ progress_store: ProgressStore,
219
+ fullscreen: bool,
220
+ ) -> int:
221
+ try:
222
+ root = tk.Tk()
223
+ except tk.TclError as exc:
224
+ print(f"rsvpreader: cannot open a display: {exc}", file=sys.stderr)
225
+ return 1
226
+ App(
227
+ root,
228
+ book_root=book_root,
229
+ settings_store=settings_store,
230
+ progress_store=progress_store,
231
+ fullscreen=fullscreen,
232
+ )
233
+ root.mainloop()
234
+ return 0