keystrike 1.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 (64) hide show
  1. keystrike/__init__.py +1 -0
  2. keystrike/__main__.py +9 -0
  3. keystrike/app.py +111 -0
  4. keystrike/application/__init__.py +0 -0
  5. keystrike/application/build_lesson.py +202 -0
  6. keystrike/application/learn_budget_use_cases.py +33 -0
  7. keystrike/application/prepare_practice.py +53 -0
  8. keystrike/application/session_use_cases.py +372 -0
  9. keystrike/application/settings_use_cases.py +74 -0
  10. keystrike/application/stats_use_cases.py +107 -0
  11. keystrike/application/sync_use_cases.py +41 -0
  12. keystrike/cli.py +95 -0
  13. keystrike/domain/__init__.py +0 -0
  14. keystrike/domain/aggregate.py +179 -0
  15. keystrike/domain/confidence.py +221 -0
  16. keystrike/domain/daily_learn.py +79 -0
  17. keystrike/domain/enums.py +29 -0
  18. keystrike/domain/generator.py +120 -0
  19. keystrike/domain/learn_order.py +37 -0
  20. keystrike/domain/markov.py +84 -0
  21. keystrike/domain/models.py +119 -0
  22. keystrike/domain/null_adapters.py +55 -0
  23. keystrike/domain/protocols.py +83 -0
  24. keystrike/domain/regression.py +97 -0
  25. keystrike/domain/session.py +38 -0
  26. keystrike/domain/sync_merge.py +156 -0
  27. keystrike/infrastructure/__init__.py +0 -0
  28. keystrike/infrastructure/aggregates_cache.py +62 -0
  29. keystrike/infrastructure/atomic_write.py +13 -0
  30. keystrike/infrastructure/bundled_layouts/__init__.py +0 -0
  31. keystrike/infrastructure/bundled_layouts/_grid.py +61 -0
  32. keystrike/infrastructure/bundled_layouts/colemak.py +3 -0
  33. keystrike/infrastructure/bundled_layouts/colemak_dh.py +11 -0
  34. keystrike/infrastructure/bundled_layouts/dvorak.py +3 -0
  35. keystrike/infrastructure/bundled_layouts/qwerty.py +3 -0
  36. keystrike/infrastructure/clock.py +9 -0
  37. keystrike/infrastructure/code_generators/__init__.py +0 -0
  38. keystrike/infrastructure/id_gen.py +22 -0
  39. keystrike/infrastructure/languages/__init__.py +32 -0
  40. keystrike/infrastructure/languages/data/en_markov.json.gz +0 -0
  41. keystrike/infrastructure/layout_repo.py +38 -0
  42. keystrike/infrastructure/layout_toml.py +98 -0
  43. keystrike/infrastructure/paths.py +56 -0
  44. keystrike/infrastructure/session_repo_jsonl.py +161 -0
  45. keystrike/infrastructure/settings_repo_toml.py +73 -0
  46. keystrike/infrastructure/sync_git.py +197 -0
  47. keystrike/presentation/__init__.py +0 -0
  48. keystrike/presentation/bindings.py +10 -0
  49. keystrike/presentation/screens/__init__.py +0 -0
  50. keystrike/presentation/screens/home.py +102 -0
  51. keystrike/presentation/screens/practice.py +170 -0
  52. keystrike/presentation/screens/settings.py +145 -0
  53. keystrike/presentation/screens/stats.py +179 -0
  54. keystrike/presentation/textual_app.py +119 -0
  55. keystrike/presentation/theme.py +7 -0
  56. keystrike/presentation/widgets/__init__.py +0 -0
  57. keystrike/presentation/widgets/hud.py +92 -0
  58. keystrike/presentation/widgets/kb_heatmap.py +125 -0
  59. keystrike/presentation/widgets/typing_area.py +71 -0
  60. keystrike-1.1.0.dist-info/METADATA +175 -0
  61. keystrike-1.1.0.dist-info/RECORD +64 -0
  62. keystrike-1.1.0.dist-info/WHEEL +4 -0
  63. keystrike-1.1.0.dist-info/entry_points.txt +2 -0
  64. keystrike-1.1.0.dist-info/licenses/LICENSE +21 -0
