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,52 @@
|
|
|
1
|
+
"""Leaderboard screen — local top runs (online tiers arrive in Phase 2)."""
|
|
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
|
+
|
|
12
|
+
class LeaderboardScreen(PanelScreen):
|
|
13
|
+
title_text = "LEADERBOARD (local)"
|
|
14
|
+
|
|
15
|
+
def body(self) -> RenderableType:
|
|
16
|
+
stats = self.app.services.stats # type: ignore[attr-defined]
|
|
17
|
+
blocks: list[RenderableType] = []
|
|
18
|
+
|
|
19
|
+
for seconds in (30, 60, 120):
|
|
20
|
+
runs = stats.top_time_runs(seconds, limit=5)
|
|
21
|
+
table = Table(title=f"Time Attack · {seconds}s", title_style="bold", expand=True)
|
|
22
|
+
table.add_column("#", justify="right")
|
|
23
|
+
table.add_column("WPM", justify="right")
|
|
24
|
+
table.add_column("Acc", justify="right")
|
|
25
|
+
table.add_column("Date")
|
|
26
|
+
if not runs:
|
|
27
|
+
table.add_row("—", "—", "—", "no runs yet")
|
|
28
|
+
for i, r in enumerate(runs, 1):
|
|
29
|
+
table.add_row(str(i), f"{r.wpm:.0f}", f"{r.accuracy * 100:.0f}%", r.started_at[:10])
|
|
30
|
+
blocks.append(table)
|
|
31
|
+
|
|
32
|
+
quote_runs = stats.top_quote_runs(limit=5)
|
|
33
|
+
qt = Table(title="Quote · top WPM", title_style="bold", expand=True)
|
|
34
|
+
qt.add_column("#", justify="right")
|
|
35
|
+
qt.add_column("WPM", justify="right")
|
|
36
|
+
qt.add_column("Acc", justify="right")
|
|
37
|
+
qt.add_column("Time", justify="right")
|
|
38
|
+
if not quote_runs:
|
|
39
|
+
qt.add_row("—", "—", "—", "no runs yet")
|
|
40
|
+
for i, r in enumerate(quote_runs, 1):
|
|
41
|
+
qt.add_row(
|
|
42
|
+
str(i), f"{r.wpm:.0f}", f"{r.accuracy * 100:.0f}%", f"{r.duration_ms / 1000:.1f}s"
|
|
43
|
+
)
|
|
44
|
+
blocks.append(qt)
|
|
45
|
+
|
|
46
|
+
blocks.append(
|
|
47
|
+
Text(
|
|
48
|
+
"\n🌐 Global / Daily / Weekly leaderboards arrive with online mode (Phase 2).",
|
|
49
|
+
style="grey58",
|
|
50
|
+
)
|
|
51
|
+
)
|
|
52
|
+
return Group(*blocks)
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""Main menu — keyboard-driven entry point."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from rich.text import Text
|
|
6
|
+
from textual.app import ComposeResult
|
|
7
|
+
from textual.containers import Vertical
|
|
8
|
+
from textual.screen import Screen
|
|
9
|
+
from textual.widgets import OptionList, Static
|
|
10
|
+
from textual.widgets.option_list import Option
|
|
11
|
+
|
|
12
|
+
from ...domain.models import RaceKind, RaceMode
|
|
13
|
+
from ...services.race_service import RaceConfig
|
|
14
|
+
|
|
15
|
+
_TIMES = [30, 60, 120]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class MainMenu(Screen[None]):
|
|
19
|
+
BINDINGS = [
|
|
20
|
+
("q", "quit_app", "Quit"),
|
|
21
|
+
("question_mark", "help", "Help"),
|
|
22
|
+
("left", "time_change(-1)", "−time"),
|
|
23
|
+
("right", "time_change(1)", "+time"),
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
def __init__(self) -> None:
|
|
27
|
+
super().__init__()
|
|
28
|
+
self._time_seconds = 60
|
|
29
|
+
|
|
30
|
+
def _time_label(self) -> str:
|
|
31
|
+
return f"Time Attack ‹ {self._time_seconds}s › (←/→ to change)"
|
|
32
|
+
|
|
33
|
+
def _items(self) -> list[tuple[str, str]]:
|
|
34
|
+
return [
|
|
35
|
+
("quick", "▸ Quick Race (new quote each time)"),
|
|
36
|
+
("time", self._time_label()),
|
|
37
|
+
("practice", "Practice"),
|
|
38
|
+
("daily", "Daily Challenge"),
|
|
39
|
+
("stats", "Stats"),
|
|
40
|
+
("history", "History"),
|
|
41
|
+
("profile", "Profile"),
|
|
42
|
+
("leaderboard", "Leaderboard"),
|
|
43
|
+
("settings", "Settings"),
|
|
44
|
+
("quit", "Quit"),
|
|
45
|
+
]
|
|
46
|
+
|
|
47
|
+
def compose(self) -> ComposeResult:
|
|
48
|
+
with Vertical(id="menu-wrap"):
|
|
49
|
+
yield Static(Text("⌨ T Y P E F A S T E R", justify="center"), id="title")
|
|
50
|
+
yield Static(self._tagline(), id="subtitle")
|
|
51
|
+
yield OptionList(*[Option(label, id=key) for key, label in self._items()])
|
|
52
|
+
|
|
53
|
+
def _tagline(self) -> Text:
|
|
54
|
+
p = self.app.services.profile.get() # type: ignore[attr-defined]
|
|
55
|
+
return Text(
|
|
56
|
+
f"best {p.best_wpm:.0f} wpm · races {p.races_played} · offline mode",
|
|
57
|
+
justify="center",
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
def on_mount(self) -> None:
|
|
61
|
+
self.query_one(OptionList).focus()
|
|
62
|
+
|
|
63
|
+
# ── inline duration selector on the Time Attack row ────────────────
|
|
64
|
+
def action_time_change(self, delta: int) -> None:
|
|
65
|
+
ol = self.query_one(OptionList)
|
|
66
|
+
highlighted = ol.highlighted
|
|
67
|
+
# Only adjust when the Time Attack row is the highlighted one.
|
|
68
|
+
if highlighted is None or ol.get_option_at_index(highlighted).id != "time":
|
|
69
|
+
return
|
|
70
|
+
i = (_TIMES.index(self._time_seconds) + delta) % len(_TIMES)
|
|
71
|
+
self._time_seconds = _TIMES[i]
|
|
72
|
+
ol.replace_option_prompt("time", self._time_label())
|
|
73
|
+
|
|
74
|
+
def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> None:
|
|
75
|
+
app = self.app
|
|
76
|
+
match event.option.id:
|
|
77
|
+
case "quick":
|
|
78
|
+
app.start_race( # type: ignore[attr-defined]
|
|
79
|
+
RaceConfig(kind=RaceKind.QUOTE, ghost_kind=None)
|
|
80
|
+
)
|
|
81
|
+
case "time":
|
|
82
|
+
app.start_race( # type: ignore[attr-defined]
|
|
83
|
+
RaceConfig(kind=RaceKind.TIME, mode=RaceMode(self._time_seconds))
|
|
84
|
+
)
|
|
85
|
+
case "quit":
|
|
86
|
+
app.exit()
|
|
87
|
+
case None:
|
|
88
|
+
return
|
|
89
|
+
case name:
|
|
90
|
+
app.open(name) # type: ignore[attr-defined]
|
|
91
|
+
|
|
92
|
+
def action_quit_app(self) -> None:
|
|
93
|
+
self.app.exit()
|
|
94
|
+
|
|
95
|
+
def action_help(self) -> None:
|
|
96
|
+
self.app.open("help") # type: ignore[attr-defined]
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
"""Online multiplayer race screen.
|
|
2
|
+
|
|
3
|
+
Connects to the server over WebSocket, auto-readies, and lets the **server**
|
|
4
|
+
drive countdown/start/finish. The client only renders state, reports progress,
|
|
5
|
+
and submits a final result for server-side validation.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import contextlib
|
|
11
|
+
import json
|
|
12
|
+
import time
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
import websockets
|
|
16
|
+
from rich.console import Group
|
|
17
|
+
from rich.table import Table
|
|
18
|
+
from rich.text import Text
|
|
19
|
+
from textual import events
|
|
20
|
+
from textual.app import ComposeResult
|
|
21
|
+
from textual.containers import Vertical, VerticalScroll
|
|
22
|
+
from textual.screen import Screen
|
|
23
|
+
from textual.widgets import Static
|
|
24
|
+
|
|
25
|
+
from ...domain.typing_engine import TypingEngine
|
|
26
|
+
from ..widgets.live_stats import LiveStats
|
|
27
|
+
from ..widgets.typing_field import TypingField
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _bar(pct: float, width: int = 24) -> str:
|
|
31
|
+
filled = max(0, min(width, round(pct / 100.0 * width)))
|
|
32
|
+
return "#" * filled + "-" * (width - filled)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class OnlineRaceScreen(Screen[None]):
|
|
36
|
+
BINDINGS = [("escape", "leave", "Leave")]
|
|
37
|
+
|
|
38
|
+
def __init__(self, ws_url: str, username: str, mode_seconds: int) -> None:
|
|
39
|
+
super().__init__()
|
|
40
|
+
self.ws_url = ws_url
|
|
41
|
+
self.username = username
|
|
42
|
+
self.mode_seconds = mode_seconds
|
|
43
|
+
self.engine: TypingEngine | None = None
|
|
44
|
+
self._ws: Any = None
|
|
45
|
+
self._start_ms: int | None = None
|
|
46
|
+
self._typing = False
|
|
47
|
+
self._finished = False
|
|
48
|
+
self._opponents: dict[str, dict[str, float]] = {}
|
|
49
|
+
self._status = "Connecting…"
|
|
50
|
+
self._standings: list[dict[str, Any]] | None = None
|
|
51
|
+
|
|
52
|
+
def compose(self) -> ComposeResult:
|
|
53
|
+
with Vertical(id="race-wrap"):
|
|
54
|
+
yield Static("", id="net-status", classes="dim")
|
|
55
|
+
yield LiveStats()
|
|
56
|
+
yield TypingField()
|
|
57
|
+
with VerticalScroll():
|
|
58
|
+
yield Static("", id="bars")
|
|
59
|
+
yield Static("esc to leave", classes="dim")
|
|
60
|
+
|
|
61
|
+
def on_mount(self) -> None:
|
|
62
|
+
self.query_one(LiveStats).display = False
|
|
63
|
+
self.query_one(TypingField).display = False
|
|
64
|
+
self._render_status()
|
|
65
|
+
self.run_worker(self._net_loop(), exclusive=True, name="ws")
|
|
66
|
+
self.set_interval(0.2, self._tick)
|
|
67
|
+
|
|
68
|
+
# ── network ────────────────────────────────────────────────────────
|
|
69
|
+
async def _net_loop(self) -> None:
|
|
70
|
+
try:
|
|
71
|
+
async with websockets.connect(self.ws_url) as ws:
|
|
72
|
+
self._ws = ws
|
|
73
|
+
self._status = "Connected. Waiting for players to ready up…"
|
|
74
|
+
self._render_status()
|
|
75
|
+
await ws.send(json.dumps({"type": "SET_READY", "data": {"ready": True}}))
|
|
76
|
+
async for raw in ws:
|
|
77
|
+
self._handle(json.loads(raw))
|
|
78
|
+
except Exception as exc:
|
|
79
|
+
self._status = f"Disconnected: {exc}"
|
|
80
|
+
self._render_status()
|
|
81
|
+
|
|
82
|
+
async def _send(self, type_: str, **data: Any) -> None:
|
|
83
|
+
if self._ws is not None:
|
|
84
|
+
with contextlib.suppress(Exception):
|
|
85
|
+
await self._ws.send(json.dumps({"type": type_, "data": data}))
|
|
86
|
+
|
|
87
|
+
def _handle(self, msg: dict[str, Any]) -> None:
|
|
88
|
+
etype = msg.get("type")
|
|
89
|
+
data = msg.get("data", {})
|
|
90
|
+
if etype == "RACE_COUNTDOWN":
|
|
91
|
+
self._status = f"Race starts in {data.get('count')}…"
|
|
92
|
+
self._render_status()
|
|
93
|
+
elif etype == "RACE_START":
|
|
94
|
+
self._begin(data["text"])
|
|
95
|
+
elif etype == "RACE_PROGRESS":
|
|
96
|
+
user = data.get("username")
|
|
97
|
+
if user and user != self.username:
|
|
98
|
+
self._opponents[user] = {
|
|
99
|
+
"progress": float(data.get("progress", 0)),
|
|
100
|
+
"wpm": float(data.get("wpm", 0)),
|
|
101
|
+
}
|
|
102
|
+
self._render_bars()
|
|
103
|
+
elif etype == "RACE_FINISHED":
|
|
104
|
+
if data.get("final"):
|
|
105
|
+
self._standings = data.get("standings", [])
|
|
106
|
+
self._show_standings()
|
|
107
|
+
else:
|
|
108
|
+
user = data.get("username")
|
|
109
|
+
if user and user != self.username:
|
|
110
|
+
self._opponents.setdefault(user, {})["progress"] = 100.0
|
|
111
|
+
self._render_bars()
|
|
112
|
+
elif etype == "CHAT_MESSAGE":
|
|
113
|
+
pass # chat panel could render here
|
|
114
|
+
|
|
115
|
+
# ── race lifecycle ─────────────────────────────────────────────────
|
|
116
|
+
def _begin(self, text: str) -> None:
|
|
117
|
+
self.engine = TypingEngine(text)
|
|
118
|
+
self._start_ms = int(time.time() * 1000)
|
|
119
|
+
self._typing = True
|
|
120
|
+
self._status = "GO!"
|
|
121
|
+
self.query_one("#net-status", Static).display = False
|
|
122
|
+
self.query_one(LiveStats).display = True
|
|
123
|
+
self.query_one(TypingField).display = True
|
|
124
|
+
self._render_field()
|
|
125
|
+
|
|
126
|
+
def _elapsed(self) -> int:
|
|
127
|
+
return 0 if self._start_ms is None else int(time.time() * 1000) - self._start_ms
|
|
128
|
+
|
|
129
|
+
def _tick(self) -> None:
|
|
130
|
+
if not self._typing or self.engine is None:
|
|
131
|
+
return
|
|
132
|
+
elapsed = self._elapsed()
|
|
133
|
+
seconds_left = (self.mode_seconds * 1000 - elapsed) / 1000.0
|
|
134
|
+
self.query_one(LiveStats).show(
|
|
135
|
+
wpm=self.engine.live_wpm(max(elapsed, 1)),
|
|
136
|
+
accuracy=self.engine.live_accuracy(),
|
|
137
|
+
progress=self.engine.progress,
|
|
138
|
+
seconds_left=seconds_left,
|
|
139
|
+
)
|
|
140
|
+
self._render_bars()
|
|
141
|
+
# Report progress to the server.
|
|
142
|
+
self.run_worker(
|
|
143
|
+
self._send(
|
|
144
|
+
"PROGRESS",
|
|
145
|
+
progress=self.engine.progress * 100.0,
|
|
146
|
+
wpm=round(self.engine.live_wpm(max(elapsed, 1)), 1),
|
|
147
|
+
),
|
|
148
|
+
name="progress",
|
|
149
|
+
)
|
|
150
|
+
if (elapsed >= self.mode_seconds * 1000 or self.engine.finished) and not self._finished:
|
|
151
|
+
self._submit_finish()
|
|
152
|
+
|
|
153
|
+
def on_key(self, event: events.Key) -> None:
|
|
154
|
+
if not self._typing or self._finished or self.engine is None:
|
|
155
|
+
return
|
|
156
|
+
t = self._elapsed()
|
|
157
|
+
if event.key == "backspace":
|
|
158
|
+
self.engine.backspace(t)
|
|
159
|
+
event.stop()
|
|
160
|
+
elif event.character is not None and event.character.isprintable():
|
|
161
|
+
self.engine.type_char(event.character, t)
|
|
162
|
+
event.stop()
|
|
163
|
+
else:
|
|
164
|
+
return
|
|
165
|
+
self._render_field()
|
|
166
|
+
if self.engine.finished:
|
|
167
|
+
self._submit_finish()
|
|
168
|
+
|
|
169
|
+
def _submit_finish(self) -> None:
|
|
170
|
+
if self._finished or self.engine is None:
|
|
171
|
+
return
|
|
172
|
+
self._finished = True
|
|
173
|
+
self._typing = False
|
|
174
|
+
eng = self.engine
|
|
175
|
+
self.run_worker(
|
|
176
|
+
self._send(
|
|
177
|
+
"FINISH",
|
|
178
|
+
duration_ms=self._elapsed(),
|
|
179
|
+
correct_chars=eng.correct_chars,
|
|
180
|
+
incorrect_chars=eng.incorrect_chars,
|
|
181
|
+
total_keystrokes=eng.total_keystrokes,
|
|
182
|
+
correct_keystrokes=eng.correct_keystrokes,
|
|
183
|
+
pasted=False,
|
|
184
|
+
),
|
|
185
|
+
name="finish",
|
|
186
|
+
)
|
|
187
|
+
self._status = "Finished — waiting for other racers…"
|
|
188
|
+
self.query_one("#net-status", Static).display = True
|
|
189
|
+
self._render_status()
|
|
190
|
+
|
|
191
|
+
# ── rendering ──────────────────────────────────────────────────────
|
|
192
|
+
def _render_status(self) -> None:
|
|
193
|
+
self.query_one("#net-status", Static).update(Text(self._status, justify="center"))
|
|
194
|
+
|
|
195
|
+
def _render_field(self) -> None:
|
|
196
|
+
if self.engine is not None:
|
|
197
|
+
self.query_one(TypingField).show(
|
|
198
|
+
self.engine.target, self.engine.states, self.engine.cursor
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
def _render_bars(self) -> None:
|
|
202
|
+
text = Text()
|
|
203
|
+
me_pct = self.engine.progress * 100.0 if self.engine else 0.0
|
|
204
|
+
text.append("You ", style="bold")
|
|
205
|
+
text.append(f"[{_bar(me_pct)}] {me_pct:3.0f}%\n", style="cyan")
|
|
206
|
+
for name, st in sorted(self._opponents.items()):
|
|
207
|
+
pct = st.get("progress", 0.0)
|
|
208
|
+
text.append(f"{name[:7]:<7}", style="bold")
|
|
209
|
+
text.append(f"[{_bar(pct)}] {pct:3.0f}%\n", style="magenta")
|
|
210
|
+
self.query_one("#bars", Static).update(text)
|
|
211
|
+
|
|
212
|
+
def _show_standings(self) -> None:
|
|
213
|
+
self._typing = False
|
|
214
|
+
table = Table(title="Final Standings", title_style="bold")
|
|
215
|
+
table.add_column("#", justify="right")
|
|
216
|
+
table.add_column("Player")
|
|
217
|
+
table.add_column("WPM", justify="right")
|
|
218
|
+
table.add_column("Acc", justify="right")
|
|
219
|
+
for i, row in enumerate(self._standings or [], 1):
|
|
220
|
+
flag = " ⚑" if row.get("suspicious") else ""
|
|
221
|
+
table.add_row(
|
|
222
|
+
str(i),
|
|
223
|
+
f"{row.get('username', '?')}{flag}",
|
|
224
|
+
f"{row.get('wpm', 0):.0f}",
|
|
225
|
+
f"{row.get('accuracy', 0) * 100:.0f}%",
|
|
226
|
+
)
|
|
227
|
+
self.query_one("#net-status", Static).display = True
|
|
228
|
+
self.query_one(TypingField).display = False
|
|
229
|
+
self.query_one(LiveStats).display = False
|
|
230
|
+
self.query_one("#bars", Static).update(Group(table, Text("\nesc to leave", style="grey58")))
|
|
231
|
+
|
|
232
|
+
# ── exit ───────────────────────────────────────────────────────────
|
|
233
|
+
def action_leave(self) -> None:
|
|
234
|
+
self.run_worker(self._send("LEAVE"), name="leave")
|
|
235
|
+
if self._ws is not None:
|
|
236
|
+
self.run_worker(self._close_ws(), name="close")
|
|
237
|
+
self.app.pop_screen()
|
|
238
|
+
|
|
239
|
+
async def _close_ws(self) -> None:
|
|
240
|
+
with contextlib.suppress(Exception):
|
|
241
|
+
await self._ws.close()
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""Practice mode — pick a race configuration, then race."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from rich.text import Text
|
|
6
|
+
from textual.app import ComposeResult
|
|
7
|
+
from textual.containers import Vertical
|
|
8
|
+
from textual.screen import Screen
|
|
9
|
+
from textual.widgets import OptionList, Static
|
|
10
|
+
from textual.widgets.option_list import Option
|
|
11
|
+
|
|
12
|
+
from ...domain.models import GhostKind, RaceKind, RaceMode
|
|
13
|
+
from ...services.race_service import RaceConfig
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class PracticeScreen(Screen[None]):
|
|
17
|
+
BINDINGS = [("escape", "back", "Back")]
|
|
18
|
+
|
|
19
|
+
_ITEMS: list[tuple[str, str, RaceConfig]] = [
|
|
20
|
+
("q_rand", "Quote · random", RaceConfig(kind=RaceKind.QUOTE)),
|
|
21
|
+
(
|
|
22
|
+
"q_pb",
|
|
23
|
+
"Quote · vs Personal Best ghost",
|
|
24
|
+
RaceConfig(kind=RaceKind.QUOTE, ghost_kind=GhostKind.PERSONAL_BEST),
|
|
25
|
+
),
|
|
26
|
+
(
|
|
27
|
+
"q_last",
|
|
28
|
+
"Quote · vs Last race ghost",
|
|
29
|
+
RaceConfig(kind=RaceKind.QUOTE, ghost_kind=GhostKind.LAST),
|
|
30
|
+
),
|
|
31
|
+
(
|
|
32
|
+
"q_rng",
|
|
33
|
+
"Quote · vs Random ghost",
|
|
34
|
+
RaceConfig(kind=RaceKind.QUOTE, ghost_kind=GhostKind.RANDOM),
|
|
35
|
+
),
|
|
36
|
+
("t30", "Time Attack · 30s", RaceConfig(kind=RaceKind.TIME, mode=RaceMode.SHORT)),
|
|
37
|
+
("t60", "Time Attack · 60s", RaceConfig(kind=RaceKind.TIME, mode=RaceMode.NORMAL)),
|
|
38
|
+
("t120", "Time Attack · 120s", RaceConfig(kind=RaceKind.TIME, mode=RaceMode.LONG)),
|
|
39
|
+
]
|
|
40
|
+
|
|
41
|
+
def compose(self) -> ComposeResult:
|
|
42
|
+
with Vertical(id="menu-wrap"):
|
|
43
|
+
yield Static(Text("PRACTICE", justify="center"), id="title")
|
|
44
|
+
yield OptionList(*[Option(label, id=key) for key, label, _ in self._ITEMS])
|
|
45
|
+
yield Static("esc back", classes="dim")
|
|
46
|
+
|
|
47
|
+
def on_mount(self) -> None:
|
|
48
|
+
self.query_one(OptionList).focus()
|
|
49
|
+
|
|
50
|
+
def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> None:
|
|
51
|
+
for key, _, config in self._ITEMS:
|
|
52
|
+
if key == event.option.id:
|
|
53
|
+
self.app.start_race(config) # type: ignore[attr-defined]
|
|
54
|
+
return
|
|
55
|
+
|
|
56
|
+
def action_back(self) -> None:
|
|
57
|
+
self.app.pop_screen()
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""Profile 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
|
+
from .stats import _fmt_ms
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class ProfileScreen(PanelScreen):
|
|
14
|
+
title_text = "PROFILE"
|
|
15
|
+
|
|
16
|
+
def body(self) -> RenderableType:
|
|
17
|
+
p = self.app.services.profile.get() # type: ignore[attr-defined]
|
|
18
|
+
grid = Table.grid(padding=(0, 3))
|
|
19
|
+
grid.add_column(justify="right", style="grey58")
|
|
20
|
+
grid.add_column(justify="left")
|
|
21
|
+
grid.add_row("Name", p.display_name)
|
|
22
|
+
grid.add_row("Member since", (p.created_at or "—")[:10])
|
|
23
|
+
grid.add_row("Races", str(p.races_played))
|
|
24
|
+
grid.add_row("Best WPM", f"{p.best_wpm:.0f}")
|
|
25
|
+
grid.add_row("Total time", _fmt_ms(p.total_time_ms))
|
|
26
|
+
grid.add_row("Total chars", f"{p.total_chars:,}")
|
|
27
|
+
note = Text("\nAchievements & online profile arrive in Phase 2.", style="grey58")
|
|
28
|
+
return Group(grid, note)
|