rsvp-pivot-reader 0.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.
- rsvp_pivot_reader-0.1.0/PKG-INFO +30 -0
- rsvp_pivot_reader-0.1.0/README.md +1 -0
- rsvp_pivot_reader-0.1.0/my_package/rsvp_pivot_reader.egg-info/PKG-INFO +30 -0
- rsvp_pivot_reader-0.1.0/my_package/rsvp_pivot_reader.egg-info/SOURCES.txt +9 -0
- rsvp_pivot_reader-0.1.0/my_package/rsvp_pivot_reader.egg-info/dependency_links.txt +1 -0
- rsvp_pivot_reader-0.1.0/my_package/rsvp_pivot_reader.egg-info/entry_points.txt +2 -0
- rsvp_pivot_reader-0.1.0/my_package/rsvp_pivot_reader.egg-info/requires.txt +6 -0
- rsvp_pivot_reader-0.1.0/my_package/rsvp_pivot_reader.egg-info/top_level.txt +1 -0
- rsvp_pivot_reader-0.1.0/my_package/rsvp_reader.py +1077 -0
- rsvp_pivot_reader-0.1.0/pyproject.toml +59 -0
- rsvp_pivot_reader-0.1.0/setup.cfg +4 -0
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: rsvp-pivot-reader
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A Rapid Serial Visual Presentation (RSVP) speed reader with a Spritz-style center-anchored pivot-letter display, folder browser, and persistent settings
|
|
5
|
+
Author: Rutvik Babar
|
|
6
|
+
Maintainer: Rutvik Babar
|
|
7
|
+
License-Expression: MIT
|
|
8
|
+
Project-URL: Homepage, https://github.com/RutvikBabar/RSVP-Reader
|
|
9
|
+
Project-URL: Repository, https://github.com/RutvikBabar/RSVP-Reader
|
|
10
|
+
Project-URL: Issues, https://github.com/RutvikBabar/RSVP-Reader/issues
|
|
11
|
+
Keywords: rsvp,speed-reading,spritz,pivot,reader,tkinter,productivity
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Intended Audience :: End Users/Desktop
|
|
14
|
+
Classifier: Operating System :: OS Independent
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
20
|
+
Classifier: Topic :: Text Processing
|
|
21
|
+
Classifier: Topic :: Utilities
|
|
22
|
+
Requires-Python: >=3.10
|
|
23
|
+
Description-Content-Type: text/markdown
|
|
24
|
+
Provides-Extra: dev
|
|
25
|
+
Requires-Dist: ruff>=0.5; extra == "dev"
|
|
26
|
+
Requires-Dist: mypy>=1.10; extra == "dev"
|
|
27
|
+
Requires-Dist: build>=1.2; extra == "dev"
|
|
28
|
+
Requires-Dist: twine>=5.0; extra == "dev"
|
|
29
|
+
|
|
30
|
+
# RSVP Pivot Reader
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# RSVP Pivot Reader
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: rsvp-pivot-reader
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A Rapid Serial Visual Presentation (RSVP) speed reader with a Spritz-style center-anchored pivot-letter display, folder browser, and persistent settings
|
|
5
|
+
Author: Rutvik Babar
|
|
6
|
+
Maintainer: Rutvik Babar
|
|
7
|
+
License-Expression: MIT
|
|
8
|
+
Project-URL: Homepage, https://github.com/RutvikBabar/RSVP-Reader
|
|
9
|
+
Project-URL: Repository, https://github.com/RutvikBabar/RSVP-Reader
|
|
10
|
+
Project-URL: Issues, https://github.com/RutvikBabar/RSVP-Reader/issues
|
|
11
|
+
Keywords: rsvp,speed-reading,spritz,pivot,reader,tkinter,productivity
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Intended Audience :: End Users/Desktop
|
|
14
|
+
Classifier: Operating System :: OS Independent
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
20
|
+
Classifier: Topic :: Text Processing
|
|
21
|
+
Classifier: Topic :: Utilities
|
|
22
|
+
Requires-Python: >=3.10
|
|
23
|
+
Description-Content-Type: text/markdown
|
|
24
|
+
Provides-Extra: dev
|
|
25
|
+
Requires-Dist: ruff>=0.5; extra == "dev"
|
|
26
|
+
Requires-Dist: mypy>=1.10; extra == "dev"
|
|
27
|
+
Requires-Dist: build>=1.2; extra == "dev"
|
|
28
|
+
Requires-Dist: twine>=5.0; extra == "dev"
|
|
29
|
+
|
|
30
|
+
# RSVP Pivot Reader
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
my_package/rsvp_reader.py
|
|
4
|
+
my_package/rsvp_pivot_reader.egg-info/PKG-INFO
|
|
5
|
+
my_package/rsvp_pivot_reader.egg-info/SOURCES.txt
|
|
6
|
+
my_package/rsvp_pivot_reader.egg-info/dependency_links.txt
|
|
7
|
+
my_package/rsvp_pivot_reader.egg-info/entry_points.txt
|
|
8
|
+
my_package/rsvp_pivot_reader.egg-info/requires.txt
|
|
9
|
+
my_package/rsvp_pivot_reader.egg-info/top_level.txt
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
rsvp_reader
|
|
@@ -0,0 +1,1077 @@
|
|
|
1
|
+
"""
|
|
2
|
+
RSVP Reader - Full Application
|
|
3
|
+
--------------------------------
|
|
4
|
+
Browses a book folder (with subfolders) and displays any selected .txt file
|
|
5
|
+
as an RSVP reader (chunks of N words) on a fullscreen window, with a
|
|
6
|
+
center-anchored pivot-letter guide box (Spritz-style) and a persistent
|
|
7
|
+
settings menu.
|
|
8
|
+
|
|
9
|
+
CHUNKING RULES
|
|
10
|
+
1. Regular punctuation (comma, semicolon, colon, apostrophe, etc.) attaches
|
|
11
|
+
to its word and never forces an early or late break. The chunk just
|
|
12
|
+
keeps filling toward CHUNK_SIZE.
|
|
13
|
+
2. A period (.) forces the current chunk to close immediately on the word
|
|
14
|
+
carrying it (chunk can end at 1, 2, or 3 words). The next word starts a
|
|
15
|
+
fresh chunk.
|
|
16
|
+
3. An opening quotation mark (straight " or curly “) forces the PREVIOUS
|
|
17
|
+
chunk to close right before it, so the quoted word always starts a new
|
|
18
|
+
chunk.
|
|
19
|
+
4. A closing quotation mark (straight " or curly ”) follows the same rule
|
|
20
|
+
as a period: it forces the chunk to close immediately on the word
|
|
21
|
+
carrying it.
|
|
22
|
+
|
|
23
|
+
A preprocessing pass fixes glued text with no space around punctuation or
|
|
24
|
+
quotes (e.g. text pasted as one line, like 'it."Believe' or 'forth,The'),
|
|
25
|
+
which otherwise hides these boundaries from the chunker. Straight double
|
|
26
|
+
quotes are handled with a parity tracker (1st, 3rd, 5th... occurrence is
|
|
27
|
+
treated as opening; 2nd, 4th, 6th... as closing).
|
|
28
|
+
|
|
29
|
+
PIVOT-LETTER HIGHLIGHT (center-anchored RSVP display)
|
|
30
|
+
- Within each displayed chunk, exactly one word is the "pivot word":
|
|
31
|
+
* 3-word chunk -> the middle (2nd) word.
|
|
32
|
+
* 2-word chunk -> whichever word has more letters (ties -> first word).
|
|
33
|
+
* 1-word chunk -> that word.
|
|
34
|
+
- Within the pivot word, exactly one LETTER is the "pivot letter", counting
|
|
35
|
+
only alphabetic characters (punctuation is ignored for this calculation):
|
|
36
|
+
* Odd letter-count -> the exact middle letter.
|
|
37
|
+
* Even letter-count -> the letter just left of center
|
|
38
|
+
(e.g. "Bull" -> 4 letters -> index 1 -> "u").
|
|
39
|
+
- The pivot letter is rendered inside a FIXED-SIZE box (dimensions constant
|
|
40
|
+
across every chunk/letter -- sized once per render to the widest glyph
|
|
41
|
+
the current font can render -- so it never grows or shrinks
|
|
42
|
+
letter-to-letter). Two short vertical tick marks are drawn perpendicular
|
|
43
|
+
to the box, one centered just above it and one just below it (classic
|
|
44
|
+
Spritz-style guide marks), rather than horizontal bars. This pivot letter
|
|
45
|
+
is drawn at a fixed x position at the exact horizontal center of the
|
|
46
|
+
screen; the rest of the chunk's text is drawn snugly against the box's
|
|
47
|
+
left/right edges (measured by actual pixel text width), so the pivot
|
|
48
|
+
letter itself never moves on screen no matter how long or short the
|
|
49
|
+
chunk/word is, and there's minimal dead space between the box and the
|
|
50
|
+
flowing text. This is implemented with a Canvas (not a Label), since a
|
|
51
|
+
Label cannot pin one character to a fixed coordinate while text flows
|
|
52
|
+
around it. Inter-word spacing is intentionally tightened.
|
|
53
|
+
- SENTENCE MODE (toggle in settings): instead of word-chunks with a pivot
|
|
54
|
+
box, the reader shows one full sentence at a time (text up to and
|
|
55
|
+
including the next period) as a single static block of text, with no
|
|
56
|
+
pivot letter/box/lines at all. Left-click/hold-to-autoplay and
|
|
57
|
+
right-click/back still work the same way, just advancing by sentence
|
|
58
|
+
instead of by chunk.
|
|
59
|
+
|
|
60
|
+
SETTINGS MENU (gear icon, top-right corner)
|
|
61
|
+
- Click the gear icon to open/close the settings panel directly.
|
|
62
|
+
- Additionally, holding right-click for 3+ seconds anywhere in the reader
|
|
63
|
+
also opens the settings panel (a short right-click under 3 seconds still
|
|
64
|
+
just goes back one chunk/sentence as before).
|
|
65
|
+
- Settings:
|
|
66
|
+
1. Chunk size: 1 or 3 words. Changing this while a book is open
|
|
67
|
+
re-chunks immediately from the current reading position (tries to
|
|
68
|
+
preserve your place in the underlying text).
|
|
69
|
+
2. Words-per-minute (WPM) autoplay rate: this is the MAXIMUM speed
|
|
70
|
+
autoplay ramps up to. Holding left-click for 2+ seconds starts
|
|
71
|
+
autoplay; it begins at a slow pace and gradually accelerates,
|
|
72
|
+
reaching the configured max WPM after 5 seconds of continuous
|
|
73
|
+
holding, then holds steady at max WPM until released. Releasing
|
|
74
|
+
the mouse button stops autoplay immediately. A short left-click
|
|
75
|
+
(under 2 seconds) just advances one chunk/sentence as normal.
|
|
76
|
+
3. Font family.
|
|
77
|
+
4. Font size.
|
|
78
|
+
5. Brightness (dims/brightens the displayed text and UI accent color).
|
|
79
|
+
6. Font color: opens the OS-native color-wheel/picker dialog
|
|
80
|
+
(tkinter.colorchooser) so the user can choose ANY color, rather than
|
|
81
|
+
being limited to a fixed swatch palette. A live preview swatch next
|
|
82
|
+
to the button shows the currently selected color.
|
|
83
|
+
7. Sentence mode on/off (see above).
|
|
84
|
+
- All settings persist across sessions via a small JSON config file
|
|
85
|
+
(rsvp_settings.json) saved next to this script.
|
|
86
|
+
|
|
87
|
+
NAVIGATION
|
|
88
|
+
- Folder browser: left-click a folder to open it, left-click a .txt file to
|
|
89
|
+
start reading it. The current folder path is shown at the very top, with
|
|
90
|
+
a separator line beneath it; the folder/file list itself is dead-centered
|
|
91
|
+
in the remaining window space.
|
|
92
|
+
- Reader:
|
|
93
|
+
* Short left-click -> forward one chunk/sentence.
|
|
94
|
+
* Left-click held 2+ seconds -> autoplay forward, ramping from a slow
|
|
95
|
+
start up to the configured max WPM over 5 seconds, until released.
|
|
96
|
+
* Short right-click -> back one chunk/sentence.
|
|
97
|
+
* Right-click held 3+ seconds -> opens the settings panel.
|
|
98
|
+
- Escape goes back exactly one level (reader -> the folder it was opened
|
|
99
|
+
from -> parent folder -> ... -> root folder), using an explicit
|
|
100
|
+
navigation STACK so each press pops exactly one level. Once you are
|
|
101
|
+
already at the root folder view (nothing left to pop), Escape instead
|
|
102
|
+
shrinks the window out of fullscreen mode. Escape also closes the
|
|
103
|
+
settings panel first, if it's open.
|
|
104
|
+
- 'q' quits the application from anywhere.
|
|
105
|
+
|
|
106
|
+
SETUP
|
|
107
|
+
- Place your book .txt files (and optional subfolders) inside a folder
|
|
108
|
+
named "book" next to this script.
|
|
109
|
+
"""
|
|
110
|
+
|
|
111
|
+
import os
|
|
112
|
+
import re
|
|
113
|
+
import json
|
|
114
|
+
import string
|
|
115
|
+
import tkinter as tk
|
|
116
|
+
import tkinter.font as tkfont
|
|
117
|
+
from tkinter import colorchooser
|
|
118
|
+
|
|
119
|
+
BOOK_ROOT = "book"
|
|
120
|
+
SETTINGS_PATH = "rsvp_settings.json"
|
|
121
|
+
|
|
122
|
+
DEFAULT_SETTINGS = {
|
|
123
|
+
"chunk_size": 3, # 1 or 3
|
|
124
|
+
"wpm": 300, # this is the MAX wpm autoplay ramps up to
|
|
125
|
+
"font_family": "Consolas",
|
|
126
|
+
"font_size": 40,
|
|
127
|
+
"brightness": 100, # 0-100 percent
|
|
128
|
+
"font_color": "#FF4500", # base (100% brightness) accent/text color
|
|
129
|
+
"sentence_mode": False,
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
BG_COLOR = "#000000"
|
|
133
|
+
PIVOT_FG_COLOR = "#FFFFFF"
|
|
134
|
+
STATUS_COLOR = "#552200"
|
|
135
|
+
BROWSER_FONT = ("Consolas", 20)
|
|
136
|
+
HEADER_FONT = ("Consolas", 14, "bold")
|
|
137
|
+
STATUS_FONT = ("Consolas", 10)
|
|
138
|
+
GEAR_FONT = ("Consolas", 22)
|
|
139
|
+
PANEL_LABEL_FONT = ("Consolas", 13, "bold")
|
|
140
|
+
PANEL_FONT = ("Consolas", 12)
|
|
141
|
+
|
|
142
|
+
STRAIGHT_QUOTE = '"'
|
|
143
|
+
OPEN_CURLY = "\u201c"
|
|
144
|
+
CLOSE_CURLY = "\u201d"
|
|
145
|
+
OPENING_QUOTES = (STRAIGHT_QUOTE, OPEN_CURLY)
|
|
146
|
+
CLOSING_QUOTES = (STRAIGHT_QUOTE, CLOSE_CURLY)
|
|
147
|
+
NO_SPACE_NEEDED_AFTER = (" ", "\n", "\t", ".", ",", ";", ":", "!", "?")
|
|
148
|
+
|
|
149
|
+
FONT_CHOICES = ["Consolas", "Courier New", "Georgia", "Arial", "Times New Roman", "Verdana"]
|
|
150
|
+
|
|
151
|
+
# All glyphs considered when measuring the widest-possible character, so the
|
|
152
|
+
# pivot box can be sized once (fixed) rather than per-letter.
|
|
153
|
+
WIDTH_PROBE_CHARS = string.ascii_uppercase + string.ascii_lowercase
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
# ---------------------------------------------------------------------------
|
|
157
|
+
# Settings persistence
|
|
158
|
+
# ---------------------------------------------------------------------------
|
|
159
|
+
|
|
160
|
+
def load_settings():
|
|
161
|
+
settings = dict(DEFAULT_SETTINGS)
|
|
162
|
+
if os.path.isfile(SETTINGS_PATH):
|
|
163
|
+
try:
|
|
164
|
+
with open(SETTINGS_PATH, "r", encoding="utf-8") as f:
|
|
165
|
+
saved = json.load(f)
|
|
166
|
+
for k in DEFAULT_SETTINGS:
|
|
167
|
+
if k in saved:
|
|
168
|
+
settings[k] = saved[k]
|
|
169
|
+
except (OSError, json.JSONDecodeError):
|
|
170
|
+
pass
|
|
171
|
+
return settings
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def save_settings(settings):
|
|
175
|
+
try:
|
|
176
|
+
with open(SETTINGS_PATH, "w", encoding="utf-8") as f:
|
|
177
|
+
json.dump(settings, f, indent=2)
|
|
178
|
+
except OSError:
|
|
179
|
+
pass
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def apply_brightness(hex_color, brightness_pct):
|
|
183
|
+
"""Scales an #RRGGBB color's channels by brightness_pct (0-100)."""
|
|
184
|
+
hex_color = hex_color.lstrip("#")
|
|
185
|
+
if len(hex_color) != 6:
|
|
186
|
+
hex_color = "FF4500"
|
|
187
|
+
r = int(hex_color[0:2], 16)
|
|
188
|
+
g = int(hex_color[2:4], 16)
|
|
189
|
+
b = int(hex_color[4:6], 16)
|
|
190
|
+
scale = max(0, min(100, brightness_pct)) / 100.0
|
|
191
|
+
r = max(0, min(255, round(r * scale)))
|
|
192
|
+
g = max(0, min(255, round(g * scale)))
|
|
193
|
+
b = max(0, min(255, round(b * scale)))
|
|
194
|
+
return f"#{r:02X}{g:02X}{b:02X}"
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
# ---------------------------------------------------------------------------
|
|
198
|
+
# Text preprocessing / chunking (word-chunk mode)
|
|
199
|
+
# ---------------------------------------------------------------------------
|
|
200
|
+
|
|
201
|
+
def fix_glued_punctuation(text):
|
|
202
|
+
"""
|
|
203
|
+
Inserts missing spaces around punctuation and quotes so line-wrapped or
|
|
204
|
+
single-line-pasted text doesn't hide chunk boundaries, e.g.
|
|
205
|
+
"forth,The" becomes "forth, The", and 'it."Believe' becomes 'it. "Believe'.
|
|
206
|
+
|
|
207
|
+
Uses a running parity flag to classify straight quotes as opening/closing,
|
|
208
|
+
since the same character is used for both.
|
|
209
|
+
"""
|
|
210
|
+
text = text.replace("\r\n", " ").replace("\n", " ")
|
|
211
|
+
text = re.sub(r"\s+", " ", text)
|
|
212
|
+
|
|
213
|
+
out = []
|
|
214
|
+
quote_open = False
|
|
215
|
+
n = len(text)
|
|
216
|
+
i = 0
|
|
217
|
+
while i < n:
|
|
218
|
+
ch = text[i]
|
|
219
|
+
nxt = text[i + 1] if i + 1 < n else ""
|
|
220
|
+
|
|
221
|
+
if ch == STRAIGHT_QUOTE:
|
|
222
|
+
if not quote_open:
|
|
223
|
+
if out and out[-1] not in (" ",):
|
|
224
|
+
out.append(" ")
|
|
225
|
+
out.append(ch)
|
|
226
|
+
quote_open = True
|
|
227
|
+
else:
|
|
228
|
+
out.append(ch)
|
|
229
|
+
quote_open = False
|
|
230
|
+
if nxt and nxt not in NO_SPACE_NEEDED_AFTER:
|
|
231
|
+
out.append(" ")
|
|
232
|
+
elif ch == OPEN_CURLY:
|
|
233
|
+
if out and out[-1] not in (" ",):
|
|
234
|
+
out.append(" ")
|
|
235
|
+
out.append(ch)
|
|
236
|
+
elif ch == CLOSE_CURLY:
|
|
237
|
+
out.append(ch)
|
|
238
|
+
if nxt and nxt not in NO_SPACE_NEEDED_AFTER:
|
|
239
|
+
out.append(" ")
|
|
240
|
+
elif ch in (".", ","):
|
|
241
|
+
out.append(ch)
|
|
242
|
+
if (
|
|
243
|
+
nxt
|
|
244
|
+
and nxt not in NO_SPACE_NEEDED_AFTER
|
|
245
|
+
and nxt not in OPENING_QUOTES
|
|
246
|
+
and nxt not in CLOSING_QUOTES
|
|
247
|
+
):
|
|
248
|
+
out.append(" ")
|
|
249
|
+
else:
|
|
250
|
+
out.append(ch)
|
|
251
|
+
i += 1
|
|
252
|
+
|
|
253
|
+
return re.sub(r"\s+", " ", "".join(out)).strip()
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def tokenize(text):
|
|
257
|
+
raw_tokens = text.split()
|
|
258
|
+
tokens = []
|
|
259
|
+
for tok in raw_tokens:
|
|
260
|
+
if not tok:
|
|
261
|
+
continue
|
|
262
|
+
if tok[0] in OPENING_QUOTES:
|
|
263
|
+
tokens.append(("OPEN_QUOTE_MARKER", tok[0]))
|
|
264
|
+
rest = tok[1:]
|
|
265
|
+
if rest:
|
|
266
|
+
tokens.append(("WORD", rest))
|
|
267
|
+
else:
|
|
268
|
+
tokens.append(("WORD", tok))
|
|
269
|
+
return tokens
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def word_ends_chunk(word):
|
|
273
|
+
"""
|
|
274
|
+
A word forces the chunk to close on it if it ends with a period or a
|
|
275
|
+
closing quote (covers "word." and 'word."').
|
|
276
|
+
"""
|
|
277
|
+
stripped = word.rstrip()
|
|
278
|
+
if not stripped:
|
|
279
|
+
return False
|
|
280
|
+
if stripped[-1] in CLOSING_QUOTES:
|
|
281
|
+
return True
|
|
282
|
+
if stripped.endswith("."):
|
|
283
|
+
return True
|
|
284
|
+
return False
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def build_chunks(text, chunk_size):
|
|
288
|
+
tokens = tokenize(text)
|
|
289
|
+
chunks = []
|
|
290
|
+
current = []
|
|
291
|
+
|
|
292
|
+
for kind, value in tokens:
|
|
293
|
+
if kind == "OPEN_QUOTE_MARKER":
|
|
294
|
+
if current:
|
|
295
|
+
chunks.append(" ".join(current))
|
|
296
|
+
current = []
|
|
297
|
+
current.append(value)
|
|
298
|
+
continue
|
|
299
|
+
|
|
300
|
+
word = value
|
|
301
|
+
if current and current[-1] in OPENING_QUOTES:
|
|
302
|
+
current[-1] = current[-1] + word
|
|
303
|
+
else:
|
|
304
|
+
current.append(word)
|
|
305
|
+
|
|
306
|
+
if word_ends_chunk(word):
|
|
307
|
+
chunks.append(" ".join(current))
|
|
308
|
+
current = []
|
|
309
|
+
elif len(current) >= chunk_size:
|
|
310
|
+
chunks.append(" ".join(current))
|
|
311
|
+
current = []
|
|
312
|
+
|
|
313
|
+
if current:
|
|
314
|
+
chunks.append(" ".join(current))
|
|
315
|
+
|
|
316
|
+
return chunks
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def build_sentences(text):
|
|
320
|
+
"""
|
|
321
|
+
Splits cleaned text into sentences: a sentence runs up to and including
|
|
322
|
+
the next period (or closing quote immediately following a period), or
|
|
323
|
+
to the end of the text if no more periods remain.
|
|
324
|
+
"""
|
|
325
|
+
tokens = tokenize(text)
|
|
326
|
+
sentences = []
|
|
327
|
+
current = []
|
|
328
|
+
|
|
329
|
+
for kind, value in tokens:
|
|
330
|
+
if kind == "OPEN_QUOTE_MARKER":
|
|
331
|
+
if not current:
|
|
332
|
+
current.append(value)
|
|
333
|
+
continue
|
|
334
|
+
current.append(value)
|
|
335
|
+
continue
|
|
336
|
+
|
|
337
|
+
word = value
|
|
338
|
+
if current and current[-1] in OPENING_QUOTES:
|
|
339
|
+
current[-1] = current[-1] + word
|
|
340
|
+
else:
|
|
341
|
+
current.append(word)
|
|
342
|
+
|
|
343
|
+
stripped = word.rstrip()
|
|
344
|
+
if stripped.endswith(".") or (
|
|
345
|
+
len(stripped) >= 2 and stripped[-1] in CLOSING_QUOTES and stripped[-2:-1] == "."
|
|
346
|
+
):
|
|
347
|
+
sentences.append(" ".join(current))
|
|
348
|
+
current = []
|
|
349
|
+
|
|
350
|
+
if current:
|
|
351
|
+
sentences.append(" ".join(current))
|
|
352
|
+
|
|
353
|
+
return sentences
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def load_book_text(path):
|
|
357
|
+
with open(path, "r", encoding="utf-8-sig", errors="ignore") as f:
|
|
358
|
+
raw_text = f.read()
|
|
359
|
+
return fix_glued_punctuation(raw_text)
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
# ---------------------------------------------------------------------------
|
|
363
|
+
# Pivot-letter calculation (word-chunk mode only)
|
|
364
|
+
# ---------------------------------------------------------------------------
|
|
365
|
+
|
|
366
|
+
def choose_pivot_word_index(words):
|
|
367
|
+
"""
|
|
368
|
+
- 3 words -> index 1 (the middle word).
|
|
369
|
+
- 2 words -> index of whichever word has the most alphabetic letters
|
|
370
|
+
(tie -> first word, index 0).
|
|
371
|
+
- 1 word -> index 0.
|
|
372
|
+
"""
|
|
373
|
+
if len(words) <= 1:
|
|
374
|
+
return 0
|
|
375
|
+
if len(words) == 2:
|
|
376
|
+
letters0 = sum(1 for c in words[0] if c.isalpha())
|
|
377
|
+
letters1 = sum(1 for c in words[1] if c.isalpha())
|
|
378
|
+
return 0 if letters0 >= letters1 else 1
|
|
379
|
+
return len(words) // 2
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
def choose_pivot_letter_index(word):
|
|
383
|
+
"""
|
|
384
|
+
Counting only alphabetic characters:
|
|
385
|
+
- Odd letter-count -> the exact middle letter.
|
|
386
|
+
- Even letter-count -> the letter just left of center.
|
|
387
|
+
Returns None if the word has no alphabetic characters at all.
|
|
388
|
+
"""
|
|
389
|
+
letter_positions = [i for i, c in enumerate(word) if c.isalpha()]
|
|
390
|
+
n = len(letter_positions)
|
|
391
|
+
if n == 0:
|
|
392
|
+
return None
|
|
393
|
+
if n % 2 == 1:
|
|
394
|
+
middle = n // 2
|
|
395
|
+
else:
|
|
396
|
+
middle = (n // 2) - 1
|
|
397
|
+
return letter_positions[middle]
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
def split_chunk_for_pivot(chunk_text):
|
|
401
|
+
"""
|
|
402
|
+
Returns (before_text, pivot_letter, after_text) such that
|
|
403
|
+
before_text + pivot_letter + after_text reconstructs chunk_text exactly.
|
|
404
|
+
pivot_letter is None if the chunk has no alphabetic character at all.
|
|
405
|
+
"""
|
|
406
|
+
words = chunk_text.split(" ")
|
|
407
|
+
pivot_word_idx = choose_pivot_word_index(words)
|
|
408
|
+
pivot_word = words[pivot_word_idx]
|
|
409
|
+
|
|
410
|
+
letter_idx = choose_pivot_letter_index(pivot_word)
|
|
411
|
+
if letter_idx is None:
|
|
412
|
+
return chunk_text, None, ""
|
|
413
|
+
|
|
414
|
+
before_words = words[:pivot_word_idx]
|
|
415
|
+
after_words = words[pivot_word_idx + 1:]
|
|
416
|
+
|
|
417
|
+
pivot_word_before = pivot_word[:letter_idx]
|
|
418
|
+
pivot_letter = pivot_word[letter_idx]
|
|
419
|
+
pivot_word_after = pivot_word[letter_idx + 1:]
|
|
420
|
+
|
|
421
|
+
before_text = " ".join(before_words)
|
|
422
|
+
if before_text:
|
|
423
|
+
before_text += " "
|
|
424
|
+
before_text += pivot_word_before
|
|
425
|
+
|
|
426
|
+
after_text = pivot_word_after
|
|
427
|
+
if after_words:
|
|
428
|
+
after_text += " " + " ".join(after_words)
|
|
429
|
+
|
|
430
|
+
return before_text, pivot_letter, after_text
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
class App:
|
|
434
|
+
def __init__(self, root, book_root=BOOK_ROOT):
|
|
435
|
+
self.root = root
|
|
436
|
+
self.book_root = book_root
|
|
437
|
+
self.settings = load_settings()
|
|
438
|
+
|
|
439
|
+
self.root.title("RSVP Reader")
|
|
440
|
+
self.root.configure(bg=BG_COLOR)
|
|
441
|
+
self.root.attributes("-fullscreen", True)
|
|
442
|
+
self.is_fullscreen = True
|
|
443
|
+
|
|
444
|
+
self.container = tk.Frame(self.root, bg=BG_COLOR)
|
|
445
|
+
self.container.pack(fill="both", expand=True)
|
|
446
|
+
|
|
447
|
+
self.nav_stack = []
|
|
448
|
+
self.settings_panel = None # currently-open settings overlay Frame, or None
|
|
449
|
+
|
|
450
|
+
# hold-to-act timers/state, used only in the reader view
|
|
451
|
+
self._left_hold_job = None
|
|
452
|
+
self._left_autoplay_active = False
|
|
453
|
+
self._left_autoplay_job = None
|
|
454
|
+
self._left_autoplay_start_time = 0
|
|
455
|
+
self._left_autoplay_last_advance = 0
|
|
456
|
+
self._right_hold_job = None
|
|
457
|
+
self._right_settings_triggered = False
|
|
458
|
+
|
|
459
|
+
self.root.bind("<Escape>", self.on_escape)
|
|
460
|
+
self.root.bind("q", lambda e: self.root.destroy())
|
|
461
|
+
|
|
462
|
+
if not os.path.isdir(self.book_root):
|
|
463
|
+
os.makedirs(self.book_root, exist_ok=True)
|
|
464
|
+
|
|
465
|
+
self.push_folder(self.book_root)
|
|
466
|
+
|
|
467
|
+
# -- color/appearance helpers -----------------------------------------
|
|
468
|
+
def current_text_color(self):
|
|
469
|
+
return apply_brightness(self.settings["font_color"], self.settings["brightness"])
|
|
470
|
+
|
|
471
|
+
def current_reader_font(self):
|
|
472
|
+
return (self.settings["font_family"], self.settings["font_size"], "bold")
|
|
473
|
+
|
|
474
|
+
# -- navigation stack helpers -----------------------------------------
|
|
475
|
+
def push_folder(self, path):
|
|
476
|
+
self.nav_stack.append(("folder", path))
|
|
477
|
+
self.render_folder(path)
|
|
478
|
+
|
|
479
|
+
def push_reader(self, book_path):
|
|
480
|
+
self.nav_stack.append(("reader", book_path))
|
|
481
|
+
self.render_reader(book_path)
|
|
482
|
+
|
|
483
|
+
def on_escape(self, event=None):
|
|
484
|
+
if self.settings_panel is not None:
|
|
485
|
+
self.close_settings_panel()
|
|
486
|
+
return
|
|
487
|
+
|
|
488
|
+
if len(self.nav_stack) > 1:
|
|
489
|
+
self.nav_stack.pop()
|
|
490
|
+
kind, data = self.nav_stack[-1]
|
|
491
|
+
if kind == "folder":
|
|
492
|
+
self.render_folder(data)
|
|
493
|
+
else:
|
|
494
|
+
self.render_reader(data)
|
|
495
|
+
return
|
|
496
|
+
|
|
497
|
+
if self.is_fullscreen:
|
|
498
|
+
self.root.attributes("-fullscreen", False)
|
|
499
|
+
self.root.geometry("900x600")
|
|
500
|
+
self.is_fullscreen = False
|
|
501
|
+
|
|
502
|
+
def clear_container(self):
|
|
503
|
+
self.close_settings_panel()
|
|
504
|
+
for widget in self.container.winfo_children():
|
|
505
|
+
widget.destroy()
|
|
506
|
+
|
|
507
|
+
# -- gear icon (present on every view) ---------------------------------
|
|
508
|
+
def add_gear_icon(self):
|
|
509
|
+
gear = tk.Label(
|
|
510
|
+
self.container,
|
|
511
|
+
text="\u2699",
|
|
512
|
+
font=GEAR_FONT,
|
|
513
|
+
fg=self.current_text_color(),
|
|
514
|
+
bg=BG_COLOR,
|
|
515
|
+
cursor="hand2",
|
|
516
|
+
)
|
|
517
|
+
gear.place(relx=1.0, rely=0.0, x=-16, y=10, anchor="ne")
|
|
518
|
+
gear.bind("<Button-1>", lambda e: self.toggle_settings_panel())
|
|
519
|
+
|
|
520
|
+
# -- settings panel -----------------------------------------------------
|
|
521
|
+
def toggle_settings_panel(self):
|
|
522
|
+
if self.settings_panel is not None:
|
|
523
|
+
self.close_settings_panel()
|
|
524
|
+
else:
|
|
525
|
+
self.open_settings_panel()
|
|
526
|
+
|
|
527
|
+
def close_settings_panel(self):
|
|
528
|
+
if self.settings_panel is not None:
|
|
529
|
+
self.settings_panel.destroy()
|
|
530
|
+
self.settings_panel = None
|
|
531
|
+
|
|
532
|
+
def open_settings_panel(self):
|
|
533
|
+
if self.settings_panel is not None:
|
|
534
|
+
return
|
|
535
|
+
|
|
536
|
+
panel = tk.Frame(self.container, bg="#111111", highlightbackground=self.current_text_color(),
|
|
537
|
+
highlightthickness=2)
|
|
538
|
+
panel.place(relx=1.0, rely=0.0, x=-10, y=54, anchor="ne", width=340)
|
|
539
|
+
self.settings_panel = panel
|
|
540
|
+
|
|
541
|
+
def row(label_text):
|
|
542
|
+
r = tk.Frame(panel, bg="#111111")
|
|
543
|
+
r.pack(fill="x", padx=12, pady=(10, 0))
|
|
544
|
+
tk.Label(r, text=label_text, font=PANEL_LABEL_FONT, fg=self.current_text_color(),
|
|
545
|
+
bg="#111111", anchor="w").pack(fill="x")
|
|
546
|
+
return r
|
|
547
|
+
|
|
548
|
+
# 1) chunk size
|
|
549
|
+
r1 = row("Chunk size")
|
|
550
|
+
chunk_var = tk.IntVar(value=self.settings["chunk_size"])
|
|
551
|
+
for val in (1, 3):
|
|
552
|
+
tk.Radiobutton(
|
|
553
|
+
r1, text=f"{val} word{'s' if val > 1 else ''}", variable=chunk_var, value=val,
|
|
554
|
+
font=PANEL_FONT, fg="white", bg="#111111", selectcolor="#333333",
|
|
555
|
+
activebackground="#111111", command=lambda: self.on_chunk_size_change(chunk_var.get()),
|
|
556
|
+
).pack(side="left", padx=(0, 10))
|
|
557
|
+
|
|
558
|
+
# 2) WPM (max autoplay speed)
|
|
559
|
+
r2 = row("Autoplay max speed (WPM)")
|
|
560
|
+
wpm_var = tk.IntVar(value=self.settings["wpm"])
|
|
561
|
+
wpm_scale = tk.Scale(
|
|
562
|
+
r2, from_=100, to=800, orient="horizontal", variable=wpm_var,
|
|
563
|
+
bg="#111111", fg="white", troughcolor="#333333", highlightthickness=0,
|
|
564
|
+
command=lambda v: self.on_wpm_change(int(float(v))),
|
|
565
|
+
)
|
|
566
|
+
wpm_scale.pack(fill="x")
|
|
567
|
+
|
|
568
|
+
# 3) font family
|
|
569
|
+
r3 = row("Font")
|
|
570
|
+
font_var = tk.StringVar(value=self.settings["font_family"])
|
|
571
|
+
font_menu = tk.OptionMenu(r3, font_var, *FONT_CHOICES, command=self.on_font_family_change)
|
|
572
|
+
font_menu.configure(bg="#333333", fg="white", highlightthickness=0)
|
|
573
|
+
font_menu["menu"].configure(bg="#333333", fg="white")
|
|
574
|
+
font_menu.pack(fill="x")
|
|
575
|
+
|
|
576
|
+
# 4) font size
|
|
577
|
+
r4 = row("Font size")
|
|
578
|
+
size_var = tk.IntVar(value=self.settings["font_size"])
|
|
579
|
+
size_scale = tk.Scale(
|
|
580
|
+
r4, from_=16, to=100, orient="horizontal", variable=size_var,
|
|
581
|
+
bg="#111111", fg="white", troughcolor="#333333", highlightthickness=0,
|
|
582
|
+
command=lambda v: self.on_font_size_change(int(float(v))),
|
|
583
|
+
)
|
|
584
|
+
size_scale.pack(fill="x")
|
|
585
|
+
|
|
586
|
+
# 5) brightness
|
|
587
|
+
r5 = row("Brightness")
|
|
588
|
+
bright_var = tk.IntVar(value=self.settings["brightness"])
|
|
589
|
+
bright_scale = tk.Scale(
|
|
590
|
+
r5, from_=10, to=100, orient="horizontal", variable=bright_var,
|
|
591
|
+
bg="#111111", fg="white", troughcolor="#333333", highlightthickness=0,
|
|
592
|
+
command=lambda v: self.on_brightness_change(int(float(v))),
|
|
593
|
+
)
|
|
594
|
+
bright_scale.pack(fill="x")
|
|
595
|
+
|
|
596
|
+
# 6) font color -- full color-wheel picker (tkinter.colorchooser)
|
|
597
|
+
# instead of a fixed swatch palette, so the user can pick ANY color.
|
|
598
|
+
r6 = row("Font color")
|
|
599
|
+
color_picker_row = tk.Frame(r6, bg="#111111")
|
|
600
|
+
color_picker_row.pack(fill="x", pady=(4, 0))
|
|
601
|
+
|
|
602
|
+
current_color_preview = tk.Label(
|
|
603
|
+
color_picker_row, text=" ", bg=self.settings["font_color"],
|
|
604
|
+
width=4, relief="ridge", bd=2,
|
|
605
|
+
)
|
|
606
|
+
current_color_preview.pack(side="left", padx=(0, 8))
|
|
607
|
+
self._color_preview_widget = current_color_preview
|
|
608
|
+
|
|
609
|
+
pick_color_btn = tk.Label(
|
|
610
|
+
color_picker_row, text="Choose color...", font=PANEL_FONT,
|
|
611
|
+
fg="black", bg="#DDDDDD", cursor="hand2", padx=8, pady=3,
|
|
612
|
+
)
|
|
613
|
+
pick_color_btn.pack(side="left", fill="x", expand=True)
|
|
614
|
+
pick_color_btn.bind("<Button-1>", lambda e: self.open_color_wheel())
|
|
615
|
+
|
|
616
|
+
# 7) sentence mode
|
|
617
|
+
r7 = row("Sentence mode")
|
|
618
|
+
sentence_var = tk.BooleanVar(value=self.settings["sentence_mode"])
|
|
619
|
+
tk.Checkbutton(
|
|
620
|
+
r7, text="Show full sentences, no pivot box", variable=sentence_var,
|
|
621
|
+
font=PANEL_FONT, fg="white", bg="#111111", selectcolor="#333333",
|
|
622
|
+
activebackground="#111111",
|
|
623
|
+
command=lambda: self.on_sentence_mode_change(sentence_var.get()),
|
|
624
|
+
).pack(fill="x")
|
|
625
|
+
|
|
626
|
+
close_btn = tk.Label(
|
|
627
|
+
panel, text="Close", font=PANEL_FONT, fg="black", bg=self.current_text_color(),
|
|
628
|
+
cursor="hand2",
|
|
629
|
+
)
|
|
630
|
+
close_btn.pack(fill="x", padx=12, pady=12)
|
|
631
|
+
close_btn.bind("<Button-1>", lambda e: self.close_settings_panel())
|
|
632
|
+
|
|
633
|
+
def open_color_wheel(self):
|
|
634
|
+
"""
|
|
635
|
+
Opens the OS-native color-wheel/picker dialog, pre-seeded with the
|
|
636
|
+
currently selected font color, and applies whatever the user picks.
|
|
637
|
+
"""
|
|
638
|
+
result = colorchooser.askcolor(
|
|
639
|
+
color=self.settings["font_color"],
|
|
640
|
+
title="Choose RSVP text color",
|
|
641
|
+
parent=self.root,
|
|
642
|
+
)
|
|
643
|
+
# askcolor returns ((r, g, b), "#rrggbb") or (None, None) on cancel
|
|
644
|
+
if result and result[1]:
|
|
645
|
+
self.on_font_color_change(result[1])
|
|
646
|
+
|
|
647
|
+
# -- settings change handlers ------------------------------------------
|
|
648
|
+
def _save(self):
|
|
649
|
+
save_settings(self.settings)
|
|
650
|
+
|
|
651
|
+
def _current_reader_state(self):
|
|
652
|
+
"""Returns (book_path, state_dict) if a reader view is currently on top, else (None, None)."""
|
|
653
|
+
if self.nav_stack and self.nav_stack[-1][0] == "reader":
|
|
654
|
+
return self.nav_stack[-1][1], getattr(self, "_reader_state", None)
|
|
655
|
+
return None, None
|
|
656
|
+
|
|
657
|
+
def on_chunk_size_change(self, new_size):
|
|
658
|
+
self.settings["chunk_size"] = new_size
|
|
659
|
+
self._save()
|
|
660
|
+
book_path, state = self._current_reader_state()
|
|
661
|
+
if book_path and state is not None:
|
|
662
|
+
self.rechunk_current_reader(book_path, state)
|
|
663
|
+
|
|
664
|
+
def on_wpm_change(self, new_wpm):
|
|
665
|
+
self.settings["wpm"] = max(1, new_wpm)
|
|
666
|
+
self._save()
|
|
667
|
+
|
|
668
|
+
def on_font_family_change(self, new_family):
|
|
669
|
+
self.settings["font_family"] = new_family
|
|
670
|
+
self._save()
|
|
671
|
+
self.refresh_reader_visuals()
|
|
672
|
+
|
|
673
|
+
def on_font_size_change(self, new_size):
|
|
674
|
+
self.settings["font_size"] = new_size
|
|
675
|
+
self._save()
|
|
676
|
+
self.refresh_reader_visuals()
|
|
677
|
+
|
|
678
|
+
def on_brightness_change(self, new_brightness):
|
|
679
|
+
self.settings["brightness"] = new_brightness
|
|
680
|
+
self._save()
|
|
681
|
+
self.refresh_reader_visuals()
|
|
682
|
+
|
|
683
|
+
def on_font_color_change(self, new_color):
|
|
684
|
+
self.settings["font_color"] = new_color
|
|
685
|
+
self._save()
|
|
686
|
+
self.refresh_reader_visuals()
|
|
687
|
+
if hasattr(self, "_color_preview_widget") and self._color_preview_widget is not None:
|
|
688
|
+
try:
|
|
689
|
+
self._color_preview_widget.configure(bg=new_color)
|
|
690
|
+
except tk.TclError:
|
|
691
|
+
pass
|
|
692
|
+
|
|
693
|
+
def on_sentence_mode_change(self, enabled):
|
|
694
|
+
self.settings["sentence_mode"] = enabled
|
|
695
|
+
self._save()
|
|
696
|
+
book_path, state = self._current_reader_state()
|
|
697
|
+
if book_path and state is not None:
|
|
698
|
+
self.rechunk_current_reader(book_path, state)
|
|
699
|
+
|
|
700
|
+
def refresh_reader_visuals(self):
|
|
701
|
+
book_path, state = self._current_reader_state()
|
|
702
|
+
if book_path and state is not None and hasattr(self, "_reader_show_chunk"):
|
|
703
|
+
self._reader_show_chunk()
|
|
704
|
+
if hasattr(self, "_reader_gear_widget") and self._reader_gear_widget is not None:
|
|
705
|
+
self._reader_gear_widget.configure(fg=self.current_text_color())
|
|
706
|
+
|
|
707
|
+
def rechunk_current_reader(self, book_path, state):
|
|
708
|
+
"""
|
|
709
|
+
Re-derives chunks/sentences using the current settings, trying to keep
|
|
710
|
+
the reading position at (approximately) the same point in the text.
|
|
711
|
+
"""
|
|
712
|
+
old_units = state["chunks"]
|
|
713
|
+
old_idx = state["index"]
|
|
714
|
+
chars_before = sum(len(u) + 1 for u in old_units[:old_idx])
|
|
715
|
+
|
|
716
|
+
full_text = state["full_text"]
|
|
717
|
+
if self.settings["sentence_mode"]:
|
|
718
|
+
new_units = build_sentences(full_text)
|
|
719
|
+
else:
|
|
720
|
+
new_units = build_chunks(full_text, self.settings["chunk_size"])
|
|
721
|
+
|
|
722
|
+
running = 0
|
|
723
|
+
new_idx = 0
|
|
724
|
+
for i, u in enumerate(new_units):
|
|
725
|
+
if running >= chars_before:
|
|
726
|
+
new_idx = i
|
|
727
|
+
break
|
|
728
|
+
running += len(u) + 1
|
|
729
|
+
else:
|
|
730
|
+
new_idx = max(0, len(new_units) - 1)
|
|
731
|
+
|
|
732
|
+
state["chunks"] = new_units
|
|
733
|
+
state["index"] = min(new_idx, max(0, len(new_units) - 1))
|
|
734
|
+
if hasattr(self, "_reader_show_chunk"):
|
|
735
|
+
self._reader_show_chunk()
|
|
736
|
+
|
|
737
|
+
# -- folder browser view ------------------------------------------------
|
|
738
|
+
def render_folder(self, path):
|
|
739
|
+
self.clear_container()
|
|
740
|
+
|
|
741
|
+
rel_path = os.path.relpath(path, self.book_root)
|
|
742
|
+
display_path = "book" if rel_path == "." else f"book/{rel_path}"
|
|
743
|
+
|
|
744
|
+
header = tk.Label(
|
|
745
|
+
self.container,
|
|
746
|
+
text=display_path,
|
|
747
|
+
font=HEADER_FONT,
|
|
748
|
+
fg=self.current_text_color(),
|
|
749
|
+
bg=BG_COLOR,
|
|
750
|
+
)
|
|
751
|
+
header.pack(side="top", pady=(20, 4))
|
|
752
|
+
|
|
753
|
+
separator = tk.Frame(self.container, bg=self.current_text_color(), height=2)
|
|
754
|
+
separator.pack(side="top", fill="x", padx=40, pady=(0, 10))
|
|
755
|
+
|
|
756
|
+
list_frame = tk.Frame(self.container, bg=BG_COLOR)
|
|
757
|
+
list_frame.place(relx=0.5, rely=0.5, anchor="center")
|
|
758
|
+
|
|
759
|
+
try:
|
|
760
|
+
entries = sorted(os.listdir(path))
|
|
761
|
+
except OSError:
|
|
762
|
+
entries = []
|
|
763
|
+
|
|
764
|
+
folders = [e for e in entries if os.path.isdir(os.path.join(path, e))]
|
|
765
|
+
books = [e for e in entries if e.lower().endswith(".txt")]
|
|
766
|
+
|
|
767
|
+
if not folders and not books:
|
|
768
|
+
empty = tk.Label(
|
|
769
|
+
list_frame,
|
|
770
|
+
text="(empty folder)",
|
|
771
|
+
font=BROWSER_FONT,
|
|
772
|
+
fg=self.current_text_color(),
|
|
773
|
+
bg=BG_COLOR,
|
|
774
|
+
)
|
|
775
|
+
empty.pack(pady=10)
|
|
776
|
+
|
|
777
|
+
for name in folders:
|
|
778
|
+
full_path = os.path.join(path, name)
|
|
779
|
+
lbl = tk.Label(
|
|
780
|
+
list_frame,
|
|
781
|
+
text=f"[Folder] {name}",
|
|
782
|
+
font=BROWSER_FONT,
|
|
783
|
+
fg=self.current_text_color(),
|
|
784
|
+
bg=BG_COLOR,
|
|
785
|
+
cursor="hand2",
|
|
786
|
+
)
|
|
787
|
+
lbl.pack(pady=4)
|
|
788
|
+
lbl.bind("<Button-1>", lambda e, p=full_path: self.push_folder(p))
|
|
789
|
+
|
|
790
|
+
for name in books:
|
|
791
|
+
full_path = os.path.join(path, name)
|
|
792
|
+
lbl = tk.Label(
|
|
793
|
+
list_frame,
|
|
794
|
+
text=f"[Book] {name}",
|
|
795
|
+
font=BROWSER_FONT,
|
|
796
|
+
fg=self.current_text_color(),
|
|
797
|
+
bg=BG_COLOR,
|
|
798
|
+
cursor="hand2",
|
|
799
|
+
)
|
|
800
|
+
lbl.pack(pady=4)
|
|
801
|
+
lbl.bind("<Button-1>", lambda e, p=full_path: self.push_reader(p))
|
|
802
|
+
|
|
803
|
+
self.add_gear_icon()
|
|
804
|
+
|
|
805
|
+
# -- reader view ----------------------------------------------------------
|
|
806
|
+
def render_reader(self, book_path):
|
|
807
|
+
self.clear_container()
|
|
808
|
+
|
|
809
|
+
full_text = load_book_text(book_path)
|
|
810
|
+
if self.settings["sentence_mode"]:
|
|
811
|
+
units = build_sentences(full_text)
|
|
812
|
+
else:
|
|
813
|
+
units = build_chunks(full_text, self.settings["chunk_size"])
|
|
814
|
+
|
|
815
|
+
state = {"index": 0, "chunks": units, "full_text": full_text}
|
|
816
|
+
self._reader_state = state
|
|
817
|
+
|
|
818
|
+
screen_w = self.root.winfo_screenwidth()
|
|
819
|
+
screen_h = self.root.winfo_screenheight()
|
|
820
|
+
|
|
821
|
+
canvas = tk.Canvas(
|
|
822
|
+
self.container,
|
|
823
|
+
width=screen_w,
|
|
824
|
+
height=screen_h,
|
|
825
|
+
bg=BG_COLOR,
|
|
826
|
+
highlightthickness=0,
|
|
827
|
+
)
|
|
828
|
+
canvas.place(x=0, y=0, relwidth=1, relheight=1)
|
|
829
|
+
|
|
830
|
+
status_label = tk.Label(
|
|
831
|
+
self.container,
|
|
832
|
+
text="",
|
|
833
|
+
font=STATUS_FONT,
|
|
834
|
+
fg=STATUS_COLOR,
|
|
835
|
+
bg=BG_COLOR,
|
|
836
|
+
)
|
|
837
|
+
status_label.place(relx=0.5, rely=0.97, anchor="center")
|
|
838
|
+
|
|
839
|
+
center_x = screen_w // 2
|
|
840
|
+
center_y = screen_h // 2
|
|
841
|
+
|
|
842
|
+
PIVOT_PAD_X = 2 # minimal horizontal padding inside the box
|
|
843
|
+
PIVOT_PAD_Y = 2
|
|
844
|
+
TICK_GAP = 4 # gap between box edge and the start of each tick mark
|
|
845
|
+
TICK_LENGTH = 14 # length of each perpendicular (vertical) tick mark
|
|
846
|
+
WORD_SPACING_REDUCTION = 6
|
|
847
|
+
GAP_TO_PIVOT_BOX = 0 # flowing text sits flush against the box edge
|
|
848
|
+
SENTENCE_WRAP_FRAC = 0.8
|
|
849
|
+
|
|
850
|
+
def get_reader_font():
|
|
851
|
+
return tkfont.Font(root=self.root, font=self.current_reader_font())
|
|
852
|
+
|
|
853
|
+
def compute_fixed_pivot_box_width(reader_font):
|
|
854
|
+
"""
|
|
855
|
+
The pivot box must never change size between letters/chunks, so we
|
|
856
|
+
measure the widest glyph the current font can render (over a fixed
|
|
857
|
+
probe set) every time show_chunk() runs, and use that as the box's
|
|
858
|
+
fixed inner width -- constant regardless of which letter is
|
|
859
|
+
actually being highlighted.
|
|
860
|
+
"""
|
|
861
|
+
return max(reader_font.measure(c) for c in WIDTH_PROBE_CHARS)
|
|
862
|
+
|
|
863
|
+
def draw_flow_text(canvas_, reader_font, text_color, x, y, text, anchor_right):
|
|
864
|
+
if text == "":
|
|
865
|
+
return
|
|
866
|
+
words = text.split(" ")
|
|
867
|
+
widths = [reader_font.measure(w) for w in words]
|
|
868
|
+
space_w = max(reader_font.measure(" ") - WORD_SPACING_REDUCTION, 2)
|
|
869
|
+
total_w = sum(widths) + space_w * (len(words) - 1 if len(words) > 1 else 0)
|
|
870
|
+
cursor = (x - total_w) if anchor_right else x
|
|
871
|
+
for i, w in enumerate(words):
|
|
872
|
+
canvas_.create_text(
|
|
873
|
+
cursor, y, text=w, anchor="nw",
|
|
874
|
+
font=self.current_reader_font(), fill=text_color,
|
|
875
|
+
)
|
|
876
|
+
cursor += widths[i]
|
|
877
|
+
if i < len(words) - 1:
|
|
878
|
+
cursor += space_w
|
|
879
|
+
|
|
880
|
+
def show_chunk():
|
|
881
|
+
idx = state["index"]
|
|
882
|
+
if not (0 <= idx < len(state["chunks"])):
|
|
883
|
+
return
|
|
884
|
+
|
|
885
|
+
canvas.delete("all")
|
|
886
|
+
unit_text = state["chunks"][idx]
|
|
887
|
+
text_color = self.current_text_color()
|
|
888
|
+
reader_font = get_reader_font()
|
|
889
|
+
|
|
890
|
+
if self.settings["sentence_mode"]:
|
|
891
|
+
canvas.create_text(
|
|
892
|
+
center_x, center_y, text=unit_text,
|
|
893
|
+
font=self.current_reader_font(), fill=text_color, anchor="center",
|
|
894
|
+
width=int(screen_w * SENTENCE_WRAP_FRAC), justify="center",
|
|
895
|
+
)
|
|
896
|
+
else:
|
|
897
|
+
before_text, pivot_letter, after_text = split_chunk_for_pivot(unit_text)
|
|
898
|
+
|
|
899
|
+
if pivot_letter is None:
|
|
900
|
+
canvas.create_text(
|
|
901
|
+
center_x, center_y, text=before_text,
|
|
902
|
+
font=self.current_reader_font(), fill=text_color, anchor="center",
|
|
903
|
+
)
|
|
904
|
+
else:
|
|
905
|
+
ascent = reader_font.metrics("ascent")
|
|
906
|
+
descent = reader_font.metrics("descent")
|
|
907
|
+
text_h = ascent + descent
|
|
908
|
+
|
|
909
|
+
fixed_box_w = compute_fixed_pivot_box_width(reader_font)
|
|
910
|
+
|
|
911
|
+
box_left = center_x - fixed_box_w / 2 - PIVOT_PAD_X
|
|
912
|
+
box_right = center_x + fixed_box_w / 2 + PIVOT_PAD_X
|
|
913
|
+
box_top = center_y - text_h / 2 - PIVOT_PAD_Y
|
|
914
|
+
box_bottom = center_y + text_h / 2 + PIVOT_PAD_Y
|
|
915
|
+
|
|
916
|
+
canvas.create_line(
|
|
917
|
+
center_x, box_top - TICK_GAP,
|
|
918
|
+
center_x, box_top - TICK_GAP - TICK_LENGTH,
|
|
919
|
+
fill=text_color, width=2,
|
|
920
|
+
)
|
|
921
|
+
canvas.create_line(
|
|
922
|
+
center_x, box_bottom + TICK_GAP,
|
|
923
|
+
center_x, box_bottom + TICK_GAP + TICK_LENGTH,
|
|
924
|
+
fill=text_color, width=2,
|
|
925
|
+
)
|
|
926
|
+
|
|
927
|
+
canvas.create_rectangle(
|
|
928
|
+
box_left, box_top, box_right, box_bottom,
|
|
929
|
+
fill=text_color, outline=text_color,
|
|
930
|
+
)
|
|
931
|
+
canvas.create_text(
|
|
932
|
+
center_x, center_y, text=pivot_letter,
|
|
933
|
+
font=self.current_reader_font(), fill=PIVOT_FG_COLOR, anchor="center",
|
|
934
|
+
)
|
|
935
|
+
draw_flow_text(
|
|
936
|
+
canvas, reader_font, text_color,
|
|
937
|
+
box_left - GAP_TO_PIVOT_BOX, center_y - text_h / 2,
|
|
938
|
+
before_text, anchor_right=True,
|
|
939
|
+
)
|
|
940
|
+
draw_flow_text(
|
|
941
|
+
canvas, reader_font, text_color,
|
|
942
|
+
box_right + GAP_TO_PIVOT_BOX, center_y - text_h / 2,
|
|
943
|
+
after_text, anchor_right=False,
|
|
944
|
+
)
|
|
945
|
+
|
|
946
|
+
unit_label = "Sentence" if self.settings["sentence_mode"] else "Chunk"
|
|
947
|
+
wpm_suffix = ""
|
|
948
|
+
if self._left_autoplay_active:
|
|
949
|
+
wpm_suffix = f" | ~{int(self._current_ramped_wpm())} WPM (ramping to {self.settings['wpm']})"
|
|
950
|
+
status_label.config(
|
|
951
|
+
text=f"{os.path.basename(book_path)} | {unit_label} {idx + 1} / {len(state['chunks'])}"
|
|
952
|
+
f"{wpm_suffix}"
|
|
953
|
+
)
|
|
954
|
+
|
|
955
|
+
self._reader_show_chunk = show_chunk
|
|
956
|
+
|
|
957
|
+
def advance_one(forward=True):
|
|
958
|
+
if forward:
|
|
959
|
+
if state["index"] < len(state["chunks"]) - 1:
|
|
960
|
+
state["index"] += 1
|
|
961
|
+
show_chunk()
|
|
962
|
+
else:
|
|
963
|
+
if state["index"] > 0:
|
|
964
|
+
state["index"] -= 1
|
|
965
|
+
show_chunk()
|
|
966
|
+
|
|
967
|
+
# -- left click / hold-to-autoplay (gradual ramp-up to max WPM) ------
|
|
968
|
+
RAMP_DURATION_MS = 5000 # time to reach max configured WPM
|
|
969
|
+
RAMP_START_DIVISOR = 6 # starting speed = max_wpm / this divisor
|
|
970
|
+
RAMP_MIN_START_WPM = 60 # never start slower than this
|
|
971
|
+
RAMP_TICK_MS = 100 # how often we recompute the current speed
|
|
972
|
+
|
|
973
|
+
def current_ramped_wpm():
|
|
974
|
+
max_wpm = max(1, self.settings["wpm"])
|
|
975
|
+
start_wpm = max(RAMP_MIN_START_WPM, max_wpm / RAMP_START_DIVISOR)
|
|
976
|
+
start_wpm = min(start_wpm, max_wpm)
|
|
977
|
+
now_ms = self.root.tk.call("clock", "milliseconds")
|
|
978
|
+
elapsed_ms = now_ms - self._left_autoplay_start_time
|
|
979
|
+
progress = min(1.0, max(0.0, elapsed_ms / RAMP_DURATION_MS))
|
|
980
|
+
return start_wpm + (max_wpm - start_wpm) * progress
|
|
981
|
+
|
|
982
|
+
self._current_ramped_wpm = current_ramped_wpm
|
|
983
|
+
|
|
984
|
+
def stop_autoplay():
|
|
985
|
+
self._left_autoplay_active = False
|
|
986
|
+
if self._left_autoplay_job is not None:
|
|
987
|
+
self.root.after_cancel(self._left_autoplay_job)
|
|
988
|
+
self._left_autoplay_job = None
|
|
989
|
+
|
|
990
|
+
def autoplay_tick():
|
|
991
|
+
if not self._left_autoplay_active:
|
|
992
|
+
return
|
|
993
|
+
if state["index"] >= len(state["chunks"]) - 1:
|
|
994
|
+
stop_autoplay()
|
|
995
|
+
return
|
|
996
|
+
|
|
997
|
+
now_ms = self.root.tk.call("clock", "milliseconds")
|
|
998
|
+
wpm_now = current_ramped_wpm()
|
|
999
|
+
interval_ms = max(1, int(60000 / max(1, wpm_now)))
|
|
1000
|
+
|
|
1001
|
+
if now_ms - self._left_autoplay_last_advance >= interval_ms:
|
|
1002
|
+
advance_one(forward=True)
|
|
1003
|
+
self._left_autoplay_last_advance = now_ms
|
|
1004
|
+
else:
|
|
1005
|
+
show_chunk() # refresh status bar WPM readout even without advancing
|
|
1006
|
+
|
|
1007
|
+
self._left_autoplay_job = self.root.after(RAMP_TICK_MS, autoplay_tick)
|
|
1008
|
+
|
|
1009
|
+
def start_autoplay():
|
|
1010
|
+
self._left_hold_job = None
|
|
1011
|
+
self._left_autoplay_active = True
|
|
1012
|
+
now_ms = self.root.tk.call("clock", "milliseconds")
|
|
1013
|
+
self._left_autoplay_start_time = now_ms
|
|
1014
|
+
self._left_autoplay_last_advance = now_ms
|
|
1015
|
+
advance_one(forward=True)
|
|
1016
|
+
self._left_autoplay_job = self.root.after(RAMP_TICK_MS, autoplay_tick)
|
|
1017
|
+
|
|
1018
|
+
def on_left_press(event):
|
|
1019
|
+
stop_autoplay()
|
|
1020
|
+
if self._left_hold_job is not None:
|
|
1021
|
+
self.root.after_cancel(self._left_hold_job)
|
|
1022
|
+
self._left_hold_job = self.root.after(2000, start_autoplay)
|
|
1023
|
+
|
|
1024
|
+
def on_left_release(event):
|
|
1025
|
+
if self._left_hold_job is not None:
|
|
1026
|
+
self.root.after_cancel(self._left_hold_job)
|
|
1027
|
+
self._left_hold_job = None
|
|
1028
|
+
if self._left_autoplay_active:
|
|
1029
|
+
stop_autoplay()
|
|
1030
|
+
show_chunk()
|
|
1031
|
+
else:
|
|
1032
|
+
advance_one(forward=True)
|
|
1033
|
+
|
|
1034
|
+
# -- right click / hold-to-open-settings ------------------------------
|
|
1035
|
+
def open_settings_from_hold():
|
|
1036
|
+
self._right_hold_job = None
|
|
1037
|
+
self._right_settings_triggered = True
|
|
1038
|
+
self.open_settings_panel()
|
|
1039
|
+
|
|
1040
|
+
def on_right_press(event):
|
|
1041
|
+
self._right_settings_triggered = False
|
|
1042
|
+
if self._right_hold_job is not None:
|
|
1043
|
+
self.root.after_cancel(self._right_hold_job)
|
|
1044
|
+
self._right_hold_job = self.root.after(3000, open_settings_from_hold)
|
|
1045
|
+
|
|
1046
|
+
def on_right_release(event):
|
|
1047
|
+
if self._right_hold_job is not None:
|
|
1048
|
+
self.root.after_cancel(self._right_hold_job)
|
|
1049
|
+
self._right_hold_job = None
|
|
1050
|
+
if not self._right_settings_triggered:
|
|
1051
|
+
advance_one(forward=False)
|
|
1052
|
+
|
|
1053
|
+
canvas.bind("<ButtonPress-1>", on_left_press)
|
|
1054
|
+
canvas.bind("<ButtonRelease-1>", on_left_release)
|
|
1055
|
+
canvas.bind("<ButtonPress-3>", on_right_press)
|
|
1056
|
+
canvas.bind("<ButtonRelease-3>", on_right_release)
|
|
1057
|
+
self.container.bind("<ButtonPress-1>", on_left_press)
|
|
1058
|
+
self.container.bind("<ButtonRelease-1>", on_left_release)
|
|
1059
|
+
self.container.bind("<ButtonPress-3>", on_right_press)
|
|
1060
|
+
self.container.bind("<ButtonRelease-3>", on_right_release)
|
|
1061
|
+
|
|
1062
|
+
show_chunk()
|
|
1063
|
+
self.add_gear_icon()
|
|
1064
|
+
# keep a handle to the gear widget for live-recoloring on settings change
|
|
1065
|
+
for w in self.container.winfo_children():
|
|
1066
|
+
if isinstance(w, tk.Label) and w.cget("text") == "\u2699":
|
|
1067
|
+
self._reader_gear_widget = w
|
|
1068
|
+
|
|
1069
|
+
|
|
1070
|
+
def main():
|
|
1071
|
+
root = tk.Tk()
|
|
1072
|
+
App(root)
|
|
1073
|
+
root.mainloop()
|
|
1074
|
+
|
|
1075
|
+
|
|
1076
|
+
if __name__ == "__main__":
|
|
1077
|
+
main()
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=77", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "rsvp-pivot-reader"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "A Rapid Serial Visual Presentation (RSVP) speed reader with a Spritz-style center-anchored pivot-letter display, folder browser, and persistent settings"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
authors = [{ name = "Rutvik Babar" }]
|
|
13
|
+
maintainers = [{ name = "Rutvik Babar" }]
|
|
14
|
+
keywords = ["rsvp", "speed-reading", "spritz", "pivot", "reader", "tkinter", "productivity"]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Development Status :: 4 - Beta",
|
|
17
|
+
"Intended Audience :: End Users/Desktop",
|
|
18
|
+
"Operating System :: OS Independent",
|
|
19
|
+
"Programming Language :: Python :: 3",
|
|
20
|
+
"Programming Language :: Python :: 3.10",
|
|
21
|
+
"Programming Language :: Python :: 3.11",
|
|
22
|
+
"Programming Language :: Python :: 3.12",
|
|
23
|
+
"Programming Language :: Python :: 3.13",
|
|
24
|
+
"Topic :: Text Processing",
|
|
25
|
+
"Topic :: Utilities",
|
|
26
|
+
]
|
|
27
|
+
dependencies = []
|
|
28
|
+
|
|
29
|
+
[project.optional-dependencies]
|
|
30
|
+
dev = [
|
|
31
|
+
"ruff>=0.5",
|
|
32
|
+
"mypy>=1.10",
|
|
33
|
+
"build>=1.2",
|
|
34
|
+
"twine>=5.0",
|
|
35
|
+
]
|
|
36
|
+
|
|
37
|
+
[project.urls]
|
|
38
|
+
Homepage = "https://github.com/RutvikBabar/RSVP-Reader"
|
|
39
|
+
Repository = "https://github.com/RutvikBabar/RSVP-Reader"
|
|
40
|
+
Issues = "https://github.com/RutvikBabar/RSVP-Reader/issues"
|
|
41
|
+
|
|
42
|
+
[project.gui-scripts]
|
|
43
|
+
rsvp-pivot-reader = "rsvp_reader:main"
|
|
44
|
+
|
|
45
|
+
[tool.setuptools]
|
|
46
|
+
package-dir = { "" = "my_package" }
|
|
47
|
+
py-modules = ["rsvp_reader"]
|
|
48
|
+
|
|
49
|
+
[tool.ruff]
|
|
50
|
+
target-version = "py310"
|
|
51
|
+
line-length = 100
|
|
52
|
+
|
|
53
|
+
[tool.ruff.lint]
|
|
54
|
+
select = ["E", "F", "I", "UP", "B", "SIM", "W"]
|
|
55
|
+
|
|
56
|
+
[tool.mypy]
|
|
57
|
+
python_version = "3.10"
|
|
58
|
+
files = ["my_package/rsvp_reader.py"]
|
|
59
|
+
ignore_missing_imports = true
|