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.
Files changed (100) hide show
  1. {vimniscient-0.2.1 → vimniscient-0.2.2}/PKG-INFO +3 -1
  2. {vimniscient-0.2.1 → vimniscient-0.2.2}/README.md +2 -0
  3. {vimniscient-0.2.1 → vimniscient-0.2.2}/pyproject.toml +1 -1
  4. {vimniscient-0.2.1 → vimniscient-0.2.2}/src/vimniscient/app.py +23 -5
  5. vimniscient-0.2.2/src/vimniscient/diagnose.py +113 -0
  6. {vimniscient-0.2.1 → vimniscient-0.2.2}/src/vimniscient/endless.py +1 -1
  7. vimniscient-0.2.2/src/vimniscient/generate.py +215 -0
  8. {vimniscient-0.2.1 → vimniscient-0.2.2}/src/vimniscient/lessons/w1d1-words.yaml +1 -0
  9. {vimniscient-0.2.1 → vimniscient-0.2.2}/src/vimniscient/lessons/w1d2-pairs.yaml +6 -0
  10. {vimniscient-0.2.1 → vimniscient-0.2.2}/src/vimniscient/lessons/w1d3-sentences.yaml +1 -0
  11. {vimniscient-0.2.1 → vimniscient-0.2.2}/src/vimniscient/lessons/w1d4-find.yaml +2 -0
  12. {vimniscient-0.2.1 → vimniscient-0.2.2}/src/vimniscient/lessons/w1d5-lines.yaml +4 -0
  13. {vimniscient-0.2.1 → vimniscient-0.2.2}/src/vimniscient/lessons/w1d6-counts.yaml +4 -0
  14. {vimniscient-0.2.1 → vimniscient-0.2.2}/src/vimniscient/lessons/w1d7-review.yaml +1 -0
  15. {vimniscient-0.2.1 → vimniscient-0.2.2}/src/vimniscient/lessons/w2d1-dot.yaml +1 -0
  16. {vimniscient-0.2.1 → vimniscient-0.2.2}/src/vimniscient/lessons/w2d2-search.yaml +1 -0
  17. {vimniscient-0.2.1 → vimniscient-0.2.2}/src/vimniscient/lessons/w2d3-cgn.yaml +1 -0
  18. {vimniscient-0.2.1 → vimniscient-0.2.2}/src/vimniscient/lessons/w2d4-case-numbers.yaml +1 -0
  19. {vimniscient-0.2.1 → vimniscient-0.2.2}/src/vimniscient/lessons/w2d5-insert.yaml +1 -0
  20. {vimniscient-0.2.1 → vimniscient-0.2.2}/src/vimniscient/lessons/w2d6-visual.yaml +1 -0
  21. {vimniscient-0.2.1 → vimniscient-0.2.2}/src/vimniscient/lessons/w2d7-review.yaml +1 -0
  22. {vimniscient-0.2.1 → vimniscient-0.2.2}/src/vimniscient/lessons/w3d1-macros.yaml +1 -0
  23. {vimniscient-0.2.1 → vimniscient-0.2.2}/src/vimniscient/lessons/w3d2-macros-more.yaml +1 -0
  24. {vimniscient-0.2.1 → vimniscient-0.2.2}/src/vimniscient/lessons/w3d3-registers.yaml +1 -0
  25. {vimniscient-0.2.1 → vimniscient-0.2.2}/src/vimniscient/lessons/w3d4-marks.yaml +1 -0
  26. {vimniscient-0.2.1 → vimniscient-0.2.2}/src/vimniscient/lessons/w3d5-visual-block.yaml +1 -0
  27. {vimniscient-0.2.1 → vimniscient-0.2.2}/src/vimniscient/lessons/w3d6-substitute.yaml +1 -0
  28. {vimniscient-0.2.1 → vimniscient-0.2.2}/src/vimniscient/lessons/w3d7-review.yaml +1 -0
  29. {vimniscient-0.2.1 → vimniscient-0.2.2}/src/vimniscient/lessons/w4d1-ranges.yaml +1 -0
  30. {vimniscient-0.2.1 → vimniscient-0.2.2}/src/vimniscient/lessons/w4d2-sort-filter.yaml +1 -0
  31. {vimniscient-0.2.1 → vimniscient-0.2.2}/src/vimniscient/lessons/w4d3-regex.yaml +1 -0
  32. {vimniscient-0.2.1 → vimniscient-0.2.2}/src/vimniscient/lessons/w4d4-norm.yaml +1 -0
  33. {vimniscient-0.2.1 → vimniscient-0.2.2}/src/vimniscient/lessons/w4d5-motions.yaml +1 -0
  34. {vimniscient-0.2.1 → vimniscient-0.2.2}/src/vimniscient/lessons/w4d6-indent.yaml +1 -0
  35. {vimniscient-0.2.1 → vimniscient-0.2.2}/src/vimniscient/lessons/w4d7-review.yaml +1 -0
  36. {vimniscient-0.2.1 → vimniscient-0.2.2}/src/vimniscient/lessons/w5d1-arguments.yaml +1 -0
  37. {vimniscient-0.2.1 → vimniscient-0.2.2}/src/vimniscient/lessons/w5d2-any-quote-bracket.yaml +1 -0
  38. {vimniscient-0.2.1 → vimniscient-0.2.2}/src/vimniscient/lessons/w5d3-functions.yaml +1 -0
  39. {vimniscient-0.2.1 → vimniscient-0.2.2}/src/vimniscient/lessons/w5d4-small-objects.yaml +1 -0
  40. {vimniscient-0.2.1 → vimniscient-0.2.2}/src/vimniscient/lessons/w5d5-comments.yaml +1 -0
  41. {vimniscient-0.2.1 → vimniscient-0.2.2}/src/vimniscient/lessons/w5d6-review.yaml +1 -0
  42. {vimniscient-0.2.1 → vimniscient-0.2.2}/src/vimniscient/lessons/w6d1-code.yaml +1 -0
  43. {vimniscient-0.2.1 → vimniscient-0.2.2}/src/vimniscient/lessons/w6d2-notes.yaml +1 -0
  44. {vimniscient-0.2.1 → vimniscient-0.2.2}/src/vimniscient/lessons/w6d3-data.yaml +1 -0
  45. {vimniscient-0.2.1 → vimniscient-0.2.2}/src/vimniscient/lessons.py +5 -0
  46. {vimniscient-0.2.1 → vimniscient-0.2.2}/src/vimniscient/progress.py +17 -0
  47. {vimniscient-0.2.1 → vimniscient-0.2.2}/src/vimniscient/session.py +6 -3
  48. {vimniscient-0.2.1 → vimniscient-0.2.2}/src/vimniscient/today.py +3 -1
  49. vimniscient-0.2.2/src/vimniscient/transfer.py +56 -0
  50. vimniscient-0.2.2/src/vimniscient/transfer.yaml +184 -0
  51. {vimniscient-0.2.1 → vimniscient-0.2.2}/src/vimniscient/ui/builtin_lessons.py +15 -1
  52. {vimniscient-0.2.1 → vimniscient-0.2.2}/src/vimniscient/ui/lesson.py +17 -2
  53. {vimniscient-0.2.1 → vimniscient-0.2.2}/src/vimniscient/ui/main.py +4 -1
  54. {vimniscient-0.2.1 → vimniscient-0.2.2}/tests/check_app.py +18 -0
  55. vimniscient-0.2.2/tests/check_diagnose.py +59 -0
  56. {vimniscient-0.2.1 → vimniscient-0.2.2}/tests/check_generate.py +1 -1
  57. {vimniscient-0.2.1 → vimniscient-0.2.2}/tests/check_lessons.py +4 -1
  58. {vimniscient-0.2.1 → vimniscient-0.2.2}/tests/check_today.py +12 -0
  59. vimniscient-0.2.2/tests/check_transfer.py +65 -0
  60. vimniscient-0.2.1/src/vimniscient/generate.py +0 -82
  61. {vimniscient-0.2.1 → vimniscient-0.2.2}/.gitignore +0 -0
  62. {vimniscient-0.2.1 → vimniscient-0.2.2}/LICENSE +0 -0
  63. {vimniscient-0.2.1 → vimniscient-0.2.2}/extras/nvim/vimniscient.lua +0 -0
  64. {vimniscient-0.2.1 → vimniscient-0.2.2}/src/vimniscient/__init__.py +0 -0
  65. {vimniscient-0.2.1 → vimniscient-0.2.2}/src/vimniscient/__main__.py +0 -0
  66. {vimniscient-0.2.1 → vimniscient-0.2.2}/src/vimniscient/answer.py +0 -0
  67. {vimniscient-0.2.1 → vimniscient-0.2.2}/src/vimniscient/cards/foot.yaml +0 -0
  68. {vimniscient-0.2.1 → vimniscient-0.2.2}/src/vimniscient/cards/lazyvim.yaml +0 -0
  69. {vimniscient-0.2.1 → vimniscient-0.2.2}/src/vimniscient/cards/shell.yaml +0 -0
  70. {vimniscient-0.2.1 → vimniscient-0.2.2}/src/vimniscient/cards/vim.yaml +0 -0
  71. {vimniscient-0.2.1 → vimniscient-0.2.2}/src/vimniscient/cards/zathura.yaml +0 -0
  72. {vimniscient-0.2.1 → vimniscient-0.2.2}/src/vimniscient/cards.py +0 -0
  73. {vimniscient-0.2.1 → vimniscient-0.2.2}/src/vimniscient/cli.py +0 -0
  74. {vimniscient-0.2.1 → vimniscient-0.2.2}/src/vimniscient/drills/omarchy.yaml +0 -0
  75. {vimniscient-0.2.1 → vimniscient-0.2.2}/src/vimniscient/drills.py +0 -0
  76. {vimniscient-0.2.1 → vimniscient-0.2.2}/src/vimniscient/habits.py +0 -0
  77. {vimniscient-0.2.1 → vimniscient-0.2.2}/src/vimniscient/keys.py +0 -0
  78. {vimniscient-0.2.1 → vimniscient-0.2.2}/src/vimniscient/lessons/sources.md +0 -0
  79. {vimniscient-0.2.1 → vimniscient-0.2.2}/src/vimniscient/mnemonics.py +0 -0
  80. {vimniscient-0.2.1 → vimniscient-0.2.2}/src/vimniscient/mnemonics.yaml +0 -0
  81. {vimniscient-0.2.1 → vimniscient-0.2.2}/src/vimniscient/nvim/keymaps.lua +0 -0
  82. {vimniscient-0.2.1 → vimniscient-0.2.2}/src/vimniscient/nvim/runner.lua +0 -0
  83. {vimniscient-0.2.1 → vimniscient-0.2.2}/src/vimniscient/reminders.py +0 -0
  84. {vimniscient-0.2.1 → vimniscient-0.2.2}/src/vimniscient/settings.py +0 -0
  85. {vimniscient-0.2.1 → vimniscient-0.2.2}/src/vimniscient/srs.py +0 -0
  86. {vimniscient-0.2.1 → vimniscient-0.2.2}/src/vimniscient/ui/__init__.py +0 -0
  87. {vimniscient-0.2.1 → vimniscient-0.2.2}/src/vimniscient/ui/cards.py +0 -0
  88. {vimniscient-0.2.1 → vimniscient-0.2.2}/src/vimniscient/ui/common.py +0 -0
  89. {vimniscient-0.2.1 → vimniscient-0.2.2}/src/vimniscient/ui/drills.py +0 -0
  90. {vimniscient-0.2.1 → vimniscient-0.2.2}/src/vimniscient/ui/stats.py +0 -0
  91. {vimniscient-0.2.1 → vimniscient-0.2.2}/src/vimniscient/vimgolf.py +0 -0
  92. {vimniscient-0.2.1 → vimniscient-0.2.2}/tests/check_answers.py +0 -0
  93. {vimniscient-0.2.1 → vimniscient-0.2.2}/tests/check_drills.py +0 -0
  94. {vimniscient-0.2.1 → vimniscient-0.2.2}/tests/check_lessons.lua +0 -0
  95. {vimniscient-0.2.1 → vimniscient-0.2.2}/tests/check_timing.py +0 -0
  96. {vimniscient-0.2.1 → vimniscient-0.2.2}/tests/live_drills.py +0 -0
  97. {vimniscient-0.2.1 → vimniscient-0.2.2}/tests/live_endless.py +0 -0
  98. {vimniscient-0.2.1 → vimniscient-0.2.2}/tests/live_lessons.py +0 -0
  99. {vimniscient-0.2.1 → vimniscient-0.2.2}/tests/live_retry.py +0 -0
  100. {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.1
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)
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "vimniscient"
3
- version = "0.2.1"
3
+ version = "0.2.2"
4
4
  description = "Get fluent in Vim in fifteen minutes a day, in your own Neovim."
