vimniscient 0.2.0__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 (123) hide show
  1. {vimniscient-0.2.0 → vimniscient-0.2.2}/PKG-INFO +3 -1
  2. {vimniscient-0.2.0 → vimniscient-0.2.2}/README.md +2 -0
  3. {vimniscient-0.2.0 → vimniscient-0.2.2}/pyproject.toml +1 -1
  4. {vimniscient-0.2.0 → vimniscient-0.2.2}/src/vimniscient/answer.py +2 -2
  5. {vimniscient-0.2.0 → vimniscient-0.2.2}/src/vimniscient/app.py +23 -5
  6. {vimniscient-0.2.0 → vimniscient-0.2.2}/src/vimniscient/cards/lazyvim.yaml +1 -1
  7. {vimniscient-0.2.0 → vimniscient-0.2.2}/src/vimniscient/cards/shell.yaml +3 -3
  8. {vimniscient-0.2.0 → vimniscient-0.2.2}/src/vimniscient/cards/vim.yaml +2 -2
  9. {vimniscient-0.2.0 → vimniscient-0.2.2}/src/vimniscient/cards.py +1 -1
  10. vimniscient-0.2.2/src/vimniscient/diagnose.py +113 -0
  11. {vimniscient-0.2.0 → vimniscient-0.2.2}/src/vimniscient/endless.py +1 -1
  12. vimniscient-0.2.2/src/vimniscient/generate.py +215 -0
  13. {vimniscient-0.2.0 → vimniscient-0.2.2}/src/vimniscient/habits.py +2 -1
  14. {vimniscient-0.2.0 → vimniscient-0.2.2}/src/vimniscient/lessons/sources.md +3 -3
  15. {vimniscient-0.2.0 → vimniscient-0.2.2}/src/vimniscient/lessons/w1d1-words.yaml +1 -39
  16. vimniscient-0.2.2/src/vimniscient/lessons/w1d2-pairs.yaml +107 -0
  17. vimniscient-0.2.2/src/vimniscient/lessons/w1d3-sentences.yaml +116 -0
  18. vimniscient-0.2.2/src/vimniscient/lessons/w1d4-find.yaml +92 -0
  19. vimniscient-0.2.2/src/vimniscient/lessons/w1d5-lines.yaml +136 -0
  20. vimniscient-0.2.2/src/vimniscient/lessons/w1d6-counts.yaml +175 -0
  21. {vimniscient-0.2.0 → vimniscient-0.2.2}/src/vimniscient/lessons/w1d7-review.yaml +16 -103
  22. vimniscient-0.2.2/src/vimniscient/lessons/w2d1-dot.yaml +94 -0
  23. {vimniscient-0.2.0 → vimniscient-0.2.2}/src/vimniscient/lessons/w2d2-search.yaml +1 -91
  24. vimniscient-0.2.2/src/vimniscient/lessons/w2d3-cgn.yaml +93 -0
  25. vimniscient-0.2.2/src/vimniscient/lessons/w2d4-case-numbers.yaml +101 -0
  26. vimniscient-0.2.2/src/vimniscient/lessons/w2d5-insert.yaml +107 -0
  27. vimniscient-0.2.2/src/vimniscient/lessons/w2d6-visual.yaml +103 -0
  28. vimniscient-0.2.2/src/vimniscient/lessons/w2d7-review.yaml +99 -0
  29. {vimniscient-0.2.0 → vimniscient-0.2.2}/src/vimniscient/lessons/w3d1-macros.yaml +19 -125
  30. vimniscient-0.2.2/src/vimniscient/lessons/w3d2-macros-more.yaml +97 -0
  31. {vimniscient-0.2.0 → vimniscient-0.2.2}/src/vimniscient/lessons/w3d3-registers.yaml +4 -104
  32. {vimniscient-0.2.0 → vimniscient-0.2.2}/src/vimniscient/lessons/w3d4-marks.yaml +6 -89
  33. vimniscient-0.2.2/src/vimniscient/lessons/w3d5-visual-block.yaml +114 -0
  34. vimniscient-0.2.2/src/vimniscient/lessons/w3d6-substitute.yaml +163 -0
  35. vimniscient-0.2.2/src/vimniscient/lessons/w3d7-review.yaml +115 -0
  36. vimniscient-0.2.2/src/vimniscient/lessons/w4d1-ranges.yaml +123 -0
  37. vimniscient-0.2.2/src/vimniscient/lessons/w4d2-sort-filter.yaml +138 -0
  38. {vimniscient-0.2.0 → vimniscient-0.2.2}/src/vimniscient/lessons/w4d3-regex.yaml +24 -92
  39. vimniscient-0.2.2/src/vimniscient/lessons/w4d4-norm.yaml +91 -0
  40. {vimniscient-0.2.0 → vimniscient-0.2.2}/src/vimniscient/lessons/w4d5-motions.yaml +7 -67
  41. {vimniscient-0.2.0 → vimniscient-0.2.2}/src/vimniscient/lessons/w4d6-indent.yaml +19 -77
  42. vimniscient-0.2.2/src/vimniscient/lessons/w4d7-review.yaml +101 -0
  43. vimniscient-0.2.2/src/vimniscient/lessons/w5d1-arguments.yaml +83 -0
  44. {vimniscient-0.2.0 → vimniscient-0.2.2}/src/vimniscient/lessons/w5d2-any-quote-bracket.yaml +5 -58
  45. vimniscient-0.2.2/src/vimniscient/lessons/w5d3-functions.yaml +111 -0
  46. vimniscient-0.2.2/src/vimniscient/lessons/w5d4-small-objects.yaml +108 -0
  47. vimniscient-0.2.2/src/vimniscient/lessons/w5d5-comments.yaml +97 -0
  48. {vimniscient-0.2.0 → vimniscient-0.2.2}/src/vimniscient/lessons/w5d6-review.yaml +1 -18
  49. {vimniscient-0.2.0 → vimniscient-0.2.2}/src/vimniscient/lessons/w6d1-code.yaml +1 -0
  50. {vimniscient-0.2.0 → vimniscient-0.2.2}/src/vimniscient/lessons/w6d2-notes.yaml +1 -0
  51. {vimniscient-0.2.0 → vimniscient-0.2.2}/src/vimniscient/lessons/w6d3-data.yaml +1 -0
  52. {vimniscient-0.2.0 → vimniscient-0.2.2}/src/vimniscient/lessons.py +5 -0
  53. {vimniscient-0.2.0 → vimniscient-0.2.2}/src/vimniscient/nvim/runner.lua +34 -24
  54. {vimniscient-0.2.0 → vimniscient-0.2.2}/src/vimniscient/progress.py +17 -0
  55. {vimniscient-0.2.0 → vimniscient-0.2.2}/src/vimniscient/session.py +6 -3
  56. {vimniscient-0.2.0 → vimniscient-0.2.2}/src/vimniscient/today.py +3 -1
  57. vimniscient-0.2.2/src/vimniscient/transfer.py +56 -0
  58. vimniscient-0.2.2/src/vimniscient/transfer.yaml +184 -0
  59. {vimniscient-0.2.0 → vimniscient-0.2.2}/src/vimniscient/ui/builtin_lessons.py +15 -1
  60. {vimniscient-0.2.0 → vimniscient-0.2.2}/src/vimniscient/ui/lesson.py +17 -2
  61. {vimniscient-0.2.0 → vimniscient-0.2.2}/src/vimniscient/ui/main.py +15 -2
  62. {vimniscient-0.2.0 → vimniscient-0.2.2}/tests/check_answers.py +2 -0
  63. {vimniscient-0.2.0 → vimniscient-0.2.2}/tests/check_app.py +28 -0
  64. vimniscient-0.2.2/tests/check_diagnose.py +59 -0
  65. {vimniscient-0.2.0 → vimniscient-0.2.2}/tests/check_generate.py +1 -1
  66. {vimniscient-0.2.0 → vimniscient-0.2.2}/tests/check_lessons.py +4 -1
  67. {vimniscient-0.2.0 → vimniscient-0.2.2}/tests/check_today.py +12 -0
  68. vimniscient-0.2.2/tests/check_transfer.py +65 -0
  69. vimniscient-0.2.0/src/vimniscient/generate.py +0 -102
  70. vimniscient-0.2.0/src/vimniscient/lessons/w1d2-pairs.yaml +0 -188
  71. vimniscient-0.2.0/src/vimniscient/lessons/w1d3-sentences.yaml +0 -247
  72. vimniscient-0.2.0/src/vimniscient/lessons/w1d4-find.yaml +0 -184
  73. vimniscient-0.2.0/src/vimniscient/lessons/w1d5-lines.yaml +0 -249
  74. vimniscient-0.2.0/src/vimniscient/lessons/w1d6-counts.yaml +0 -351
  75. vimniscient-0.2.0/src/vimniscient/lessons/w2d1-dot.yaml +0 -195
  76. vimniscient-0.2.0/src/vimniscient/lessons/w2d3-cgn.yaml +0 -196
  77. vimniscient-0.2.0/src/vimniscient/lessons/w2d4-case-numbers.yaml +0 -201
  78. vimniscient-0.2.0/src/vimniscient/lessons/w2d5-insert.yaml +0 -229
  79. vimniscient-0.2.0/src/vimniscient/lessons/w2d6-visual.yaml +0 -206
  80. vimniscient-0.2.0/src/vimniscient/lessons/w2d7-review.yaml +0 -197
  81. vimniscient-0.2.0/src/vimniscient/lessons/w3d2-macros-more.yaml +0 -170
  82. vimniscient-0.2.0/src/vimniscient/lessons/w3d5-visual-block.yaml +0 -201
  83. vimniscient-0.2.0/src/vimniscient/lessons/w3d6-substitute.yaml +0 -290
  84. vimniscient-0.2.0/src/vimniscient/lessons/w3d7-review.yaml +0 -204
  85. vimniscient-0.2.0/src/vimniscient/lessons/w4d1-ranges.yaml +0 -214
  86. vimniscient-0.2.0/src/vimniscient/lessons/w4d2-sort-filter.yaml +0 -243
  87. vimniscient-0.2.0/src/vimniscient/lessons/w4d4-norm.yaml +0 -158
  88. vimniscient-0.2.0/src/vimniscient/lessons/w4d7-review.yaml +0 -175
  89. vimniscient-0.2.0/src/vimniscient/lessons/w5d1-arguments.yaml +0 -133
  90. vimniscient-0.2.0/src/vimniscient/lessons/w5d3-functions.yaml +0 -193
  91. vimniscient-0.2.0/src/vimniscient/lessons/w5d4-small-objects.yaml +0 -185
  92. vimniscient-0.2.0/src/vimniscient/lessons/w5d5-comments.yaml +0 -161
  93. {vimniscient-0.2.0 → vimniscient-0.2.2}/.gitignore +0 -0
  94. {vimniscient-0.2.0 → vimniscient-0.2.2}/LICENSE +0 -0
  95. {vimniscient-0.2.0 → vimniscient-0.2.2}/extras/nvim/vimniscient.lua +0 -0
  96. {vimniscient-0.2.0 → vimniscient-0.2.2}/src/vimniscient/__init__.py +0 -0
  97. {vimniscient-0.2.0 → vimniscient-0.2.2}/src/vimniscient/__main__.py +0 -0
  98. {vimniscient-0.2.0 → vimniscient-0.2.2}/src/vimniscient/cards/foot.yaml +0 -0
  99. {vimniscient-0.2.0 → vimniscient-0.2.2}/src/vimniscient/cards/zathura.yaml +0 -0
  100. {vimniscient-0.2.0 → vimniscient-0.2.2}/src/vimniscient/cli.py +0 -0
  101. {vimniscient-0.2.0 → vimniscient-0.2.2}/src/vimniscient/drills/omarchy.yaml +0 -0
  102. {vimniscient-0.2.0 → vimniscient-0.2.2}/src/vimniscient/drills.py +0 -0
  103. {vimniscient-0.2.0 → vimniscient-0.2.2}/src/vimniscient/keys.py +0 -0
  104. {vimniscient-0.2.0 → vimniscient-0.2.2}/src/vimniscient/mnemonics.py +0 -0
  105. {vimniscient-0.2.0 → vimniscient-0.2.2}/src/vimniscient/mnemonics.yaml +0 -0
  106. {vimniscient-0.2.0 → vimniscient-0.2.2}/src/vimniscient/nvim/keymaps.lua +0 -0
  107. {vimniscient-0.2.0 → vimniscient-0.2.2}/src/vimniscient/reminders.py +0 -0
  108. {vimniscient-0.2.0 → vimniscient-0.2.2}/src/vimniscient/settings.py +0 -0
  109. {vimniscient-0.2.0 → vimniscient-0.2.2}/src/vimniscient/srs.py +0 -0
  110. {vimniscient-0.2.0 → vimniscient-0.2.2}/src/vimniscient/ui/__init__.py +0 -0
  111. {vimniscient-0.2.0 → vimniscient-0.2.2}/src/vimniscient/ui/cards.py +0 -0
  112. {vimniscient-0.2.0 → vimniscient-0.2.2}/src/vimniscient/ui/common.py +0 -0
  113. {vimniscient-0.2.0 → vimniscient-0.2.2}/src/vimniscient/ui/drills.py +0 -0
  114. {vimniscient-0.2.0 → vimniscient-0.2.2}/src/vimniscient/ui/stats.py +0 -0
  115. {vimniscient-0.2.0 → vimniscient-0.2.2}/src/vimniscient/vimgolf.py +0 -0
  116. {vimniscient-0.2.0 → vimniscient-0.2.2}/tests/check_drills.py +0 -0
  117. {vimniscient-0.2.0 → vimniscient-0.2.2}/tests/check_lessons.lua +0 -0
  118. {vimniscient-0.2.0 → vimniscient-0.2.2}/tests/check_timing.py +0 -0
  119. {vimniscient-0.2.0 → vimniscient-0.2.2}/tests/live_drills.py +0 -0
  120. {vimniscient-0.2.0 → vimniscient-0.2.2}/tests/live_endless.py +0 -0
  121. {vimniscient-0.2.0 → vimniscient-0.2.2}/tests/live_lessons.py +0 -0
  122. {vimniscient-0.2.0 → vimniscient-0.2.2}/tests/live_retry.py +0 -0
  123. {vimniscient-0.2.0 → 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.0
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.0"
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"
@@ -40,7 +40,7 @@ def _norm_token(match: re.Match) -> str:
40
40
 
41
41
  def norm_keys(text: str, case_sensitive: bool = True) -> str:
42
42
  s = text.strip()
43
- s = re.sub(r"(?i)^(leader|space|spc)(\s+|$)", "<leader>", s)
43
+ s = re.sub(r"(?i)^(leader|space|spc)(\s*\+\s*|\s+|$)", "<leader>", s)
44
44
  s = re.sub(r"(?i)(?<![<\w])space(?![>\w])", "<Space>", s)
45
45
  s = re.sub(r"(?i)\b(?:ctrl|control)[-+ ]+(\S)", lambda m: f"<C-{m.group(1)}>", s)
46
46
  s = re.sub(r"(?i)\b(?:alt|meta)[-+ ]+(\S)", lambda m: f"<M-{m.group(1)}>", s)
@@ -55,7 +55,7 @@ _MOD_ALIASES = {
55
55
  "SUPER": "SUPER", "WIN": "SUPER", "CMD": "SUPER", "MOD": "SUPER", "LOGO": "SUPER",
56
56
  "CTRL": "CTRL", "CONTROL": "CTRL", "CTL": "CTRL",
57
57
  "ALT": "ALT", "OPT": "ALT", "OPTION": "ALT", "MOD1": "ALT", "META": "ALT",
58
- "SHIFT": "SHIFT", "PREFIX": "PREFIX", "COPYMODE": "COPYMODE",
58
+ "SHIFT": "SHIFT", "PREFIX": "PREFIX", "LEADER": "PREFIX", "COPYMODE": "COPYMODE",
59
59
  }
60
60
  _KEY_ALIASES = {
61
61
  "ENTER": "RETURN", "CR": "RETURN", "ESC": "ESCAPE", "DEL": "DELETE", "INS": "INSERT",
@@ -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()
@@ -12,7 +12,7 @@ cards:
12
12
  - {prompt: Grep the project (root dir), answer: [<leader>/, <leader>sg]}
13
13
  - {prompt: Switch buffer, answer: ['<leader>,', '<leader>fb']}
14
14
  - {prompt: Recent files, answer: [<leader>fr]}
15
- - {prompt: File explorer (neo-tree), answer: [<leader>e]}
15
+ - {prompt: File explorer (neo-tree), answer: [<leader>e], note: 'e is explorer.'}
16
16
  - {prompt: Next buffer, answer: [<S-l>, ']b']}
17
17
  - {prompt: Previous buffer, answer: [<S-h>, '[b']}
18
18
  - {prompt: Delete the buffer, answer: [<leader>bd]}
@@ -5,9 +5,9 @@ case_sensitive: false
5
5
  note: Readline keys work in bash and in most terminal prompts.
6
6
  cards:
7
7
  - {prompt: Search back through your command history, answer: [<C-r>]}
8
- - {prompt: Jump to the start of the line, answer: [<C-a>, <Home>]}
9
- - {prompt: Jump to the end of the line, answer: [<C-e>, <End>]}
10
- - {prompt: Delete the word before the cursor, answer: [<C-w>]}
8
+ - {prompt: Jump to the start of the line, answer: [<C-a>, <Home>], note: 'a is the start of the alphabet. The shell isn’t Vim: 0 and $ just type themselves.'}
9
+ - {prompt: Jump to the end of the line, answer: [<C-e>, <End>], note: 'e is end. The shell isn’t Vim: $ just types itself.'}
10
+ - {prompt: Delete the word before the cursor, answer: [<C-w>], note: 'w is word.'}
11
11
  - {prompt: Delete from the cursor to the start of the line, answer: [<C-u>]}
12
12
  - {prompt: Delete from the cursor to the end of the line, answer: [<C-k>]}
13
13
  - {prompt: Paste what you just deleted with Ctrl-w/u/k, answer: [<C-y>]}
@@ -7,8 +7,8 @@ case_sensitive: true
7
7
  cards:
8
8
  # week 1: text objects, find/till, lines, counts
9
9
  - {week: 1, day: 1, prompt: 'Change the whole word under the cursor, wherever in it the cursor is', answer: [ciw], note: 'cw only changes from the cursor to the end of the word.'}
10
- - {week: 1, day: 1, prompt: Delete a word and the space after it, answer: [daw]}
11
- - {week: 1, day: 1, prompt: 'Change a WORD (space to space, punctuation included)', answer: [ciW]}
10
+ - {week: 1, day: 1, id: 'vim:Delete a word and the space after it', prompt: 'Delete the whole word under the cursor, wherever in it the cursor is, and the space after it', answer: [daw], note: 'dw only deletes from the cursor on.'}
11
+ - {week: 1, day: 1, id: 'vim:Change a WORD (space to space, punctuation included)', prompt: 'Change the whole WORD under the cursor (space to space, punctuation included), keeping the space after it', answer: [ciW]}
12
12
  - {week: 1, day: 1, prompt: Yank the word under the cursor, answer: [yiw]}
13
13
  - {week: 1, day: 2, prompt: Change everything inside the double quotes, answer: ['ci"']}
14
14
  - {week: 1, day: 2, prompt: Delete the quotes and what is inside them, answer: ['da"']}
@@ -77,7 +77,7 @@ def _static_decks() -> dict[str, Deck]:
77
77
  deck.cards.append(
78
78
  Card(
79
79
  deck=deck.id,
80
- id=f"{deck.id}:{c['prompt']}",
80
+ id=c.get("id", f"{deck.id}:{c['prompt']}"), # `id` keeps a reworded card's history
81
81
  prompt=c["prompt"],
82
82
  answers=[str(a) for a in c["answer"]],
83
83
  style=data.get("style", "keys"),
@@ -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)
@@ -15,6 +15,7 @@ from pathlib import Path
15
15
 
16
16
  from vimniscient.cards import Card, Deck
17
17
  from vimniscient.keys import split_keys
18
+ from vimniscient.mnemonics import OPERATORS
18
19
 
19
20
  LOG = Path(os.environ.get("XDG_STATE_HOME", Path.home() / ".local/state")) / "nvim" / "hardtime.nvim.log"
20
21
 
@@ -91,7 +92,7 @@ def habit_deck() -> Deck:
91
92
  answers=[better],
92
93
  style="keys",
93
94
  case_sensitive=True,
94
- note=f"hardtime caught this {habit.total}×.",
95
+ note=f"{'Verb first, then the motion. ' if better[0] in OPERATORS else ''}hardtime caught this {habit.total}×.",
95
96
  order=i,
96
97
  )
97
98
  )
@@ -1,8 +1,8 @@
1
1
  # Sources for the facts in the lesson texts
2
2
 
3
- The lesson texts are mnemonic: they describe the key being practised (see
4
- `../mnemonics.yaml`), and the rest is about the science of learning and memory.
5
- Every factual claim in them should trace back to one of these.
3
+ Lesson practice texts are mnemonic: each one is about the keys it practises (see
4
+ `../mnemonics.yaml`). Memory science belongs in lesson intros, where it explains
5
+ why the training works. Every factual claim there should trace back to one of these.
6
6
 
7
7
  - **Atkinson & Raugh (1975).** An application of the mnemonic keyword method to
8
8
  the acquisition of a Russian vocabulary. *JEP: Human Learning and Memory*
@@ -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
@@ -32,12 +33,6 @@ tasks:
32
33
  target: |-
33
34
  Change inner word: c is the verb, iw the noun, and the cursor sits anywhere in it.
34
35
  solution: 'ciwanywhere<Esc>'
35
- - goal: 'Fix "memroy" to "memory" (the cursor is in the middle of the word).'
36
- start: |-
37
- Testing yourself beats rereading: type ciw from mem▮roy, not from the page.
38
- target: |-
39
- Testing yourself beats rereading: type ciw from memory, not from the page.
40
- solution: 'ciwmemory<Esc>'
41
36
  - goal: 'Fix "undersocres" to "underscores" (the cursor is in the middle of the word).'
42
37
  start: |-
43
38
  In ciw, the w is a word: letters, digits and under▮socres, nothing else.
@@ -64,11 +59,6 @@ tasks:
64
59
  daw takes a word and what is around it, its ▮its trailing space.
65
60
  target: |-
66
61
  daw takes a word and what is around it, its trailing space.
67
- - goal: 'Delete one "himself".'
68
- start: |-
69
- Ebbinghaus tested ▮himself himself on nonsense syllables; daw cuts the echo.
70
- target: |-
71
- Ebbinghaus tested himself on nonsense syllables; daw cuts the echo.
72
62
  - goal: 'Delete one "so".'
73
63
  start: |-
74
64
  With daw, a means around: the word goes, and ▮so so does one space.
@@ -101,12 +91,6 @@ tasks:
101
91
  target: |-
102
92
  ciW sees a path as one WORD, slashes and all.
103
93
  solution: 'ciWa path<Esc>'
104
- - goal: 'Replace "Fitts&Posner(1967)" with "Fitts-and-Posner".'
105
- start: |-
106
- ciW: from space to space, Fitts&Pos▮ner(1967) counts as one WORD.
107
- target: |-
108
- ciW: from space to space, Fitts-and-Posner counts as one WORD.
109
- solution: 'ciWFitts-and-Posner<Esc>'
110
94
  - goal: 'Spell the function name out as change_inner_word.'
111
95
  ft: python
112
96
  start: |-
@@ -180,18 +164,6 @@ tasks:
180
164
  target: |-
181
165
  viwp swaps a selection for the yank, so select, then put the yank
182
166
  solution: 'yiw$viwp'
183
- - goal: 'Copy "spaced" over "massed".'
184
- start: |-
185
- ▮spaced reviews beat massed ones, so keep them massed
186
- target: |-
187
- spaced reviews beat massed ones, so keep them spaced
188
- solution: 'yiw$viwp'
189
- - goal: 'Copy "mixed" over the last "blocked".'
190
- start: |-
191
- Rohrer and Taylor: blocked practice feels better than ▮mixed, yet scores below blocked
192
- target: |-
193
- Rohrer and Taylor: blocked practice feels better than mixed, yet scores below mixed
194
- solution: 'yiw$viwp'
195
167
  - goal: 'Delete the doubled "the".'
196
168
  start: |-
197
169
  daw: delete a word, and ▮the the space after it.
@@ -206,16 +178,6 @@ tasks:
206
178
  Doubled words ▮like like this one are what daw is for.
207
179
  target: |-
208
180
  Doubled words like this one are what daw is for.
209
- - goal: 'Delete the doubled "four".'
210
- start: |-
211
- Cowan put short-term memory at about ▮four four chunks.
212
- target: |-
213
- Cowan put short-term memory at about four chunks.
214
- - goal: 'Delete the doubled "familiar".'
215
- start: |-
216
- Rereading builds a fluency illusion: the text feels ▮familiar familiar, not known.
217
- target: |-
218
- Rereading builds a fluency illusion: the text feels familiar, not known.
219
181
  - goal: 'Delete the doubled "and".'
220
182
  start: |-
221
183
  daw: d for delete, a for a word ▮and and its space.