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.
- typefaster/__init__.py +3 -0
- typefaster/__main__.py +8 -0
- typefaster/assets/__init__.py +1 -0
- typefaster/assets/quotes.json +2642 -0
- typefaster/cli.py +196 -0
- typefaster/domain/__init__.py +1 -0
- typefaster/domain/anti_cheat.py +46 -0
- typefaster/domain/calculators.py +47 -0
- typefaster/domain/errors.py +19 -0
- typefaster/domain/ghost.py +61 -0
- typefaster/domain/models.py +186 -0
- typefaster/domain/typing_engine.py +150 -0
- typefaster/infra/__init__.py +1 -0
- typefaster/infra/clock.py +36 -0
- typefaster/infra/config.py +41 -0
- typefaster/infra/db.py +31 -0
- typefaster/infra/migrations.py +106 -0
- typefaster/infra/paths.py +35 -0
- typefaster/infra/quote_loader.py +53 -0
- typefaster/infra/replay_store.py +16 -0
- typefaster/infra/repository.py +63 -0
- typefaster/infra/sqlite_repository.py +360 -0
- typefaster/net/__init__.py +1 -0
- typefaster/net/api.py +103 -0
- typefaster/net/commands.py +188 -0
- typefaster/net/token_store.py +46 -0
- typefaster/services/__init__.py +1 -0
- typefaster/services/container.py +42 -0
- typefaster/services/daily_service.py +23 -0
- typefaster/services/ghost_service.py +50 -0
- typefaster/services/profile_service.py +18 -0
- typefaster/services/race_service.py +152 -0
- typefaster/services/stats_service.py +52 -0
- typefaster/ui/__init__.py +1 -0
- typefaster/ui/app.py +93 -0
- typefaster/ui/online_app.py +24 -0
- typefaster/ui/screens/__init__.py +1 -0
- typefaster/ui/screens/_base.py +30 -0
- typefaster/ui/screens/daily.py +64 -0
- typefaster/ui/screens/help.py +26 -0
- typefaster/ui/screens/history.py +34 -0
- typefaster/ui/screens/leaderboard.py +52 -0
- typefaster/ui/screens/main_menu.py +96 -0
- typefaster/ui/screens/online_race.py +241 -0
- typefaster/ui/screens/practice.py +57 -0
- typefaster/ui/screens/profile.py +28 -0
- typefaster/ui/screens/race.py +238 -0
- typefaster/ui/screens/results.py +89 -0
- typefaster/ui/screens/settings.py +81 -0
- typefaster/ui/screens/stats.py +65 -0
- typefaster/ui/theme.py +82 -0
- typefaster/ui/widgets/__init__.py +1 -0
- typefaster/ui/widgets/bigtext.py +39 -0
- typefaster/ui/widgets/live_stats.py +19 -0
- typefaster/ui/widgets/progress_bars.py +44 -0
- typefaster/ui/widgets/typing_field.py +28 -0
- typefaster_cli-0.1.0.dist-info/METADATA +168 -0
- typefaster_cli-0.1.0.dist-info/RECORD +61 -0
- typefaster_cli-0.1.0.dist-info/WHEEL +4 -0
- typefaster_cli-0.1.0.dist-info/entry_points.txt +2 -0
- 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)
|