5
5
  readme = "README.md"
6
6
  license = "AGPL-3.0-or-later"
@@ -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: 8; }
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.versions)))
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: 1
3
3
  title: Word text objects
4
+ skill: text-objects
4
5
  intro: |
5
6
  Read a Vim command as a small sentence: a verb, then a noun. The verbs are
6
7
  `c` *change*, `d` *delete* and `y` *yank* (Vim's old word for copy). The noun
@@ -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: 3
3
3
  title: Sentences and paragraphs
4
+ skill: text-objects
4
5
  intro: |
5
6
  Bigger nouns: `s` is a *sentence* and `p` a *paragraph* (a block of lines
6
7
  between empty lines). `is`/`ip` are the thing itself; `as`/`ap` take the
@@ -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: |-
@@ -2,6 +2,7 @@ week: 1
2
2
  day: 7
3
3
  review: true
4
4
  title: Week 1 review
5
+ skill: mixed
5
6
  intro: |
6
7
  A mix of everything from this week. Try each one before reaching for
7
8
  `:Hint`, and try to match the reference keystroke count. Mixing topics like
@@ -1,6 +1,7 @@
1
1
  week: 2
2
2
  day: 1
3
3
  title: The dot command
4
+ skill: repeats
4
5
  intro: |
5
6
  `.` repeats your last change: the whole thing, including the text you
6
7
  typed in insert mode. Read it as a full stop that says *again*. Make one
@@ -1,6 +1,7 @@
1
1
  week: 2
2
2
  day: 2
3
3
  title: Searching as a motion
4
+ skill: search
4
5
  intro: |
5
6
  `/text<Enter>` jumps to the next match (the slash leans forward),
6
7
  `?text<Enter>` to the previous one (a question asks back). `n` repeats it,
@@ -1,6 +1,7 @@
1
1
  week: 2
2
2
  day: 3
3
3
  title: The cgn trick
4
+ skill: repeats
4
5
  intro: |
5
6
  `gn` reads as *go to the next match* and grab it: it selects the next
6
7
  search match. So `cgn` changes the next match, and `.` then changes the one
@@ -1,6 +1,7 @@
1
1
  week: 2
2
2
  day: 4
3
3
  title: Case and numbers
4
+ skill: edits
4
5
  intro: |
5
6
  `~` flips the case of one character: the tilde is a little wave. `gU`, `gu`
6
7
  and `g~` are operators: U is *up* (capitals), u is down. `gUiw` uppercases
@@ -1,6 +1,7 @@
1
1
  week: 2
2
2
  day: 5
3
3
  title: Getting into insert mode well
4
+ skill: edits
4
5
  intro: |
5
6
  Choosing the right way into insert mode saves moving first: `o`/`O` *open*
6
7
  a line below/above, `ea` goes to the *end* of a word and *appends*, `R`
@@ -1,6 +1,7 @@
1
1
  week: 2
2
2
  day: 6
3
3
  title: Visual mode
4
+ skill: visual
4
5
  intro: |
5
6
  `v` is *visual*: you see what you'll act on. `v` selects characters,
6
7
  capital `V` whole lines. Move to grow the selection, then give an operator:
@@ -2,6 +2,7 @@ week: 2
2
2
  day: 7
3
3
  review: true
4
4
  title: Week 2 review
5
+ skill: mixed
5
6
  intro: |
6
7
  Dot, search, cgn, case, numbers and visual mode, mixed. Look for the
7
8
  shortest route before you start typing. Each retrieval you do now, a few