typefaster-cli 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. typefaster/__init__.py +3 -0
  2. typefaster/__main__.py +8 -0
  3. typefaster/assets/__init__.py +1 -0
  4. typefaster/assets/quotes.json +2642 -0
  5. typefaster/cli.py +196 -0
  6. typefaster/domain/__init__.py +1 -0
  7. typefaster/domain/anti_cheat.py +46 -0
  8. typefaster/domain/calculators.py +47 -0
  9. typefaster/domain/errors.py +19 -0
  10. typefaster/domain/ghost.py +61 -0
  11. typefaster/domain/models.py +186 -0
  12. typefaster/domain/typing_engine.py +150 -0
  13. typefaster/infra/__init__.py +1 -0
  14. typefaster/infra/clock.py +36 -0
  15. typefaster/infra/config.py +41 -0
  16. typefaster/infra/db.py +31 -0
  17. typefaster/infra/migrations.py +106 -0
  18. typefaster/infra/paths.py +35 -0
  19. typefaster/infra/quote_loader.py +53 -0
  20. typefaster/infra/replay_store.py +16 -0
  21. typefaster/infra/repository.py +63 -0
  22. typefaster/infra/sqlite_repository.py +360 -0
  23. typefaster/net/__init__.py +1 -0
  24. typefaster/net/api.py +103 -0
  25. typefaster/net/commands.py +188 -0
  26. typefaster/net/token_store.py +46 -0
  27. typefaster/services/__init__.py +1 -0
  28. typefaster/services/container.py +42 -0
  29. typefaster/services/daily_service.py +23 -0
  30. typefaster/services/ghost_service.py +50 -0
  31. typefaster/services/profile_service.py +18 -0
  32. typefaster/services/race_service.py +152 -0
  33. typefaster/services/stats_service.py +52 -0
  34. typefaster/ui/__init__.py +1 -0
  35. typefaster/ui/app.py +93 -0
  36. typefaster/ui/online_app.py +24 -0
  37. typefaster/ui/screens/__init__.py +1 -0
  38. typefaster/ui/screens/_base.py +30 -0
  39. typefaster/ui/screens/daily.py +64 -0
  40. typefaster/ui/screens/help.py +26 -0
  41. typefaster/ui/screens/history.py +34 -0
  42. typefaster/ui/screens/leaderboard.py +52 -0
  43. typefaster/ui/screens/main_menu.py +96 -0
  44. typefaster/ui/screens/online_race.py +241 -0
  45. typefaster/ui/screens/practice.py +57 -0
  46. typefaster/ui/screens/profile.py +28 -0
  47. typefaster/ui/screens/race.py +238 -0
  48. typefaster/ui/screens/results.py +89 -0
  49. typefaster/ui/screens/settings.py +81 -0
  50. typefaster/ui/screens/stats.py +65 -0
  51. typefaster/ui/theme.py +82 -0
  52. typefaster/ui/widgets/__init__.py +1 -0
  53. typefaster/ui/widgets/bigtext.py +39 -0
  54. typefaster/ui/widgets/live_stats.py +19 -0
  55. typefaster/ui/widgets/progress_bars.py +44 -0
  56. typefaster/ui/widgets/typing_field.py +28 -0
  57. typefaster_cli-0.1.0.dist-info/METADATA +168 -0
  58. typefaster_cli-0.1.0.dist-info/RECORD +61 -0
  59. typefaster_cli-0.1.0.dist-info/WHEEL +4 -0
  60. typefaster_cli-0.1.0.dist-info/entry_points.txt +2 -0
  61. typefaster_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,238 @@