keystrike/__init__.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "1.1.0"
keystrike/__main__.py ADDED
@@ -0,0 +1,9 @@
1
+ from keystrike.cli import app
2
+
3
+
4
+ def main() -> None:
5
+ app()
6
+
7
+
8
+ if __name__ == "__main__":
9
+ main()
keystrike/app.py ADDED
@@ -0,0 +1,111 @@
1
+ """Composition root: wire all dependencies and return a ready-to-run KeystrikeApp."""
2
+
3
+ from dataclasses import dataclass
4
+ from random import Random
5
+
6
+ from keystrike.application.build_lesson import BuildLesson
7
+ from keystrike.application.learn_budget_use_cases import GetDailyLearnBudget
8
+ from keystrike.application.prepare_practice import PreparePracticeSession
9
+ from keystrike.application.session_use_cases import (
10
+ FinishSession,
11
+ RecordKeystroke,
12
+ StartSession,
13
+ )
14
+ from keystrike.application.settings_use_cases import CycleLayout, UpdateSettings
15
+ from keystrike.application.stats_use_cases import GetHeatmap, GetHistory, RebuildAggregates
16
+ from keystrike.application.sync_use_cases import GetSyncStatus, InitSync, PullSync, PushSync
17
+ from keystrike.infrastructure.aggregates_cache import FileAggregatesCache
18
+ from keystrike.infrastructure.clock import MonotonicClock
19
+ from keystrike.infrastructure.id_gen import UlidGenerator
20
+ from keystrike.infrastructure.languages import BundledLanguageProvider
21
+ from keystrike.infrastructure.layout_repo import CompositeLayoutRepository
22
+ from keystrike.infrastructure.paths import default_paths, ensure_dirs
23
+ from keystrike.infrastructure.session_repo_jsonl import JsonlSessionRepository
24
+ from keystrike.infrastructure.settings_repo_toml import TomlSettingsRepository
25
+ from keystrike.infrastructure.sync_git import GitSyncGateway
26
+ from keystrike.presentation.textual_app import KeystrikeApp
27
+
28
+
29
+ @dataclass(frozen=True, slots=True)
30
+ class SyncServices:
31
+ init: InitSync
32
+ pull: PullSync
33
+ push: PushSync
34
+ status: GetSyncStatus
35
+
36
+
37
+ def build_sync() -> SyncServices:
38
+ paths = default_paths()
39
+ ensure_dirs(paths)
40
+ session_repo = JsonlSessionRepository(paths)
41
+ aggregates_cache = FileAggregatesCache(paths)
42
+ store = GitSyncGateway(paths)
43
+ rebuild = RebuildAggregates(repo=session_repo, cache=aggregates_cache)
44
+ return SyncServices(
45
+ init=InitSync(gateway=store),
46
+ pull=PullSync(gateway=store, rebuild=rebuild),
47
+ push=PushSync(gateway=store),
48
+ status=GetSyncStatus(gateway=store),
49
+ )
50
+
51
+
52
+ def build() -> KeystrikeApp:
53
+ paths = default_paths()
54
+ ensure_dirs(paths)
55
+
56
+ clock = MonotonicClock()
57
+ id_gen = UlidGenerator()
58
+ session_repo = JsonlSessionRepository(paths)
59
+ settings_repo = TomlSettingsRepository(paths)
60
+ layout_repo = CompositeLayoutRepository(paths)
61
+ aggregates_cache = FileAggregatesCache(paths)
62
+ language_provider = BundledLanguageProvider()
63
+
64
+ start = StartSession(clock=clock, id_gen=id_gen)
65
+ record = RecordKeystroke(clock=clock, repo=session_repo)
66
+ finish = FinishSession(
67
+ clock=clock,
68
+ repo=session_repo,
69
+ aggregates_cache=aggregates_cache,
70
+ settings_repo=settings_repo,
71
+ layout_repo=layout_repo,
72
+ )
73
+ rebuild_aggregates = RebuildAggregates(repo=session_repo, cache=aggregates_cache)
74
+ get_heatmap = GetHeatmap(cache=aggregates_cache, settings_repo=settings_repo)
75
+ get_history = GetHistory(repo=session_repo)
76
+ get_daily_learn_budget = GetDailyLearnBudget(
77
+ clock=clock,
78
+ repo=session_repo,
79
+ settings_repo=settings_repo,
80
+ )
81
+ cycle_layout = CycleLayout(settings_repo=settings_repo, layout_repo=layout_repo)
82
+ update_settings = UpdateSettings(repo=settings_repo)
83
+ build_lesson = BuildLesson(
84
+ layout_repo=layout_repo,
85
+ aggregates_cache=aggregates_cache,
86
+ settings_repo=settings_repo,
87
+ language_provider=language_provider,
88
+ rng=Random(),
89
+ )
90
+ prepare_practice = PreparePracticeSession(
91
+ settings_repo=settings_repo,
92
+ layout_repo=layout_repo,
93
+ build_lesson=build_lesson,
94
+ get_daily_learn_budget=get_daily_learn_budget,
95
+ )
96
+
97
+ return KeystrikeApp(
98
+ clock=clock,
99
+ start=start,
100
+ record=record,
101
+ finish=finish,
102
+ settings_repo=settings_repo,
103
+ layout_repo=layout_repo,
104
+ prepare_practice=prepare_practice,
105
+ rebuild_aggregates=rebuild_aggregates,
106
+ get_heatmap=get_heatmap,
107
+ get_history=get_history,
108
+ cycle_layout=cycle_layout,
109
+ update_settings=update_settings,
110
+ get_daily_learn_budget=get_daily_learn_budget,
111
+ )
File without changes
@@ -0,0 +1,202 @@
1
+ """BuildLesson: the adaptive engine — figure out which keys are unlocked,
2
+ pick a focus key, and generate practice text for them.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ import time
8
+ from dataclasses import dataclass
9
+ from random import Random
10
+
11
+ from keystrike.domain.aggregate import transition_key
12
+ from keystrike.domain.confidence import (
13
+ compute_unlocked,
14
+ confidence_of,
15
+ focus_key_from_transition,
16
+ practice_weight,
17
+ review_urgency,
18
+ select_focus,
19
+ select_focus_transition,
20
+ target_ms_per_char,
21
+ transition_confidence_of,
22
+ transition_practice_weight,
23
+ )
24
+ from keystrike.domain.generator import AdaptiveGenerator
25
+ from keystrike.domain.learn_order import keyboard_order
26
+ from keystrike.domain.models import (
27
+ KeyStats,
28
+ Layout,
29
+ LessonKey,
30
+ LessonState,
31
+ Settings,
32
+ TransitionStats,
33
+ )
34
+ from keystrike.domain.protocols import (
35
+ AggregatesCache,
36
+ LanguageProvider,
37
+ LayoutRepository,
38
+ SettingsRepository,
39
+ )
40
+
41
+ WORD_COUNT = 12
42
+ _CONFIDENCE_GOOD = 1.0
43
+
44
+
45
+ def _focus_reason(
46
+ focus: int,
47
+ stats: dict[int, KeyStats],
48
+ target: float,
49
+ now: float,
50
+ ) -> str | None:
51
+ key_stats = stats.get(focus)
52
+ urgency = review_urgency(key_stats.last_seen if key_stats else 0.0, now)
53
+ confidence = confidence_of(focus, stats, target)
54
+ if urgency > 0 and confidence >= _CONFIDENCE_GOOD:
55
+ return "review"
56
+ if confidence < _CONFIDENCE_GOOD:
57
+ return "weak"
58
+ return None
59
+
60
+
61
+ def _focus_reason_transition(
62
+ prev_cp: int,
63
+ next_cp: int,
64
+ transitions: dict[str, TransitionStats],
65
+ target: float,
66
+ now: float,
67
+ ) -> str | None:
68
+ pair = chr(prev_cp) + chr(next_cp)
69
+ t_stats = transitions.get(transition_key(prev_cp, next_cp))
70
+ urgency = review_urgency(t_stats.last_seen if t_stats else 0.0, now)
71
+ confidence = transition_confidence_of(prev_cp, next_cp, transitions, target)
72
+ if urgency > 0 and confidence >= _CONFIDENCE_GOOD:
73
+ return f"{pair} review transition"
74
+ if confidence < _CONFIDENCE_GOOD:
75
+ return f"{pair} weak transition"
76
+ return None
77
+
78
+
79
+ @dataclass(slots=True)
80
+ class Lesson:
81
+ text: str
82
+ state: LessonState
83
+ urgency: dict[int, float]
84
+ focus_reason: str | None
85
+
86
+ @property
87
+ def focus_key(self) -> int:
88
+ return next(k.codepoint for k in self.state.keys if k.is_focus)
89
+
90
+ @property
91
+ def heatmap(self) -> dict[int, float]:
92
+ return {k.codepoint: k.confidence for k in self.state.keys}
93
+
94
+
95
+ def _lesson_progress(
96
+ layout_name: str,
97
+ layout: Layout,
98
+ stats: dict[int, KeyStats],
99
+ settings: Settings,
100
+ now: float,
101
+ *,
102
+ transitions: dict[str, TransitionStats] | None = None,
103
+ ) -> tuple[tuple[int, ...], int, LessonState, tuple[int, int] | None]:
104
+ target = target_ms_per_char(settings.target_speed_cpm)
105
+ order = keyboard_order(layout)
106
+ unlocked = compute_unlocked(order, settings.alphabet_size, stats, target)
107
+ focus_bigram = (
108
+ select_focus_transition(unlocked, transitions, target, now)
109
+ if transitions else None
110
+ )
111
+ if focus_bigram is not None:
112
+ focus = focus_key_from_transition(*focus_bigram)
113
+ else:
114
+ focus = select_focus(unlocked, stats, target, now)
115
+
116
+ keys = tuple(
117
+ LessonKey(
118
+ codepoint=cp,
119
+ unlocked=True,
120
+ confidence=confidence_of(cp, stats, target),
121
+ is_focus=(cp == focus),
122
+ )
123
+ for cp in unlocked
124
+ )
125
+ state = LessonState(
126
+ layout=layout_name,
127
+ keys=keys,
128
+ alphabet_size=settings.alphabet_size,
129
+ target_speed_cpm=settings.target_speed_cpm,
130
+ )
131
+ return unlocked, focus, state, focus_bigram
132
+
133
+
134
+ @dataclass(slots=True)
135
+ class BuildLesson:
136
+ layout_repo: LayoutRepository
137
+ aggregates_cache: AggregatesCache
138
+ settings_repo: SettingsRepository
139
+ language_provider: LanguageProvider
140
+ rng: Random
141
+
142
+ def __call__(self, layout_name: str) -> Lesson:
143
+ settings = self.settings_repo.load()
144
+ layout = self.layout_repo.get(layout_name)
145
+ aggregates = self.aggregates_cache.get(layout_name)
146
+ stats = aggregates.keys if aggregates else {}
147
+ transitions = aggregates.transitions if aggregates else {}
148
+ now = time.time()
149
+ unlocked, focus, state, focus_bigram = _lesson_progress(
150
+ layout_name, layout, stats, settings, now, transitions=transitions,
151
+ )
152
+ target = target_ms_per_char(settings.target_speed_cpm)
153
+
154
+ table = self.language_provider.transitions(settings.lang)
155
+ generator = AdaptiveGenerator(table=table, rng=self.rng)
156
+ alphabet_chars = frozenset(chr(cp) for cp in unlocked)
157
+ char_weights = {
158
+ chr(k.codepoint): practice_weight(
159
+ k.confidence,
160
+ urgency=review_urgency(
161
+ stats[k.codepoint].last_seen if k.codepoint in stats else 0.0, now,
162
+ ),
163
+ )
164
+ for k in state.keys
165
+ }
166
+ transition_weights = {
167
+ transition_key(prev, nxt): transition_practice_weight(
168
+ transition_confidence_of(prev, nxt, transitions, target),
169
+ urgency=review_urgency(
170
+ transitions[transition_key(prev, nxt)].last_seen
171
+ if transition_key(prev, nxt) in transitions else 0.0,
172
+ now,
173
+ ),
174
+ )
175
+ for prev in unlocked
176
+ for nxt in unlocked
177
+ }
178
+ text = generator.generate_lesson(
179
+ alphabet_chars,
180
+ chr(focus),
181
+ word_count=WORD_COUNT,
182
+ char_weights=char_weights,
183
+ layout=layout,
184
+ transition_weights=transition_weights,
185
+ focus_bigram=focus_bigram,
186
+ )
187
+
188
+ urgency = {
189
+ cp: review_urgency(stats[cp].last_seen if cp in stats else 0.0, now)
190
+ for cp in unlocked
191
+ }
192
+ if focus_bigram is not None:
193
+ prev_cp, next_cp = focus_bigram
194
+ reason = _focus_reason_transition(prev_cp, next_cp, transitions, target, now)
195
+ else:
196
+ reason = _focus_reason(focus, stats, target, now)
197
+ return Lesson(
198
+ text=text,
199
+ state=state,
200
+ urgency=urgency,
201
+ focus_reason=reason,
202
+ )
@@ -0,0 +1,33 @@
1
+ """Use cases for the daily adaptive (learn) mode time budget."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import datetime as dt
6
+ from dataclasses import dataclass
7
+
8
+ from keystrike.domain.daily_learn import (
9
+ DailyLearnBudget,
10
+ compute_daily_learn_budget,
11
+ daily_learn_duration_ns,
12
+ session_local_date,
13
+ )
14
+ from keystrike.domain.protocols import Clock, SessionRepository, SettingsRepository
15
+
16
+
17
+ @dataclass(slots=True)
18
+ class GetDailyLearnBudget:
19
+ clock: Clock
20
+ repo: SessionRepository
21
+ settings_repo: SettingsRepository
22
+ tz: dt.tzinfo | None = None
23
+
24
+ def __call__(self, *, extra_ns: int = 0) -> DailyLearnBudget:
25
+ tz = self.tz or dt.datetime.now().astimezone().tzinfo or dt.UTC
26
+ today = session_local_date(self.clock.wall_epoch(), tz)
27
+ completed_ns = daily_learn_duration_ns(self.repo.iter_all_headers(), today, tz=tz)
28
+ limit_minutes = self.settings_repo.load().learn_daily_minutes
29
+ return compute_daily_learn_budget(
30
+ completed_ns=completed_ns,
31
+ limit_minutes=limit_minutes,
32
+ extra_ns=extra_ns,
33
+ )
@@ -0,0 +1,53 @@
1
+ """PreparePracticeSession: build SessionPrep for adaptive practice."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Callable
6
+ from dataclasses import dataclass
7
+
8
+ from keystrike.application.build_lesson import BuildLesson
9
+ from keystrike.domain.enums import Mode
10
+ from keystrike.domain.models import Layout
11
+ from keystrike.domain.protocols import (
12
+ DailyLearnBudgetProvider,
13
+ LayoutRepository,
14
+ SettingsRepository,
15
+ )
16
+
17
+ PrepareNextSession = Callable[[], "SessionPrep | None"]
18
+
19
+
20
+ @dataclass(frozen=True, slots=True)
21
+ class SessionPrep:
22
+ target_text: str
23
+ layout: str
24
+ mode: Mode
25
+ focus_key: int | None
26
+ focus_reason: str | None
27
+ layout_obj: Layout | None
28
+ lesson_heatmap: dict[int, float] | None
29
+ lesson_urgency: dict[int, float] | None
30
+
31
+
32
+ @dataclass(slots=True)
33
+ class PreparePracticeSession:
34
+ settings_repo: SettingsRepository
35
+ layout_repo: LayoutRepository
36
+ build_lesson: BuildLesson
37
+ get_daily_learn_budget: DailyLearnBudgetProvider
38
+
39
+ def __call__(self) -> SessionPrep | None:
40
+ if self.get_daily_learn_budget().limit_reached:
41
+ return None
42
+ settings = self.settings_repo.load()
43
+ lesson = self.build_lesson(settings.layout)
44
+ return SessionPrep(
45
+ target_text=lesson.text,
46
+ layout=settings.layout,
47
+ mode=Mode.ADAPTIVE,
48
+ focus_key=lesson.focus_key,
49
+ focus_reason=lesson.focus_reason,
50
+ layout_obj=self.layout_repo.get(settings.layout),
51
+ lesson_heatmap=lesson.heatmap,
52
+ lesson_urgency=lesson.urgency,
53
+ )