keyflow-typing 1.4.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.
- keyflow/__init__.py +3 -0
- keyflow/__main__.py +5 -0
- keyflow/app.py +569 -0
- keyflow/engine.py +185 -0
- keyflow/i18n.py +24 -0
- keyflow/keyboard.py +161 -0
- keyflow/macprofile.py +189 -0
- keyflow/screens.py +1179 -0
- keyflow/stats.py +251 -0
- keyflow/storage.py +189 -0
- keyflow/stories.py +366 -0
- keyflow/stories_en.py +327 -0
- keyflow/terminal.py +282 -0
- keyflow/textgen.py +391 -0
- keyflow/ui.py +515 -0
- keyflow/updates.py +103 -0
- keyflow/words.py +238 -0
- keyflow_typing-1.4.0.dist-info/METADATA +208 -0
- keyflow_typing-1.4.0.dist-info/RECORD +22 -0
- keyflow_typing-1.4.0.dist-info/WHEEL +5 -0
- keyflow_typing-1.4.0.dist-info/entry_points.txt +2 -0
- keyflow_typing-1.4.0.dist-info/top_level.txt +1 -0
keyflow/__init__.py
ADDED
keyflow/__main__.py
ADDED
keyflow/app.py
ADDED
|
@@ -0,0 +1,569 @@
|
|
|
1
|
+
"""Programmablauf: Hauptmenü, Tests starten, Ergebnisse speichern."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import random
|
|
5
|
+
import sys
|
|
6
|
+
import time
|
|
7
|
+
import webbrowser
|
|
8
|
+
from datetime import date, datetime
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from . import i18n, screens, stats, stories, textgen, ui, updates
|
|
12
|
+
from . import terminal as T
|
|
13
|
+
from .engine import TypingTest
|
|
14
|
+
from .i18n import tr
|
|
15
|
+
from .storage import Store
|
|
16
|
+
from . import __version__
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
ENDLESS_START = 10.0 # Startzeit im Unendlich-Modus (Sekunden)
|
|
20
|
+
ENDLESS_MAX = 20.0 # mehr Zeitvorrat als das gibt es nicht
|
|
21
|
+
ENDLESS_PENALTY = 1.0 # Sekunden Abzug pro Tippfehler
|
|
22
|
+
ENDLESS_WORDS_PER_LEVEL = 20
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def endless_target_wpm(level):
|
|
26
|
+
"""Tempo, mit dem man im jeweiligen Level genau gleich viel Zeit gewinnt, wie
|
|
27
|
+
man verbraucht. Wer schneller tippt, baut Vorrat auf."""
|
|
28
|
+
return 25 + 5 * level
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class Mode:
|
|
32
|
+
"""Beschreibt einen Test.
|
|
33
|
+
|
|
34
|
+
kind: "time" (Zeitlimit), "text" (fester Text), "free" (endlos) oder
|
|
35
|
+
"endless" (Unendlich-Modus: Zeit läuft ab, richtige Wörter bringen Zeit).
|
|
36
|
+
source: liefert bei "time"/"free" immer neue Textstücke.
|
|
37
|
+
make_text: liefert bei "text" (text, hinweis)."""
|
|
38
|
+
|
|
39
|
+
def __init__(self, key, label, kind, limit=0, source=None, make_text=None,
|
|
40
|
+
on_complete=None, repeatable=True, count_words=False, extra=None):
|
|
41
|
+
self.key = key
|
|
42
|
+
self.label = label
|
|
43
|
+
self.kind = kind
|
|
44
|
+
self.limit = limit
|
|
45
|
+
self.source = source
|
|
46
|
+
self.make_text = make_text
|
|
47
|
+
self.on_complete = on_complete
|
|
48
|
+
self.repeatable = repeatable and kind == "text"
|
|
49
|
+
self.count_words = count_words
|
|
50
|
+
self.extra = extra # () -> zusätzliche Felder für den Verlauf
|
|
51
|
+
self.state = None # Laufzeitwerte des Unendlich-Modus
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class App:
|
|
55
|
+
def __init__(self, term, store, rng=None):
|
|
56
|
+
self.term = term
|
|
57
|
+
self.store = store
|
|
58
|
+
self.rng = rng or random.Random()
|
|
59
|
+
i18n.set_language(store.settings["ui_language"])
|
|
60
|
+
|
|
61
|
+
# --- Hauptschleife ---------------------------------------------------
|
|
62
|
+
def apply_look(self):
|
|
63
|
+
"""Farbmodus, Animationen und Terminal-Hintergrund aus den Einstellungen."""
|
|
64
|
+
ui.configure(self.settings)
|
|
65
|
+
_, rgb, opacity = ui.BACKGROUNDS[self.settings["background"]]
|
|
66
|
+
self.term.set_background(rgb, opacity)
|
|
67
|
+
|
|
68
|
+
def set_ui_language(self, lang):
|
|
69
|
+
"""Oberfläche und Übungstexte auf eine Sprache umstellen."""
|
|
70
|
+
i18n.set_language(lang)
|
|
71
|
+
self.settings["ui_language"] = lang
|
|
72
|
+
self.settings["language"] = lang
|
|
73
|
+
self.store.save()
|
|
74
|
+
|
|
75
|
+
def run(self):
|
|
76
|
+
self.apply_look()
|
|
77
|
+
if self.store.needs_language:
|
|
78
|
+
lang = screens.language_picker(self.term)
|
|
79
|
+
self.settings["kb_layout"] = lang
|
|
80
|
+
self.set_ui_language(lang)
|
|
81
|
+
self.store.needs_language = False
|
|
82
|
+
if self.store.load_error:
|
|
83
|
+
path, error, backup = self.store.load_error
|
|
84
|
+
ui.message(self.term, tr("Hinweis", "Note"), [
|
|
85
|
+
tr("Die Datei %s war beschädigt (%s) und wurde nach %s verschoben.",
|
|
86
|
+
"The file %s was damaged (%s) and was moved to %s.") % (path, error, backup)])
|
|
87
|
+
self.update_achievements()
|
|
88
|
+
self.updater = updates.UpdateChecker(enabled=self.settings["check_updates"])
|
|
89
|
+
index = 0
|
|
90
|
+
while True:
|
|
91
|
+
action, index = screens.main_menu(self.term, self.store, index, self.updater)
|
|
92
|
+
if action in (None, "quit"):
|
|
93
|
+
return
|
|
94
|
+
if action == "quick":
|
|
95
|
+
self.open(self.settings["menu"]["last"])
|
|
96
|
+
elif action == "settings":
|
|
97
|
+
screens.settings_screen(self.term, self.store, on_look_change=self.apply_look,
|
|
98
|
+
on_language_change=self.set_ui_language)
|
|
99
|
+
elif action == "info":
|
|
100
|
+
self.info()
|
|
101
|
+
else:
|
|
102
|
+
self.category(action)
|
|
103
|
+
|
|
104
|
+
def info(self):
|
|
105
|
+
index = 0
|
|
106
|
+
while True:
|
|
107
|
+
action, index = screens.info_screen(self.term, self.store, self.updater, index)
|
|
108
|
+
if action in (None, "back"):
|
|
109
|
+
return
|
|
110
|
+
if action == "github":
|
|
111
|
+
webbrowser.open(updates.GITHUB_USER_URL)
|
|
112
|
+
elif action == "repo":
|
|
113
|
+
webbrowser.open(updates.GITHUB_REPO_URL)
|
|
114
|
+
elif action == "pypi":
|
|
115
|
+
webbrowser.open(updates.PROJECT_URL)
|
|
116
|
+
elif action == "check":
|
|
117
|
+
self.updater.start()
|
|
118
|
+
elif action == "update":
|
|
119
|
+
self.update_now()
|
|
120
|
+
|
|
121
|
+
def update_now(self):
|
|
122
|
+
with self.term.cooked():
|
|
123
|
+
print(tr("KeyFlow wird aktualisiert: ", "Updating KeyFlow: ") + updates.update_command_text() + "\n")
|
|
124
|
+
ok = updates.run_update()
|
|
125
|
+
print()
|
|
126
|
+
if ok:
|
|
127
|
+
print(tr("Fertig! Starte KeyFlow neu, um die neue Version zu nutzen.",
|
|
128
|
+
"Done! Restart KeyFlow to use the new version."))
|
|
129
|
+
else:
|
|
130
|
+
print(tr("Das Update hat nicht geklappt. Du kannst es selbst ausführen mit:",
|
|
131
|
+
"The update did not work. You can run it yourself with:"))
|
|
132
|
+
print(" " + updates.update_command_text())
|
|
133
|
+
try:
|
|
134
|
+
input(tr("\nWeiter mit Enter …", "\nPress Enter to continue …"))
|
|
135
|
+
except (EOFError, KeyboardInterrupt):
|
|
136
|
+
pass
|
|
137
|
+
|
|
138
|
+
def category(self, category):
|
|
139
|
+
index = 0
|
|
140
|
+
while True:
|
|
141
|
+
action, index = screens.category_menu(self.term, self.store, category, index)
|
|
142
|
+
if action in (None, "back"):
|
|
143
|
+
return
|
|
144
|
+
self.open(action)
|
|
145
|
+
|
|
146
|
+
def open(self, action):
|
|
147
|
+
"""Startet einen Modus oder öffnet einen Bildschirm."""
|
|
148
|
+
if action == "stats":
|
|
149
|
+
screens.stats_screen(self.term, self.store)
|
|
150
|
+
elif action == "activity":
|
|
151
|
+
screens.activity_screen(self.term, self.store)
|
|
152
|
+
elif action == "achievements":
|
|
153
|
+
screens.achievements_screen(self.term, self.store)
|
|
154
|
+
elif action == "custom":
|
|
155
|
+
self.custom_texts()
|
|
156
|
+
else:
|
|
157
|
+
if self.settings["menu"]["last"] != action:
|
|
158
|
+
self.settings["menu"]["last"] = action
|
|
159
|
+
self.store.save()
|
|
160
|
+
if action == "stories":
|
|
161
|
+
self.stories_menu()
|
|
162
|
+
else:
|
|
163
|
+
self.run_mode(self.build_mode(action))
|
|
164
|
+
|
|
165
|
+
# --- Modi ------------------------------------------------------------
|
|
166
|
+
@property
|
|
167
|
+
def settings(self):
|
|
168
|
+
return self.store.settings
|
|
169
|
+
|
|
170
|
+
def _lang(self):
|
|
171
|
+
return self.settings["language"]
|
|
172
|
+
|
|
173
|
+
def _word_variant(self):
|
|
174
|
+
s = self.settings
|
|
175
|
+
key = s["language"] + ("-p" if s["punctuation"] else "") + ("-n" if s["numbers"] else "")
|
|
176
|
+
label = s["language"].upper()
|
|
177
|
+
if s["punctuation"]:
|
|
178
|
+
label += tr(" · Satzzeichen", " · punctuation")
|
|
179
|
+
if s["numbers"]:
|
|
180
|
+
label += tr(" · Zahlen", " · numbers")
|
|
181
|
+
return key, label
|
|
182
|
+
|
|
183
|
+
def _word_source(self):
|
|
184
|
+
s = self.settings
|
|
185
|
+
return textgen.WordSource(s["language"], s["difficulty"], s["punctuation"],
|
|
186
|
+
s["numbers"], s["lowercase"], rng=self.rng)
|
|
187
|
+
|
|
188
|
+
def _free_source(self, kind):
|
|
189
|
+
lang = self._lang()
|
|
190
|
+
if kind == "sentences":
|
|
191
|
+
return textgen.SentenceSource(lang, rng=self.rng)
|
|
192
|
+
if kind == "numbers":
|
|
193
|
+
return textgen.NumberSource(lang, rng=self.rng)
|
|
194
|
+
if kind == "symbols":
|
|
195
|
+
return textgen.SymbolSource(lang, rng=self.rng)
|
|
196
|
+
if kind == "mixed":
|
|
197
|
+
return textgen.MixedSource(lang, self.settings["difficulty"], rng=self.rng)
|
|
198
|
+
return self._word_source()
|
|
199
|
+
|
|
200
|
+
def _fixed(self, make_source, count):
|
|
201
|
+
return lambda: (make_source().text(count), "")
|
|
202
|
+
|
|
203
|
+
def build_mode(self, action):
|
|
204
|
+
value = self.settings["menu"][action]
|
|
205
|
+
lang = self._lang()
|
|
206
|
+
up = lang.upper()
|
|
207
|
+
if action == "time":
|
|
208
|
+
variant, label = self._word_variant()
|
|
209
|
+
return Mode("zeit-%d-%s" % (value, variant), tr("Zeit %d s · %s", "Time %d s · %s") % (value, label),
|
|
210
|
+
"time", limit=value, source=self._word_source())
|
|
211
|
+
if action == "words":
|
|
212
|
+
variant, label = self._word_variant()
|
|
213
|
+
return Mode("woerter-%d-%s" % (value, variant), "%s · %s" % (screens.words_label(value), label),
|
|
214
|
+
"text", make_text=self._fixed(self._word_source, value), count_words=True)
|
|
215
|
+
if action == "free":
|
|
216
|
+
if value == "words":
|
|
217
|
+
variant, label = self._word_variant()
|
|
218
|
+
else:
|
|
219
|
+
variant, label = lang, up
|
|
220
|
+
return Mode("frei-%s-%s" % (value, variant),
|
|
221
|
+
tr("Freier Modus · %s · %s", "Free mode · %s · %s") % (screens.free_label(value), label),
|
|
222
|
+
"free", source=self._free_source(value))
|
|
223
|
+
if action == "endless":
|
|
224
|
+
source = textgen.EndlessSource(lang, rng=self.rng)
|
|
225
|
+
mode = Mode("unendlich-%d-%s" % (value, lang),
|
|
226
|
+
tr("Unendlich ab Level %d · %s", "Endless from level %d · %s") % (value, up),
|
|
227
|
+
"endless", limit=value, source=source)
|
|
228
|
+
mode.extra = lambda: {"score": mode.state["words"], "level": mode.state["level"]}
|
|
229
|
+
return mode
|
|
230
|
+
if action == "sentences":
|
|
231
|
+
return Mode("saetze-%d-%s" % (value, lang), "%s · %s" % (
|
|
232
|
+
tr("1 Satz", "1 sentence") if value == 1 else tr("%d Sätze", "%d sentences") % value, up),
|
|
233
|
+
"text",
|
|
234
|
+
make_text=self._fixed(lambda: textgen.SentenceSource(lang, rng=self.rng), value))
|
|
235
|
+
if action == "numbers":
|
|
236
|
+
return Mode("zahlen-%d-%s" % (value, lang), tr("Zahlen %d · %s", "Numbers %d · %s") % (value, up),
|
|
237
|
+
"text",
|
|
238
|
+
make_text=self._fixed(lambda: textgen.NumberSource(lang, rng=self.rng), value))
|
|
239
|
+
if action == "symbols":
|
|
240
|
+
return Mode("zeichen-%d-%s" % (value, lang), tr("Sonderzeichen %d · %s", "Symbols %d · %s")
|
|
241
|
+
% (value, up), "text",
|
|
242
|
+
make_text=self._fixed(lambda: textgen.SymbolSource(lang, rng=self.rng), value))
|
|
243
|
+
if action == "mixed":
|
|
244
|
+
return Mode("gemischt-%d-%s" % (value, lang), tr("Gemischt %d · %s", "Mixed %d · %s") % (value, up),
|
|
245
|
+
"text",
|
|
246
|
+
make_text=self._fixed(lambda: textgen.MixedSource(
|
|
247
|
+
lang, self.settings["difficulty"], rng=self.rng), value))
|
|
248
|
+
if action == "quotes":
|
|
249
|
+
def make_quote():
|
|
250
|
+
text, source = textgen.pick_quote(lang, value, self.rng)
|
|
251
|
+
return text, "— " + source
|
|
252
|
+
return Mode("zitat-%s-%s" % (value, lang), tr("Zitat (%s) · %s", "Quote (%s) · %s") % (
|
|
253
|
+
screens.quote_label(value), up), "text", make_text=make_quote)
|
|
254
|
+
if action == "weak":
|
|
255
|
+
def make_weak():
|
|
256
|
+
weak = [row[0] for row in stats.weak_keys(self.store.key_stats)]
|
|
257
|
+
source = textgen.WeakSource(lang, weak, rng=self.rng)
|
|
258
|
+
if weak:
|
|
259
|
+
note = tr("Fokus auf: ", "Focus on: ") + " ".join(weak)
|
|
260
|
+
else:
|
|
261
|
+
note = tr("Noch zu wenig Daten – übe erst ein paar andere Tests.",
|
|
262
|
+
"Not enough data yet – do a few other tests first.")
|
|
263
|
+
return source.text(value), note
|
|
264
|
+
return Mode("schwaechen-%d-%s" % (value, lang),
|
|
265
|
+
tr("Schwächen-Training %d · %s", "Weak keys %d · %s") % (value, up),
|
|
266
|
+
"text", make_text=make_weak, count_words=True)
|
|
267
|
+
raise ValueError(action)
|
|
268
|
+
|
|
269
|
+
# --- Ablauf eines Tests ----------------------------------------------
|
|
270
|
+
def run_mode(self, mode):
|
|
271
|
+
text = note = None
|
|
272
|
+
while True:
|
|
273
|
+
if mode.kind == "text" and text is None:
|
|
274
|
+
text, note = mode.make_text()
|
|
275
|
+
text = textgen.finalize(text, self.settings)
|
|
276
|
+
outcome, test = self.run_test(mode, text, note)
|
|
277
|
+
if outcome == "abort":
|
|
278
|
+
return
|
|
279
|
+
if outcome == "restart":
|
|
280
|
+
text = None
|
|
281
|
+
continue
|
|
282
|
+
result = test.result()
|
|
283
|
+
info = self.save_result(mode, result)
|
|
284
|
+
if info["saved"] and mode.on_complete and test.complete():
|
|
285
|
+
mode.on_complete(result)
|
|
286
|
+
choice = screens.result_screen(self.term, mode, result, info, self.store)
|
|
287
|
+
if choice == "menu":
|
|
288
|
+
return
|
|
289
|
+
if choice == "next":
|
|
290
|
+
text = None
|
|
291
|
+
|
|
292
|
+
def run_test(self, mode, text, note):
|
|
293
|
+
settings = self.settings
|
|
294
|
+
endless = mode.kind == "endless"
|
|
295
|
+
if endless:
|
|
296
|
+
mode.state = {"budget": ENDLESS_START, "level": mode.limit, "words": 0, "credited": set()}
|
|
297
|
+
mode.source.level = mode.limit
|
|
298
|
+
if mode.kind == "text":
|
|
299
|
+
target = text
|
|
300
|
+
else:
|
|
301
|
+
target = self._next_chunk(mode)
|
|
302
|
+
while len(target) < (150 if endless else 400):
|
|
303
|
+
target += " " + self._next_chunk(mode)
|
|
304
|
+
test = TypingTest(target, strict=settings["strict"])
|
|
305
|
+
wrong = None # (falsch gedrückte Taste, Zeitpunkt) für das rote Aufblinken
|
|
306
|
+
self.term.flush_input()
|
|
307
|
+
while True:
|
|
308
|
+
now = time.monotonic()
|
|
309
|
+
if test.started:
|
|
310
|
+
test.sample(now)
|
|
311
|
+
if mode.kind == "time" and test.elapsed(now) >= mode.limit:
|
|
312
|
+
test.finish(test.start_time + mode.limit)
|
|
313
|
+
return "done", test
|
|
314
|
+
if endless and test.elapsed(now) >= mode.state["budget"]:
|
|
315
|
+
test.finish(test.start_time + max(0.0, mode.state["budget"]))
|
|
316
|
+
return "done", test
|
|
317
|
+
if mode.kind != "text" and len(test.target) - test.pos < (120 if endless else 250):
|
|
318
|
+
test.extend(" " + self._next_chunk(mode))
|
|
319
|
+
flash = wrong[0] if wrong and now - wrong[1] < 0.35 else None
|
|
320
|
+
screens.draw_test(self.term, test, now, mode, settings, note, flash)
|
|
321
|
+
|
|
322
|
+
key = self.term.read_key(0.08 if test.started or settings["animations"] else 0.5)
|
|
323
|
+
if key is None:
|
|
324
|
+
continue
|
|
325
|
+
if key in (T.ESC, T.CTRL_C):
|
|
326
|
+
if mode.kind in ("free", "endless") and test.pos > 0:
|
|
327
|
+
test.finish(time.monotonic())
|
|
328
|
+
return "done", test
|
|
329
|
+
return "abort", test
|
|
330
|
+
if key == T.TAB:
|
|
331
|
+
return "restart", test
|
|
332
|
+
if key == T.BACKSPACE:
|
|
333
|
+
test.backspace()
|
|
334
|
+
elif key == T.CTRL_BACKSPACE:
|
|
335
|
+
test.backspace_word()
|
|
336
|
+
elif T.is_char(key):
|
|
337
|
+
ok = test.type_char(key, time.monotonic())
|
|
338
|
+
if ok is False:
|
|
339
|
+
wrong = (key, time.monotonic())
|
|
340
|
+
if settings["bell"]:
|
|
341
|
+
self.term.bell()
|
|
342
|
+
if endless and ok is not None:
|
|
343
|
+
self._endless_step(mode, test, ok)
|
|
344
|
+
if mode.kind == "text" and test.complete():
|
|
345
|
+
test.finish(time.monotonic())
|
|
346
|
+
return "done", test
|
|
347
|
+
|
|
348
|
+
def _endless_step(self, mode, test, ok):
|
|
349
|
+
"""Unendlich-Modus: Fehler kosten Zeit, fertige Wörter bringen Zeit."""
|
|
350
|
+
state = mode.state
|
|
351
|
+
now_elapsed = test.elapsed(time.monotonic())
|
|
352
|
+
if not ok:
|
|
353
|
+
state["budget"] -= ENDLESS_PENALTY
|
|
354
|
+
return
|
|
355
|
+
end = test.pos - 1
|
|
356
|
+
if test.target[end] != " " or end in state["credited"]:
|
|
357
|
+
return
|
|
358
|
+
start = test.target.rfind(" ", 0, end) + 1
|
|
359
|
+
if not all(test.marks[start:end + 1]):
|
|
360
|
+
return
|
|
361
|
+
state["credited"].add(end)
|
|
362
|
+
state["words"] += 1
|
|
363
|
+
seconds_per_char = 60.0 / (5 * endless_target_wpm(state["level"]))
|
|
364
|
+
state["budget"] = min(state["budget"] + (end + 1 - start) * seconds_per_char,
|
|
365
|
+
now_elapsed + ENDLESS_MAX)
|
|
366
|
+
state["level"] = mode.limit + state["words"] // ENDLESS_WORDS_PER_LEVEL
|
|
367
|
+
mode.source.level = state["level"]
|
|
368
|
+
|
|
369
|
+
def _next_chunk(self, mode):
|
|
370
|
+
return textgen.finalize(mode.source.chunk(), self.settings)
|
|
371
|
+
|
|
372
|
+
def save_result(self, mode, result):
|
|
373
|
+
if result["keystrokes"] < 5 or result["duration"] < 1:
|
|
374
|
+
return {"saved": False}
|
|
375
|
+
entry = {
|
|
376
|
+
"ts": datetime.now().isoformat(timespec="seconds"),
|
|
377
|
+
"mode": mode.key,
|
|
378
|
+
"label": mode.label,
|
|
379
|
+
"lang": self._lang(),
|
|
380
|
+
"wpm": result["wpm"],
|
|
381
|
+
"raw": result["raw"],
|
|
382
|
+
"acc": result["acc"],
|
|
383
|
+
"duration": result["duration"],
|
|
384
|
+
"chars": result["chars"],
|
|
385
|
+
"errors": result["errors"],
|
|
386
|
+
"consistency": result["consistency"],
|
|
387
|
+
}
|
|
388
|
+
if mode.extra:
|
|
389
|
+
entry.update(mode.extra())
|
|
390
|
+
previous = stats.records(self.store.history).get(mode.key)
|
|
391
|
+
record = stats.record_eligible(entry) and (
|
|
392
|
+
previous is None or stats.record_value(entry) > stats.record_value(previous))
|
|
393
|
+
self.store.add_result(entry, result["key_attempts"], result["key_errors"])
|
|
394
|
+
new = self.update_achievements()
|
|
395
|
+
return {"saved": True, "record": record, "previous": previous, "achievements": new,
|
|
396
|
+
"entry": entry}
|
|
397
|
+
|
|
398
|
+
def update_achievements(self):
|
|
399
|
+
got = stats.achieved(self.store.history, self.settings["daily_goal"], date.today())
|
|
400
|
+
new = [key for key, _, _ in stats.ACHIEVEMENTS
|
|
401
|
+
if key in got and key not in self.store.achievements]
|
|
402
|
+
if new:
|
|
403
|
+
stamp = datetime.now().isoformat(timespec="seconds")
|
|
404
|
+
for key in new:
|
|
405
|
+
self.store.achievements[key] = stamp
|
|
406
|
+
self.store.save()
|
|
407
|
+
return new
|
|
408
|
+
|
|
409
|
+
# --- Geschichten ------------------------------------------------------
|
|
410
|
+
def stories_menu(self):
|
|
411
|
+
level = self.settings["menu"]["stories"]
|
|
412
|
+
index = 0
|
|
413
|
+
while True:
|
|
414
|
+
action, index, _ = screens.stories_menu(self.term, self.store, level, index)
|
|
415
|
+
if action is None or action == "back":
|
|
416
|
+
return
|
|
417
|
+
self.run_mode(self._story_mode(level, action))
|
|
418
|
+
|
|
419
|
+
def _story_mode(self, level, first):
|
|
420
|
+
lang = self._lang()
|
|
421
|
+
items = stories.stories_for(lang, level)
|
|
422
|
+
label = stories.level_label(level)
|
|
423
|
+
story = tr("Geschichte", "Story")
|
|
424
|
+
state = {"i": first, "shown": first}
|
|
425
|
+
|
|
426
|
+
def make_text():
|
|
427
|
+
state["shown"] = state["i"]
|
|
428
|
+
title, text = items[state["i"]]
|
|
429
|
+
return text, "\"%s\" · %s %d/%d (%s)" % (title, story, state["i"] + 1, len(items), label)
|
|
430
|
+
|
|
431
|
+
def on_complete(result):
|
|
432
|
+
state["i"] = (state["shown"] + 1) % len(items)
|
|
433
|
+
|
|
434
|
+
key = "geschichte-%s" % level + ("-en" if lang == "en" else "")
|
|
435
|
+
mode = Mode(key, "%s (%s)" % (story, label), "text", make_text=make_text, on_complete=on_complete)
|
|
436
|
+
mode.extra = lambda: {"story": stories.story_id(level, state["shown"]), "lang": lang,
|
|
437
|
+
"label": "%s (%s) · %s" % (story, label, items[state["shown"]][0])}
|
|
438
|
+
return mode
|
|
439
|
+
|
|
440
|
+
# --- Eigene Texte ----------------------------------------------------
|
|
441
|
+
def custom_texts(self):
|
|
442
|
+
index = 0
|
|
443
|
+
while True:
|
|
444
|
+
action, index, key = screens.custom_menu(self.term, self.store, index)
|
|
445
|
+
if action in (None, "back"):
|
|
446
|
+
return
|
|
447
|
+
if action == "new":
|
|
448
|
+
self._add_pasted_text()
|
|
449
|
+
elif action == "file":
|
|
450
|
+
self._add_file_text()
|
|
451
|
+
elif isinstance(action, int):
|
|
452
|
+
entry = self.store.custom_texts[action]
|
|
453
|
+
if key in (T.DELETE, "d", "D"):
|
|
454
|
+
title = entry.get("title") or entry["text"][:30]
|
|
455
|
+
if ui.confirm(self.term, tr("Text löschen", "Delete text"),
|
|
456
|
+
tr("„%s“ wirklich löschen?", "Really delete \"%s\"?") % title):
|
|
457
|
+
del self.store.custom_texts[action]
|
|
458
|
+
self.store.save()
|
|
459
|
+
index = max(0, index - 1)
|
|
460
|
+
else:
|
|
461
|
+
self.run_mode(self._custom_mode(entry))
|
|
462
|
+
|
|
463
|
+
def _custom_mode(self, entry):
|
|
464
|
+
title = entry.get("title") or entry["text"][:30]
|
|
465
|
+
state = {}
|
|
466
|
+
|
|
467
|
+
def make_text():
|
|
468
|
+
start, end = textgen.custom_chunk(entry["text"], entry.get("pos", 0))
|
|
469
|
+
state["end"] = end
|
|
470
|
+
note = ""
|
|
471
|
+
if end - start < len(entry["text"]):
|
|
472
|
+
note = tr("Abschnitt: %d %% – %d %% des Textes", "Part: %d %% – %d %% of the text") % (
|
|
473
|
+
100 * start // len(entry["text"]), 100 * end // len(entry["text"]))
|
|
474
|
+
return entry["text"][start:end].strip(), note
|
|
475
|
+
|
|
476
|
+
def on_complete(result):
|
|
477
|
+
end = state.get("end", 0)
|
|
478
|
+
entry["pos"] = 0 if end >= len(entry["text"]) else end
|
|
479
|
+
self.store.save()
|
|
480
|
+
|
|
481
|
+
return Mode("eigener", tr("Eigener Text · %s", "Own text · %s") % title[:40], "text",
|
|
482
|
+
make_text=make_text, on_complete=on_complete)
|
|
483
|
+
|
|
484
|
+
def _save_custom(self, title, text):
|
|
485
|
+
text = textgen.normalize_text(text)
|
|
486
|
+
if not text:
|
|
487
|
+
ui.message(self.term, tr("Eigener Text", "Your own text"),
|
|
488
|
+
[tr("Der Text ist leer – nichts gespeichert.", "The text is empty – nothing saved.")])
|
|
489
|
+
return
|
|
490
|
+
title = textgen.normalize_text(title) or text[:30]
|
|
491
|
+
self.store.custom_texts.append({
|
|
492
|
+
"title": title[:60], "text": text, "pos": 0,
|
|
493
|
+
"added": datetime.now().isoformat(timespec="seconds"),
|
|
494
|
+
})
|
|
495
|
+
self.store.save()
|
|
496
|
+
|
|
497
|
+
def _add_pasted_text(self):
|
|
498
|
+
lines, title = [], ""
|
|
499
|
+
with self.term.cooked():
|
|
500
|
+
print(tr("EIGENEN TEXT EINFÜGEN\n", "PASTE YOUR OWN TEXT\n"))
|
|
501
|
+
print(tr("Füge deinen Text ein (z. B. mit Cmd+V, Strg+Umschalt+V oder Rechtsklick).",
|
|
502
|
+
"Paste your text (e.g. with Cmd+V, Ctrl+Shift+V or right-click)."))
|
|
503
|
+
print(tr("Zum Abschließen zweimal Enter drücken – oder eine Zeile mit nur einem Punkt.\n",
|
|
504
|
+
"Press Enter twice to finish – or type a line with just a period.\n"))
|
|
505
|
+
empty = 0
|
|
506
|
+
try:
|
|
507
|
+
while True:
|
|
508
|
+
line = input()
|
|
509
|
+
if line.strip() == ".":
|
|
510
|
+
break
|
|
511
|
+
if not line.strip():
|
|
512
|
+
empty += 1
|
|
513
|
+
if empty >= 2 or not lines:
|
|
514
|
+
break
|
|
515
|
+
continue
|
|
516
|
+
empty = 0
|
|
517
|
+
lines.append(line)
|
|
518
|
+
if lines:
|
|
519
|
+
title = input(tr("\nTitel (optional, Enter = automatisch): ",
|
|
520
|
+
"\nTitle (optional, Enter = automatic): "))
|
|
521
|
+
except (EOFError, KeyboardInterrupt):
|
|
522
|
+
lines = []
|
|
523
|
+
if lines:
|
|
524
|
+
self._save_custom(title, " ".join(lines))
|
|
525
|
+
|
|
526
|
+
def _add_file_text(self):
|
|
527
|
+
with self.term.cooked():
|
|
528
|
+
print(tr("TEXT AUS DATEI LADEN\n", "LOAD TEXT FROM FILE\n"))
|
|
529
|
+
try:
|
|
530
|
+
raw = input(tr("Pfad zur Datei (leer = abbrechen): ",
|
|
531
|
+
"Path to the file (empty = cancel): ")).strip().strip("\"'")
|
|
532
|
+
except (EOFError, KeyboardInterrupt):
|
|
533
|
+
raw = ""
|
|
534
|
+
if not raw:
|
|
535
|
+
return
|
|
536
|
+
path = Path(raw).expanduser()
|
|
537
|
+
try:
|
|
538
|
+
text = path.read_text(encoding="utf-8", errors="replace")
|
|
539
|
+
except OSError as exc:
|
|
540
|
+
ui.message(self.term, tr("Datei laden", "Load file"),
|
|
541
|
+
[tr("Die Datei konnte nicht gelesen werden:", "The file could not be read:"), str(exc)])
|
|
542
|
+
return
|
|
543
|
+
self._save_custom(path.stem, text)
|
|
544
|
+
|
|
545
|
+
|
|
546
|
+
def main(argv=None):
|
|
547
|
+
parser = argparse.ArgumentParser(prog="keyflow",
|
|
548
|
+
description="KeyFlow – a typing trainer for the terminal. "
|
|
549
|
+
"Made by YnsLaf")
|
|
550
|
+
parser.add_argument("--data", "--daten", dest="data", metavar="FILE",
|
|
551
|
+
help="use a different data file (default: ~/.keyflow/daten.json)")
|
|
552
|
+
parser.add_argument("--version", action="version", version="KeyFlow %s" % __version__)
|
|
553
|
+
args = parser.parse_args(argv)
|
|
554
|
+
|
|
555
|
+
store = Store(args.data)
|
|
556
|
+
try:
|
|
557
|
+
with T.Terminal() as term:
|
|
558
|
+
try:
|
|
559
|
+
App(term, store).run()
|
|
560
|
+
except (KeyboardInterrupt, EOFError):
|
|
561
|
+
pass
|
|
562
|
+
except RuntimeError as exc:
|
|
563
|
+
print(exc, file=sys.stderr)
|
|
564
|
+
return 1
|
|
565
|
+
days = stats.daily_seconds(store.history)
|
|
566
|
+
today_minutes = days.get(date.today(), 0) / 60
|
|
567
|
+
print(tr("Bis bald! Heute geübt: %s min.", "See you soon! Practiced today: %s min.")
|
|
568
|
+
% ui.fmt_minutes(today_minutes * 60))
|
|
569
|
+
return 0
|