typefaster-cli 0.2.0__py3-none-any.whl → 0.3.1__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 +1 -1
- typefaster/domain/drills.py +39 -0
- typefaster/domain/keyboard.py +74 -0
- typefaster/domain/models.py +2 -0
- typefaster/domain/text_modifiers.py +34 -0
- typefaster/domain/typing_engine.py +19 -0
- typefaster/infra/config.py +3 -0
- typefaster/infra/migrations.py +13 -0
- typefaster/infra/quote_loader.py +13 -0
- typefaster/infra/repository.py +5 -0
- typefaster/infra/sqlite_repository.py +34 -0
- typefaster/net/api.py +27 -2
- typefaster/net/commands.py +1 -2
- typefaster/net/token_store.py +50 -6
- typefaster/services/coach_service.py +52 -0
- typefaster/services/container.py +9 -1
- typefaster/services/daily_service.py +13 -1
- typefaster/services/race_service.py +60 -5
- typefaster/ui/app.py +9 -0
- typefaster/ui/screens/coach.py +112 -0
- typefaster/ui/screens/daily.py +4 -0
- typefaster/ui/screens/main_menu.py +1 -0
- typefaster/ui/screens/online_race.py +9 -3
- typefaster/ui/screens/results.py +16 -0
- typefaster/ui/screens/settings.py +13 -0
- {typefaster_cli-0.2.0.dist-info → typefaster_cli-0.3.1.dist-info}/METADATA +15 -5
- {typefaster_cli-0.2.0.dist-info → typefaster_cli-0.3.1.dist-info}/RECORD +30 -25
- {typefaster_cli-0.2.0.dist-info → typefaster_cli-0.3.1.dist-info}/WHEEL +1 -1
- {typefaster_cli-0.2.0.dist-info → typefaster_cli-0.3.1.dist-info}/entry_points.txt +0 -0
- {typefaster_cli-0.2.0.dist-info → typefaster_cli-0.3.1.dist-info}/licenses/LICENSE +0 -0
typefaster/__init__.py
CHANGED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""Adaptive drill generation — assemble practice text biased toward weak keys.
|
|
2
|
+
|
|
3
|
+
Pure and deterministic (the word list and RNG are injected). The service layer
|
|
4
|
+
supplies words from the quote corpus.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import random
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def build_drill(
|
|
13
|
+
weak_keys: list[str],
|
|
14
|
+
words: list[str],
|
|
15
|
+
*,
|
|
16
|
+
length: int = 30,
|
|
17
|
+
rng: random.Random | None = None,
|
|
18
|
+
) -> str:
|
|
19
|
+
"""Return a space-joined drill of ``length`` words, weighted so words
|
|
20
|
+
containing the player's weak keys appear more often. Falls back to a plain
|
|
21
|
+
random sample when there are no weak keys or no matching words."""
|
|
22
|
+
rng = rng or random.Random()
|
|
23
|
+
clean = [w.lower() for w in words if w.isalpha()]
|
|
24
|
+
if not clean:
|
|
25
|
+
raise ValueError("no words available to build a drill")
|
|
26
|
+
|
|
27
|
+
weak = {c.lower() for c in weak_keys if c.strip()} # ignore the space key
|
|
28
|
+
if not weak:
|
|
29
|
+
return " ".join(rng.choices(clean, k=length))
|
|
30
|
+
|
|
31
|
+
# Weight each word by how many distinct weak keys it exercises.
|
|
32
|
+
scored = [(w, sum(1 for ch in set(w) if ch in weak)) for w in clean]
|
|
33
|
+
weighted = [(w, s) for w, s in scored if s > 0]
|
|
34
|
+
if not weighted:
|
|
35
|
+
weighted = [(w, 1) for w in clean]
|
|
36
|
+
|
|
37
|
+
population = [w for w, _ in weighted]
|
|
38
|
+
weights = [s for _, s in weighted]
|
|
39
|
+
return " ".join(rng.choices(population, weights=weights, k=length))
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""Static QWERTY touch-typing model — which finger reaches each key and how.
|
|
2
|
+
|
|
3
|
+
Pure reference data (no I/O), used by the typing coach to give finger-position
|
|
4
|
+
guidance and to lay out the per-key heatmap. Keyed by the lowercase character,
|
|
5
|
+
matching how ``TypingEngine`` records key stats.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
|
|
12
|
+
# Visual rows for the heatmap, left-to-right, top-to-bottom.
|
|
13
|
+
ROWS: tuple[str, ...] = ("qwertyuiop", "asdfghjkl", "zxcvbnm")
|
|
14
|
+
|
|
15
|
+
# Finger assignment for the standard touch-typing layout.
|
|
16
|
+
_FINGER: dict[str, str] = {
|
|
17
|
+
**dict.fromkeys("qaz", "left pinky"),
|
|
18
|
+
**dict.fromkeys("wsx", "left ring"),
|
|
19
|
+
**dict.fromkeys("edc", "left middle"),
|
|
20
|
+
**dict.fromkeys("rfvtgb", "left index"),
|
|
21
|
+
**dict.fromkeys("yhnujm", "right index"),
|
|
22
|
+
**dict.fromkeys("ik,", "right middle"),
|
|
23
|
+
**dict.fromkeys("ol.", "right ring"),
|
|
24
|
+
**dict.fromkeys("p;/", "right pinky"),
|
|
25
|
+
" ": "thumb",
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
# The resting (home-row) key for each finger.
|
|
29
|
+
_HOME: dict[str, str] = {
|
|
30
|
+
"left pinky": "a",
|
|
31
|
+
"left ring": "s",
|
|
32
|
+
"left middle": "d",
|
|
33
|
+
"left index": "f",
|
|
34
|
+
"right index": "j",
|
|
35
|
+
"right middle": "k",
|
|
36
|
+
"right ring": "l",
|
|
37
|
+
"right pinky": ";",
|
|
38
|
+
"thumb": "space",
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
HOME_ROW: frozenset[str] = frozenset("asdfjkl;")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass(frozen=True, slots=True)
|
|
45
|
+
class KeyInfo:
|
|
46
|
+
finger: str
|
|
47
|
+
home_key: str
|
|
48
|
+
tip: str
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def key_info(ch: str) -> KeyInfo | None:
|
|
52
|
+
"""Finger/home/tip for a single key, or None if it's not on the model
|
|
53
|
+
(e.g. digits or symbols we don't coach)."""
|
|
54
|
+
ch = ch.lower()
|
|
55
|
+
finger = _FINGER.get(ch)
|
|
56
|
+
if finger is None:
|
|
57
|
+
return None
|
|
58
|
+
home = _HOME[finger]
|
|
59
|
+
if ch == " ":
|
|
60
|
+
tip = "tap with your thumb"
|
|
61
|
+
elif ch in HOME_ROW:
|
|
62
|
+
tip = f"home row — rest your {finger} here"
|
|
63
|
+
elif _row_of(ch) == 0:
|
|
64
|
+
tip = f"reach up from {home.upper()} with your {finger}"
|
|
65
|
+
else:
|
|
66
|
+
tip = f"reach down from {home.upper()} with your {finger}"
|
|
67
|
+
return KeyInfo(finger=finger, home_key=home, tip=tip)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _row_of(ch: str) -> int:
|
|
71
|
+
for i, row in enumerate(ROWS):
|
|
72
|
+
if ch in row:
|
|
73
|
+
return i
|
|
74
|
+
return 1 # default to home row
|
typefaster/domain/models.py
CHANGED
|
@@ -135,6 +135,8 @@ class RaceResult:
|
|
|
135
135
|
# Set when the run is implausible (paste/auto-input) — not recorded.
|
|
136
136
|
suspicious: bool = False
|
|
137
137
|
flags: tuple[str, ...] = ()
|
|
138
|
+
# Per-key (attempts, misses), folded by case — feeds the typing coach.
|
|
139
|
+
key_stats: dict[str, tuple[int, int]] = field(default_factory=dict)
|
|
138
140
|
|
|
139
141
|
@property
|
|
140
142
|
def completed(self) -> bool:
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""MonkeyType-style text modifiers — pure transforms applied to race text.
|
|
2
|
+
|
|
3
|
+
These reshape the *target text* a player types (e.g. strip everything down to
|
|
4
|
+
lowercase words) without touching the original quote that gets persisted. Kept
|
|
5
|
+
dependency-free and deterministic so they're trivially unit-testable.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import re
|
|
11
|
+
|
|
12
|
+
_NON_WORD = re.compile(r"[^A-Za-z ]+")
|
|
13
|
+
_WHITESPACE = re.compile(r"\s+")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def apply_modifiers(text: str, *, lowercase: bool, words_only: bool) -> str:
|
|
17
|
+
"""Apply the active modifiers to ``text``.
|
|
18
|
+
|
|
19
|
+
- ``words_only`` keeps only ``[a-z]`` and single spaces (implies lowercase).
|
|
20
|
+
- ``lowercase`` (when words_only is off) just lowercases.
|
|
21
|
+
|
|
22
|
+
Never returns an empty string: if a transform would empty the text (e.g. a
|
|
23
|
+
quote made entirely of digits/punctuation), the original is returned, since
|
|
24
|
+
``TypingEngine`` rejects an empty target.
|
|
25
|
+
"""
|
|
26
|
+
out = text
|
|
27
|
+
if words_only:
|
|
28
|
+
out = _WHITESPACE.sub(" ", out) # tabs/newlines become separators first
|
|
29
|
+
out = _NON_WORD.sub("", out) # drop punctuation/digits, keep letters + space
|
|
30
|
+
out = _WHITESPACE.sub(" ", out).strip() # collapse any doubled spaces
|
|
31
|
+
out = out.lower()
|
|
32
|
+
elif lowercase:
|
|
33
|
+
out = out.lower()
|
|
34
|
+
return out or text
|
|
@@ -37,6 +37,11 @@ class TypingEngine:
|
|
|
37
37
|
self._total_keystrokes = 0
|
|
38
38
|
self._correct_keystrokes = 0
|
|
39
39
|
|
|
40
|
+
# Per-key (case-folded) attempt/miss counts for the typing coach. These
|
|
41
|
+
# count every keypress, so a mistake counts even if later corrected.
|
|
42
|
+
self._key_attempts: dict[str, int] = {}
|
|
43
|
+
self._key_misses: dict[str, int] = {}
|
|
44
|
+
|
|
40
45
|
self._timeline: list[ReplayPoint] = [ReplayPoint(0, 0.0)]
|
|
41
46
|
self._last_progress_pct = 0.0
|
|
42
47
|
|
|
@@ -57,6 +62,12 @@ class TypingEngine:
|
|
|
57
62
|
if is_correct:
|
|
58
63
|
self._correct_keystrokes += 1
|
|
59
64
|
|
|
65
|
+
# Track the *expected* key (case-folded) so the coach can rank weak keys.
|
|
66
|
+
k = expected.lower()
|
|
67
|
+
self._key_attempts[k] = self._key_attempts.get(k, 0) + 1
|
|
68
|
+
if not is_correct:
|
|
69
|
+
self._key_misses[k] = self._key_misses.get(k, 0) + 1
|
|
70
|
+
|
|
60
71
|
self._record(t_ms)
|
|
61
72
|
|
|
62
73
|
def backspace(self, t_ms: int) -> None:
|
|
@@ -95,6 +106,13 @@ class TypingEngine:
|
|
|
95
106
|
def correct_keystrokes(self) -> int:
|
|
96
107
|
return self._correct_keystrokes
|
|
97
108
|
|
|
109
|
+
@property
|
|
110
|
+
def key_stats(self) -> dict[str, tuple[int, int]]:
|
|
111
|
+
"""Per-key (attempts, misses), case-folded, for the typing coach."""
|
|
112
|
+
return {
|
|
113
|
+
k: (attempts, self._key_misses.get(k, 0)) for k, attempts in self._key_attempts.items()
|
|
114
|
+
}
|
|
115
|
+
|
|
98
116
|
@property
|
|
99
117
|
def progress(self) -> float:
|
|
100
118
|
"""Completion fraction 0..1 based on cursor position."""
|
|
@@ -147,4 +165,5 @@ class TypingEngine:
|
|
|
147
165
|
timeline=self.timeline,
|
|
148
166
|
ghost_kind=ghost_kind,
|
|
149
167
|
ghost_won=ghost_won,
|
|
168
|
+
key_stats=self.key_stats,
|
|
150
169
|
)
|
typefaster/infra/config.py
CHANGED
|
@@ -20,6 +20,9 @@ class Settings:
|
|
|
20
20
|
allow_backspace: bool = True
|
|
21
21
|
default_ghost: str = "personal-best"
|
|
22
22
|
sound: bool = False
|
|
23
|
+
# MonkeyType-style text modifiers (applied to the text you type).
|
|
24
|
+
lowercase_only: bool = False
|
|
25
|
+
words_only: bool = False
|
|
23
26
|
|
|
24
27
|
@classmethod
|
|
25
28
|
def load(cls, path: Path | None = None) -> Settings:
|
typefaster/infra/migrations.py
CHANGED
|
@@ -78,6 +78,19 @@ _MIGRATIONS: list[tuple[int, str]] = [
|
|
|
78
78
|
CREATE INDEX IF NOT EXISTS idx_race_kind_wpm ON race(race_kind, mode_seconds, wpm DESC);
|
|
79
79
|
""",
|
|
80
80
|
),
|
|
81
|
+
(
|
|
82
|
+
3,
|
|
83
|
+
# Running per-key (case-folded) attempt/miss aggregate for the typing
|
|
84
|
+
# coach. One row per key; upserted after every race. Local-only.
|
|
85
|
+
"""
|
|
86
|
+
CREATE TABLE IF NOT EXISTS key_stats (
|
|
87
|
+
key_char TEXT PRIMARY KEY,
|
|
88
|
+
attempts INTEGER NOT NULL DEFAULT 0,
|
|
89
|
+
misses INTEGER NOT NULL DEFAULT 0,
|
|
90
|
+
updated_at TEXT NOT NULL
|
|
91
|
+
);
|
|
92
|
+
""",
|
|
93
|
+
),
|
|
81
94
|
]
|
|
82
95
|
|
|
83
96
|
|
typefaster/infra/quote_loader.py
CHANGED
|
@@ -42,6 +42,19 @@ def quotes_by_difficulty(difficulty: Difficulty) -> list[Quote]:
|
|
|
42
42
|
return [q for q in all_quotes() if q.difficulty == difficulty]
|
|
43
43
|
|
|
44
44
|
|
|
45
|
+
@lru_cache(maxsize=1)
|
|
46
|
+
def corpus_words() -> tuple[str, ...]:
|
|
47
|
+
"""Distinct lowercase alphabetic words from the quote corpus, for building
|
|
48
|
+
typing drills. Cached since the corpus is static."""
|
|
49
|
+
seen: set[str] = set()
|
|
50
|
+
for quote in all_quotes():
|
|
51
|
+
for raw in quote.text.split():
|
|
52
|
+
word = "".join(c for c in raw if c.isalpha()).lower()
|
|
53
|
+
if 2 <= len(word) <= 12:
|
|
54
|
+
seen.add(word)
|
|
55
|
+
return tuple(sorted(seen))
|
|
56
|
+
|
|
57
|
+
|
|
45
58
|
def daily_quote(day: date | None = None) -> Quote:
|
|
46
59
|
"""Deterministically pick the same quote for everyone on a given UTC day."""
|
|
47
60
|
quotes = all_quotes()
|
typefaster/infra/repository.py
CHANGED
|
@@ -44,6 +44,10 @@ class Repository(Protocol):
|
|
|
44
44
|
|
|
45
45
|
def list_history(self, limit: int = 20, offset: int = 0) -> list[RaceRecord]: ...
|
|
46
46
|
def count_races(self) -> int: ...
|
|
47
|
+
|
|
48
|
+
# per-key stats (typing coach)
|
|
49
|
+
def get_key_stats(self) -> dict[str, tuple[int, int]]: ...
|
|
50
|
+
def record_key_stats(self, key_stats: dict[str, tuple[int, int]], updated_at: str) -> None: ...
|
|
47
51
|
def average_wpm_accuracy(self) -> tuple[float, float]: ...
|
|
48
52
|
def best_by_mode(self, kind: RaceKind = ...) -> dict[int, float]: ...
|
|
49
53
|
def best_quote_run(self) -> tuple[float, int] | None: ...
|
|
@@ -60,4 +64,5 @@ class Repository(Protocol):
|
|
|
60
64
|
|
|
61
65
|
# daily
|
|
62
66
|
def get_or_create_daily(self, day: str, quote: Quote) -> DailyChallenge: ...
|
|
67
|
+
def daily_days_played(self) -> list[str]: ...
|
|
63
68
|
def daily_leaderboard(self, day: str, limit: int = 20) -> list[RaceRecord]: ...
|
|
@@ -107,6 +107,7 @@ class SQLiteRepository:
|
|
|
107
107
|
with transaction(self._conn):
|
|
108
108
|
self._conn.execute("DELETE FROM race")
|
|
109
109
|
self._conn.execute("DELETE FROM daily_challenge")
|
|
110
|
+
self._conn.execute("DELETE FROM key_stats")
|
|
110
111
|
self.recompute_profile()
|
|
111
112
|
|
|
112
113
|
# ── quotes ─────────────────────────────────────────────────────────
|
|
@@ -168,10 +169,36 @@ class SQLiteRepository:
|
|
|
168
169
|
(race_id, replay_store.serialize(result.timeline)),
|
|
169
170
|
)
|
|
170
171
|
self._bump_profile(result)
|
|
172
|
+
self._bump_key_stats(result.key_stats, started_at)
|
|
171
173
|
if is_daily:
|
|
172
174
|
self._bump_daily(started_at[:10], quote_id, result.wpm)
|
|
173
175
|
return race_id
|
|
174
176
|
|
|
177
|
+
def _bump_key_stats(self, key_stats: dict[str, tuple[int, int]], updated_at: str) -> None:
|
|
178
|
+
"""Add this race's per-key attempts/misses into the running aggregate."""
|
|
179
|
+
for key_char, (attempts, misses) in key_stats.items():
|
|
180
|
+
self._conn.execute(
|
|
181
|
+
"""
|
|
182
|
+
INSERT INTO key_stats(key_char, attempts, misses, updated_at)
|
|
183
|
+
VALUES(?, ?, ?, ?)
|
|
184
|
+
ON CONFLICT(key_char) DO UPDATE SET
|
|
185
|
+
attempts = attempts + excluded.attempts,
|
|
186
|
+
misses = misses + excluded.misses,
|
|
187
|
+
updated_at = excluded.updated_at
|
|
188
|
+
""",
|
|
189
|
+
(key_char, attempts, misses, updated_at),
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
def record_key_stats(self, key_stats: dict[str, tuple[int, int]], updated_at: str) -> None:
|
|
193
|
+
"""Standalone per-key update (used by drills, which feed the coach but
|
|
194
|
+
are not recorded as competitive races)."""
|
|
195
|
+
with transaction(self._conn):
|
|
196
|
+
self._bump_key_stats(key_stats, updated_at)
|
|
197
|
+
|
|
198
|
+
def get_key_stats(self) -> dict[str, tuple[int, int]]:
|
|
199
|
+
rows = self._conn.execute("SELECT key_char, attempts, misses FROM key_stats").fetchall()
|
|
200
|
+
return {r["key_char"]: (r["attempts"], r["misses"]) for r in rows}
|
|
201
|
+
|
|
175
202
|
def _bump_profile(self, result: RaceResult) -> None:
|
|
176
203
|
won = 1 if result.ghost_won else 0
|
|
177
204
|
self._conn.execute(
|
|
@@ -300,6 +327,13 @@ class SQLiteRepository:
|
|
|
300
327
|
return self._replay_for("AND q.ext_id = ? ORDER BY r.wpm DESC", (ext_id,))
|
|
301
328
|
|
|
302
329
|
# ── daily ──────────────────────────────────────────────────────────
|
|
330
|
+
def daily_days_played(self) -> list[str]:
|
|
331
|
+
"""ISO days (YYYY-MM-DD, ascending) on which the daily was attempted."""
|
|
332
|
+
rows = self._conn.execute(
|
|
333
|
+
"SELECT day FROM daily_challenge WHERE attempts > 0 ORDER BY day"
|
|
334
|
+
).fetchall()
|
|
335
|
+
return [r["day"] for r in rows]
|
|
336
|
+
|
|
303
337
|
def get_or_create_daily(self, day: str, quote: Quote) -> DailyChallenge:
|
|
304
338
|
quote_id = self.upsert_quote(quote)
|
|
305
339
|
row = self._conn.execute("SELECT * FROM daily_challenge WHERE day = ?", (day,)).fetchone()
|
typefaster/net/api.py
CHANGED
|
@@ -2,11 +2,12 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
+
import contextlib
|
|
5
6
|
from typing import Any
|
|
6
7
|
|
|
7
8
|
import httpx
|
|
8
9
|
|
|
9
|
-
from .token_store import Session
|
|
10
|
+
from .token_store import DEFAULT_SERVER_URL, Session
|
|
10
11
|
|
|
11
12
|
|
|
12
13
|
class ApiError(Exception):
|
|
@@ -19,7 +20,11 @@ class ApiError(Exception):
|
|
|
19
20
|
class ApiClient:
|
|
20
21
|
def __init__(self, session: Session, timeout: float = 10.0) -> None:
|
|
21
22
|
self.session = session
|
|
23
|
+
self._timeout = timeout
|
|
22
24
|
self._client = httpx.Client(base_url=session.server_url, timeout=timeout)
|
|
25
|
+
# Set when a connection failure forced a fallback to the default server,
|
|
26
|
+
# so callers can surface a one-time "saved server was unreachable" notice.
|
|
27
|
+
self.failed_over = False
|
|
23
28
|
|
|
24
29
|
def close(self) -> None:
|
|
25
30
|
self._client.close()
|
|
@@ -40,7 +45,17 @@ class ApiClient:
|
|
|
40
45
|
try:
|
|
41
46
|
resp = self._client.request(method, path, **kwargs)
|
|
42
47
|
except httpx.HTTPError as exc:
|
|
43
|
-
|
|
48
|
+
# A connection-level failure (DNS/unreachable) against a non-default
|
|
49
|
+
# server may mean the saved server is dead. Retry once on the default
|
|
50
|
+
# and, if that works, persist it so the user self-heals.
|
|
51
|
+
if self.session.server_url != DEFAULT_SERVER_URL and not self.failed_over:
|
|
52
|
+
self._failover_to_default()
|
|
53
|
+
try:
|
|
54
|
+
resp = self._client.request(method, path, **kwargs)
|
|
55
|
+
except httpx.HTTPError as exc2:
|
|
56
|
+
raise ApiError(0, f"connection failed: {exc2}") from exc2
|
|
57
|
+
else:
|
|
58
|
+
raise ApiError(0, f"connection failed: {exc}") from exc
|
|
44
59
|
if resp.status_code >= 400:
|
|
45
60
|
detail = _extract_detail(resp)
|
|
46
61
|
raise ApiError(resp.status_code, detail)
|
|
@@ -48,6 +63,16 @@ class ApiClient:
|
|
|
48
63
|
return None
|
|
49
64
|
return resp.json()
|
|
50
65
|
|
|
66
|
+
def _failover_to_default(self) -> None:
|
|
67
|
+
"""Switch the session to the default server, persist it, and rebuild the
|
|
68
|
+
underlying client so the retry targets the new base URL."""
|
|
69
|
+
self.session.server_url = DEFAULT_SERVER_URL
|
|
70
|
+
with contextlib.suppress(OSError):
|
|
71
|
+
self.session.save() # persistence is best-effort; in-memory switch still works
|
|
72
|
+
self._client.close()
|
|
73
|
+
self._client = httpx.Client(base_url=DEFAULT_SERVER_URL, timeout=self._timeout)
|
|
74
|
+
self.failed_over = True
|
|
75
|
+
|
|
51
76
|
# ── auth ───────────────────────────────────────────────────────────
|
|
52
77
|
# These return parsed JSON; typed as Any since the shape is documented
|
|
53
78
|
# by the server's response models rather than enforced client-side.
|
typefaster/net/commands.py
CHANGED
|
@@ -93,8 +93,7 @@ def _oauth_login(provider: str) -> None:
|
|
|
93
93
|
deadline = time.time() + int(start.get("expires_in", 900))
|
|
94
94
|
|
|
95
95
|
console.print(
|
|
96
|
-
f"\n Open [bold underline]{uri}[/]\n"
|
|
97
|
-
f" Enter code: [bold cyan]{code}[/]\n"
|
|
96
|
+
f"\n Open [bold underline]{uri}[/]\n" f" Enter code: [bold cyan]{code}[/]\n"
|
|
98
97
|
)
|
|
99
98
|
with contextlib.suppress(Exception):
|
|
100
99
|
webbrowser.open(uri)
|
typefaster/net/token_store.py
CHANGED
|
@@ -2,29 +2,66 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
+
import base64
|
|
5
6
|
import json
|
|
7
|
+
import time
|
|
6
8
|
from dataclasses import asdict, dataclass
|
|
7
9
|
from pathlib import Path
|
|
10
|
+
from urllib.parse import urlsplit
|
|
8
11
|
|
|
9
12
|
from ..infra.paths import config_dir
|
|
10
13
|
|
|
14
|
+
# Single source of truth for the public TYPEFASTER server. A fresh install can
|
|
15
|
+
# register/play with zero config; override with `typefaster config set-server`
|
|
16
|
+
# (e.g. a local server or your own deployment).
|
|
17
|
+
DEFAULT_SERVER_URL = "https://140.245.248.113.sslip.io"
|
|
18
|
+
|
|
11
19
|
|
|
12
20
|
def _auth_path() -> Path:
|
|
13
21
|
return config_dir() / "auth.json"
|
|
14
22
|
|
|
15
23
|
|
|
24
|
+
def _is_ephemeral_host(url: str) -> bool:
|
|
25
|
+
"""True for throwaway tunnel hosts that never persist (e.g. Cloudflare
|
|
26
|
+
quick-tunnels). A saved URL like these is always stale and safe to reset."""
|
|
27
|
+
host = (urlsplit(url).hostname or "").lower()
|
|
28
|
+
return host.endswith(".trycloudflare.com")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _is_token_expired(token: str | None) -> bool:
|
|
32
|
+
"""Check if JWT token is expired by decoding and comparing exp claim.
|
|
33
|
+
Frontend validation for UX; server always validates for security.
|
|
34
|
+
Expects JWT format: header.payload.signature (3 parts, 2 dots).
|
|
35
|
+
Non-JWT tokens are assumed valid."""
|
|
36
|
+
if not token:
|
|
37
|
+
return True
|
|
38
|
+
parts = token.split(".")
|
|
39
|
+
if len(parts) != 3:
|
|
40
|
+
return False
|
|
41
|
+
try:
|
|
42
|
+
payload = parts[1]
|
|
43
|
+
decoded = json.loads(base64.urlsafe_b64decode(payload + "=="))
|
|
44
|
+
if not isinstance(decoded, dict):
|
|
45
|
+
return True
|
|
46
|
+
exp = decoded.get("exp")
|
|
47
|
+
if exp is None or not isinstance(exp, (int, float)):
|
|
48
|
+
return False
|
|
49
|
+
return time.time() > exp
|
|
50
|
+
except Exception:
|
|
51
|
+
return True
|
|
52
|
+
|
|
53
|
+
|
|
16
54
|
@dataclass(slots=True)
|
|
17
55
|
class Session:
|
|
18
|
-
|
|
19
|
-
# register/play with zero config. Override with `typefaster config set-server`
|
|
20
|
-
# (e.g. a local server or your own deployment).
|
|
21
|
-
server_url: str = "https://140.245.248.113.sslip.io"
|
|
56
|
+
server_url: str = DEFAULT_SERVER_URL
|
|
22
57
|
token: str | None = None
|
|
23
58
|
username: str | None = None
|
|
24
59
|
|
|
25
60
|
@property
|
|
26
61
|
def logged_in(self) -> bool:
|
|
27
|
-
|
|
62
|
+
if not self.token:
|
|
63
|
+
return False
|
|
64
|
+
return not _is_token_expired(self.token)
|
|
28
65
|
|
|
29
66
|
@classmethod
|
|
30
67
|
def load(cls, path: Path | None = None) -> Session:
|
|
@@ -36,7 +73,14 @@ class Session:
|
|
|
36
73
|
except (json.JSONDecodeError, OSError):
|
|
37
74
|
return cls()
|
|
38
75
|
known = set(cls.__slots__)
|
|
39
|
-
|
|
76
|
+
session = cls(**{k: v for k, v in data.items() if k in known})
|
|
77
|
+
# Self-heal: a saved ephemeral-tunnel URL (from early testing) is dead and
|
|
78
|
+
# can never come back. Reset it to the default so online play works again
|
|
79
|
+
# without the user having to run `config set-server`.
|
|
80
|
+
if _is_ephemeral_host(session.server_url):
|
|
81
|
+
session.server_url = DEFAULT_SERVER_URL
|
|
82
|
+
session.save(path)
|
|
83
|
+
return session
|
|
40
84
|
|
|
41
85
|
def save(self, path: Path | None = None) -> None:
|
|
42
86
|
path = path or _auth_path()
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""Typing coach — deterministic analysis over local per-key stats.
|
|
2
|
+
|
|
3
|
+
Fully offline and free: ranks the keys you miss most and exposes a per-key
|
|
4
|
+
accuracy map. No network, no LLM. Reads the running ``key_stats`` aggregate the
|
|
5
|
+
repository accumulates after every race.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
|
|
12
|
+
from ..infra.repository import Repository
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(frozen=True, slots=True)
|
|
16
|
+
class KeyAccuracy:
|
|
17
|
+
key: str
|
|
18
|
+
attempts: int
|
|
19
|
+
misses: int
|
|
20
|
+
accuracy: float # 0..1
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class CoachService:
|
|
24
|
+
# Below this many total keypresses the data is too thin to coach on.
|
|
25
|
+
MIN_TOTAL_ATTEMPTS = 50
|
|
26
|
+
|
|
27
|
+
def __init__(self, repo: Repository) -> None:
|
|
28
|
+
self._repo = repo
|
|
29
|
+
|
|
30
|
+
def enough_data(self) -> bool:
|
|
31
|
+
stats = self._repo.get_key_stats()
|
|
32
|
+
return sum(attempts for attempts, _ in stats.values()) >= self.MIN_TOTAL_ATTEMPTS
|
|
33
|
+
|
|
34
|
+
def weakest_keys(self, *, min_attempts: int = 20, limit: int = 10) -> list[KeyAccuracy]:
|
|
35
|
+
"""Keys with the worst accuracy, ignoring keys with too few attempts so a
|
|
36
|
+
single typo can't dominate. Worst first; ties broken by more attempts."""
|
|
37
|
+
out: list[KeyAccuracy] = []
|
|
38
|
+
for key, (attempts, misses) in self._repo.get_key_stats().items():
|
|
39
|
+
if attempts < min_attempts:
|
|
40
|
+
continue
|
|
41
|
+
accuracy = (attempts - misses) / attempts
|
|
42
|
+
out.append(KeyAccuracy(key=key, attempts=attempts, misses=misses, accuracy=accuracy))
|
|
43
|
+
out.sort(key=lambda k: (k.accuracy, -k.attempts))
|
|
44
|
+
return out[:limit]
|
|
45
|
+
|
|
46
|
+
def heatmap(self) -> dict[str, float]:
|
|
47
|
+
"""Per-key accuracy (0..1) for every key seen, for rendering."""
|
|
48
|
+
return {
|
|
49
|
+
key: (attempts - misses) / attempts
|
|
50
|
+
for key, (attempts, misses) in self._repo.get_key_stats().items()
|
|
51
|
+
if attempts > 0
|
|
52
|
+
}
|
typefaster/services/container.py
CHANGED
|
@@ -7,6 +7,7 @@ from pathlib import Path
|
|
|
7
7
|
|
|
8
8
|
from ..infra.config import Settings
|
|
9
9
|
from ..infra.sqlite_repository import SQLiteRepository
|
|
10
|
+
from .coach_service import CoachService
|
|
10
11
|
from .daily_service import DailyService
|
|
11
12
|
from .ghost_service import GhostService
|
|
12
13
|
from .profile_service import ProfileService
|
|
@@ -23,6 +24,7 @@ class App:
|
|
|
23
24
|
stats: StatsService
|
|
24
25
|
daily: DailyService
|
|
25
26
|
ghosts: GhostService
|
|
27
|
+
coach: CoachService
|
|
26
28
|
|
|
27
29
|
def close(self) -> None:
|
|
28
30
|
self.repo.close()
|
|
@@ -34,9 +36,15 @@ def build_app(db_path: Path | str | None = None) -> App:
|
|
|
34
36
|
return App(
|
|
35
37
|
settings=settings,
|
|
36
38
|
repo=repo,
|
|
37
|
-
race=RaceService(
|
|
39
|
+
race=RaceService(
|
|
40
|
+
repo,
|
|
41
|
+
allow_backspace=settings.allow_backspace,
|
|
42
|
+
lowercase_only=settings.lowercase_only,
|
|
43
|
+
words_only=settings.words_only,
|
|
44
|
+
),
|
|
38
45
|
profile=ProfileService(repo),
|
|
39
46
|
stats=StatsService(repo),
|
|
40
47
|
daily=DailyService(repo),
|
|
41
48
|
ghosts=GhostService(repo),
|
|
49
|
+
coach=CoachService(repo),
|
|
42
50
|
)
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
-
from datetime import UTC, date, datetime
|
|
5
|
+
from datetime import UTC, date, datetime, timedelta
|
|
6
6
|
|
|
7
7
|
from ..domain.models import DailyChallenge, RaceRecord
|
|
8
8
|
from ..infra import quote_loader
|
|
@@ -32,3 +32,15 @@ class DailyService:
|
|
|
32
32
|
def leaderboard(self, day: date | None = None, limit: int = 20) -> list[RaceRecord]:
|
|
33
33
|
day = day or _utc_today()
|
|
34
34
|
return self._repo.daily_leaderboard(day.isoformat(), limit=limit)
|
|
35
|
+
|
|
36
|
+
def streak(self, day: date | None = None) -> int:
|
|
37
|
+
"""Consecutive daily-challenge days ending today (or yesterday, if today
|
|
38
|
+
hasn't been played yet — the streak is still alive, just not extended)."""
|
|
39
|
+
today = day or _utc_today()
|
|
40
|
+
played = set(self._repo.daily_days_played())
|
|
41
|
+
cursor = today if today.isoformat() in played else today - timedelta(days=1)
|
|
42
|
+
n = 0
|
|
43
|
+
while cursor.isoformat() in played:
|
|
44
|
+
n += 1
|
|
45
|
+
cursor -= timedelta(days=1)
|
|
46
|
+
return n
|
|
@@ -13,9 +13,11 @@ Two race kinds (see ``RaceKind``):
|
|
|
13
13
|
from __future__ import annotations
|
|
14
14
|
|
|
15
15
|
from dataclasses import dataclass
|
|
16
|
-
from datetime import UTC,
|
|
16
|
+
from datetime import UTC, datetime
|
|
17
17
|
|
|
18
|
+
from ..domain.drills import build_drill
|
|
18
19
|
from ..domain.models import Ghost, GhostKind, Quote, RaceKind, RaceMode, RaceResult
|
|
20
|
+
from ..domain.text_modifiers import apply_modifiers
|
|
19
21
|
from ..infra import quote_loader
|
|
20
22
|
from ..infra.repository import Repository
|
|
21
23
|
from .ghost_service import GhostService
|
|
@@ -23,6 +25,10 @@ from .ghost_service import GhostService
|
|
|
23
25
|
# Placeholder quote row that TIME-mode races reference (they have no single text).
|
|
24
26
|
_TIME_QUOTE = Quote(ext_id="__time_mode__", text="(time mode)", source="Time")
|
|
25
27
|
|
|
28
|
+
# Synthetic ext_id for coach drills: they feed per-key stats but are not recorded
|
|
29
|
+
# as competitive races (no PB, leaderboard, or history).
|
|
30
|
+
_DRILL_EXT_ID = "__drill__"
|
|
31
|
+
|
|
26
32
|
|
|
27
33
|
@dataclass(frozen=True, slots=True)
|
|
28
34
|
class RaceConfig:
|
|
@@ -55,10 +61,36 @@ class RaceSummary:
|
|
|
55
61
|
|
|
56
62
|
|
|
57
63
|
class RaceService:
|
|
58
|
-
def __init__(
|
|
64
|
+
def __init__(
|
|
65
|
+
self,
|
|
66
|
+
repo: Repository,
|
|
67
|
+
*,
|
|
68
|
+
allow_backspace: bool = True,
|
|
69
|
+
lowercase_only: bool = False,
|
|
70
|
+
words_only: bool = False,
|
|
71
|
+
) -> None:
|
|
59
72
|
self._repo = repo
|
|
60
73
|
self._ghosts = GhostService(repo)
|
|
61
74
|
self._allow_backspace = allow_backspace
|
|
75
|
+
self._lowercase_only = lowercase_only
|
|
76
|
+
self._words_only = words_only
|
|
77
|
+
|
|
78
|
+
def set_modifiers(self, *, lowercase_only: bool, words_only: bool) -> None:
|
|
79
|
+
"""Update the active text modifiers live (e.g. from the Settings screen)
|
|
80
|
+
so a toggle applies to the next race without restarting the app."""
|
|
81
|
+
self._lowercase_only = lowercase_only
|
|
82
|
+
self._words_only = words_only
|
|
83
|
+
|
|
84
|
+
def set_backspace(self, allow: bool) -> None:
|
|
85
|
+
"""Apply the backspace setting live, same contract as set_modifiers."""
|
|
86
|
+
self._allow_backspace = allow
|
|
87
|
+
|
|
88
|
+
def _modify(self, text: str) -> str:
|
|
89
|
+
"""Apply the active text modifiers to what the player will type. The
|
|
90
|
+
original quote is still persisted; only the target text changes. Ghosts
|
|
91
|
+
stay percentage-based, so they still render over modified text (a
|
|
92
|
+
modified run just isn't apples-to-apples with an unmodified ghost)."""
|
|
93
|
+
return apply_modifiers(text, lowercase=self._lowercase_only, words_only=self._words_only)
|
|
62
94
|
|
|
63
95
|
def prepare(
|
|
64
96
|
self,
|
|
@@ -77,7 +109,9 @@ class RaceService:
|
|
|
77
109
|
) -> RaceSetup:
|
|
78
110
|
ghost: Ghost | None = None
|
|
79
111
|
if daily:
|
|
80
|
-
|
|
112
|
+
# UTC, matching DailyService/DailyScreen — otherwise a local-midnight
|
|
113
|
+
# window would race a different quote than the challenge records.
|
|
114
|
+
quote = quote_loader.daily_quote(datetime.now(UTC).date())
|
|
81
115
|
# Daily ghost = your best run on *today's* quote (same text), if any.
|
|
82
116
|
ghost = self._ghosts.best_for_quote(quote.ext_id)
|
|
83
117
|
elif ghost_kind is not None:
|
|
@@ -93,7 +127,7 @@ class RaceService:
|
|
|
93
127
|
|
|
94
128
|
return RaceSetup(
|
|
95
129
|
quote=quote,
|
|
96
|
-
target_text=quote.text,
|
|
130
|
+
target_text=self._modify(quote.text),
|
|
97
131
|
kind=RaceKind.QUOTE,
|
|
98
132
|
mode=mode,
|
|
99
133
|
ghost=ghost,
|
|
@@ -109,7 +143,7 @@ class RaceService:
|
|
|
109
143
|
text = _stream_text(target_chars)
|
|
110
144
|
return RaceSetup(
|
|
111
145
|
quote=_TIME_QUOTE,
|
|
112
|
-
target_text=text,
|
|
146
|
+
target_text=self._modify(text),
|
|
113
147
|
kind=RaceKind.TIME,
|
|
114
148
|
mode=mode,
|
|
115
149
|
ghost=None, # ghosts are a QUOTE-mode feature
|
|
@@ -118,7 +152,28 @@ class RaceService:
|
|
|
118
152
|
started_at=datetime.now(UTC).isoformat(),
|
|
119
153
|
)
|
|
120
154
|
|
|
155
|
+
def prepare_drill(self, weak_keys: list[str], *, length: int = 30) -> RaceSetup:
|
|
156
|
+
"""Build a practice race whose text is weighted toward the player's weak
|
|
157
|
+
keys. Drills feed the coach's per-key stats but never set records."""
|
|
158
|
+
text = build_drill(weak_keys, list(quote_loader.corpus_words()), length=length)
|
|
159
|
+
return RaceSetup(
|
|
160
|
+
quote=Quote(ext_id=_DRILL_EXT_ID, text=text, source="Drill"),
|
|
161
|
+
target_text=text,
|
|
162
|
+
kind=RaceKind.QUOTE,
|
|
163
|
+
mode=RaceMode.NORMAL,
|
|
164
|
+
ghost=None,
|
|
165
|
+
allow_backspace=self._allow_backspace,
|
|
166
|
+
is_daily=False,
|
|
167
|
+
started_at=datetime.now(UTC).isoformat(),
|
|
168
|
+
)
|
|
169
|
+
|
|
121
170
|
def finish(self, setup: RaceSetup, result: RaceResult) -> RaceSummary:
|
|
171
|
+
if setup.quote.ext_id == _DRILL_EXT_ID:
|
|
172
|
+
# Practice only: improve the coach heatmap, don't record a race.
|
|
173
|
+
self._repo.record_key_stats(result.key_stats, setup.started_at)
|
|
174
|
+
return RaceSummary(
|
|
175
|
+
result=result, race_id=0, new_personal_best=False, previous_best_wpm=0.0
|
|
176
|
+
)
|
|
122
177
|
# Compare against the best of the *same* kind so the two don't mix.
|
|
123
178
|
previous_best = self._best_wpm_for_kind(result.kind)
|
|
124
179
|
race_id = self._repo.save_race(
|
typefaster/ui/app.py
CHANGED
|
@@ -9,6 +9,7 @@ from ..services.container import App as Services
|
|
|
9
9
|
from ..services.container import build_app
|
|
10
10
|
from ..services.race_service import RaceConfig, RaceSetup
|
|
11
11
|
from .screens.account import AccountScreen
|
|
12
|
+
from .screens.coach import CoachScreen
|
|
12
13
|
from .screens.daily import DailyScreen
|
|
13
14
|
from .screens.help import HelpScreen
|
|
14
15
|
from .screens.history import HistoryScreen
|
|
@@ -27,6 +28,7 @@ _PANELS = {
|
|
|
27
28
|
"practice": PracticeScreen,
|
|
28
29
|
"daily": DailyScreen,
|
|
29
30
|
"stats": StatsScreen,
|
|
31
|
+
"coach": CoachScreen,
|
|
30
32
|
"history": HistoryScreen,
|
|
31
33
|
"profile": ProfileScreen,
|
|
32
34
|
"leaderboard": LeaderboardScreen,
|
|
@@ -75,6 +77,13 @@ class TypefasterApp(App[None]):
|
|
|
75
77
|
self._last_config = config
|
|
76
78
|
self.push_screen(RaceScreen(setup), lambda result: self._after_race(setup, result))
|
|
77
79
|
|
|
80
|
+
def start_drill(self) -> None:
|
|
81
|
+
"""Launch a coach drill weighted toward the player's weakest keys."""
|
|
82
|
+
weak = [k.key for k in self.services.coach.weakest_keys()]
|
|
83
|
+
setup = self.services.race.prepare_drill(weak)
|
|
84
|
+
self._last_config = None # drills regenerate each time, not via "again"
|
|
85
|
+
self.push_screen(RaceScreen(setup), lambda result: self._after_race(setup, result))
|
|
86
|
+
|
|
78
87
|
def _after_race(self, setup: RaceSetup, result: RaceResult | None) -> None:
|
|
79
88
|
if result is None:
|
|
80
89
|
return # race quit — return to whatever is underneath
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"""Typing Coach screen — most-missed keys, a per-key heatmap, and finger tips.
|
|
2
|
+
|
|
3
|
+
All local and deterministic: it reads the offline per-key stats the coach service
|
|
4
|
+
aggregates. No network, no LLM.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from rich.console import Group, RenderableType
|
|
10
|
+
from rich.table import Table
|
|
11
|
+
from rich.text import Text
|
|
12
|
+
|
|
13
|
+
from ...domain import keyboard
|
|
14
|
+
from ...services.coach_service import KeyAccuracy
|
|
15
|
+
from ._base import PanelScreen
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _key_label(key: str) -> str:
|
|
19
|
+
return "␣" if key == " " else key.upper()
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _heat_style(accuracy: float) -> str:
|
|
23
|
+
if accuracy >= 0.98:
|
|
24
|
+
return "bold green"
|
|
25
|
+
if accuracy >= 0.95:
|
|
26
|
+
return "yellow"
|
|
27
|
+
return "bold red"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class CoachScreen(PanelScreen):
|
|
31
|
+
title_text = "TYPING COACH"
|
|
32
|
+
BINDINGS = [
|
|
33
|
+
("escape", "back", "Back"),
|
|
34
|
+
("q", "back", "Back"),
|
|
35
|
+
("d", "drill", "Drill weak keys"),
|
|
36
|
+
]
|
|
37
|
+
|
|
38
|
+
def action_drill(self) -> None:
|
|
39
|
+
coach = self.app.services.coach # type: ignore[attr-defined]
|
|
40
|
+
if coach.enough_data():
|
|
41
|
+
self.app.start_drill() # type: ignore[attr-defined]
|
|
42
|
+
|
|
43
|
+
def body(self) -> RenderableType:
|
|
44
|
+
coach = self.app.services.coach # type: ignore[attr-defined]
|
|
45
|
+
if not coach.enough_data():
|
|
46
|
+
return Text(
|
|
47
|
+
"Play a few races to unlock your coach.\n\n"
|
|
48
|
+
"Once you've typed enough, this shows the keys you miss most, a\n"
|
|
49
|
+
"keyboard heatmap, and how to position your fingers to fix them.",
|
|
50
|
+
justify="center",
|
|
51
|
+
style="grey58",
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
weak = coach.weakest_keys()
|
|
55
|
+
return Group(
|
|
56
|
+
self._weak_table(weak),
|
|
57
|
+
Text(""),
|
|
58
|
+
Text("Keyboard heatmap", style="bold"),
|
|
59
|
+
self._heatmap(coach.heatmap()),
|
|
60
|
+
Text("green ≥98% yellow ≥95% red <95% dim = not enough data", style="grey42"),
|
|
61
|
+
Text(""),
|
|
62
|
+
Text("Finger positioning", style="bold"),
|
|
63
|
+
self._tips(weak),
|
|
64
|
+
Text(""),
|
|
65
|
+
Text("press d to drill your weak keys", style="bold cyan", justify="center"),
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
def _weak_table(self, weak: list[KeyAccuracy]) -> Table:
|
|
69
|
+
tbl = Table(title="Most-missed keys", title_style="bold")
|
|
70
|
+
tbl.add_column("Key", justify="center")
|
|
71
|
+
tbl.add_column("Accuracy", justify="right")
|
|
72
|
+
tbl.add_column("Misses", justify="right")
|
|
73
|
+
tbl.add_column("Attempts", justify="right")
|
|
74
|
+
if not weak:
|
|
75
|
+
tbl.add_row("—", "great!", "0", "—")
|
|
76
|
+
for k in weak:
|
|
77
|
+
tbl.add_row(
|
|
78
|
+
_key_label(k.key),
|
|
79
|
+
Text(f"{k.accuracy * 100:.1f}%", style=_heat_style(k.accuracy)),
|
|
80
|
+
str(k.misses),
|
|
81
|
+
str(k.attempts),
|
|
82
|
+
)
|
|
83
|
+
return tbl
|
|
84
|
+
|
|
85
|
+
def _heatmap(self, heat: dict[str, float]) -> Text:
|
|
86
|
+
out = Text()
|
|
87
|
+
for indent, row in enumerate(keyboard.ROWS):
|
|
88
|
+
out.append(" " * indent)
|
|
89
|
+
for ch in row:
|
|
90
|
+
if ch in heat:
|
|
91
|
+
out.append(f"{ch.upper()} ", style=_heat_style(heat[ch]))
|
|
92
|
+
else:
|
|
93
|
+
out.append(f"{ch.upper()} ", style="grey37")
|
|
94
|
+
out.append("\n")
|
|
95
|
+
return out
|
|
96
|
+
|
|
97
|
+
def _tips(self, weak: list[KeyAccuracy]) -> RenderableType:
|
|
98
|
+
tbl = Table.grid(padding=(0, 2))
|
|
99
|
+
tbl.add_column(justify="center", style="bold cyan")
|
|
100
|
+
tbl.add_column(justify="left")
|
|
101
|
+
shown = 0
|
|
102
|
+
for k in weak:
|
|
103
|
+
info = keyboard.key_info(k.key)
|
|
104
|
+
if info is None:
|
|
105
|
+
continue
|
|
106
|
+
tbl.add_row(_key_label(k.key), info.tip)
|
|
107
|
+
shown += 1
|
|
108
|
+
if shown >= 5:
|
|
109
|
+
break
|
|
110
|
+
if shown == 0:
|
|
111
|
+
return Text("Nothing to fix — your weak keys are all solid.", style="grey58")
|
|
112
|
+
return tbl
|
typefaster/ui/screens/daily.py
CHANGED
|
@@ -47,6 +47,10 @@ class DailyScreen(Screen[None]):
|
|
|
47
47
|
f"\nYour best today: {challenge.best_wpm:.0f} wpm · attempts {challenge.attempts}\n",
|
|
48
48
|
style="bold",
|
|
49
49
|
)
|
|
50
|
+
streak = svc.daily.streak()
|
|
51
|
+
if streak:
|
|
52
|
+
unit = "day" if streak == 1 else "days"
|
|
53
|
+
quote.append(f"🔥 Streak: {streak} {unit}\n", style="bold yellow")
|
|
50
54
|
|
|
51
55
|
table = Table(title="Local daily leaderboard", title_style="bold", expand=True)
|
|
52
56
|
table.add_column("#", justify="right")
|
|
@@ -42,6 +42,7 @@ class MainMenu(Screen[None]):
|
|
|
42
42
|
("online", "🌐 Play Online (multiplayer lobbies)"),
|
|
43
43
|
("account", "🌐 Account / Login"),
|
|
44
44
|
("stats", "Stats"),
|
|
45
|
+
("coach", "🎯 Typing Coach"),
|
|
45
46
|
("history", "History"),
|
|
46
47
|
("profile", "Profile"),
|
|
47
48
|
("leaderboard", "Leaderboard"),
|
|
@@ -162,7 +162,11 @@ class OnlineRaceScreen(Screen[None]):
|
|
|
162
162
|
),
|
|
163
163
|
name="progress",
|
|
164
164
|
)
|
|
165
|
-
if
|
|
165
|
+
if (
|
|
166
|
+
self._start_ms is not None
|
|
167
|
+
and elapsed >= self.mode_seconds * 1000
|
|
168
|
+
and not self._finished
|
|
169
|
+
):
|
|
166
170
|
self._submit_finish()
|
|
167
171
|
|
|
168
172
|
def on_key(self, event: events.Key) -> None:
|
|
@@ -219,7 +223,9 @@ class OnlineRaceScreen(Screen[None]):
|
|
|
219
223
|
|
|
220
224
|
# ── rendering ──────────────────────────────────────────────────────
|
|
221
225
|
def _render_status(self) -> None:
|
|
222
|
-
self.query_one("#net-status", Static).update(
|
|
226
|
+
self.query_one("#net-status", Static).update(
|
|
227
|
+
Text.from_markup(self._status, justify="center")
|
|
228
|
+
)
|
|
223
229
|
|
|
224
230
|
def _render_lobby(self, data: dict[str, Any]) -> None:
|
|
225
231
|
name = data.get("name", "Lobby")
|
|
@@ -278,7 +284,7 @@ class OnlineRaceScreen(Screen[None]):
|
|
|
278
284
|
str(i),
|
|
279
285
|
f"{row.get('username', '?')}{flag}",
|
|
280
286
|
f"{row.get('wpm', 0):.0f}",
|
|
281
|
-
f"{row.get('accuracy', 0) * 100:.
|
|
287
|
+
f"{row.get('accuracy', 0) * 100:.1f}%",
|
|
282
288
|
)
|
|
283
289
|
self.query_one("#net-status", Static).update(
|
|
284
290
|
Text("RACE COMPLETE", justify="center", style="bold green")
|
typefaster/ui/screens/results.py
CHANGED
|
@@ -14,6 +14,16 @@ from ...services.race_service import RaceSetup, RaceSummary
|
|
|
14
14
|
from ..widgets import bigtext
|
|
15
15
|
|
|
16
16
|
|
|
17
|
+
def _top_missed(key_stats: dict[str, tuple[int, int]], limit: int = 3) -> str:
|
|
18
|
+
"""Format this race's most-fumbled keys, worst first — e.g. 'E x4 . R x2' (rendered with multiplication signs)."""
|
|
19
|
+
worst = sorted(
|
|
20
|
+
((k, m) for k, (_, m) in key_stats.items() if m > 0),
|
|
21
|
+
key=lambda km: km[1],
|
|
22
|
+
reverse=True,
|
|
23
|
+
)[:limit]
|
|
24
|
+
return " · ".join(f"{'␣' if k == ' ' else k.upper()} ×{m}" for k, m in worst)
|
|
25
|
+
|
|
26
|
+
|
|
17
27
|
class ResultsScreen(Screen[str]):
|
|
18
28
|
"""Shows the outcome. Dismisses with an action string: 'again' or 'menu'.
|
|
19
29
|
|
|
@@ -55,6 +65,12 @@ class ResultsScreen(Screen[str]):
|
|
|
55
65
|
table.add_row("Chars", f"{r.correct_chars} correct · {r.incorrect_chars} wrong")
|
|
56
66
|
table.add_row("Completion", f"{r.progress * 100:.0f}%")
|
|
57
67
|
table.add_row("Time", f"{r.duration_ms / 1000:.1f}s")
|
|
68
|
+
missed = _top_missed(r.key_stats)
|
|
69
|
+
if missed:
|
|
70
|
+
table.add_row(
|
|
71
|
+
"Missed keys",
|
|
72
|
+
f"[bold red]{missed}[/] [grey58]· 🎯 Typing Coach can drill these[/]",
|
|
73
|
+
)
|
|
58
74
|
if self.summary and self.summary.new_personal_best:
|
|
59
75
|
table.add_row(
|
|
60
76
|
"Personal best",
|
|
@@ -47,6 +47,8 @@ class SettingsScreen(Screen[None]):
|
|
|
47
47
|
),
|
|
48
48
|
("default_ghost", f"Default ghost ‹ {s.default_ghost} ›"),
|
|
49
49
|
("sound", f"Sound (bell) ‹ {'on' if s.sound else 'off'} ›"),
|
|
50
|
+
("lowercase_only", f"Lowercase only ‹ {'on' if s.lowercase_only else 'off'} ›"),
|
|
51
|
+
("words_only", f"Words only ‹ {'on' if s.words_only else 'off'} ›"),
|
|
50
52
|
]
|
|
51
53
|
for key, label in rows:
|
|
52
54
|
ol.add_option(Option(label, id=key))
|
|
@@ -64,7 +66,18 @@ class SettingsScreen(Screen[None]):
|
|
|
64
66
|
s.default_ghost = self._cycle(self._GHOSTS, s.default_ghost)
|
|
65
67
|
elif key == "sound":
|
|
66
68
|
s.sound = not s.sound
|
|
69
|
+
elif key == "lowercase_only":
|
|
70
|
+
s.lowercase_only = not s.lowercase_only
|
|
71
|
+
elif key == "words_only":
|
|
72
|
+
s.words_only = not s.words_only
|
|
67
73
|
s.save()
|
|
74
|
+
# Apply gameplay-affecting changes to the live race service immediately.
|
|
75
|
+
if key in ("lowercase_only", "words_only"):
|
|
76
|
+
self.app.services.race.set_modifiers( # type: ignore[attr-defined]
|
|
77
|
+
lowercase_only=s.lowercase_only, words_only=s.words_only
|
|
78
|
+
)
|
|
79
|
+
elif key == "allow_backspace":
|
|
80
|
+
self.app.services.race.set_backspace(s.allow_backspace) # type: ignore[attr-defined]
|
|
68
81
|
idx = event.option_index
|
|
69
82
|
self._refresh()
|
|
70
83
|
self.query_one(OptionList).highlighted = idx
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: typefaster-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.1
|
|
4
4
|
Summary: A terminal-first typing game inspired by MonkeyType and TypeRacer.
|
|
5
5
|
Project-URL: Homepage, https://github.com/Anoshor/typefaster-cli
|
|
6
6
|
Project-URL: Repository, https://github.com/Anoshor/typefaster-cli
|
|
@@ -53,14 +53,16 @@ typefaster
|
|
|
53
53
|
**Homebrew** (macOS / Linux)
|
|
54
54
|
```bash
|
|
55
55
|
brew install Anoshor/typefaster/typefaster
|
|
56
|
+
# or tap once, then use the short name:
|
|
57
|
+
brew tap anoshor/typefaster && brew install typefaster
|
|
56
58
|
```
|
|
57
59
|
|
|
58
|
-
**pipx** (any OS with Python 3.11+) — fastest
|
|
60
|
+
**pipx** (any OS with Python 3.11+) — fastest install
|
|
59
61
|
```bash
|
|
60
62
|
pipx install typefaster-cli
|
|
61
63
|
```
|
|
62
64
|
|
|
63
|
-
Verify: `typefaster version`
|
|
65
|
+
Verify: `typefaster version` · Upgrade: `brew upgrade typefaster` or `pipx upgrade typefaster-cli`
|
|
64
66
|
|
|
65
67
|
## Play (offline — no account, no internet)
|
|
66
68
|
|
|
@@ -73,8 +75,14 @@ Keyboard-only menu:
|
|
|
73
75
|
- **Time Attack** — type for 30 / 60 / 120s (←/→ to change the duration inline).
|
|
74
76
|
- **Practice** — pick a mode/ghost.
|
|
75
77
|
- **Daily Challenge** — same quote for everyone each day, local leaderboard.
|
|
78
|
+
- **🎯 Typing Coach** — see the keys you miss most, a per-key accuracy heatmap, and
|
|
79
|
+
finger-position tips; press **d** to launch a drill weighted toward your weak
|
|
80
|
+
keys. All computed locally — no account, no internet, no AI service.
|
|
76
81
|
- **Stats / History / Profile / Leaderboard / Settings**.
|
|
77
82
|
|
|
83
|
+
**Practice modes (MonkeyType-style):** in **Settings**, toggle **Lowercase only**
|
|
84
|
+
or **Words only** (pure a–z, no punctuation or numbers) to strip the text down.
|
|
85
|
+
|
|
78
86
|
Live WPM, accuracy, progress, and an animated ghost bar. Backspace corrects
|
|
79
87
|
mistakes (original errors still count, MonkeyType-style). All progress is saved
|
|
80
88
|
locally in SQLite.
|
|
@@ -136,9 +144,11 @@ Deploy guides: [`docs/deploy-oracle.md`](docs/deploy-oracle.md) (free 24/7 VM)
|
|
|
136
144
|
```bash
|
|
137
145
|
make install # editable install + dev deps
|
|
138
146
|
make play # run it
|
|
139
|
-
make check # ruff + mypy + pytest
|
|
147
|
+
make check # ruff + mypy + pytest (CI parity)
|
|
140
148
|
```
|
|
141
|
-
Contributions welcome —
|
|
149
|
+
Contributions welcome! `main` is protected — fork, branch, and open a PR; CI must
|
|
150
|
+
pass and a maintainer review is required before merge. Full guide:
|
|
151
|
+
[CONTRIBUTING.md](CONTRIBUTING.md).
|
|
142
152
|
|
|
143
153
|
## License
|
|
144
154
|
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
typefaster/__init__.py,sha256=
|
|
1
|
+
typefaster/__init__.py,sha256=9aHe77_XFkp9l6PfpQqspmt2xKjgHMbhFUIrz3VrTw8,78
|
|
2
2
|
typefaster/__main__.py,sha256=2dxSnCI2wKDhIgdMFPwlZkdbJ-GQwy6uZApfeoyFAkA,135
|
|
3
3
|
typefaster/cli.py,sha256=NnKzBjvFtr36tBgA5x4XgCuqWo-QGB_40XLLy34Gu9I,6549
|
|
4
4
|
typefaster/assets/__init__.py,sha256=uDRfxDegVWAPzgSUlv-yy_ILP5UTMVIa9lNTh4moyU4,43
|
|
@@ -6,58 +6,63 @@ typefaster/assets/quotes.json,sha256=ooE992FtEyzB6-cJ6BEPx_Hfzs5iSekEb8wPvqvOHP4
|
|
|
6
6
|
typefaster/domain/__init__.py,sha256=5y9Qy9eMH_c75Gq_um7E-6qVPB1a2r0flS-yIVaCqEk,73
|
|
7
7
|
typefaster/domain/anti_cheat.py,sha256=zwB5-ZPANMX63zWxwDn8d9EPtW3kf6PGQ1g-35fAQeM,1429
|
|
8
8
|
typefaster/domain/calculators.py,sha256=Igj1ivDyM67Uf-7hu6hm9H2BCOAvbgaqQ_a0dU6H6t8,1438
|
|
9
|
+
typefaster/domain/drills.py,sha256=2RFUIv2sg19upvqhuMhS1seLqRtwtk2gy8aYPMEtvLg,1354
|
|
9
10
|
typefaster/domain/errors.py,sha256=AP-wzNosxDpxAwtQ8cXfhl32xVGodm3ablp2wawJeak,493
|
|
10
11
|
typefaster/domain/ghost.py,sha256=ilLKCJ24nJs3NQvpetuQwJxJaeq0t6QOrGYYEydbnvQ,2072
|
|
11
|
-
typefaster/domain/
|
|
12
|
-
typefaster/domain/
|
|
12
|
+
typefaster/domain/keyboard.py,sha256=bdp_dO3DWGXFa35ZMgLEPM7jIjv-NCr1pstlIjQsFOk,2162
|
|
13
|
+
typefaster/domain/models.py,sha256=n3EKliBns9Ta4yRefgUmv7oWcXPX6Rzq1D4MnQT8Kqc,4567
|
|
14
|
+
typefaster/domain/text_modifiers.py,sha256=e-xOsl4ZfK7iKaBUVhptOQ45j-n5ckL3_9BY6X9XO2U,1288
|
|
15
|
+
typefaster/domain/typing_engine.py,sha256=FSwukYbdU73bBFudAuNNMLCKVDEqkv1ShaMENEC4nQY,6392
|
|
13
16
|
typefaster/infra/__init__.py,sha256=i_oY7TZxRHpRRp61_m-qQWsg8qk0CTVHxHT9UGV1do4,76
|
|
14
17
|
typefaster/infra/clock.py,sha256=6DZWBtaTueN0cisnFEXs9bwLuNWb1NOY8e8LiC7w8Nc,775
|
|
15
|
-
typefaster/infra/config.py,sha256=
|
|
18
|
+
typefaster/infra/config.py,sha256=nsu3P41EyBzr5dikW132qbe4Qm2U-Z9cFmc-27Mg3k4,1361
|
|
16
19
|
typefaster/infra/db.py,sha256=5S2v-o__L9EYaFAyg6WmS7eRL2qszwZIv_S23IHB4AM,942
|
|
17
|
-
typefaster/infra/migrations.py,sha256=
|
|
20
|
+
typefaster/infra/migrations.py,sha256=JRr2CcDjK36ygIDmeNHfnhd_5sUNaQG3nASYEQSrV2s,4433
|
|
18
21
|
typefaster/infra/paths.py,sha256=aH8IeRj34_-_mG4C5450bE_mvThgy3B6Xb4ZSnWxcSY,877
|
|
19
|
-
typefaster/infra/quote_loader.py,sha256=
|
|
22
|
+
typefaster/infra/quote_loader.py,sha256=j8QsBVNpwbm1UvQ7sZ3iubhpU0MV6vmKUs0C2BYLhG0,2083
|
|
20
23
|
typefaster/infra/replay_store.py,sha256=dwkjHPFy9-yx3x9BC_THZoY8da3H3EChpQerzlnQGA8,474
|
|
21
|
-
typefaster/infra/repository.py,sha256=
|
|
22
|
-
typefaster/infra/sqlite_repository.py,sha256=
|
|
24
|
+
typefaster/infra/repository.py,sha256=VsW8yDb4ArZCJfrn1RvixZMYPxmzH19rjX9bkgYxwsc,2275
|
|
25
|
+
typefaster/infra/sqlite_repository.py,sha256=G-o-b2gu-ZnNEV7Nq2LHE1c26lljKaO2g8-RqJF8T1k,16388
|
|
23
26
|
typefaster/net/__init__.py,sha256=tSx4UMBc0kUlp60corti6SvOnc9fJ_n5guJlysdRUZ0,74
|
|
24
|
-
typefaster/net/api.py,sha256=
|
|
25
|
-
typefaster/net/commands.py,sha256=
|
|
26
|
-
typefaster/net/token_store.py,sha256=
|
|
27
|
+
typefaster/net/api.py,sha256=9B37OcnUqjLgqKMPWu_c8LS2LGApf8uyfHCIrpskPGM,5842
|
|
28
|
+
typefaster/net/commands.py,sha256=Myy-q_YCZ6Qyd2O-RCz09Z0ShytthYldfoHFbmPDP0A,9779
|
|
29
|
+
typefaster/net/token_store.py,sha256=uE07VnC5arw7G70ILq9SdTILSJAsFsjxAhNeAJ42G4c,3137
|
|
27
30
|
typefaster/services/__init__.py,sha256=4VngeItS-jc6-I71DsWkaWnvHODCni7ot4kiDICfMaw,79
|
|
28
|
-
typefaster/services/
|
|
29
|
-
typefaster/services/
|
|
31
|
+
typefaster/services/coach_service.py,sha256=MgzPULFwX1Rsj-ILxvytoNv-3OdBuDbSIvgN6Sqy5y8,1854
|
|
32
|
+
typefaster/services/container.py,sha256=F62UVI0EG1aPA5obWA3kymG3axTR8jj6wHjex2T2cyg,1372
|
|
33
|
+
typefaster/services/daily_service.py,sha256=q7yh_0Hu4ltXFK1CrYXVrGeh5-N5h-XxACnBuK3EXoE,1729
|
|
30
34
|
typefaster/services/ghost_service.py,sha256=U6XPMDtgi8ORvf6w-mGpyZf8aiHaDD35zZ6F3cjzSsw,1676
|
|
31
35
|
typefaster/services/profile_service.py,sha256=EquonTLismSHcs3HTQlUC_Rtt3uI3CT-Q5igFwRk220,473
|
|
32
|
-
typefaster/services/race_service.py,sha256=
|
|
36
|
+
typefaster/services/race_service.py,sha256=ujLvawa1gUOzNitS585Ku4vmquF74x4hboMEvB-IMvo,8031
|
|
33
37
|
typefaster/services/stats_service.py,sha256=yLP2hwubhqUr5rPxnxWX9n5PbINpgn9dQc1RANiYBeU,1912
|
|
34
38
|
typefaster/ui/__init__.py,sha256=IbB-ajsaTRD4HS4zy-fNbpkv8_broTEEhJgl_1d93q4,72
|
|
35
|
-
typefaster/ui/app.py,sha256=
|
|
39
|
+
typefaster/ui/app.py,sha256=5kU_iCabm4jfws__FGKfBvpF3cvLBEstjRW3wW5lGos,4065
|
|
36
40
|
typefaster/ui/online_app.py,sha256=PHycCB32LGho8hHx9sfJ-o-gyiDT82CUTRRVF5ov33Y,678
|
|
37
41
|
typefaster/ui/theme.py,sha256=XzJFft9L3QP4qqUbbHmZ0v3ZeWodvcUY89PhsEHqlfs,1487
|
|
38
42
|
typefaster/ui/screens/__init__.py,sha256=-L4TEWNuG14Jofv6FNRLMKYGYoNquB1HvGmYV3YXYus,23
|
|
39
43
|
typefaster/ui/screens/_base.py,sha256=yNdf-HUSYgYM5-nNsM622DAiN1DvNDptY_gFH3hk0l0,1184
|
|
40
44
|
typefaster/ui/screens/account.py,sha256=C9xf52ju2XGxA9mkGWqed8goTooZ7ZJmnfPYmsNGNLA,7749
|
|
41
|
-
typefaster/ui/screens/
|
|
45
|
+
typefaster/ui/screens/coach.py,sha256=WP65Gxh21-vk5fw3eu0moDYANkxxIPZ7m7UNKlImM5A,3835
|
|
46
|
+
typefaster/ui/screens/daily.py,sha256=QGOBYENF-Do4fWerHIuBNS00D06O3MLCH2qgYIPX6kM,2768
|
|
42
47
|
typefaster/ui/screens/help.py,sha256=OCzQlsGWzY7CwZMAcElGxMtBmao_KRjIFse7RuABtNE,788
|
|
43
48
|
typefaster/ui/screens/history.py,sha256=PQ0f5F7aHv1E6pmJvsNNYlbm59HthwAHGtYR0bX_i8s,1073
|
|
44
49
|
typefaster/ui/screens/leaderboard.py,sha256=kZhuixwIZLVsHaDjwsB258sQyMBZ9dYs2Y0QdRaFpKg,1968
|
|
45
50
|
typefaster/ui/screens/lobby_browser.py,sha256=5upNsBBXd_jurXOv1IuQ-C94AvdVZzOzfR68bz209q8,6102
|
|
46
|
-
typefaster/ui/screens/main_menu.py,sha256=
|
|
47
|
-
typefaster/ui/screens/online_race.py,sha256=
|
|
51
|
+
typefaster/ui/screens/main_menu.py,sha256=CxHY-MYdlXMgvgtCgH_UPyoAocV32ftycV53S6ga7IM,4134
|
|
52
|
+
typefaster/ui/screens/online_race.py,sha256=RC5jqE5uAMa45WAoVUr8G2DK4dNK4H_w_eeNLSjEqnU,13372
|
|
48
53
|
typefaster/ui/screens/practice.py,sha256=6gvzVFYprGrbDClPArWM4COnzRi0zASrtnKjP6AREx8,2115
|
|
49
54
|
typefaster/ui/screens/profile.py,sha256=1QiUkhMBH38q3Vkw5kx4O1LUZc8vrNtKF8q4p8GABAk,995
|
|
50
55
|
typefaster/ui/screens/race.py,sha256=JlCxayx2W-fjnIza4ASK4Yg-UGkWSXDQv2YkndnxOMY,9961
|
|
51
|
-
typefaster/ui/screens/results.py,sha256=
|
|
52
|
-
typefaster/ui/screens/settings.py,sha256=
|
|
56
|
+
typefaster/ui/screens/results.py,sha256=Q4YcU9O2Xe02MZYEDoxwYr-B0GyzGYDz92VnRNlhqFQ,3964
|
|
57
|
+
typefaster/ui/screens/settings.py,sha256=8PIecUQBXzYvSZs5LqCYaUfmOBfw-W97UcUqjrYIh7o,3605
|
|
53
58
|
typefaster/ui/screens/stats.py,sha256=eePL3sU729EptA2DRJOlpezVUgOjE9zPkwT_dOSF0os,2342
|
|
54
59
|
typefaster/ui/widgets/__init__.py,sha256=D2YleDqZvFyzPhI501R-V8RmwDSSvOFRasAyVHpxUBo,56
|
|
55
60
|
typefaster/ui/widgets/bigtext.py,sha256=6naRaTuxke6tP0Q7j67KyvMsibTlRdhdiuEiszNdKYo,2162
|
|
56
61
|
typefaster/ui/widgets/live_stats.py,sha256=Q17tvAOHanhf4qS78G59sLEY-mieAUyDjyvAZN8tAaw,683
|
|
57
62
|
typefaster/ui/widgets/progress_bars.py,sha256=fZTh9a0KPVK1UnRq75Jf0YzSF4r85e3xn12oSUmIzmA,1392
|
|
58
63
|
typefaster/ui/widgets/typing_field.py,sha256=gYKOjytwsMEU_k5Nu9mnYLBP4smNEw4bZ6lR27_m-k0,934
|
|
59
|
-
typefaster_cli-0.
|
|
60
|
-
typefaster_cli-0.
|
|
61
|
-
typefaster_cli-0.
|
|
62
|
-
typefaster_cli-0.
|
|
63
|
-
typefaster_cli-0.
|
|
64
|
+
typefaster_cli-0.3.1.dist-info/METADATA,sha256=fNmnsbp0LykOjeWWo3k53ls72K9OTHJYjghGZ9JXeyE,5728
|
|
65
|
+
typefaster_cli-0.3.1.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
|
|
66
|
+
typefaster_cli-0.3.1.dist-info/entry_points.txt,sha256=-ix_zmJiKj-cikp7-QfkxqB6MPIYCD0hiGd36Q-XlS8,50
|
|
67
|
+
typefaster_cli-0.3.1.dist-info/licenses/LICENSE,sha256=ZrmIfNfrisYorCVzUnlZHoQZxJQB06oC_0lrNMkkXls,1079
|
|
68
|
+
typefaster_cli-0.3.1.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|