1
+ """The race screen — countdown, live typing, ghost animation.
2
+
3
+ Drives a pure ``TypingEngine`` from keyboard events and ticks a timer to update
4
+ the clock, live stats, and the ghost bar. On finish it dismisses with the
5
+ computed ``RaceResult`` so the app can persist it and show results.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import replace
11
+
12
+ from rich.text import Text
13
+ from textual import events
14
+ from textual.app import ComposeResult
15
+ from textual.containers import Vertical
16
+ from textual.screen import Screen
17
+ from textual.timer import Timer
18
+ from textual.widgets import Static
19
+
20
+ from ...domain import anti_cheat
21
+ from ...domain.ghost import ghost_won, progress_at
22
+ from ...domain.models import RaceKind, RaceResult
23
+ from ...domain.typing_engine import TypingEngine
24
+ from ...infra.clock import Clock, SystemClock
25
+ from ...services.race_service import RaceSetup
26
+ from ..widgets import bigtext
27
+ from ..widgets.live_stats import LiveStats
28
+ from ..widgets.progress_bars import ProgressBars
29
+ from ..widgets.typing_field import TypingField
30
+
31
+ _COUNTDOWN_FROM = 3
32
+ _TICK = 1 / 15 # ~15 fps updates
33
+
34
+
35
+ class RaceScreen(Screen[RaceResult | None]):
36
+ BINDINGS = [
37
+ ("escape", "quit_race", "Quit"),
38
+ ]
39
+
40
+ def __init__(self, setup: RaceSetup, clock: Clock | None = None) -> None:
41
+ super().__init__()
42
+ self.setup = setup
43
+ self.clock = clock or SystemClock()
44
+ self.engine = TypingEngine(setup.target_text, allow_backspace=setup.allow_backspace)
45
+ # The clock starts on the FIRST keystroke (MonkeyType/TypeRacer style),
46
+ # so _start_ms stays None until the player actually types.
47
+ self._start_ms: int | None = None
48
+ self._typing_enabled = False
49
+ self._finished = False
50
+ self._pasted = False
51
+ self._countdown = _COUNTDOWN_FROM
52
+ self._countdown_timer: Timer | None = None
53
+ self._race_timer: Timer | None = None
54
+
55
+ def compose(self) -> ComposeResult:
56
+ with Vertical(id="race-wrap"):
57
+ yield Static("", id="countdown")
58
+ yield LiveStats()
59
+ yield TypingField()
60
+ yield ProgressBars()
61
+ if self.setup.kind is RaceKind.TIME:
62
+ label = f"TIME mode · {self.setup.mode.value}s"
63
+ else:
64
+ label = f"QUOTE mode · {self.setup.quote.source or 'unknown'}"
65
+ yield Static(f"{label} · esc to quit", classes="dim")
66
+
67
+ def on_mount(self) -> None:
68
+ self.query_one(LiveStats).display = False
69
+ self.query_one(TypingField).display = False
70
+ self.query_one(ProgressBars).display = False
71
+ self._render_countdown()
72
+ self._countdown_timer = self.set_interval(1.0, self._tick_countdown)
73
+
74
+ # ── countdown ──────────────────────────────────────────────────────
75
+ def _render_countdown(self) -> None:
76
+ big = bigtext.render(str(self._countdown))
77
+ self.query_one("#countdown", Static).update(Text(f"Get ready…\n\n{big}", justify="center"))
78
+
79
+ def _tick_countdown(self) -> None:
80
+ self._countdown -= 1
81
+ if self._countdown <= 0:
82
+ # Stop the countdown timer so it can't fire again (and re-run _begin).
83
+ if self._countdown_timer is not None:
84
+ self._countdown_timer.stop()
85
+ self._begin()
86
+ return
87
+ self._render_countdown()
88
+
89
+ def _begin(self) -> None:
90
+ if self._typing_enabled: # guard: only ever begin once
91
+ return
92
+ self.query_one("#countdown", Static).update(
93
+ Text(bigtext.render("GO!"), justify="center", style="bold green")
94
+ )
95
+ self.query_one(LiveStats).display = True
96
+ self.query_one(TypingField).display = True
97
+ self.query_one(ProgressBars).display = True
98
+ self._typing_enabled = True
99
+ # Note: the clock is NOT started here — it starts on the first keystroke.
100
+ self._refresh_field()
101
+ self._race_timer = self.set_interval(_TICK, self._tick_race)
102
+
103
+ # ── live loop ──────────────────────────────────────────────────────
104
+ def _elapsed_ms(self) -> int:
105
+ if self._start_ms is None:
106
+ return 0
107
+ return self.clock.now_ms() - self._start_ms
108
+
109
+ def _tick_race(self) -> None:
110
+ if self._finished:
111
+ return
112
+ # Before the first keystroke the clock hasn't started: show a full timer.
113
+ if self._start_ms is None:
114
+ self.query_one(LiveStats).show(
115
+ wpm=0.0,
116
+ accuracy=self.engine.live_accuracy(),
117
+ progress=0.0,
118
+ seconds_left=float(self.setup.mode.value),
119
+ )
120
+ self._refresh_bars(0)
121
+ return
122
+
123
+ elapsed = self._elapsed_ms()
124
+
125
+ if self.setup.kind is RaceKind.TIME:
126
+ limit_ms = self.setup.mode.value * 1000
127
+ seconds_left = (limit_ms - elapsed) / 1000.0
128
+ # TIME mode: progress is how far through the clock we are.
129
+ progress = min(1.0, elapsed / limit_ms) if limit_ms else 0.0
130
+ self.query_one(LiveStats).show(
131
+ wpm=self.engine.live_wpm(max(elapsed, 1)),
132
+ accuracy=self.engine.live_accuracy(),
133
+ progress=progress,
134
+ seconds_left=seconds_left,
135
+ )
136
+ self._refresh_bars(elapsed, player_pct=progress * 100.0)
137
+ if elapsed >= limit_ms or self.engine.finished:
138
+ self._finish(elapsed)
139
+ else:
140
+ # QUOTE mode: no time limit; the clock counts up; end on completion.
141
+ self.query_one(LiveStats).show(
142
+ wpm=self.engine.live_wpm(max(elapsed, 1)),
143
+ accuracy=self.engine.live_accuracy(),
144
+ progress=self.engine.progress,
145
+ seconds_left=elapsed / 1000.0, # shown as elapsed time
146
+ )
147
+ self._refresh_bars(elapsed, player_pct=self.engine.progress * 100.0)
148
+ if self.engine.finished:
149
+ self._finish(elapsed)
150
+
151
+ def _refresh_field(self) -> None:
152
+ self.query_one(TypingField).show(self.engine.target, self.engine.states, self.engine.cursor)
153
+
154
+ def _refresh_bars(self, elapsed: int, *, player_pct: float | None = None) -> None:
155
+ ghost = self.setup.ghost
156
+ ghost_pct = None
157
+ ghost_label = ""
158
+ if ghost is not None:
159
+ ghost_pct = progress_at(ghost.timeline, elapsed)
160
+ ghost_label = ghost.label
161
+ if player_pct is None:
162
+ player_pct = self.engine.progress * 100.0
163
+ self.query_one(ProgressBars).show(
164
+ player_pct=player_pct,
165
+ ghost_pct=ghost_pct,
166
+ ghost_label=ghost_label,
167
+ )
168
+
169
+ # ── input ──────────────────────────────────────────────────────────
170
+ def on_paste(self, event: events.Paste) -> None:
171
+ # Pasting the quote is not typing — swallow it and flag the race so the
172
+ # result is not recorded.
173
+ if self._typing_enabled and not self._finished:
174
+ self._pasted = True
175
+ event.stop()
176
+
177
+ def on_key(self, event: events.Key) -> None:
178
+ if not self._typing_enabled or self._finished:
179
+ return
180
+ if event.key == "backspace":
181
+ if self._start_ms is None:
182
+ return # nothing typed yet; clock hasn't started
183
+ self.engine.backspace(self._elapsed_ms())
184
+ event.stop()
185
+ elif event.character is not None and event.character.isprintable():
186
+ # The clock starts on the first real keystroke.
187
+ if self._start_ms is None:
188
+ self._start_ms = self.clock.now_ms()
189
+ self.query_one("#countdown", Static).display = False # hide "GO!"
190
+ self.engine.type_char(event.character, self._elapsed_ms())
191
+ event.stop()
192
+ else:
193
+ return
194
+ self._refresh_field()
195
+ if self.engine.finished:
196
+ self._finish(self._elapsed_ms())
197
+
198
+ # ── finish ─────────────────────────────────────────────────────────
199
+ def _finish(self, elapsed: int) -> None:
200
+ if self._finished:
201
+ return
202
+ self._finished = True
203
+ ghost = self.setup.ghost
204
+
205
+ # ``ghost_won`` in the result means the *player* beat the ghost: the
206
+ # player completed the quote before the ghost reached 100%. Only
207
+ # meaningful when there is a ghost and the player actually finished.
208
+ player_beat_ghost: bool | None = None
209
+ if ghost is not None and self.engine.finished:
210
+ player_beat_ghost = not ghost_won(ghost.timeline, elapsed)
211
+
212
+ mode_seconds = self.setup.mode.value if self.setup.kind is RaceKind.TIME else 0
213
+ result = self.engine.result(
214
+ max(elapsed, 1),
215
+ kind=self.setup.kind,
216
+ mode_seconds=mode_seconds,
217
+ ghost_kind=ghost.kind if ghost else None,
218
+ ghost_won=player_beat_ghost,
219
+ )
220
+
221
+ # Reject physically-impossible results (paste, held key, auto-input) so
222
+ # they don't pollute stats or personal bests.
223
+ suspicious, flags = anti_cheat.evaluate(
224
+ wpm=result.wpm,
225
+ raw_wpm=result.raw_wpm,
226
+ duration_ms=result.duration_ms,
227
+ total_keystrokes=self.engine.total_keystrokes,
228
+ quote_length=len(self.setup.quote.text),
229
+ pasted=self._pasted,
230
+ )
231
+ if suspicious:
232
+ result = replace(result, suspicious=True, flags=flags)
233
+
234
+ self.dismiss(result)
235
+
236
+ def action_quit_race(self) -> None:
237
+ self._finished = True
238
+ self.dismiss(None)
@@ -0,0 +1,89 @@
1
+ """Post-race results screen."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from rich.table import Table
6
+ from rich.text import Text
7
+ from textual.app import ComposeResult
8
+ from textual.containers import Vertical
9
+ from textual.screen import Screen
10
+ from textual.widgets import Static
11
+
12
+ from ...domain.models import RaceResult
13
+ from ...services.race_service import RaceSetup, RaceSummary
14
+ from ..widgets import bigtext
15
+
16
+
17
+ class ResultsScreen(Screen[str]):
18
+ """Shows the outcome. Dismisses with an action string: 'again' or 'menu'.
19
+
20
+ ``summary`` is ``None`` when the run was implausible (paste/auto-input) and
21
+ therefore not recorded.
22
+ """
23
+
24
+ BINDINGS = [
25
+ ("enter", "again", "Race again"),
26
+ ("escape", "menu", "Menu"),
27
+ ]
28
+
29
+ def __init__(
30
+ self, result: RaceResult, setup: RaceSetup, summary: RaceSummary | None = None
31
+ ) -> None:
32
+ super().__init__()
33
+ self.result = result
34
+ self.setup = setup
35
+ self.summary = summary
36
+
37
+ def compose(self) -> ComposeResult:
38
+ r = self.result
39
+ with Vertical(id="panel-wrap"):
40
+ yield Static(Text("RESULTS", justify="center"), id="title")
41
+
42
+ banner = self._banner()
43
+ if banner:
44
+ yield Static(banner)
45
+
46
+ # Big, prominent WPM number.
47
+ yield Static(Text(bigtext.render(f"{r.wpm:.0f}"), justify="center", style="bold cyan"))
48
+ yield Static(Text("WPM", justify="center", style="grey58"))
49
+
50
+ table = Table.grid(padding=(0, 2))
51
+ table.add_column(justify="right", style="grey58")
52
+ table.add_column(justify="left")
53
+ table.add_row("Raw WPM", f"{r.raw_wpm:.0f}")
54
+ table.add_row("Accuracy", f"[bold green]{r.accuracy * 100:.1f}%[/]")
55
+ table.add_row("Chars", f"{r.correct_chars} correct · {r.incorrect_chars} wrong")
56
+ table.add_row("Completion", f"{r.progress * 100:.0f}%")
57
+ table.add_row("Time", f"{r.duration_ms / 1000:.1f}s")
58
+ if self.summary and self.summary.new_personal_best:
59
+ table.add_row(
60
+ "Personal best",
61
+ f"[grey58]{self.summary.previous_best_wpm:.0f}[/] → "
62
+ f"[bold yellow]{r.wpm:.0f}[/] ▲ new best",
63
+ )
64
+ yield Static(table)
65
+ yield Static("", classes="dim")
66
+ yield Static("⏎ race again esc menu", classes="dim")
67
+
68
+ def _banner(self) -> Text | None:
69
+ r = self.result
70
+ if r.suspicious:
71
+ reasons = ", ".join(r.flags) if r.flags else "implausible result"
72
+ return Text(
73
+ f"⚠ Not recorded — {reasons}.\n"
74
+ "Type the text yourself (no pasting) for a valid result.",
75
+ style="bold red",
76
+ )
77
+ if r.ghost_won is True:
78
+ return Text("✓ You beat your ghost!", style="bold green")
79
+ if r.ghost_won is False:
80
+ return Text("✗ The ghost won this time.", style="bold red")
81
+ if self.summary and self.summary.new_personal_best:
82
+ return Text("🏆 New personal best!", style="bold yellow")
83
+ return None
84
+
85
+ def action_again(self) -> None:
86
+ self.dismiss("again")
87
+
88
+ def action_menu(self) -> None:
89
+ self.dismiss("menu")
@@ -0,0 +1,81 @@
1
+ """Settings screen — toggle/cycle options; changes persist immediately."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from rich.text import Text
8
+ from textual.app import ComposeResult
9
+ from textual.containers import Vertical
10
+ from textual.screen import Screen
11
+ from textual.widgets import OptionList, Static
12
+ from textual.widgets.option_list import Option
13
+
14
+ from ...infra.config import Settings
15
+
16
+
17
+ class SettingsScreen(Screen[None]):
18
+ BINDINGS = [("escape", "back", "Back")]
19
+
20
+ _THEMES = ["dark", "light"]
21
+ _TIMES = [30, 60, 120]
22
+ _GHOSTS = ["personal-best", "last", "random"]
23
+
24
+ def compose(self) -> ComposeResult:
25
+ with Vertical(id="menu-wrap"):
26
+ yield Static(Text("SETTINGS", justify="center"), id="title")
27
+ yield OptionList(id="settings-list")
28
+ yield Static("⏎ change value esc back (auto-saves)", classes="dim")
29
+
30
+ def on_mount(self) -> None:
31
+ self._refresh()
32
+ self.query_one(OptionList).focus()
33
+
34
+ def _settings(self) -> Settings:
35
+ return self.app.services.settings # type: ignore[attr-defined,no-any-return]
36
+
37
+ def _refresh(self) -> None:
38
+ s = self._settings()
39
+ ol = self.query_one(OptionList)
40
+ ol.clear_options()
41
+ rows = [
42
+ ("theme", f"Theme ‹ {s.theme} ›"),
43
+ ("default_time", f"Default race ‹ {s.default_time}s ›"),
44
+ (
45
+ "allow_backspace",
46
+ f"Backspace ‹ {'allowed' if s.allow_backspace else 'strict'} ›",
47
+ ),
48
+ ("default_ghost", f"Default ghost ‹ {s.default_ghost} ›"),
49
+ ("sound", f"Sound (bell) ‹ {'on' if s.sound else 'off'} ›"),
50
+ ]
51
+ for key, label in rows:
52
+ ol.add_option(Option(label, id=key))
53
+
54
+ def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> None:
55
+ s = self._settings()
56
+ key = event.option.id
57
+ if key == "theme":
58
+ s.theme = self._cycle(self._THEMES, s.theme)
59
+ elif key == "default_time":
60
+ s.default_time = self._cycle(self._TIMES, s.default_time)
61
+ elif key == "allow_backspace":
62
+ s.allow_backspace = not s.allow_backspace
63
+ elif key == "default_ghost":
64
+ s.default_ghost = self._cycle(self._GHOSTS, s.default_ghost)
65
+ elif key == "sound":
66
+ s.sound = not s.sound
67
+ s.save()
68
+ idx = event.option_index
69
+ self._refresh()
70
+ self.query_one(OptionList).highlighted = idx
71
+
72
+ @staticmethod
73
+ def _cycle(values: list[Any], current: Any) -> Any:
74
+ try:
75
+ i = values.index(current)
76
+ except ValueError:
77
+ i = -1
78
+ return values[(i + 1) % len(values)]
79
+
80
+ def action_back(self) -> None:
81
+ self.app.pop_screen()
@@ -0,0 +1,65 @@
1
+ """Statistics screen."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from rich.console import Group, RenderableType
6
+ from rich.table import Table
7
+ from rich.text import Text
8
+
9
+ from ._base import PanelScreen
10
+
11
+ _SPARK = "▁▂▃▄▅▆▇█"
12
+
13
+
14
+ def _sparkline(values: list[float]) -> str:
15
+ if not values:
16
+ return "—"
17
+ lo, hi = min(values), max(values)
18
+ span = hi - lo or 1.0
19
+ return "".join(_SPARK[min(7, int((v - lo) / span * 7))] for v in values)
20
+
21
+
22
+ class StatsScreen(PanelScreen):
23
+ title_text = "STATS"
24
+
25
+ def body(self) -> RenderableType:
26
+ s = self.app.services.stats.summary() # type: ignore[attr-defined]
27
+ p = s.profile
28
+
29
+ lifetime = Table.grid(padding=(0, 3))
30
+ lifetime.add_column(justify="right", style="grey58")
31
+ lifetime.add_column(justify="left")
32
+ lifetime.add_row("Races played", str(p.races_played))
33
+ lifetime.add_row("Races won", str(p.races_won))
34
+ lifetime.add_row("Best WPM", f"{p.best_wpm:.0f}")
35
+ lifetime.add_row("Avg WPM", f"{s.avg_wpm:.0f}")
36
+ lifetime.add_row("Best accuracy", f"{p.best_accuracy * 100:.1f}%")
37
+ lifetime.add_row("Avg accuracy", f"{s.avg_accuracy * 100:.1f}%")
38
+ lifetime.add_row("Total chars", f"{p.total_chars:,}")
39
+ lifetime.add_row("Total time", _fmt_ms(p.total_time_ms))
40
+
41
+ time_tbl = Table(title="Time Attack · best WPM", title_style="bold")
42
+ time_tbl.add_column("Duration")
43
+ time_tbl.add_column("Best WPM", justify="right")
44
+ for seconds in (30, 60, 120):
45
+ time_tbl.add_row(f"{seconds}s", f"{s.time_best_by_mode.get(seconds, 0.0):.0f}")
46
+
47
+ quote_tbl = Table(title="Quote · personal bests", title_style="bold")
48
+ quote_tbl.add_column("Metric")
49
+ quote_tbl.add_column("Value", justify="right")
50
+ quote_tbl.add_row("Best WPM", f"{s.quote_best_wpm:.0f}")
51
+ quote_tbl.add_row("Fastest finish", _fmt_ms(s.quote_best_ms) if s.quote_best_ms else "—")
52
+
53
+ spark = Text(f"Recent WPM {_sparkline(s.recent_wpm)}", style="cyan")
54
+ return Group(lifetime, Text(""), time_tbl, Text(""), quote_tbl, Text(""), spark)
55
+
56
+
57
+ def _fmt_ms(ms: int) -> str:
58
+ secs = ms // 1000
59
+ h, rem = divmod(secs, 3600)
60
+ m, sec = divmod(rem, 60)
61
+ if h:
62
+ return f"{h}h {m}m"
63
+ if m:
64
+ return f"{m}m {sec}s"
65
+ return f"{sec}s"
typefaster/ui/theme.py ADDED
@@ -0,0 +1,82 @@
1
+ """Shared color tokens and the Textual CSS for the app."""
2
+
3
+ from __future__ import annotations
4
+
5
+ # Semantic colors used by Rich renderables (widgets render their own text).
6
+ CORRECT = "bold green"
7
+ INCORRECT = "bold white on dark_red"
8
+ PENDING = "grey42"
9
+ CARET = "reverse"
10
+ ACCENT = "bold cyan"
11
+ MUTED = "grey58"
12
+
13
+ APP_CSS = """
14
+ Screen {
15
+ background: $surface;
16
+ align: center middle;
17
+ }
18
+
19
+ /* Menus/panels: fill most of the terminal instead of a tiny centered box. */
20
+ #menu-wrap, #panel-wrap {
21
+ width: 100%;
22
+ height: 100%;
23
+ border: round $primary;
24
+ padding: 2 4;
25
+ }
26
+
27
+ #title {
28
+ content-align: center middle;
29
+ color: $accent;
30
+ text-style: bold;
31
+ padding-bottom: 1;
32
+ }
33
+
34
+ #subtitle {
35
+ content-align: center middle;
36
+ color: $text-muted;
37
+ padding-bottom: 1;
38
+ }
39
+
40
+ OptionList {
41
+ height: auto;
42
+ border: none;
43
+ background: $surface;
44
+ }
45
+
46
+ /* Race: full-bleed so the quote and bars are as large as the terminal allows. */
47
+ #race-wrap {
48
+ width: 100%;
49
+ height: 100%;
50
+ border: round $primary;
51
+ padding: 2 4;
52
+ }
53
+
54
+ LiveStats {
55
+ height: 1;
56
+ color: $accent;
57
+ text-style: bold;
58
+ padding-bottom: 1;
59
+ }
60
+
61
+ TypingField {
62
+ height: 1fr;
63
+ min-height: 6;
64
+ padding: 2 2;
65
+ border-top: solid $primary-darken-2;
66
+ border-bottom: solid $primary-darken-2;
67
+ }
68
+
69
+ ProgressBars {
70
+ height: auto;
71
+ padding-top: 1;
72
+ }
73
+
74
+ #countdown {
75
+ content-align: center middle;
76
+ height: 1fr;
77
+ color: $accent;
78
+ text-style: bold;
79
+ }
80
+
81
+ .dim { color: $text-muted; }
82
+ """
@@ -0,0 +1 @@
1
+ """Reusable Textual widgets for the race experience."""
@@ -0,0 +1,39 @@
1
+ """Tiny block-font renderer for big, prominent numbers/words (countdown, WPM).
2
+
3
+ A terminal can't enlarge its own font, so we draw oversized glyphs out of block
4
+ characters instead. Supports digits, a few letters, and basic punctuation.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ _H = 5 # rows per glyph
10
+ _FONT: dict[str, list[str]] = {
11
+ "0": ["█████", "█ █", "█ █", "█ █", "█████"],
12
+ "1": [" ██ ", " █ ", " █ ", " █ ", " ███"],
13
+ "2": ["█████", " █", "█████", "█ ", "█████"],
14
+ "3": ["█████", " █", " ████", " █", "█████"],
15
+ "4": ["█ █", "█ █", "█████", " █", " █"],
16
+ "5": ["█████", "█ ", "█████", " █", "█████"],
17
+ "6": ["█████", "█ ", "█████", "█ █", "█████"],
18
+ "7": ["█████", " █", " █ ", " █ ", " █ "],
19
+ "8": ["█████", "█ █", "█████", "█ █", "█████"],
20
+ "9": ["█████", "█ █", "█████", " █", "█████"],
21
+ "G": ["█████", "█ ", "█ ██", "█ █", "█████"],
22
+ "O": ["█████", "█ █", "█ █", "█ █", "█████"],
23
+ "W": ["█ █", "█ █", "█ █ █", "██ ██", "█ █"],
24
+ "P": ["█████", "█ █", "█████", "█ ", "█ "],
25
+ "M": ["█ █", "██ ██", "█ █ █", "█ █", "█ █"],
26
+ "!": [" █ ", " █ ", " █ ", " ", " █ "],
27
+ "%": ["█ █", " █ ", " █ ", " █ ", "█ █"],
28
+ " ": [" ", " ", " ", " ", " "],
29
+ ".": [" ", " ", " ", " ", " █ "],
30
+ }
31
+
32
+
33
+ def render(text: str) -> str:
34
+ """Render ``text`` as multi-line block art (unsupported chars are skipped)."""
35
+ glyphs = [_FONT.get(ch.upper(), _FONT[" "]) for ch in text]
36
+ rows = []
37
+ for r in range(_H):
38
+ rows.append(" ".join(g[r] for g in glyphs))
39
+ return "\n".join(rows)
@@ -0,0 +1,19 @@
1
+ """Header line: live WPM / accuracy / progress / timer."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from rich.text import Text
6
+ from textual.widgets import Static
7
+
8
+
9
+ class LiveStats(Static):
10
+ def show(self, *, wpm: float, accuracy: float, progress: float, seconds_left: float) -> None:
11
+ text = Text()
12
+ text.append(f"WPM {wpm:5.0f}", style="bold cyan")
13
+ text.append(" ")
14
+ text.append(f"ACC {accuracy * 100:4.0f}%", style="bold green")
15
+ text.append(" ")
16
+ text.append(f"{progress * 100:3.0f}%", style="bold")
17
+ text.append(" ")
18
+ text.append(f"⏱ {max(0, int(seconds_left)):>3}s", style="bold yellow")
19
+ self.update(text)
@@ -0,0 +1,44 @@
1
+ """You-vs-ghost progress bars that rescale to the available width."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from rich.text import Text
6
+ from textual.widgets import Static
7
+
8
+ _MIN_WIDTH = 24
9
+ _BLOCK = "█"
10
+ _EMPTY = "─"
11
+
12
+
13
+ def _bar(progress_pct: float, width: int) -> str:
14
+ filled = max(0, min(width, round(progress_pct / 100.0 * width)))
15
+ return _BLOCK * filled + _EMPTY * (width - filled)
16
+
17
+
18
+ class ProgressBars(Static):
19
+ """You-vs-ghost bars that grow to fill the available width."""
20
+
21
+ def _bar_width(self) -> int:
22
+ # Reserve room for the "You [" prefix + "] 100% (Ghost)" suffix.
23
+ avail = (self.size.width or 60) - 18
24
+ return max(_MIN_WIDTH, avail)
25
+
26
+ def show(
27
+ self,
28
+ *,
29
+ player_pct: float,
30
+ ghost_pct: float | None = None,
31
+ ghost_label: str = "",
32
+ ) -> None:
33
+ width = self._bar_width()
34
+ text = Text()
35
+ text.append("You ", style="bold")
36
+ text.append(f"{_bar(player_pct, width)} ", style="cyan")
37
+ text.append(f"{player_pct:3.0f}%\n", style="bold")
38
+ if ghost_pct is not None:
39
+ text.append("Ghost ", style="bold")
40
+ text.append(f"{_bar(ghost_pct, width)} ", style="magenta")
41
+ text.append(f"{ghost_pct:3.0f}%", style="bold")
42
+ if ghost_label:
43
+ text.append(f" ({ghost_label})", style="grey58")
44
+ self.update(text)