vimniscient 0.2.1__tar.gz → 0.2.2__tar.gz
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.
- {vimniscient-0.2.1 → vimniscient-0.2.2}/PKG-INFO +3 -1
- {vimniscient-0.2.1 → vimniscient-0.2.2}/README.md +2 -0
- {vimniscient-0.2.1 → vimniscient-0.2.2}/pyproject.toml +1 -1
- {vimniscient-0.2.1 → vimniscient-0.2.2}/src/vimniscient/app.py +23 -5
- vimniscient-0.2.2/src/vimniscient/diagnose.py +113 -0
- {vimniscient-0.2.1 → vimniscient-0.2.2}/src/vimniscient/endless.py +1 -1
- vimniscient-0.2.2/src/vimniscient/generate.py +215 -0
- {vimniscient-0.2.1 → vimniscient-0.2.2}/src/vimniscient/lessons/w1d1-words.yaml +1 -0
- {vimniscient-0.2.1 → vimniscient-0.2.2}/src/vimniscient/lessons/w1d2-pairs.yaml +6 -0
- {vimniscient-0.2.1 → vimniscient-0.2.2}/src/vimniscient/lessons/w1d3-sentences.yaml +1 -0
- {vimniscient-0.2.1 → vimniscient-0.2.2}/src/vimniscient/lessons/w1d4-find.yaml +2 -0
- {vimniscient-0.2.1 → vimniscient-0.2.2}/src/vimniscient/lessons/w1d5-lines.yaml +4 -0
- {vimniscient-0.2.1 → vimniscient-0.2.2}/src/vimniscient/lessons/w1d6-counts.yaml +4 -0
- {vimniscient-0.2.1 → vimniscient-0.2.2}/src/vimniscient/lessons/w1d7-review.yaml +1 -0
- {vimniscient-0.2.1 → vimniscient-0.2.2}/src/vimniscient/lessons/w2d1-dot.yaml +1 -0
- {vimniscient-0.2.1 → vimniscient-0.2.2}/src/vimniscient/lessons/w2d2-search.yaml +1 -0
- {vimniscient-0.2.1 → vimniscient-0.2.2}/src/vimniscient/lessons/w2d3-cgn.yaml +1 -0
- {vimniscient-0.2.1 → vimniscient-0.2.2}/src/vimniscient/lessons/w2d4-case-numbers.yaml +1 -0
- {vimniscient-0.2.1 → vimniscient-0.2.2}/src/vimniscient/lessons/w2d5-insert.yaml +1 -0
- {vimniscient-0.2.1 → vimniscient-0.2.2}/src/vimniscient/lessons/w2d6-visual.yaml +1 -0
- {vimniscient-0.2.1 → vimniscient-0.2.2}/src/vimniscient/lessons/w2d7-review.yaml +1 -0
- {vimniscient-0.2.1 → vimniscient-0.2.2}/src/vimniscient/lessons/w3d1-macros.yaml +1 -0
- {vimniscient-0.2.1 → vimniscient-0.2.2}/src/vimniscient/lessons/w3d2-macros-more.yaml +1 -0
- {vimniscient-0.2.1 → vimniscient-0.2.2}/src/vimniscient/lessons/w3d3-registers.yaml +1 -0
- {vimniscient-0.2.1 → vimniscient-0.2.2}/src/vimniscient/lessons/w3d4-marks.yaml +1 -0
- {vimniscient-0.2.1 → vimniscient-0.2.2}/src/vimniscient/lessons/w3d5-visual-block.yaml +1 -0
- {vimniscient-0.2.1 → vimniscient-0.2.2}/src/vimniscient/lessons/w3d6-substitute.yaml +1 -0
- {vimniscient-0.2.1 → vimniscient-0.2.2}/src/vimniscient/lessons/w3d7-review.yaml +1 -0
- {vimniscient-0.2.1 → vimniscient-0.2.2}/src/vimniscient/lessons/w4d1-ranges.yaml +1 -0
- {vimniscient-0.2.1 → vimniscient-0.2.2}/src/vimniscient/lessons/w4d2-sort-filter.yaml +1 -0
- {vimniscient-0.2.1 → vimniscient-0.2.2}/src/vimniscient/lessons/w4d3-regex.yaml +1 -0
- {vimniscient-0.2.1 → vimniscient-0.2.2}/src/vimniscient/lessons/w4d4-norm.yaml +1 -0
- {vimniscient-0.2.1 → vimniscient-0.2.2}/src/vimniscient/lessons/w4d5-motions.yaml +1 -0
- {vimniscient-0.2.1 → vimniscient-0.2.2}/src/vimniscient/lessons/w4d6-indent.yaml +1 -0
- {vimniscient-0.2.1 → vimniscient-0.2.2}/src/vimniscient/lessons/w4d7-review.yaml +1 -0
- {vimniscient-0.2.1 → vimniscient-0.2.2}/src/vimniscient/lessons/w5d1-arguments.yaml +1 -0
- {vimniscient-0.2.1 → vimniscient-0.2.2}/src/vimniscient/lessons/w5d2-any-quote-bracket.yaml +1 -0
- {vimniscient-0.2.1 → vimniscient-0.2.2}/src/vimniscient/lessons/w5d3-functions.yaml +1 -0
- {vimniscient-0.2.1 → vimniscient-0.2.2}/src/vimniscient/lessons/w5d4-small-objects.yaml +1 -0
- {vimniscient-0.2.1 → vimniscient-0.2.2}/src/vimniscient/lessons/w5d5-comments.yaml +1 -0
- {vimniscient-0.2.1 → vimniscient-0.2.2}/src/vimniscient/lessons/w5d6-review.yaml +1 -0
- {vimniscient-0.2.1 → vimniscient-0.2.2}/src/vimniscient/lessons/w6d1-code.yaml +1 -0
- {vimniscient-0.2.1 → vimniscient-0.2.2}/src/vimniscient/lessons/w6d2-notes.yaml +1 -0
- {vimniscient-0.2.1 → vimniscient-0.2.2}/src/vimniscient/lessons/w6d3-data.yaml +1 -0
- {vimniscient-0.2.1 → vimniscient-0.2.2}/src/vimniscient/lessons.py +5 -0
- {vimniscient-0.2.1 → vimniscient-0.2.2}/src/vimniscient/progress.py +17 -0
- {vimniscient-0.2.1 → vimniscient-0.2.2}/src/vimniscient/session.py +6 -3
- {vimniscient-0.2.1 → vimniscient-0.2.2}/src/vimniscient/today.py +3 -1
- vimniscient-0.2.2/src/vimniscient/transfer.py +56 -0
- vimniscient-0.2.2/src/vimniscient/transfer.yaml +184 -0
- {vimniscient-0.2.1 → vimniscient-0.2.2}/src/vimniscient/ui/builtin_lessons.py +15 -1
- {vimniscient-0.2.1 → vimniscient-0.2.2}/src/vimniscient/ui/lesson.py +17 -2
- {vimniscient-0.2.1 → vimniscient-0.2.2}/src/vimniscient/ui/main.py +4 -1
- {vimniscient-0.2.1 → vimniscient-0.2.2}/tests/check_app.py +18 -0
- vimniscient-0.2.2/tests/check_diagnose.py +59 -0
- {vimniscient-0.2.1 → vimniscient-0.2.2}/tests/check_generate.py +1 -1
- {vimniscient-0.2.1 → vimniscient-0.2.2}/tests/check_lessons.py +4 -1
- {vimniscient-0.2.1 → vimniscient-0.2.2}/tests/check_today.py +12 -0
- vimniscient-0.2.2/tests/check_transfer.py +65 -0
- vimniscient-0.2.1/src/vimniscient/generate.py +0 -82
- {vimniscient-0.2.1 → vimniscient-0.2.2}/.gitignore +0 -0
- {vimniscient-0.2.1 → vimniscient-0.2.2}/LICENSE +0 -0
- {vimniscient-0.2.1 → vimniscient-0.2.2}/extras/nvim/vimniscient.lua +0 -0
- {vimniscient-0.2.1 → vimniscient-0.2.2}/src/vimniscient/__init__.py +0 -0
- {vimniscient-0.2.1 → vimniscient-0.2.2}/src/vimniscient/__main__.py +0 -0
- {vimniscient-0.2.1 → vimniscient-0.2.2}/src/vimniscient/answer.py +0 -0
- {vimniscient-0.2.1 → vimniscient-0.2.2}/src/vimniscient/cards/foot.yaml +0 -0
- {vimniscient-0.2.1 → vimniscient-0.2.2}/src/vimniscient/cards/lazyvim.yaml +0 -0
- {vimniscient-0.2.1 → vimniscient-0.2.2}/src/vimniscient/cards/shell.yaml +0 -0
- {vimniscient-0.2.1 → vimniscient-0.2.2}/src/vimniscient/cards/vim.yaml +0 -0
- {vimniscient-0.2.1 → vimniscient-0.2.2}/src/vimniscient/cards/zathura.yaml +0 -0
- {vimniscient-0.2.1 → vimniscient-0.2.2}/src/vimniscient/cards.py +0 -0
- {vimniscient-0.2.1 → vimniscient-0.2.2}/src/vimniscient/cli.py +0 -0
- {vimniscient-0.2.1 → vimniscient-0.2.2}/src/vimniscient/drills/omarchy.yaml +0 -0
- {vimniscient-0.2.1 → vimniscient-0.2.2}/src/vimniscient/drills.py +0 -0
- {vimniscient-0.2.1 → vimniscient-0.2.2}/src/vimniscient/habits.py +0 -0
- {vimniscient-0.2.1 → vimniscient-0.2.2}/src/vimniscient/keys.py +0 -0
- {vimniscient-0.2.1 → vimniscient-0.2.2}/src/vimniscient/lessons/sources.md +0 -0
- {vimniscient-0.2.1 → vimniscient-0.2.2}/src/vimniscient/mnemonics.py +0 -0
- {vimniscient-0.2.1 → vimniscient-0.2.2}/src/vimniscient/mnemonics.yaml +0 -0
- {vimniscient-0.2.1 → vimniscient-0.2.2}/src/vimniscient/nvim/keymaps.lua +0 -0
- {vimniscient-0.2.1 → vimniscient-0.2.2}/src/vimniscient/nvim/runner.lua +0 -0
- {vimniscient-0.2.1 → vimniscient-0.2.2}/src/vimniscient/reminders.py +0 -0
- {vimniscient-0.2.1 → vimniscient-0.2.2}/src/vimniscient/settings.py +0 -0
- {vimniscient-0.2.1 → vimniscient-0.2.2}/src/vimniscient/srs.py +0 -0
- {vimniscient-0.2.1 → vimniscient-0.2.2}/src/vimniscient/ui/__init__.py +0 -0
- {vimniscient-0.2.1 → vimniscient-0.2.2}/src/vimniscient/ui/cards.py +0 -0
- {vimniscient-0.2.1 → vimniscient-0.2.2}/src/vimniscient/ui/common.py +0 -0
- {vimniscient-0.2.1 → vimniscient-0.2.2}/src/vimniscient/ui/drills.py +0 -0
- {vimniscient-0.2.1 → vimniscient-0.2.2}/src/vimniscient/ui/stats.py +0 -0
- {vimniscient-0.2.1 → vimniscient-0.2.2}/src/vimniscient/vimgolf.py +0 -0
- {vimniscient-0.2.1 → vimniscient-0.2.2}/tests/check_answers.py +0 -0
- {vimniscient-0.2.1 → vimniscient-0.2.2}/tests/check_drills.py +0 -0
- {vimniscient-0.2.1 → vimniscient-0.2.2}/tests/check_lessons.lua +0 -0
- {vimniscient-0.2.1 → vimniscient-0.2.2}/tests/check_timing.py +0 -0
- {vimniscient-0.2.1 → vimniscient-0.2.2}/tests/live_drills.py +0 -0
- {vimniscient-0.2.1 → vimniscient-0.2.2}/tests/live_endless.py +0 -0
- {vimniscient-0.2.1 → vimniscient-0.2.2}/tests/live_lessons.py +0 -0
- {vimniscient-0.2.1 → vimniscient-0.2.2}/tests/live_retry.py +0 -0
- {vimniscient-0.2.1 → vimniscient-0.2.2}/tests/live_review.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: vimniscient
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.2
|
|
4
4
|
Summary: Get fluent in Vim in fifteen minutes a day, in your own Neovim.
|
|
5
5
|
Project-URL: Homepage, https://vimniscient.com
|
|
6
6
|
Project-URL: Source, https://github.com/dialectmaterial/Vimniscient
|
|
@@ -301,6 +301,8 @@ uv run python tests/check_lessons.py --lazyvim # LazyVim lessons, in your own c
|
|
|
301
301
|
uv run python tests/check_answers.py # card answers written different ways
|
|
302
302
|
uv run python tests/check_timing.py # task times: old progress databases upgrade safely
|
|
303
303
|
uv run python tests/check_generate.py # generated texts: reproducible, and they solve in Neovim
|
|
304
|
+
uv run python tests/check_diagnose.py # strategy feedback: no reference solution sets it off
|
|
305
|
+
uv run python tests/check_transfer.py # Choose: every way solves it in Neovim, nothing graded
|
|
304
306
|
uv run python tests/check_today.py # the daily budget, SRS scheduling, card introductions, hardtime
|
|
305
307
|
uv run python tests/check_app.py # every screen, driven headless
|
|
306
308
|
uv run python tests/live_lessons.py --all # types every solution into the real runner (needs tmux)
|
|
@@ -278,6 +278,8 @@ uv run python tests/check_lessons.py --lazyvim # LazyVim lessons, in your own c
|
|
|
278
278
|
uv run python tests/check_answers.py # card answers written different ways
|
|
279
279
|
uv run python tests/check_timing.py # task times: old progress databases upgrade safely
|
|
280
280
|
uv run python tests/check_generate.py # generated texts: reproducible, and they solve in Neovim
|
|
281
|
+
uv run python tests/check_diagnose.py # strategy feedback: no reference solution sets it off
|
|
282
|
+
uv run python tests/check_transfer.py # Choose: every way solves it in Neovim, nothing graded
|
|
281
283
|
uv run python tests/check_today.py # the daily budget, SRS scheduling, card introductions, hardtime
|
|
282
284
|
uv run python tests/check_app.py # every screen, driven headless
|
|
283
285
|
uv run python tests/live_lessons.py --all # types every solution into the real runner (needs tmux)
|
|
@@ -11,13 +11,14 @@ from dataclasses import replace
|
|
|
11
11
|
|
|
12
12
|
from textual.app import App
|
|
13
13
|
from textual.color import Color
|
|
14
|
+
from textual.content import Content
|
|
14
15
|
|
|
15
|
-
from vimniscient import drills, endless, reminders, today, vimgolf
|
|
16
|
+
from vimniscient import drills, endless, reminders, today, transfer, vimgolf
|
|
16
17
|
from vimniscient.cards import has_lazyvim, load_decks
|
|
17
18
|
from vimniscient.lessons import Lesson, load_lessons, two_rounds
|
|
18
19
|
from vimniscient.progress import Progress
|
|
19
20
|
from vimniscient.session import COLORS, TaskResult, mouse_wheel, run_endless, run_lesson
|
|
20
|
-
from vimniscient.ui.builtin_lessons import endless_lesson, placement_lesson, review_lesson, vimgolf_lesson
|
|
21
|
+
from vimniscient.ui.builtin_lessons import endless_lesson, placement_lesson, review_lesson, transfer_lesson, vimgolf_lesson
|
|
21
22
|
from vimniscient.ui.cards import CardScreen
|
|
22
23
|
from vimniscient.ui.common import CATPPUCCIN, DEFAULT_THEME, omarchy_theme, plural
|
|
23
24
|
from vimniscient.ui.drills import DrillScreen
|
|
@@ -44,7 +45,7 @@ class VimniscientApp(App):
|
|
|
44
45
|
|
|
45
46
|
#main { height: 1fr; }
|
|
46
47
|
#left { width: 46; max-width: 45%; }
|
|
47
|
-
#plan { height:
|
|
48
|
+
#plan { height: 9; }
|
|
48
49
|
#lesson-list { height: 1fr; }
|
|
49
50
|
#right { width: 1fr; }
|
|
50
51
|
#detail-pane { height: 1fr; }
|
|
@@ -74,7 +75,7 @@ class VimniscientApp(App):
|
|
|
74
75
|
#results-bar { height: 1; }
|
|
75
76
|
#note { padding: 0 0 1 0; color: $success; height: auto; }
|
|
76
77
|
#results-list { height: auto; padding-right: 1; }
|
|
77
|
-
#results-why { height: auto; padding: 1 0 0 0; }
|
|
78
|
+
#results-why, #results-advice { height: auto; padding: 1 0 0 0; }
|
|
78
79
|
|
|
79
80
|
#stats-view { height: 1fr; padding: 0 2; }
|
|
80
81
|
.section-label { color: $text-muted; text-style: bold; height: 1; margin-top: 1; }
|
|
@@ -121,7 +122,7 @@ class VimniscientApp(App):
|
|
|
121
122
|
def start_lesson(
|
|
122
123
|
self,
|
|
123
124
|
lesson: Lesson,
|
|
124
|
-
on_results: Callable[[list[TaskResult]], str] | None = None,
|
|
125
|
+
on_results: Callable[[list[TaskResult]], str | Content] | None = None,
|
|
125
126
|
done: Callable[[], None] = lambda: None,
|
|
126
127
|
) -> None:
|
|
127
128
|
"""Run a lesson in Neovim, then show the results; `done` runs when the user moves on.
|
|
@@ -188,6 +189,10 @@ class VimniscientApp(App):
|
|
|
188
189
|
elif step_id == "vimgolf":
|
|
189
190
|
if not progress.vimgolf_done_today():
|
|
190
191
|
return self.start_vimgolf
|
|
192
|
+
elif step_id == "transfer":
|
|
193
|
+
picks = transfer.pick(self.lessons, progress)
|
|
194
|
+
if picks:
|
|
195
|
+
return lambda done: self.start_transfer(picks, done)
|
|
191
196
|
elif step_id == "endless":
|
|
192
197
|
start = progress.endless_start(endless.RESTART_BELOW)
|
|
193
198
|
if direct:
|
|
@@ -252,6 +257,19 @@ class VimniscientApp(App):
|
|
|
252
257
|
def action_endless(self) -> None:
|
|
253
258
|
self.run_step("endless", lambda: None)
|
|
254
259
|
|
|
260
|
+
def action_transfer(self) -> None:
|
|
261
|
+
self.run_step("transfer", lambda: None)
|
|
262
|
+
|
|
263
|
+
def start_transfer(self, picks: list[transfer.Challenge], done: Callable[[], None]) -> None:
|
|
264
|
+
def on_results(results: list[TaskResult]) -> Content:
|
|
265
|
+
self.progress.record_transfer(results)
|
|
266
|
+
by_goal = {c.task.goal: c for c in picks}
|
|
267
|
+
lines = [f"{r.task.goal} {' or '.join(ways)}" for r in results
|
|
268
|
+
if (ways := transfer.another_way(by_goal[r.task.goal], r.keys, r.status == "solved"))]
|
|
269
|
+
return Content("Another way\n" + "\n".join(lines)) if lines else Content("")
|
|
270
|
+
|
|
271
|
+
self.start_lesson(transfer_lesson([c.task for c in picks]), on_results, done)
|
|
272
|
+
|
|
255
273
|
def start_endless(self, start: int, done: Callable[[], None]) -> None:
|
|
256
274
|
"""Climb from `start` until the user quits, then show how it went."""
|
|
257
275
|
best = self.progress.endless_best()
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"""What your own keys say about your strategy, on the results screen.
|
|
2
|
+
|
|
3
|
+
A few rules, each one certain from the keys alone, so that a task with nothing
|
|
4
|
+
clear to say says nothing. They only speak when you went over the reference,
|
|
5
|
+
and never about text you typed in insert mode (tests/check_diagnose.py checks
|
|
6
|
+
that no reference solution sets one off).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import re
|
|
10
|
+
|
|
11
|
+
from vimniscient.keys import split_keys
|
|
12
|
+
|
|
13
|
+
MOTION_RUN = 4 # jjjj; Vjj in a reference is fine, and three presses aren't a habit
|
|
14
|
+
X_RUN = 3
|
|
15
|
+
MOTIONS = {"h", "j", "k", "l", "w", "b", "e"}
|
|
16
|
+
OPERATORS = {"c", "d", "y", ">", "<", "="}
|
|
17
|
+
TAKES_CHAR = {"f", "t", "F", "T", "r", "m", "'", "`", "@", '"'}
|
|
18
|
+
INSERTS = {"i", "a", "A", "I", "o", "O", "s", "S", "C", "R"}
|
|
19
|
+
_REPEAT = re.compile(r"((?:<[^<>\s]+>|.){3,}?)\1\1", re.S)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _skip_line(tokens: list[str], i: int) -> int:
|
|
23
|
+
while i < len(tokens) and tokens[i] != "<CR>":
|
|
24
|
+
i += 1
|
|
25
|
+
return i + 1
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _skip_insert(tokens: list[str], i: int) -> int:
|
|
29
|
+
while i < len(tokens) and tokens[i] != "<Esc>":
|
|
30
|
+
i += 1
|
|
31
|
+
return i + 1
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _skip_motion(tokens: list[str], i: int) -> int:
|
|
35
|
+
"""Past what follows an operator: a count, then a motion or a text object."""
|
|
36
|
+
while i < len(tokens) and tokens[i].isdigit():
|
|
37
|
+
i += 1
|
|
38
|
+
if i >= len(tokens):
|
|
39
|
+
return i
|
|
40
|
+
key = tokens[i]
|
|
41
|
+
if key in ("/", "?"):
|
|
42
|
+
return _skip_line(tokens, i + 1)
|
|
43
|
+
return i + (2 if key in {"i", "a", "g", "f", "t", "F", "T", "'", "`"} else 1)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def normal_keys(keys: str) -> list[list[str]]:
|
|
47
|
+
"""The runs of keys pressed as commands: not typed text, search or `:` lines."""
|
|
48
|
+
tokens, runs, run = split_keys(keys), [], []
|
|
49
|
+
i, visual, recording = 0, False, False
|
|
50
|
+
while i < len(tokens):
|
|
51
|
+
key = tokens[i]
|
|
52
|
+
i += 1
|
|
53
|
+
if key in (":", "/", "?"):
|
|
54
|
+
i, visual = _skip_line(tokens, i), False
|
|
55
|
+
elif visual and key in {"c", "s", "I", "A"}:
|
|
56
|
+
i, visual = _skip_insert(tokens, i), False
|
|
57
|
+
elif visual and key in OPERATORS | {"x", "J", "U", "u", "<Esc>"}:
|
|
58
|
+
visual = False
|
|
59
|
+
elif key in OPERATORS:
|
|
60
|
+
if tokens[i:i + 1] == [key]: # dd, cc, yy
|
|
61
|
+
i += 1
|
|
62
|
+
else:
|
|
63
|
+
i = _skip_motion(tokens, i)
|
|
64
|
+
if key == "c":
|
|
65
|
+
i = _skip_insert(tokens, i)
|
|
66
|
+
elif key == "g" and tokens[i:i + 1] and tokens[i] in {"U", "u", "~"}:
|
|
67
|
+
i = _skip_motion(tokens, i + 1)
|
|
68
|
+
elif key == "g":
|
|
69
|
+
i += 1
|
|
70
|
+
elif key in TAKES_CHAR:
|
|
71
|
+
i += 1
|
|
72
|
+
elif key == "q":
|
|
73
|
+
i += 0 if recording else 1
|
|
74
|
+
recording = not recording
|
|
75
|
+
elif key in INSERTS:
|
|
76
|
+
i = _skip_insert(tokens, i)
|
|
77
|
+
else:
|
|
78
|
+
visual = visual or key in {"v", "V", "<C-v>"}
|
|
79
|
+
run.append(key)
|
|
80
|
+
continue
|
|
81
|
+
if run:
|
|
82
|
+
runs.append(run)
|
|
83
|
+
run = []
|
|
84
|
+
return [*runs, run] if run else runs
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _longest_run(runs: list[list[str]], wanted: set[str]) -> tuple[str, int]:
|
|
88
|
+
best, length = "", 0
|
|
89
|
+
for tokens in runs:
|
|
90
|
+
last, n = "", 0
|
|
91
|
+
for key in tokens:
|
|
92
|
+
n = n + 1 if key == last else 1
|
|
93
|
+
last = key
|
|
94
|
+
if key in wanted and n > length:
|
|
95
|
+
best, length = key, n
|
|
96
|
+
return best, length
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def diagnose(keys: str, count: int, ref: int) -> str:
|
|
100
|
+
"""One thing worth saying about how `keys` went about it, or nothing."""
|
|
101
|
+
if count <= ref:
|
|
102
|
+
return ""
|
|
103
|
+
runs = normal_keys(keys)
|
|
104
|
+
motion, length = _longest_run(runs, MOTIONS)
|
|
105
|
+
if length >= MOTION_RUN:
|
|
106
|
+
better = "f, t, / or a count" if motion in "hl" else "a count"
|
|
107
|
+
return f"{motion} {length} times in a row: {better} gets there in one go."
|
|
108
|
+
if _longest_run(runs, {"x"})[1] >= X_RUN:
|
|
109
|
+
return "x over and over: a count, dw or diw takes it all at once."
|
|
110
|
+
repeat = _REPEAT.search(keys)
|
|
111
|
+
if repeat and not any("." in run for run in runs) and "<Esc>" in repeat.group(1):
|
|
112
|
+
return f"You typed {repeat.group(1)} three times: . repeats the last change."
|
|
113
|
+
return ""
|
|
@@ -45,7 +45,7 @@ def ladder(lessons: list[Lesson], start: int, length: int = RUNGS, seed: object
|
|
|
45
45
|
lesson = mix[-1] if rng.random() < 0.5 else rng.choice(mix)
|
|
46
46
|
task = rng.choice([t for t in lesson.tasks if t.item_id not in recent] or lesson.tasks)
|
|
47
47
|
recent.append(task.item_id)
|
|
48
|
-
rungs.append((task, rng.randrange(task.
|
|
48
|
+
rungs.append((task, rng.randrange(task.choices)))
|
|
49
49
|
return rungs
|
|
50
50
|
|
|
51
51
|
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
"""Practice texts made from a seed, for skills where any sentence will do.
|
|
2
|
+
|
|
3
|
+
A lesson task can name a `generate` kind. Its Learn round keeps the authored
|
|
4
|
+
text; Recall and reviews can get a generated one instead, so a skill gets
|
|
5
|
+
practised on sentences you haven't seen. The same seed always gives the same
|
|
6
|
+
task, so a run can be replayed and every kind can be checked in Neovim
|
|
7
|
+
(tests/check_generate.py).
|
|
8
|
+
|
|
9
|
+
The sentences stay mnemonic: they're the hooks from mnemonics.yaml, what keys
|
|
10
|
+
stand for.
|
|
11
|
+
|
|
12
|
+
Kinds:
|
|
13
|
+
ciw-typo two letters of a word swapped; ciw and type it right
|
|
14
|
+
daw-double a word doubled; daw deletes one copy and its space
|
|
15
|
+
ciW-dotted a word written with dots between its letters; ciW rewrites it
|
|
16
|
+
ci-quote a key's meaning, wrong, in double quotes; ci" fixes it
|
|
17
|
+
di-paren the arguments of a call; di( empties it
|
|
18
|
+
da-paren a call's arguments; da( takes the parentheses too
|
|
19
|
+
di-bracket a list of keys; di[ empties it
|
|
20
|
+
dt-letter filler before a word; dt and the word's first letter
|
|
21
|
+
D-rest filler after a sentence; D deletes to the end of the line
|
|
22
|
+
J-split a sentence over two lines; J joins them
|
|
23
|
+
ddp-swap two lines the wrong way round; ddp swaps them
|
|
24
|
+
count-dw a few filler words; a count on dw
|
|
25
|
+
count-dd a few filler lines; a count on dd
|
|
26
|
+
count-x stray x letters; a count on x
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
import random
|
|
30
|
+
import re
|
|
31
|
+
from dataclasses import replace
|
|
32
|
+
from functools import cache
|
|
33
|
+
from importlib import resources
|
|
34
|
+
|
|
35
|
+
import yaml
|
|
36
|
+
|
|
37
|
+
from vimniscient.lessons import Task, load_lessons
|
|
38
|
+
from vimniscient.mnemonics import _tables
|
|
39
|
+
from vimniscient.mnemonics import hooks as key_hooks
|
|
40
|
+
|
|
41
|
+
FILLER = ("um", "well", "like", "so")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@cache
|
|
45
|
+
def sentences() -> list[str]:
|
|
46
|
+
"""The hooks of the keys week 1 teaches: nothing about keys you haven't met yet."""
|
|
47
|
+
known = {hook for lesson in load_lessons() if lesson.week == 1 for key in lesson.keys for hook in key_hooks(key)}
|
|
48
|
+
hooks = [hook for hook in dict.fromkeys(hook for table in _tables()[:2] for hook in table.values()) if hook in known]
|
|
49
|
+
# as written: a capital would change the key ("u is down" is not "U is down")
|
|
50
|
+
return [h + ("" if h.endswith(".") else ".") for h in hooks]
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@cache
|
|
54
|
+
def meanings() -> list[tuple[str, str]]:
|
|
55
|
+
"""Single-letter keys with a one-word meaning: (c, change), (d, delete) ..."""
|
|
56
|
+
data = yaml.safe_load((resources.files("vimniscient") / "mnemonics.yaml").read_text(encoding="utf-8"))
|
|
57
|
+
known = {s.removesuffix(".") for s in sentences()}
|
|
58
|
+
return [(key, entry["means"]) for key, entry in data.items() if entry["hook"].removesuffix(".") in known
|
|
59
|
+
and re.fullmatch(r"[a-zA-Z]", key) and re.fullmatch(r"[a-z]+", entry.get("means") or "")]
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
_WORD = re.compile(r"(?<![\w'.-])[A-Za-z]{4,}(?![\w'-])")
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _words(sentence: str, spaced: bool) -> list[re.Match]:
|
|
66
|
+
"""Plain words of four letters or more; `spaced` wants a space on both sides (a WORD)."""
|
|
67
|
+
found = list(_WORD.finditer(sentence))
|
|
68
|
+
if spaced:
|
|
69
|
+
found = [m for m in found if m.start() > 0 and sentence[m.start() - 1] == " "
|
|
70
|
+
and m.end() < len(sentence) and sentence[m.end()] == " "]
|
|
71
|
+
else: # daw and ciw want the word to have a space after it, or anything but a letter
|
|
72
|
+
found = [m for m in found if m.end() < len(sentence)]
|
|
73
|
+
return found
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _gaps(sentence: str) -> list[int]:
|
|
77
|
+
"""Where a new word can go: the start of every word but the first."""
|
|
78
|
+
return [m.end() for m in re.finditer(r" (?=[A-Za-z])", sentence)]
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _ci_quote(rng: random.Random) -> dict:
|
|
82
|
+
(key, right), (_, wrong) = rng.sample(meanings(), 2)
|
|
83
|
+
start = f'{key}_means = "{wrong}"'
|
|
84
|
+
return dict(goal=f'{key} stands for {right}: fix the string.', start=[start], cursor=(1, rng.randrange(len(start) - 1)),
|
|
85
|
+
target=[f'{key}_means = "{right}"'], solution=f'ci"{right}<Esc>',
|
|
86
|
+
hint='ci" changes what is inside the quotes, and finds them from anywhere before them.')
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _paren(rng: random.Random, around: bool) -> dict:
|
|
90
|
+
name = rng.choice(("around", "parens") if around else ("inner", "inside"))
|
|
91
|
+
args = ", ".join(means for _, means in rng.sample(meanings(), 3))
|
|
92
|
+
start = f"{name}({args}) + 1"
|
|
93
|
+
target, keys = (f"{name} + 1", "da(") if around else (f"{name}() + 1", "di(")
|
|
94
|
+
what = "the call's parentheses and arguments" if around else "the arguments, keeping the parentheses"
|
|
95
|
+
return dict(goal=f"Delete {what}.", start=[start], cursor=(1, len(name) + 1 + rng.randrange(len(args))), target=[target],
|
|
96
|
+
solution=keys, hint=f"{keys} works from anywhere inside the parentheses.")
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _di_bracket(rng: random.Random) -> dict:
|
|
100
|
+
name = rng.choice(("keys", "square", "bracket"))
|
|
101
|
+
items = ", ".join(f'"{key}"' for key, _ in rng.sample(meanings(), 3))
|
|
102
|
+
start = f"{name} = [{items}]"
|
|
103
|
+
return dict(goal="Empty the list, keeping the square brackets.", start=[start], cursor=(1, len(name) + 4 + rng.randrange(len(items))),
|
|
104
|
+
target=[f"{name} = []"], solution="di[", hint="[ names the square brackets: di[ empties them.")
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _dt_letter(rng: random.Random) -> dict:
|
|
108
|
+
filler = " ".join(rng.sample(FILLER, 2)) + " "
|
|
109
|
+
while True:
|
|
110
|
+
sentence = rng.choice(sentences())
|
|
111
|
+
gaps = [g for g in _gaps(sentence) if sentence[g] not in filler]
|
|
112
|
+
if gaps:
|
|
113
|
+
break
|
|
114
|
+
gap = rng.choice(gaps)
|
|
115
|
+
word = sentence[gap:].split(" ")[0].rstrip(".,:;")
|
|
116
|
+
letter = word[0]
|
|
117
|
+
return dict(goal=f'Delete everything up to "{word}".', start=[sentence[:gap] + filler + sentence[gap:]], cursor=(1, gap),
|
|
118
|
+
target=[sentence], solution=f"dt{letter}", hint=f"dt{letter} deletes till the next {letter}, and stops just before it.")
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _d_rest(rng: random.Random) -> dict:
|
|
122
|
+
sentence = rng.choice(sentences())
|
|
123
|
+
return dict(goal="Delete the filler after the full stop.", start=[f"{sentence} {' '.join(rng.sample(FILLER, 3))}"],
|
|
124
|
+
cursor=(1, len(sentence)), target=[sentence], solution="D", hint="D is a big d: it deletes to the end of the line.")
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _j_split(rng: random.Random) -> dict:
|
|
128
|
+
sentence = rng.choice([s for s in sentences() if _gaps(s)])
|
|
129
|
+
gap = rng.choice(_gaps(sentence))
|
|
130
|
+
return dict(goal="Join the two lines.", start=[sentence[:gap - 1], sentence[gap:]], cursor=(1, 0), target=[sentence],
|
|
131
|
+
solution="J", hint="J joins this line and the next, with one space.")
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _ddp_swap(rng: random.Random) -> dict:
|
|
135
|
+
first, second = rng.sample(sentences(), 2)
|
|
136
|
+
return dict(goal="Swap the two lines.", start=[first, second], cursor=(1, 0), target=[second, first],
|
|
137
|
+
solution="ddp", hint="dd cuts the line, p puts it below the next one.")
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _count_dw(rng: random.Random) -> dict:
|
|
141
|
+
n = rng.randint(2, 4)
|
|
142
|
+
sentence = rng.choice([s for s in sentences() if _gaps(s)])
|
|
143
|
+
gap = rng.choice(_gaps(sentence))
|
|
144
|
+
filler = " ".join(rng.choice(FILLER) for _ in range(n)) + " "
|
|
145
|
+
return dict(goal=f"Delete the {n} filler words.", start=[sentence[:gap] + filler + sentence[gap:]], cursor=(1, gap),
|
|
146
|
+
target=[sentence], solution=f"{n}dw", hint=f"A count repeats the verb: {n}dw deletes {n} words.")
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _count_dd(rng: random.Random) -> dict:
|
|
150
|
+
n = rng.randint(2, 4)
|
|
151
|
+
first, last = rng.sample(sentences(), 2)
|
|
152
|
+
return dict(goal=f"Delete the {n} filler lines.", start=[first, *[rng.choice(FILLER) for _ in range(n)], last], cursor=(2, 0),
|
|
153
|
+
target=[first, last], solution=f"{n}dd", hint=f"A count works on dd: {n}dd deletes {n} lines.")
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _count_x(rng: random.Random) -> dict:
|
|
157
|
+
n = rng.randint(2, 7)
|
|
158
|
+
sentence = rng.choice([s for s in sentences() if _gaps(s)])
|
|
159
|
+
gap = rng.choice(_gaps(sentence))
|
|
160
|
+
return dict(goal=f"Delete the {n} x letters.", start=[sentence[:gap] + "x" * n + sentence[gap:]], cursor=(1, gap),
|
|
161
|
+
target=[sentence], solution=f"{n}x", hint=f"x crosses out a character; {n}x crosses out {n}.")
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
MAKERS = {
|
|
165
|
+
"ci-quote": _ci_quote,
|
|
166
|
+
"di-paren": lambda rng: _paren(rng, around=False),
|
|
167
|
+
"da-paren": lambda rng: _paren(rng, around=True),
|
|
168
|
+
"di-bracket": _di_bracket,
|
|
169
|
+
"dt-letter": _dt_letter,
|
|
170
|
+
"D-rest": _d_rest,
|
|
171
|
+
"J-split": _j_split,
|
|
172
|
+
"ddp-swap": _ddp_swap,
|
|
173
|
+
"count-dw": _count_dw,
|
|
174
|
+
"count-dd": _count_dd,
|
|
175
|
+
"count-x": _count_x,
|
|
176
|
+
}
|
|
177
|
+
KINDS = ("ciw-typo", "daw-double", "ciW-dotted", *MAKERS)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def generate(kind: str, seed: str, parent: Task) -> Task:
|
|
181
|
+
"""A `kind` task from `seed`, with the parent task's hint and place in its lesson."""
|
|
182
|
+
rng = random.Random(f"{kind}:{seed}")
|
|
183
|
+
if kind in MAKERS:
|
|
184
|
+
return replace(parent, ft="", variants=[], generated=seed, **MAKERS[kind](rng))
|
|
185
|
+
while True:
|
|
186
|
+
sentence = rng.choice(sentences())
|
|
187
|
+
words = _words(sentence, spaced=kind == "ciW-dotted")
|
|
188
|
+
if kind == "daw-double":
|
|
189
|
+
words = [m for m in words if sentence[m.end()] == " "]
|
|
190
|
+
if kind == "ciw-typo":
|
|
191
|
+
words = [m for m in words if len(set(m.group()[1:-1])) > 1]
|
|
192
|
+
if words:
|
|
193
|
+
break
|
|
194
|
+
m = rng.choice(words)
|
|
195
|
+
word, before, after = m.group(), sentence[: m.start()], sentence[m.end():]
|
|
196
|
+
if kind == "ciw-typo":
|
|
197
|
+
while True:
|
|
198
|
+
i = rng.randrange(1, len(word) - 2)
|
|
199
|
+
if word[i] != word[i + 1]:
|
|
200
|
+
break
|
|
201
|
+
shown = word[:i] + word[i + 1] + word[i] + word[i + 2:]
|
|
202
|
+
start, cursor = before + shown + after, len(before) + rng.randrange(len(shown))
|
|
203
|
+
goal, solution = f'Fix "{shown}" to "{word}".', f"ciw{word}<Esc>"
|
|
204
|
+
elif kind == "daw-double":
|
|
205
|
+
start = before + word + " " + word + after
|
|
206
|
+
cursor = len(before) + rng.choice([0, len(word) + 1]) + rng.randrange(len(word))
|
|
207
|
+
goal, solution = f'Delete the doubled "{word}".', "daw"
|
|
208
|
+
elif kind == "ciW-dotted":
|
|
209
|
+
shown = ".".join(word)
|
|
210
|
+
start, cursor = before + shown + after, len(before) + rng.randrange(len(shown))
|
|
211
|
+
goal, solution = f'Write the dotted "{shown}" as "{word}".', f"ciW{word}<Esc>"
|
|
212
|
+
else:
|
|
213
|
+
raise ValueError(f"no generator called {kind}")
|
|
214
|
+
return replace(parent, goal=goal, start=[start], cursor=(1, cursor), target=[sentence],
|
|
215
|
+
solution=solution, ft="", variants=[], generated=seed)
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
week: 1
|
|
2
2
|
day: 2
|
|
3
3
|
title: Quotes, brackets and tags
|
|
4
|
+
skill: text-objects
|
|
4
5
|
intro: |
|
|
5
6
|
The same `i`/`a` trick works on pairs, and the noun is simply the pair's own
|
|
6
7
|
sign: `i"` is inside the quotes, `a"` takes the quotes too. Brackets name
|
|
@@ -10,6 +11,7 @@ intro: |
|
|
|
10
11
|
keys: ['ci"', 'da"', 'di(', 'da(', 'di[', 'cit']
|
|
11
12
|
tasks:
|
|
12
13
|
- goal: 'Set the string to "change inside".'
|
|
14
|
+
generate: ci-quote
|
|
13
15
|
ft: python
|
|
14
16
|
start: |-
|
|
15
17
|
ci_quote = "▮delete outside"
|
|
@@ -31,6 +33,7 @@ tasks:
|
|
|
31
33
|
inner_quote = "without the quotes"
|
|
32
34
|
solution: 'ci"without the quotes<Esc>'
|
|
33
35
|
- goal: 'Change the string to "the quotes", starting from the beginning of the line.'
|
|
36
|
+
generate: ci-quote
|
|
34
37
|
ft: python
|
|
35
38
|
start: |-
|
|
36
39
|
▮ci_quote_keeps = "nothing"
|
|
@@ -46,6 +49,7 @@ tasks:
|
|
|
46
49
|
ci_quote_finds = "the next quotes"
|
|
47
50
|
solution: 'ci"the next quotes<Esc>'
|
|
48
51
|
- goal: 'Empty the inner parentheses.'
|
|
52
|
+
generate: di-paren
|
|
49
53
|
ft: python
|
|
50
54
|
start: |-
|
|
51
55
|
di_paren(empties(▮this_part))
|
|
@@ -59,6 +63,7 @@ tasks:
|
|
|
59
63
|
target: |-
|
|
60
64
|
inner_paren(keeps())
|
|
61
65
|
- goal: 'Delete the argument list, parentheses included.'
|
|
66
|
+
generate: da-paren
|
|
62
67
|
ft: python
|
|
63
68
|
start: |-
|
|
64
69
|
da = deletes(▮around, it) + parens
|
|
@@ -72,6 +77,7 @@ tasks:
|
|
|
72
77
|
target: |-
|
|
73
78
|
parens_go = da + too
|
|
74
79
|
- goal: 'Empty the list.'
|
|
80
|
+
generate: di-bracket
|
|
75
81
|
ft: python
|
|
76
82
|
start: |-
|
|
77
83
|
di_bracket_leaves = [▮"just", "the", "brackets"]
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
week: 1
|
|
2
2
|
day: 4
|
|
3
3
|
title: Find and till on a line
|
|
4
|
+
skill: motions
|
|
4
5
|
intro: |
|
|
5
6
|
`f{char}` *finds* the next {char} on the line and lands *on* it; `t{char}`
|
|
6
7
|
goes *till* it and stops just before. Capital `F` and `T` look backwards.
|
|
@@ -10,6 +11,7 @@ intro: |
|
|
|
10
11
|
keys: [f, t, F, T, ";", ",", dt, ct]
|
|
11
12
|
tasks:
|
|
12
13
|
- goal: 'Delete everything up to "just".'
|
|
14
|
+
generate: dt-letter
|
|
13
15
|
start: |-
|
|
14
16
|
t means till: it stops ▮way way just before the letter.
|
|
15
17
|
target: |-
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
week: 1
|
|
2
2
|
day: 5
|
|
3
3
|
title: Whole lines and line ends
|
|
4
|
+
skill: edits
|
|
4
5
|
intro: |
|
|
5
6
|
Capitals go big: `D` deletes to the end of the line, `C` changes to the end.
|
|
6
7
|
Doubling a verb means the whole line: `dd` deletes it, `cc` changes it. `J`
|
|
@@ -10,6 +11,7 @@ intro: |
|
|
|
10
11
|
keys: [D, C, cc, dd, J, ddp, A, I]
|
|
11
12
|
tasks:
|
|
12
13
|
- goal: 'Delete the rest of the line.'
|
|
14
|
+
generate: D-rest
|
|
13
15
|
start: |-
|
|
14
16
|
D is a big d: it deletes to the end.▮ Everything after the cursor goes.
|
|
15
17
|
target: |-
|
|
@@ -62,6 +64,7 @@ tasks:
|
|
|
62
64
|
yy yanks a line
|
|
63
65
|
solution: 'cccc changes a line<Esc>'
|
|
64
66
|
- goal: 'Join the two lines.'
|
|
67
|
+
generate: J-split
|
|
65
68
|
start: |-
|
|
66
69
|
▮J joins this line
|
|
67
70
|
and the next one.
|
|
@@ -76,6 +79,7 @@ tasks:
|
|
|
76
79
|
target: |-
|
|
77
80
|
gJ joins with no space; J adds one.
|
|
78
81
|
- goal: 'Put the steps in order: delete first.'
|
|
82
|
+
generate: ddp-swap
|
|
79
83
|
start: |-
|
|
80
84
|
▮then p puts
|
|
81
85
|
dd deletes
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
week: 1
|
|
2
2
|
day: 6
|
|
3
3
|
title: Counts
|
|
4
|
+
skill: motions
|
|
4
5
|
intro: |
|
|
5
6
|
A number in front repeats the command, and it reads like a numeral before a
|
|
6
7
|
noun: `3dw` *delete three words*, `3dd` three lines, `5x` cross out five
|
|
@@ -11,6 +12,7 @@ intro: |
|
|
|
11
12
|
keys: [3dw, 3dd, c2w, 5x, "3f,", 3J, 4j, 3k]
|
|
12
13
|
tasks:
|
|
13
14
|
- goal: 'Delete the three words "one two three".'
|
|
15
|
+
generate: count-dw
|
|
14
16
|
start: |-
|
|
15
17
|
A count repeats: 3dw ▮one two three deletes three words.
|
|
16
18
|
target: |-
|
|
@@ -24,6 +26,7 @@ tasks:
|
|
|
24
26
|
target: |-
|
|
25
27
|
A count before dw repeats it.
|
|
26
28
|
- goal: 'Delete the three middle lines.'
|
|
29
|
+
generate: count-dd
|
|
27
30
|
start: |-
|
|
28
31
|
3dd deletes
|
|
29
32
|
▮this line,
|
|
@@ -61,6 +64,7 @@ tasks:
|
|
|
61
64
|
c2w: change two words.
|
|
62
65
|
solution: 'c2wtwo words<Esc>'
|
|
63
66
|
- goal: 'Fix the number: 5, not 500000.'
|
|
67
|
+
generate: count-x
|
|
64
68
|
start: |-
|
|
65
69
|
The count is 5▮00000, and x goes five times.
|
|
66
70
|
target: |-
|