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,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)