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.
@@ -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,2 @@
1
+ [gui_scripts]
2
+ rsvp-pivot-reader = rsvp_reader:main
@@ -0,0 +1,6 @@
1
+
2
+ [dev]
3
+ ruff>=0.5
4
+ mypy>=1.10
5
+ build>=1.2
6
+ twine>=5.0
@@ -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
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+