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,46 @@
|
|
|
1
|
+
"""Local persistence of the auth token and server URL."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from dataclasses import asdict, dataclass
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from ..infra.paths import config_dir
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _auth_path() -> Path:
|
|
13
|
+
return config_dir() / "auth.json"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(slots=True)
|
|
17
|
+
class Session:
|
|
18
|
+
server_url: str = "http://localhost:8000"
|
|
19
|
+
token: str | None = None
|
|
20
|
+
username: str | None = None
|
|
21
|
+
|
|
22
|
+
@property
|
|
23
|
+
def logged_in(self) -> bool:
|
|
24
|
+
return bool(self.token)
|
|
25
|
+
|
|
26
|
+
@classmethod
|
|
27
|
+
def load(cls, path: Path | None = None) -> Session:
|
|
28
|
+
path = path or _auth_path()
|
|
29
|
+
if not path.exists():
|
|
30
|
+
return cls()
|
|
31
|
+
try:
|
|
32
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
33
|
+
except (json.JSONDecodeError, OSError):
|
|
34
|
+
return cls()
|
|
35
|
+
known = set(cls.__slots__)
|
|
36
|
+
return cls(**{k: v for k, v in data.items() if k in known})
|
|
37
|
+
|
|
38
|
+
def save(self, path: Path | None = None) -> None:
|
|
39
|
+
path = path or _auth_path()
|
|
40
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
41
|
+
path.write_text(json.dumps(asdict(self), indent=2), encoding="utf-8")
|
|
42
|
+
|
|
43
|
+
def clear(self, path: Path | None = None) -> None:
|
|
44
|
+
self.token = None
|
|
45
|
+
self.username = None
|
|
46
|
+
self.save(path)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Application services orchestrating the domain and infrastructure layers."""
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""Composition root — wires settings, repository, and services together."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from ..infra.config import Settings
|
|
9
|
+
from ..infra.sqlite_repository import SQLiteRepository
|
|
10
|
+
from .daily_service import DailyService
|
|
11
|
+
from .ghost_service import GhostService
|
|
12
|
+
from .profile_service import ProfileService
|
|
13
|
+
from .race_service import RaceService
|
|
14
|
+
from .stats_service import StatsService
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(slots=True)
|
|
18
|
+
class App:
|
|
19
|
+
settings: Settings
|
|
20
|
+
repo: SQLiteRepository
|
|
21
|
+
race: RaceService
|
|
22
|
+
profile: ProfileService
|
|
23
|
+
stats: StatsService
|
|
24
|
+
daily: DailyService
|
|
25
|
+
ghosts: GhostService
|
|
26
|
+
|
|
27
|
+
def close(self) -> None:
|
|
28
|
+
self.repo.close()
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def build_app(db_path: Path | str | None = None) -> App:
|
|
32
|
+
settings = Settings.load()
|
|
33
|
+
repo = SQLiteRepository(db_path)
|
|
34
|
+
return App(
|
|
35
|
+
settings=settings,
|
|
36
|
+
repo=repo,
|
|
37
|
+
race=RaceService(repo, allow_backspace=settings.allow_backspace),
|
|
38
|
+
profile=ProfileService(repo),
|
|
39
|
+
stats=StatsService(repo),
|
|
40
|
+
daily=DailyService(repo),
|
|
41
|
+
ghosts=GhostService(repo),
|
|
42
|
+
)
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""Daily challenge: deterministic shared quote + local daily leaderboard."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from datetime import date
|
|
6
|
+
|
|
7
|
+
from ..domain.models import DailyChallenge, RaceRecord
|
|
8
|
+
from ..infra import quote_loader
|
|
9
|
+
from ..infra.repository import Repository
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class DailyService:
|
|
13
|
+
def __init__(self, repo: Repository) -> None:
|
|
14
|
+
self._repo = repo
|
|
15
|
+
|
|
16
|
+
def today(self, day: date | None = None) -> DailyChallenge:
|
|
17
|
+
day = day or date.today()
|
|
18
|
+
quote = quote_loader.daily_quote(day)
|
|
19
|
+
return self._repo.get_or_create_daily(day.isoformat(), quote)
|
|
20
|
+
|
|
21
|
+
def leaderboard(self, day: date | None = None, limit: int = 20) -> list[RaceRecord]:
|
|
22
|
+
day = day or date.today()
|
|
23
|
+
return self._repo.daily_leaderboard(day.isoformat(), limit=limit)
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""Build ghost opponents from stored replays."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from ..domain.errors import GhostUnavailableError
|
|
6
|
+
from ..domain.models import Ghost, GhostKind
|
|
7
|
+
from ..infra.repository import Repository
|
|
8
|
+
|
|
9
|
+
_LABELS = {
|
|
10
|
+
GhostKind.PERSONAL_BEST: "PB",
|
|
11
|
+
GhostKind.LAST: "Last",
|
|
12
|
+
GhostKind.RANDOM: "Random",
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class GhostService:
|
|
17
|
+
def __init__(self, repo: Repository) -> None:
|
|
18
|
+
self._repo = repo
|
|
19
|
+
|
|
20
|
+
def load(self, kind: GhostKind) -> Ghost:
|
|
21
|
+
"""Return a ghost for the requested kind, or raise if no data exists."""
|
|
22
|
+
if kind is GhostKind.PERSONAL_BEST:
|
|
23
|
+
data = self._repo.personal_best_replay()
|
|
24
|
+
elif kind is GhostKind.LAST:
|
|
25
|
+
data = self._repo.last_replay()
|
|
26
|
+
else:
|
|
27
|
+
data = self._repo.random_replay()
|
|
28
|
+
|
|
29
|
+
if data is None:
|
|
30
|
+
raise GhostUnavailableError(
|
|
31
|
+
f"No historical run available for ghost '{kind.value}'. Finish a race first!"
|
|
32
|
+
)
|
|
33
|
+
timeline, wpm, quote = data
|
|
34
|
+
return Ghost(kind=kind, label=_LABELS[kind], timeline=timeline, wpm=wpm, quote=quote)
|
|
35
|
+
|
|
36
|
+
def best_for_quote(self, ext_id: str) -> Ghost | None:
|
|
37
|
+
"""A ghost recorded on a specific quote (e.g. your best daily run)."""
|
|
38
|
+
data = self._repo.best_replay_for_quote(ext_id)
|
|
39
|
+
if data is None:
|
|
40
|
+
return None
|
|
41
|
+
timeline, wpm, quote = data
|
|
42
|
+
return Ghost(
|
|
43
|
+
kind=GhostKind.PERSONAL_BEST, label="PB", timeline=timeline, wpm=wpm, quote=quote
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
def try_load(self, kind: GhostKind) -> Ghost | None:
|
|
47
|
+
try:
|
|
48
|
+
return self.load(kind)
|
|
49
|
+
except GhostUnavailableError:
|
|
50
|
+
return None
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""Profile read/repair operations."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from ..domain.models import Profile
|
|
6
|
+
from ..infra.repository import Repository
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class ProfileService:
|
|
10
|
+
def __init__(self, repo: Repository) -> None:
|
|
11
|
+
self._repo = repo
|
|
12
|
+
|
|
13
|
+
def get(self) -> Profile:
|
|
14
|
+
return self._repo.get_profile()
|
|
15
|
+
|
|
16
|
+
def repair(self) -> Profile:
|
|
17
|
+
"""Recompute denormalized aggregates from the race table."""
|
|
18
|
+
return self._repo.recompute_profile()
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
"""Coordinate a race: choose the text/ghost/mode, then persist the result.
|
|
2
|
+
|
|
3
|
+
The interactive typing loop lives in the UI layer (Textual), which drives a
|
|
4
|
+
``TypingEngine``. This service handles everything around that: setup and
|
|
5
|
+
persistence. Keeping it UI-free means the same flow can be reused headlessly
|
|
6
|
+
(tests) and adapted server-side in Phase 2.
|
|
7
|
+
|
|
8
|
+
Two race kinds (see ``RaceKind``):
|
|
9
|
+
- QUOTE: one fixed quote, raced to completion. Ghosts (same text) live here.
|
|
10
|
+
- TIME: type continuously for N seconds; text streams from many quotes.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
from datetime import UTC, date, datetime
|
|
17
|
+
|
|
18
|
+
from ..domain.models import Ghost, GhostKind, Quote, RaceKind, RaceMode, RaceResult
|
|
19
|
+
from ..infra import quote_loader
|
|
20
|
+
from ..infra.repository import Repository
|
|
21
|
+
from .ghost_service import GhostService
|
|
22
|
+
|
|
23
|
+
# Placeholder quote row that TIME-mode races reference (they have no single text).
|
|
24
|
+
_TIME_QUOTE = Quote(ext_id="__time_mode__", text="(time mode)", source="Time")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass(frozen=True, slots=True)
|
|
28
|
+
class RaceConfig:
|
|
29
|
+
"""A request to start a race (what the UI/CLI chooses)."""
|
|
30
|
+
|
|
31
|
+
kind: RaceKind = RaceKind.QUOTE
|
|
32
|
+
mode: RaceMode = RaceMode.NORMAL
|
|
33
|
+
ghost_kind: GhostKind | None = None
|
|
34
|
+
daily: bool = False
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass(slots=True)
|
|
38
|
+
class RaceSetup:
|
|
39
|
+
quote: Quote # the row persisted with the race
|
|
40
|
+
target_text: str # what the player actually types (streamed for TIME mode)
|
|
41
|
+
kind: RaceKind
|
|
42
|
+
mode: RaceMode # the time limit for TIME mode (ignored for QUOTE)
|
|
43
|
+
ghost: Ghost | None
|
|
44
|
+
allow_backspace: bool
|
|
45
|
+
is_daily: bool
|
|
46
|
+
started_at: str
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass(slots=True)
|
|
50
|
+
class RaceSummary:
|
|
51
|
+
result: RaceResult
|
|
52
|
+
race_id: int
|
|
53
|
+
new_personal_best: bool
|
|
54
|
+
previous_best_wpm: float
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class RaceService:
|
|
58
|
+
def __init__(self, repo: Repository, *, allow_backspace: bool = True) -> None:
|
|
59
|
+
self._repo = repo
|
|
60
|
+
self._ghosts = GhostService(repo)
|
|
61
|
+
self._allow_backspace = allow_backspace
|
|
62
|
+
|
|
63
|
+
def prepare(
|
|
64
|
+
self,
|
|
65
|
+
*,
|
|
66
|
+
kind: RaceKind = RaceKind.QUOTE,
|
|
67
|
+
mode: RaceMode = RaceMode.NORMAL,
|
|
68
|
+
ghost_kind: GhostKind | None = None,
|
|
69
|
+
daily: bool = False,
|
|
70
|
+
) -> RaceSetup:
|
|
71
|
+
if kind is RaceKind.TIME:
|
|
72
|
+
return self._prepare_time(mode)
|
|
73
|
+
return self._prepare_quote(mode, ghost_kind, daily)
|
|
74
|
+
|
|
75
|
+
def _prepare_quote(
|
|
76
|
+
self, mode: RaceMode, ghost_kind: GhostKind | None, daily: bool
|
|
77
|
+
) -> RaceSetup:
|
|
78
|
+
ghost: Ghost | None = None
|
|
79
|
+
if daily:
|
|
80
|
+
quote = quote_loader.daily_quote(date.today())
|
|
81
|
+
# Daily ghost = your best run on *today's* quote (same text), if any.
|
|
82
|
+
ghost = self._ghosts.best_for_quote(quote.ext_id)
|
|
83
|
+
elif ghost_kind is not None:
|
|
84
|
+
# Explicit "vs PB/Last/Random": race the ghost's exact text for a
|
|
85
|
+
# fair head-to-head.
|
|
86
|
+
ghost = self._ghosts.try_load(ghost_kind)
|
|
87
|
+
quote = ghost.quote if ghost and ghost.quote else quote_loader.random_quote()
|
|
88
|
+
else:
|
|
89
|
+
# Quick race: a fresh random quote every time. Attach a ghost only
|
|
90
|
+
# if you've raced this exact quote before.
|
|
91
|
+
quote = quote_loader.random_quote()
|
|
92
|
+
ghost = self._ghosts.best_for_quote(quote.ext_id)
|
|
93
|
+
|
|
94
|
+
return RaceSetup(
|
|
95
|
+
quote=quote,
|
|
96
|
+
target_text=quote.text,
|
|
97
|
+
kind=RaceKind.QUOTE,
|
|
98
|
+
mode=mode,
|
|
99
|
+
ghost=ghost,
|
|
100
|
+
allow_backspace=self._allow_backspace,
|
|
101
|
+
is_daily=daily,
|
|
102
|
+
started_at=datetime.now(UTC).isoformat(),
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
def _prepare_time(self, mode: RaceMode) -> RaceSetup:
|
|
106
|
+
# Enough text that even a very fast typist won't run out before time:
|
|
107
|
+
# ~300 WPM * 5 chars = 1500 chars/min, plus a safety buffer.
|
|
108
|
+
target_chars = int(mode.value / 60 * 1500) + 600
|
|
109
|
+
text = _stream_text(target_chars)
|
|
110
|
+
return RaceSetup(
|
|
111
|
+
quote=_TIME_QUOTE,
|
|
112
|
+
target_text=text,
|
|
113
|
+
kind=RaceKind.TIME,
|
|
114
|
+
mode=mode,
|
|
115
|
+
ghost=None, # ghosts are a QUOTE-mode feature
|
|
116
|
+
allow_backspace=self._allow_backspace,
|
|
117
|
+
is_daily=False,
|
|
118
|
+
started_at=datetime.now(UTC).isoformat(),
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
def finish(self, setup: RaceSetup, result: RaceResult) -> RaceSummary:
|
|
122
|
+
# Compare against the best of the *same* kind so the two don't mix.
|
|
123
|
+
previous_best = self._best_wpm_for_kind(result.kind)
|
|
124
|
+
race_id = self._repo.save_race(
|
|
125
|
+
result=result,
|
|
126
|
+
quote=setup.quote,
|
|
127
|
+
started_at=setup.started_at,
|
|
128
|
+
is_daily=setup.is_daily,
|
|
129
|
+
)
|
|
130
|
+
return RaceSummary(
|
|
131
|
+
result=result,
|
|
132
|
+
race_id=race_id,
|
|
133
|
+
new_personal_best=result.wpm > previous_best,
|
|
134
|
+
previous_best_wpm=previous_best,
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
def _best_wpm_for_kind(self, kind: RaceKind) -> float:
|
|
138
|
+
if kind is RaceKind.QUOTE:
|
|
139
|
+
best = self._repo.best_quote_run()
|
|
140
|
+
return best[0] if best else 0.0
|
|
141
|
+
return max(self._repo.best_by_mode(RaceKind.TIME).values(), default=0.0)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _stream_text(min_chars: int) -> str:
|
|
145
|
+
"""Concatenate random quotes into one continuous block of >= min_chars."""
|
|
146
|
+
parts: list[str] = []
|
|
147
|
+
total = 0
|
|
148
|
+
while total < min_chars:
|
|
149
|
+
q = quote_loader.random_quote()
|
|
150
|
+
parts.append(q.text)
|
|
151
|
+
total += len(q.text) + 1
|
|
152
|
+
return " ".join(parts)
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""Read-side aggregation for the stats / history / leaderboard screens."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
from ..domain.models import Profile, RaceKind, RaceRecord
|
|
8
|
+
from ..infra.repository import Repository
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass(slots=True)
|
|
12
|
+
class StatsSummary:
|
|
13
|
+
profile: Profile
|
|
14
|
+
avg_wpm: float
|
|
15
|
+
avg_accuracy: float
|
|
16
|
+
time_best_by_mode: dict[int, float] # TIME mode: seconds -> best WPM
|
|
17
|
+
quote_best_wpm: float # QUOTE mode: best WPM
|
|
18
|
+
quote_best_ms: int # QUOTE mode: fastest completion (ms)
|
|
19
|
+
recent_wpm: list[float]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class StatsService:
|
|
23
|
+
def __init__(self, repo: Repository) -> None:
|
|
24
|
+
self._repo = repo
|
|
25
|
+
|
|
26
|
+
def summary(self) -> StatsSummary:
|
|
27
|
+
profile = self._repo.get_profile()
|
|
28
|
+
avg_wpm, avg_acc = self._repo.average_wpm_accuracy()
|
|
29
|
+
recent = [r.wpm for r in reversed(self._repo.list_history(limit=20))]
|
|
30
|
+
quote_best = self._repo.best_quote_run()
|
|
31
|
+
return StatsSummary(
|
|
32
|
+
profile=profile,
|
|
33
|
+
avg_wpm=round(avg_wpm, 1),
|
|
34
|
+
avg_accuracy=round(avg_acc, 4),
|
|
35
|
+
time_best_by_mode=self._repo.best_by_mode(RaceKind.TIME),
|
|
36
|
+
quote_best_wpm=quote_best[0] if quote_best else 0.0,
|
|
37
|
+
quote_best_ms=quote_best[1] if quote_best else 0,
|
|
38
|
+
recent_wpm=recent,
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
def history(self, limit: int = 20, offset: int = 0) -> list[RaceRecord]:
|
|
42
|
+
return self._repo.list_history(limit=limit, offset=offset)
|
|
43
|
+
|
|
44
|
+
def history_pages(self, page_size: int = 20) -> int:
|
|
45
|
+
total = self._repo.count_races()
|
|
46
|
+
return max(1, (total + page_size - 1) // page_size)
|
|
47
|
+
|
|
48
|
+
def top_time_runs(self, mode_seconds: int, limit: int = 10) -> list[RaceRecord]:
|
|
49
|
+
return self._repo.top_runs(mode_seconds, limit=limit, kind=RaceKind.TIME)
|
|
50
|
+
|
|
51
|
+
def top_quote_runs(self, limit: int = 10) -> list[RaceRecord]:
|
|
52
|
+
return self._repo.top_quote_runs(limit=limit)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Presentation layer — Textual screens and Rich-rendered widgets."""
|
typefaster/ui/app.py
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""The Textual application — screen orchestration and race flow."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from textual.app import App
|
|
6
|
+
|
|
7
|
+
from ..domain.models import RaceResult
|
|
8
|
+
from ..services.container import App as Services
|
|
9
|
+
from ..services.container import build_app
|
|
10
|
+
from ..services.race_service import RaceConfig, RaceSetup
|
|
11
|
+
from .screens.daily import DailyScreen
|
|
12
|
+
from .screens.help import HelpScreen
|
|
13
|
+
from .screens.history import HistoryScreen
|
|
14
|
+
from .screens.leaderboard import LeaderboardScreen
|
|
15
|
+
from .screens.main_menu import MainMenu
|
|
16
|
+
from .screens.practice import PracticeScreen
|
|
17
|
+
from .screens.profile import ProfileScreen
|
|
18
|
+
from .screens.race import RaceScreen
|
|
19
|
+
from .screens.results import ResultsScreen
|
|
20
|
+
from .screens.settings import SettingsScreen
|
|
21
|
+
from .screens.stats import StatsScreen
|
|
22
|
+
from .theme import APP_CSS
|
|
23
|
+
|
|
24
|
+
_PANELS = {
|
|
25
|
+
"practice": PracticeScreen,
|
|
26
|
+
"daily": DailyScreen,
|
|
27
|
+
"stats": StatsScreen,
|
|
28
|
+
"history": HistoryScreen,
|
|
29
|
+
"profile": ProfileScreen,
|
|
30
|
+
"leaderboard": LeaderboardScreen,
|
|
31
|
+
"settings": SettingsScreen,
|
|
32
|
+
"help": HelpScreen,
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class TypefasterApp(App[None]):
|
|
37
|
+
"""Offline TYPEFASTER terminal app."""
|
|
38
|
+
|
|
39
|
+
CSS = APP_CSS
|
|
40
|
+
TITLE = "TYPEFASTER"
|
|
41
|
+
|
|
42
|
+
def __init__(
|
|
43
|
+
self,
|
|
44
|
+
services: Services | None = None,
|
|
45
|
+
*,
|
|
46
|
+
initial_race: RaceConfig | None = None,
|
|
47
|
+
) -> None:
|
|
48
|
+
super().__init__()
|
|
49
|
+
self.services: Services = services or build_app()
|
|
50
|
+
self._last_config: RaceConfig | None = None
|
|
51
|
+
self._initial_race = initial_race
|
|
52
|
+
|
|
53
|
+
def on_mount(self) -> None:
|
|
54
|
+
self.push_screen(MainMenu())
|
|
55
|
+
if self._initial_race is not None:
|
|
56
|
+
self.start_race(self._initial_race)
|
|
57
|
+
|
|
58
|
+
# ── navigation helper used by screens ──────────────────────────────
|
|
59
|
+
def open(self, name: str) -> None:
|
|
60
|
+
"""Push a fresh panel screen by name so its data is always current."""
|
|
61
|
+
self.push_screen(_PANELS[name]())
|
|
62
|
+
|
|
63
|
+
# ── race flow ──────────────────────────────────────────────────────
|
|
64
|
+
def start_race(self, config: RaceConfig) -> None:
|
|
65
|
+
setup = self.services.race.prepare(
|
|
66
|
+
kind=config.kind,
|
|
67
|
+
mode=config.mode,
|
|
68
|
+
ghost_kind=config.ghost_kind,
|
|
69
|
+
daily=config.daily,
|
|
70
|
+
)
|
|
71
|
+
self._last_config = config
|
|
72
|
+
self.push_screen(RaceScreen(setup), lambda result: self._after_race(setup, result))
|
|
73
|
+
|
|
74
|
+
def _after_race(self, setup: RaceSetup, result: RaceResult | None) -> None:
|
|
75
|
+
if result is None:
|
|
76
|
+
return # race quit — return to whatever is underneath
|
|
77
|
+
if result.suspicious:
|
|
78
|
+
# Implausible run (paste/auto-input): show it, but do not record it.
|
|
79
|
+
self.push_screen(ResultsScreen(result, setup, summary=None), self._after_results)
|
|
80
|
+
return
|
|
81
|
+
summary = self.services.race.finish(setup, result)
|
|
82
|
+
self.push_screen(ResultsScreen(result, setup, summary=summary), self._after_results)
|
|
83
|
+
|
|
84
|
+
def _after_results(self, action: str | None) -> None:
|
|
85
|
+
if action == "again" and self._last_config is not None:
|
|
86
|
+
self.start_race(self._last_config)
|
|
87
|
+
|
|
88
|
+
def on_unmount(self) -> None:
|
|
89
|
+
self.services.close()
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def run(initial_race: RaceConfig | None = None) -> None:
|
|
93
|
+
TypefasterApp(initial_race=initial_race).run()
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""A minimal Textual app that hosts the online race screen for one lobby."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from textual.app import App
|
|
6
|
+
|
|
7
|
+
from .screens.online_race import OnlineRaceScreen
|
|
8
|
+
from .theme import APP_CSS
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class OnlineApp(App[None]):
|
|
12
|
+
CSS = APP_CSS
|
|
13
|
+
TITLE = "TYPEFASTER · online"
|
|
14
|
+
|
|
15
|
+
def __init__(self, ws_url: str, username: str, mode_seconds: int) -> None:
|
|
16
|
+
super().__init__()
|
|
17
|
+
self._args = (ws_url, username, mode_seconds)
|
|
18
|
+
|
|
19
|
+
def on_mount(self) -> None:
|
|
20
|
+
self.push_screen(OnlineRaceScreen(*self._args))
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def run_online(ws_url: str, username: str, mode_seconds: int) -> None:
|
|
24
|
+
OnlineApp(ws_url, username, mode_seconds).run()
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Textual screens."""
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""Shared base for read-only panel screens (esc to go back)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from rich.console import RenderableType
|
|
6
|
+
from rich.text import Text
|
|
7
|
+
from textual.app import ComposeResult
|
|
8
|
+
from textual.containers import Vertical, VerticalScroll
|
|
9
|
+
from textual.screen import Screen
|
|
10
|
+
from textual.widgets import Static
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class PanelScreen(Screen[None]):
|
|
14
|
+
"""A titled, scrollable panel. Subclasses implement ``body``."""
|
|
15
|
+
|
|
16
|
+
title_text: str = "PANEL"
|
|
17
|
+
BINDINGS = [("escape", "back", "Back"), ("q", "back", "Back")]
|
|
18
|
+
|
|
19
|
+
def compose(self) -> ComposeResult:
|
|
20
|
+
with Vertical(id="panel-wrap"):
|
|
21
|
+
yield Static(Text(self.title_text, justify="center"), id="title")
|
|
22
|
+
with VerticalScroll():
|
|
23
|
+
yield Static(self.body())
|
|
24
|
+
yield Static("esc back", classes="dim")
|
|
25
|
+
|
|
26
|
+
def body(self) -> RenderableType: # pragma: no cover - overridden
|
|
27
|
+
return Text("")
|
|
28
|
+
|
|
29
|
+
def action_back(self) -> None:
|
|
30
|
+
self.app.pop_screen()
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""Daily challenge screen — shared quote + local daily leaderboard."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from datetime import date
|
|
6
|
+
|
|
7
|
+
from rich.console import Group
|
|
8
|
+
from rich.table import Table
|
|
9
|
+
from rich.text import Text
|
|
10
|
+
from textual.app import ComposeResult
|
|
11
|
+
from textual.containers import Vertical, VerticalScroll
|
|
12
|
+
from textual.screen import Screen
|
|
13
|
+
from textual.widgets import Static
|
|
14
|
+
|
|
15
|
+
from ...domain.models import RaceKind
|
|
16
|
+
from ...services.race_service import RaceConfig
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class DailyScreen(Screen[None]):
|
|
20
|
+
BINDINGS = [
|
|
21
|
+
("enter", "play", "Play"),
|
|
22
|
+
("escape", "back", "Back"),
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
def compose(self) -> ComposeResult:
|
|
26
|
+
with Vertical(id="panel-wrap"):
|
|
27
|
+
yield Static(
|
|
28
|
+
Text(f"DAILY CHALLENGE · {date.today().isoformat()}", justify="center"),
|
|
29
|
+
id="title",
|
|
30
|
+
)
|
|
31
|
+
with VerticalScroll():
|
|
32
|
+
yield Static(self._body())
|
|
33
|
+
yield Static("⏎ play today's challenge esc back", classes="dim")
|
|
34
|
+
|
|
35
|
+
def _body(self) -> Group:
|
|
36
|
+
svc = self.app.services # type: ignore[attr-defined]
|
|
37
|
+
challenge = svc.daily.today()
|
|
38
|
+
board = svc.daily.leaderboard(limit=10)
|
|
39
|
+
|
|
40
|
+
quote = Text()
|
|
41
|
+
quote.append("Today's quote (same for everyone)\n", style="grey58")
|
|
42
|
+
quote.append(f'"{challenge.quote.text}"\n', style="italic")
|
|
43
|
+
quote.append(f"— {challenge.quote.source or 'unknown'}\n", style="grey58")
|
|
44
|
+
quote.append(
|
|
45
|
+
f"\nYour best today: {challenge.best_wpm:.0f} wpm · attempts {challenge.attempts}\n",
|
|
46
|
+
style="bold",
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
table = Table(title="Local daily leaderboard", title_style="bold", expand=True)
|
|
50
|
+
table.add_column("#", justify="right")
|
|
51
|
+
table.add_column("WPM", justify="right")
|
|
52
|
+
table.add_column("Acc", justify="right")
|
|
53
|
+
if not board:
|
|
54
|
+
table.add_row("—", "—", "not played yet")
|
|
55
|
+
for i, r in enumerate(board, 1):
|
|
56
|
+
table.add_row(str(i), f"{r.wpm:.0f}", f"{r.accuracy * 100:.0f}%")
|
|
57
|
+
return Group(quote, table)
|
|
58
|
+
|
|
59
|
+
def action_play(self) -> None:
|
|
60
|
+
# Daily is a fixed quote; the ghost (if any) is your best run on it.
|
|
61
|
+
self.app.start_race(RaceConfig(kind=RaceKind.QUOTE, daily=True)) # type: ignore[attr-defined]
|
|
62
|
+
|
|
63
|
+
def action_back(self) -> None:
|
|
64
|
+
self.app.pop_screen()
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""Help overlay."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from rich.console import RenderableType
|
|
6
|
+
from rich.table import Table
|
|
7
|
+
|
|
8
|
+
from ._base import PanelScreen
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class HelpScreen(PanelScreen):
|
|
12
|
+
title_text = "HELP"
|
|
13
|
+
|
|
14
|
+
def body(self) -> RenderableType:
|
|
15
|
+
table = Table.grid(padding=(0, 3))
|
|
16
|
+
table.add_column(style="bold cyan", justify="right")
|
|
17
|
+
table.add_column()
|
|
18
|
+
table.add_row("↑ / ↓ , j / k", "move between options")
|
|
19
|
+
table.add_row("Enter", "select")
|
|
20
|
+
table.add_row("Esc", "go back")
|
|
21
|
+
table.add_row("q", "quit")
|
|
22
|
+
table.add_row("?", "this help")
|
|
23
|
+
table.add_row("", "")
|
|
24
|
+
table.add_row("During a race", "just type; Backspace corrects mistakes")
|
|
25
|
+
table.add_row("", "Esc quits the race")
|
|
26
|
+
return table
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""History screen — most recent races."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from rich.console import RenderableType
|
|
6
|
+
from rich.table import Table
|
|
7
|
+
from rich.text import Text
|
|
8
|
+
|
|
9
|
+
from ._base import PanelScreen
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class HistoryScreen(PanelScreen):
|
|
13
|
+
title_text = "HISTORY"
|
|
14
|
+
|
|
15
|
+
def body(self) -> RenderableType:
|
|
16
|
+
records = self.app.services.stats.history(limit=30) # type: ignore[attr-defined]
|
|
17
|
+
if not records:
|
|
18
|
+
return Text("No races yet. Press esc, then start a Quick Race!", style="grey58")
|
|
19
|
+
|
|
20
|
+
table = Table(expand=True)
|
|
21
|
+
table.add_column("Date")
|
|
22
|
+
table.add_column("Mode", justify="right")
|
|
23
|
+
table.add_column("WPM", justify="right")
|
|
24
|
+
table.add_column("Acc", justify="right")
|
|
25
|
+
table.add_column("Source")
|
|
26
|
+
for r in records:
|
|
27
|
+
table.add_row(
|
|
28
|
+
r.started_at[:16].replace("T", " "),
|
|
29
|
+
f"{r.mode_seconds}s",
|
|
30
|
+
f"{r.wpm:.0f}",
|
|
31
|
+
f"{r.accuracy * 100:.0f}%",
|
|
32
|
+
(r.quote_source or "—")[:24],
|
|
33
|
+
)
|
|
34
|
+
return table
|