vimniscient 0.2.1__tar.gz → 0.3.0__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 (113) hide show
  1. {vimniscient-0.2.1 → vimniscient-0.3.0}/PKG-INFO +24 -7
  2. {vimniscient-0.2.1 → vimniscient-0.3.0}/README.md +23 -6
  3. {vimniscient-0.2.1 → vimniscient-0.3.0}/pyproject.toml +1 -1
  4. {vimniscient-0.2.1 → vimniscient-0.3.0}/src/vimniscient/app.py +87 -10
  5. {vimniscient-0.2.1 → vimniscient-0.3.0}/src/vimniscient/cli.py +24 -0
  6. vimniscient-0.3.0/src/vimniscient/diagnose.py +113 -0
  7. vimniscient-0.3.0/src/vimniscient/doctor.py +70 -0
  8. {vimniscient-0.2.1 → vimniscient-0.3.0}/src/vimniscient/endless.py +1 -1
  9. vimniscient-0.3.0/src/vimniscient/eval.yaml +136 -0
  10. vimniscient-0.3.0/src/vimniscient/evaluation.py +58 -0
  11. vimniscient-0.3.0/src/vimniscient/generate.py +305 -0
  12. {vimniscient-0.2.1 → vimniscient-0.3.0}/src/vimniscient/lessons/w1d1-words.yaml +1 -0
  13. {vimniscient-0.2.1 → vimniscient-0.3.0}/src/vimniscient/lessons/w1d2-pairs.yaml +6 -0
  14. {vimniscient-0.2.1 → vimniscient-0.3.0}/src/vimniscient/lessons/w1d3-sentences.yaml +1 -0
  15. {vimniscient-0.2.1 → vimniscient-0.3.0}/src/vimniscient/lessons/w1d4-find.yaml +2 -0
  16. {vimniscient-0.2.1 → vimniscient-0.3.0}/src/vimniscient/lessons/w1d5-lines.yaml +4 -0
  17. {vimniscient-0.2.1 → vimniscient-0.3.0}/src/vimniscient/lessons/w1d6-counts.yaml +4 -0
  18. {vimniscient-0.2.1 → vimniscient-0.3.0}/src/vimniscient/lessons/w1d7-review.yaml +1 -0
  19. {vimniscient-0.2.1 → vimniscient-0.3.0}/src/vimniscient/lessons/w2d1-dot.yaml +3 -0
  20. {vimniscient-0.2.1 → vimniscient-0.3.0}/src/vimniscient/lessons/w2d2-search.yaml +1 -0
  21. {vimniscient-0.2.1 → vimniscient-0.3.0}/src/vimniscient/lessons/w2d3-cgn.yaml +1 -0
  22. {vimniscient-0.2.1 → vimniscient-0.3.0}/src/vimniscient/lessons/w2d4-case-numbers.yaml +1 -0
  23. {vimniscient-0.2.1 → vimniscient-0.3.0}/src/vimniscient/lessons/w2d5-insert.yaml +1 -0
  24. {vimniscient-0.2.1 → vimniscient-0.3.0}/src/vimniscient/lessons/w2d6-visual.yaml +4 -0
  25. {vimniscient-0.2.1 → vimniscient-0.3.0}/src/vimniscient/lessons/w2d7-review.yaml +1 -0
  26. {vimniscient-0.2.1 → vimniscient-0.3.0}/src/vimniscient/lessons/w3d1-macros.yaml +3 -0
  27. {vimniscient-0.2.1 → vimniscient-0.3.0}/src/vimniscient/lessons/w3d2-macros-more.yaml +1 -0
  28. {vimniscient-0.2.1 → vimniscient-0.3.0}/src/vimniscient/lessons/w3d3-registers.yaml +1 -0
  29. {vimniscient-0.2.1 → vimniscient-0.3.0}/src/vimniscient/lessons/w3d4-marks.yaml +1 -0
  30. {vimniscient-0.2.1 → vimniscient-0.3.0}/src/vimniscient/lessons/w3d5-visual-block.yaml +3 -0
  31. {vimniscient-0.2.1 → vimniscient-0.3.0}/src/vimniscient/lessons/w3d6-substitute.yaml +2 -0
  32. {vimniscient-0.2.1 → vimniscient-0.3.0}/src/vimniscient/lessons/w3d7-review.yaml +1 -0
  33. {vimniscient-0.2.1 → vimniscient-0.3.0}/src/vimniscient/lessons/w4d1-ranges.yaml +5 -0
  34. {vimniscient-0.2.1 → vimniscient-0.3.0}/src/vimniscient/lessons/w4d2-sort-filter.yaml +3 -0
  35. {vimniscient-0.2.1 → vimniscient-0.3.0}/src/vimniscient/lessons/w4d3-regex.yaml +3 -0
  36. {vimniscient-0.2.1 → vimniscient-0.3.0}/src/vimniscient/lessons/w4d4-norm.yaml +2 -0
  37. {vimniscient-0.2.1 → vimniscient-0.3.0}/src/vimniscient/lessons/w4d5-motions.yaml +2 -0
  38. {vimniscient-0.2.1 → vimniscient-0.3.0}/src/vimniscient/lessons/w4d6-indent.yaml +1 -0
  39. {vimniscient-0.2.1 → vimniscient-0.3.0}/src/vimniscient/lessons/w4d7-review.yaml +1 -0
  40. {vimniscient-0.2.1 → vimniscient-0.3.0}/src/vimniscient/lessons/w5d1-arguments.yaml +1 -0
  41. {vimniscient-0.2.1 → vimniscient-0.3.0}/src/vimniscient/lessons/w5d2-any-quote-bracket.yaml +1 -0
  42. {vimniscient-0.2.1 → vimniscient-0.3.0}/src/vimniscient/lessons/w5d3-functions.yaml +1 -0
  43. {vimniscient-0.2.1 → vimniscient-0.3.0}/src/vimniscient/lessons/w5d4-small-objects.yaml +1 -0
  44. {vimniscient-0.2.1 → vimniscient-0.3.0}/src/vimniscient/lessons/w5d5-comments.yaml +1 -0
  45. {vimniscient-0.2.1 → vimniscient-0.3.0}/src/vimniscient/lessons/w5d6-review.yaml +1 -0
  46. {vimniscient-0.2.1 → vimniscient-0.3.0}/src/vimniscient/lessons/w6d1-code.yaml +1 -0
  47. {vimniscient-0.2.1 → vimniscient-0.3.0}/src/vimniscient/lessons/w6d2-notes.yaml +1 -0
  48. {vimniscient-0.2.1 → vimniscient-0.3.0}/src/vimniscient/lessons/w6d3-data.yaml +1 -0
  49. {vimniscient-0.2.1 → vimniscient-0.3.0}/src/vimniscient/lessons.py +10 -0
  50. vimniscient-0.3.0/src/vimniscient/missions.py +44 -0
  51. vimniscient-0.3.0/src/vimniscient/missions.yaml +121 -0
  52. vimniscient-0.3.0/src/vimniscient/nvim/mission.lua +84 -0
  53. vimniscient-0.3.0/src/vimniscient/placement.py +63 -0
  54. {vimniscient-0.2.1 → vimniscient-0.3.0}/src/vimniscient/progress.py +29 -4
  55. vimniscient-0.3.0/src/vimniscient/quick.py +49 -0
  56. {vimniscient-0.2.1 → vimniscient-0.3.0}/src/vimniscient/session.py +33 -3
  57. {vimniscient-0.2.1 → vimniscient-0.3.0}/src/vimniscient/today.py +4 -1
  58. vimniscient-0.3.0/src/vimniscient/transfer.py +56 -0
  59. vimniscient-0.3.0/src/vimniscient/transfer.yaml +184 -0
  60. {vimniscient-0.2.1 → vimniscient-0.3.0}/src/vimniscient/ui/builtin_lessons.py +29 -8
  61. {vimniscient-0.2.1 → vimniscient-0.3.0}/src/vimniscient/ui/lesson.py +17 -2
  62. {vimniscient-0.2.1 → vimniscient-0.3.0}/src/vimniscient/ui/main.py +7 -1
  63. vimniscient-0.3.0/src/vimniscient/ui/quick.py +56 -0
  64. {vimniscient-0.2.1 → vimniscient-0.3.0}/tests/check_app.py +25 -0
  65. vimniscient-0.3.0/tests/check_diagnose.py +59 -0
  66. {vimniscient-0.2.1 → vimniscient-0.3.0}/tests/check_generate.py +1 -1
  67. {vimniscient-0.2.1 → vimniscient-0.3.0}/tests/check_lessons.py +4 -1
  68. vimniscient-0.3.0/tests/check_missions.py +49 -0
  69. vimniscient-0.3.0/tests/check_placement.py +78 -0
  70. vimniscient-0.3.0/tests/check_quick.py +47 -0
  71. {vimniscient-0.2.1 → vimniscient-0.3.0}/tests/check_today.py +12 -0
  72. vimniscient-0.3.0/tests/check_transfer.py +65 -0
  73. vimniscient-0.3.0/tests/live_mission.py +45 -0
  74. vimniscient-0.2.1/src/vimniscient/generate.py +0 -82
  75. {vimniscient-0.2.1 → vimniscient-0.3.0}/.gitignore +0 -0
  76. {vimniscient-0.2.1 → vimniscient-0.3.0}/LICENSE +0 -0
  77. {vimniscient-0.2.1 → vimniscient-0.3.0}/extras/nvim/vimniscient.lua +0 -0
  78. {vimniscient-0.2.1 → vimniscient-0.3.0}/src/vimniscient/__init__.py +0 -0
  79. {vimniscient-0.2.1 → vimniscient-0.3.0}/src/vimniscient/__main__.py +0 -0
  80. {vimniscient-0.2.1 → vimniscient-0.3.0}/src/vimniscient/answer.py +0 -0
  81. {vimniscient-0.2.1 → vimniscient-0.3.0}/src/vimniscient/cards/foot.yaml +0 -0
  82. {vimniscient-0.2.1 → vimniscient-0.3.0}/src/vimniscient/cards/lazyvim.yaml +0 -0
  83. {vimniscient-0.2.1 → vimniscient-0.3.0}/src/vimniscient/cards/shell.yaml +0 -0
  84. {vimniscient-0.2.1 → vimniscient-0.3.0}/src/vimniscient/cards/vim.yaml +0 -0
  85. {vimniscient-0.2.1 → vimniscient-0.3.0}/src/vimniscient/cards/zathura.yaml +0 -0
  86. {vimniscient-0.2.1 → vimniscient-0.3.0}/src/vimniscient/cards.py +0 -0
  87. {vimniscient-0.2.1 → vimniscient-0.3.0}/src/vimniscient/drills/omarchy.yaml +0 -0
  88. {vimniscient-0.2.1 → vimniscient-0.3.0}/src/vimniscient/drills.py +0 -0
  89. {vimniscient-0.2.1 → vimniscient-0.3.0}/src/vimniscient/habits.py +0 -0
  90. {vimniscient-0.2.1 → vimniscient-0.3.0}/src/vimniscient/keys.py +0 -0
  91. {vimniscient-0.2.1 → vimniscient-0.3.0}/src/vimniscient/lessons/sources.md +0 -0
  92. {vimniscient-0.2.1 → vimniscient-0.3.0}/src/vimniscient/mnemonics.py +0 -0
  93. {vimniscient-0.2.1 → vimniscient-0.3.0}/src/vimniscient/mnemonics.yaml +0 -0
  94. {vimniscient-0.2.1 → vimniscient-0.3.0}/src/vimniscient/nvim/keymaps.lua +0 -0
  95. {vimniscient-0.2.1 → vimniscient-0.3.0}/src/vimniscient/nvim/runner.lua +0 -0
  96. {vimniscient-0.2.1 → vimniscient-0.3.0}/src/vimniscient/reminders.py +0 -0
  97. {vimniscient-0.2.1 → vimniscient-0.3.0}/src/vimniscient/settings.py +0 -0
  98. {vimniscient-0.2.1 → vimniscient-0.3.0}/src/vimniscient/srs.py +0 -0
  99. {vimniscient-0.2.1 → vimniscient-0.3.0}/src/vimniscient/ui/__init__.py +0 -0
  100. {vimniscient-0.2.1 → vimniscient-0.3.0}/src/vimniscient/ui/cards.py +0 -0
  101. {vimniscient-0.2.1 → vimniscient-0.3.0}/src/vimniscient/ui/common.py +0 -0
  102. {vimniscient-0.2.1 → vimniscient-0.3.0}/src/vimniscient/ui/drills.py +0 -0
  103. {vimniscient-0.2.1 → vimniscient-0.3.0}/src/vimniscient/ui/stats.py +0 -0
  104. {vimniscient-0.2.1 → vimniscient-0.3.0}/src/vimniscient/vimgolf.py +0 -0
  105. {vimniscient-0.2.1 → vimniscient-0.3.0}/tests/check_answers.py +0 -0
  106. {vimniscient-0.2.1 → vimniscient-0.3.0}/tests/check_drills.py +0 -0
  107. {vimniscient-0.2.1 → vimniscient-0.3.0}/tests/check_lessons.lua +0 -0
  108. {vimniscient-0.2.1 → vimniscient-0.3.0}/tests/check_timing.py +0 -0
  109. {vimniscient-0.2.1 → vimniscient-0.3.0}/tests/live_drills.py +0 -0
  110. {vimniscient-0.2.1 → vimniscient-0.3.0}/tests/live_endless.py +0 -0
  111. {vimniscient-0.2.1 → vimniscient-0.3.0}/tests/live_lessons.py +0 -0
  112. {vimniscient-0.2.1 → vimniscient-0.3.0}/tests/live_retry.py +0 -0
  113. {vimniscient-0.2.1 → vimniscient-0.3.0}/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.3.0
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
@@ -85,10 +85,8 @@ circles split by a line; `q` records a macro, a quill writing down what you
85
85
  do. Some come with a bit of history: `:t` copies lines because the old `ed`
86
86
  editor called a copy a *transfer*, and `grep` is named after `ed`'s `g/re/p`.
87
87
 
88
- The other texts are short facts about how memory and skill learning work
89
- (spacing, testing yourself, why mixed practice feels harder), the ideas
90
- Vimniscient itself is built on. Every one has a source in
91
- [`lessons/sources.md`](https://github.com/dialectmaterial/Vimniscient/blob/main/src/vimniscient/lessons/sources.md).
88
+ Week 6 and a few code tasks are the exception: they're realistic on purpose,
89
+ so that you practise on the kind of text you'll really edit.
92
90
 
93
91
  After you answer a card, and at the end of a lesson for the tasks you missed,
94
92
  Vimniscient shows what each part of the keys stands for:
@@ -187,9 +185,22 @@ lesson, and your progress with a keystroke-efficiency graph.
187
185
  | `Tab`, `j`/`k` | Move between panes and through lists |
188
186
  | `e` | Endless mode |
189
187
  | `c` / `g` / `p` | Cards / VimGolf / placement test |
188
+ | `z` | Quick practice: 1, 2 or 5 minutes on one skill, navigation drills, or a multi-file mission |
189
+ | `x` | Choose: edits with no hints, where you pick the strategy |
190
190
  | `s` | Stats |
191
191
  | `q` | Quit |
192
192
 
193
+ ## Is it working?
194
+
195
+ `vimniscient eval pre` is a short test on texts from no lesson. Take it before
196
+ you've trained much, then `vimniscient eval post` after some weeks and
197
+ `vimniscient eval delayed` a few weeks after that. `vimniscient eval` compares
198
+ them: solved, median time, and keys against the reference. Results stay on your
199
+ machine.
200
+
201
+ `vimniscient doctor` checks that Neovim and your config work with the trainer.
202
+ `vimniscient placement reset` undoes a placement test.
203
+
193
204
  ## It adapts to your setup
194
205
 
195
206
  | You have | You get |
@@ -288,8 +299,8 @@ key stands for, so that the edit leaves it true: `ciw is change inner word:
288
299
  it swaps the wh▮ile word` (fix it with `ciw`). What each key officially means,
289
300
  and the hook to use where it means nothing, is in
290
301
  [`mnemonics.yaml`](https://github.com/dialectmaterial/Vimniscient/blob/main/src/vimniscient/mnemonics.yaml). The app reads the same
291
- file to say what a key stands for after you answer. Other sentences are about
292
- how memory and skill learning work; every such fact needs a source in
302
+ file to say what a key stands for after you answer. Every task needs at least
303
+ two texts. Memory science belongs in lesson intros, with a source in
293
304
  [`lessons/sources.md`](https://github.com/dialectmaterial/Vimniscient/blob/main/src/vimniscient/lessons/sources.md). Code tasks stay
294
305
  code, with the meaning in names and strings.
295
306
 
@@ -301,6 +312,12 @@ uv run python tests/check_lessons.py --lazyvim # LazyVim lessons, in your own c
301
312
  uv run python tests/check_answers.py # card answers written different ways
302
313
  uv run python tests/check_timing.py # task times: old progress databases upgrade safely
303
314
  uv run python tests/check_generate.py # generated texts: reproducible, and they solve in Neovim
315
+ uv run python tests/check_diagnose.py # strategy feedback: no reference solution sets it off
316
+ uv run python tests/check_transfer.py # Choose: every way solves it in Neovim, nothing graded
317
+ uv run python tests/check_quick.py # quick practice: the right tasks for the time, nothing graded
318
+ uv run python tests/check_missions.py # missions: every way does the whole job on real files
319
+ uv run python tests/check_placement.py # placement, the evaluation and doctor
320
+ uv run python tests/live_mission.py # a mission in your real Neovim, in a hidden tmux session
304
321
  uv run python tests/check_today.py # the daily budget, SRS scheduling, card introductions, hardtime
305
322
  uv run python tests/check_app.py # every screen, driven headless
306
323
  uv run python tests/live_lessons.py --all # types every solution into the real runner (needs tmux)
@@ -62,10 +62,8 @@ circles split by a line; `q` records a macro, a quill writing down what you
62
62
  do. Some come with a bit of history: `:t` copies lines because the old `ed`
63
63
  editor called a copy a *transfer*, and `grep` is named after `ed`'s `g/re/p`.
64
64
 
65
- The other texts are short facts about how memory and skill learning work
66
- (spacing, testing yourself, why mixed practice feels harder), the ideas
67
- Vimniscient itself is built on. Every one has a source in
68
- [`lessons/sources.md`](https://github.com/dialectmaterial/Vimniscient/blob/main/src/vimniscient/lessons/sources.md).
65
+ Week 6 and a few code tasks are the exception: they're realistic on purpose,
66
+ so that you practise on the kind of text you'll really edit.
69
67
 
70
68
  After you answer a card, and at the end of a lesson for the tasks you missed,
71
69
  Vimniscient shows what each part of the keys stands for:
@@ -164,9 +162,22 @@ lesson, and your progress with a keystroke-efficiency graph.
164
162
  | `Tab`, `j`/`k` | Move between panes and through lists |
165
163
  | `e` | Endless mode |
166
164
  | `c` / `g` / `p` | Cards / VimGolf / placement test |
165
+ | `z` | Quick practice: 1, 2 or 5 minutes on one skill, navigation drills, or a multi-file mission |
166
+ | `x` | Choose: edits with no hints, where you pick the strategy |
167
167
  | `s` | Stats |
168
168
  | `q` | Quit |
169
169
 
170
+ ## Is it working?
171
+
172
+ `vimniscient eval pre` is a short test on texts from no lesson. Take it before
173
+ you've trained much, then `vimniscient eval post` after some weeks and
174
+ `vimniscient eval delayed` a few weeks after that. `vimniscient eval` compares
175
+ them: solved, median time, and keys against the reference. Results stay on your
176
+ machine.
177
+
178
+ `vimniscient doctor` checks that Neovim and your config work with the trainer.
179
+ `vimniscient placement reset` undoes a placement test.
180
+
170
181
  ## It adapts to your setup
171
182
 
172
183
  | You have | You get |
@@ -265,8 +276,8 @@ key stands for, so that the edit leaves it true: `ciw is change inner word:
265
276
  it swaps the wh▮ile word` (fix it with `ciw`). What each key officially means,
266
277
  and the hook to use where it means nothing, is in
267
278
  [`mnemonics.yaml`](https://github.com/dialectmaterial/Vimniscient/blob/main/src/vimniscient/mnemonics.yaml). The app reads the same
268
- file to say what a key stands for after you answer. Other sentences are about
269
- how memory and skill learning work; every such fact needs a source in
279
+ file to say what a key stands for after you answer. Every task needs at least
280
+ two texts. Memory science belongs in lesson intros, with a source in
270
281
  [`lessons/sources.md`](https://github.com/dialectmaterial/Vimniscient/blob/main/src/vimniscient/lessons/sources.md). Code tasks stay
271
282
  code, with the meaning in names and strings.
272
283
 
@@ -278,6 +289,12 @@ uv run python tests/check_lessons.py --lazyvim # LazyVim lessons, in your own c
278
289
  uv run python tests/check_answers.py # card answers written different ways
279
290
  uv run python tests/check_timing.py # task times: old progress databases upgrade safely
280
291
  uv run python tests/check_generate.py # generated texts: reproducible, and they solve in Neovim
292
+ uv run python tests/check_diagnose.py # strategy feedback: no reference solution sets it off
293
+ uv run python tests/check_transfer.py # Choose: every way solves it in Neovim, nothing graded
294
+ uv run python tests/check_quick.py # quick practice: the right tasks for the time, nothing graded
295
+ uv run python tests/check_missions.py # missions: every way does the whole job on real files
296
+ uv run python tests/check_placement.py # placement, the evaluation and doctor
297
+ uv run python tests/live_mission.py # a mission in your real Neovim, in a hidden tmux session
281
298
  uv run python tests/check_today.py # the daily budget, SRS scheduling, card introductions, hardtime
282
299
  uv run python tests/check_app.py # every screen, driven headless
283
300
  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.3.0"
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"
@@ -8,21 +8,24 @@ Omarchy theme in use (Catppuccin Mocha's own palette by default).
8
8
 
9
9
  from collections.abc import Callable
10
10
  from dataclasses import replace
11
+ from datetime import datetime
11
12
 
12
13
  from textual.app import App
13
14
  from textual.color import Color
15
+ from textual.content import Content
14
16
 
15
- from vimniscient import drills, endless, reminders, today, vimgolf
17
+ from vimniscient import drills, endless, missions, placement, quick, reminders, today, transfer, vimgolf
16
18
  from vimniscient.cards import has_lazyvim, load_decks
17
19
  from vimniscient.lessons import Lesson, load_lessons, two_rounds
18
20
  from vimniscient.progress import Progress
19
- 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.session import COLORS, TaskResult, mouse_wheel, run_endless, run_lesson, run_mission
22
+ from vimniscient.ui.builtin_lessons import endless_lesson, mission_lesson, placement_lesson, quick_lesson, review_lesson, transfer_lesson, vimgolf_lesson
21
23
  from vimniscient.ui.cards import CardScreen
22
24
  from vimniscient.ui.common import CATPPUCCIN, DEFAULT_THEME, omarchy_theme, plural
23
25
  from vimniscient.ui.drills import DrillScreen
24
26
  from vimniscient.ui.lesson import LessonScreen, ResultsScreen
25
27
  from vimniscient.ui.main import MainScreen
28
+ from vimniscient.ui.quick import QuickScreen
26
29
  from vimniscient.ui.stats import StatsScreen
27
30
 
28
31
 
@@ -44,7 +47,7 @@ class VimniscientApp(App):
44
47
 
45
48
  #main { height: 1fr; }
46
49
  #left { width: 46; max-width: 45%; }
47
- #plan { height: 8; }
50
+ #plan { height: 10; }
48
51
  #lesson-list { height: 1fr; }
49
52
  #right { width: 1fr; }
50
53
  #detail-pane { height: 1fr; }
@@ -74,7 +77,7 @@ class VimniscientApp(App):
74
77
  #results-bar { height: 1; }
75
78
  #note { padding: 0 0 1 0; color: $success; height: auto; }
76
79
  #results-list { height: auto; padding-right: 1; }
77
- #results-why { height: auto; padding: 1 0 0 0; }
80
+ #results-why, #results-advice { height: auto; padding: 1 0 0 0; }
78
81
 
79
82
  #stats-view { height: 1fr; padding: 0 2; }
80
83
  .section-label { color: $text-muted; text-style: bold; height: 1; margin-top: 1; }
@@ -121,7 +124,7 @@ class VimniscientApp(App):
121
124
  def start_lesson(
122
125
  self,
123
126
  lesson: Lesson,
124
- on_results: Callable[[list[TaskResult]], str] | None = None,
127
+ on_results: Callable[[list[TaskResult]], str | Content] | None = None,
125
128
  done: Callable[[], None] = lambda: None,
126
129
  ) -> None:
127
130
  """Run a lesson in Neovim, then show the results; `done` runs when the user moves on.
@@ -188,6 +191,12 @@ class VimniscientApp(App):
188
191
  elif step_id == "vimgolf":
189
192
  if not progress.vimgolf_done_today():
190
193
  return self.start_vimgolf
194
+ elif step_id == "quick":
195
+ return self.start_quick
196
+ elif step_id == "transfer":
197
+ picks = transfer.pick(self.lessons, progress)
198
+ if picks:
199
+ return lambda done: self.start_transfer(picks, done)
191
200
  elif step_id == "endless":
192
201
  start = progress.endless_start(endless.RESTART_BELOW)
193
202
  if direct:
@@ -240,18 +249,86 @@ class VimniscientApp(App):
240
249
  self.notify("No cards due. Come back tomorrow.")
241
250
 
242
251
  def action_placement(self) -> None:
252
+ search = placement.Search(self.lessons)
253
+ self.push_screen(LessonScreen(placement_lesson(search.round()), start=lambda done: self.placement_round(search, done)))
254
+
255
+ def placement_round(self, search: placement.Search, done: Callable[[], None]) -> None:
256
+ tasks = search.round()
257
+
243
258
  def on_results(results: list[TaskResult]) -> str:
244
259
  self.progress.record(results)
245
- placed = self.progress.place(self.lessons, results)
246
- if not placed:
260
+ search.update(results)
261
+ search.seen |= {r.task.item_id for r in results}
262
+ if not search.finished:
263
+ return "That round is done. The next one follows from how it went."
264
+ known = search.known()
265
+ self.progress.mark_known(known, search.seen)
266
+ if not known:
247
267
  return "No lessons skipped. Start from Week 1 · Day 1."
248
- return "Known already: " + ", ".join(f"{p.label} ({p.title})" for p in placed)
268
+ return "Known already: " + ", ".join(f"{p.label} ({p.title})" for p in known)
269
+
270
+ def after() -> None:
271
+ self.placement_round(search, done) if not search.finished else done()
249
272
 
250
- self.push_screen(LessonScreen(placement_lesson(self.lessons), on_results))
273
+ self.start_lesson(placement_lesson(tasks), on_results, after)
251
274
 
252
275
  def action_endless(self) -> None:
253
276
  self.run_step("endless", lambda: None)
254
277
 
278
+ def action_quick(self) -> None:
279
+ self.run_step("quick", lambda: None)
280
+
281
+ def start_quick(self, done: Callable[[], None]) -> None:
282
+ finished = {lesson.id for lesson in self.lessons if self.progress.is_done(lesson)}
283
+ pools = quick.pools(self.lessons, finished)
284
+ mission = missions.pick(self.lessons, self.progress)
285
+
286
+ def chosen(choice: tuple[str, int] | None) -> None:
287
+ if choice is None:
288
+ done()
289
+ return
290
+ skill, minutes = choice
291
+ if skill == "mission":
292
+ self.start_mission(mission, done)
293
+ return
294
+ count, seed = quick.how_many(minutes, self.progress.typical_task_seconds()), datetime.now().isoformat()
295
+ tasks = (quick.navigation(count, self.lessons[0].tasks[0], seed) if skill == quick.NAVIGATION
296
+ else quick.tasks(pools[skill], count, seed))
297
+
298
+ def on_results(results: list[TaskResult]) -> str:
299
+ self.progress.record_ungraded(results, "quick")
300
+ return ""
301
+
302
+ self.start_lesson(quick_lesson(quick.title(skill), tasks), on_results, done)
303
+
304
+ self.push_screen(QuickScreen({skill: len(tasks) for skill, tasks in pools.items()}, mission.title if mission else ""), chosen)
305
+
306
+ def start_mission(self, mission: missions.Mission, done: Callable[[], None]) -> None:
307
+ with self.suspend():
308
+ result = run_mission(mission)
309
+ mouse_wheel(False)
310
+ self.progress.record_ungraded([result], "mission")
311
+ ways = "\n".join(mission.ways)
312
+ note = Content(f"{'Another way' if result.status == 'solved' else 'Ways to do it'}\n{ways}")
313
+
314
+ def after(again: bool) -> None:
315
+ self.start_mission(mission, done) if again else done()
316
+
317
+ self.push_screen(ResultsScreen(mission_lesson(mission), [result], note), after)
318
+
319
+ def action_transfer(self) -> None:
320
+ self.run_step("transfer", lambda: None)
321
+
322
+ def start_transfer(self, picks: list[transfer.Challenge], done: Callable[[], None]) -> None:
323
+ def on_results(results: list[TaskResult]) -> Content:
324
+ self.progress.record_ungraded(results, "transfer")
325
+ by_goal = {c.task.goal: c for c in picks}
326
+ lines = [f"{r.task.goal} {' or '.join(ways)}" for r in results
327
+ if (ways := transfer.another_way(by_goal[r.task.goal], r.keys, r.status == "solved"))]
328
+ return Content("Another way\n" + "\n".join(lines)) if lines else Content("")
329
+
330
+ self.start_lesson(transfer_lesson([c.task for c in picks]), on_results, done)
331
+
255
332
  def start_endless(self, start: int, done: Callable[[], None]) -> None:
256
333
  """Climb from `start` until the user quits, then show how it went."""
257
334
  best = self.progress.endless_best()
@@ -18,6 +18,11 @@ def main() -> None:
18
18
  remind.add_argument("--check", action="store_true", help="a follow-up reminder firing")
19
19
  budget = sub.add_parser("minutes", help="how long today's routine is meant to take (15 by default)")
20
20
  budget.add_argument("minutes", type=int, nargs="?", help="set it; leave it out to see it")
21
+ place = sub.add_parser("placement", help="undo the placement test")
22
+ place.add_argument("action", choices=["reset"])
23
+ sub.add_parser("doctor", help="check that Neovim and your setup work with the trainer")
24
+ evaluation = sub.add_parser("eval", help="measure your progress: a test now, one later, compared")
25
+ evaluation.add_argument("stage", choices=["pre", "post", "delayed", "report"], nargs="?", default="report")
21
26
  setup = sub.add_parser("reminders", help="daily practice reminders through Omarchy")
22
27
  setup.add_argument("action", choices=["install", "uninstall", "status"], nargs="?", default="status")
23
28
  args = parser.parse_args()
@@ -46,6 +51,14 @@ def main() -> None:
46
51
  settings.set_minutes(args.minutes)
47
52
  print(f"Today's routine takes about {settings.minutes()} minutes. ({settings.CONFIG})")
48
53
  return
54
+ if args.command == "doctor":
55
+ from vimniscient import doctor
56
+
57
+ ok = True
58
+ for check in doctor.checks():
59
+ print(f"{check.mark} {check.name}: {check.detail}")
60
+ ok = ok and (check.passed or not check.required)
61
+ raise SystemExit(0 if ok else 1)
49
62
  if args.command == "reminders":
50
63
  from vimniscient import reminders
51
64
 
@@ -69,6 +82,17 @@ def main() -> None:
69
82
  from vimniscient.progress import Progress
70
83
  from vimniscient.session import run_endless, run_lesson
71
84
 
85
+ if args.command == "placement":
86
+ print(f"Placement undone: {Progress().reset_placement()} lessons are back on the list.")
87
+ return
88
+ if args.command == "eval":
89
+ from vimniscient import evaluation as evaluate
90
+
91
+ progress = Progress()
92
+ if args.stage != "report":
93
+ progress.record_ungraded(run_lesson(evaluate.lesson(args.stage)), f"eval-{args.stage}")
94
+ print(evaluate.report(progress))
95
+ return
72
96
  if args.command == "endless":
73
97
  from vimniscient import endless
74
98
  from vimniscient.cards import has_lazyvim
@@ -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 ""
@@ -0,0 +1,70 @@
1
+ """`vimniscient doctor`: does this machine work with the trainer? Required
2
+ checks stop it working; the rest say what you'd be missing."""
3
+
4
+ import os
5
+ import re
6
+ import shutil
7
+ import sqlite3
8
+ import subprocess
9
+ from dataclasses import dataclass
10
+
11
+ from vimniscient.cards import has_lazyvim, has_plugin
12
+
13
+ MIN_NVIM = (0, 10)
14
+ MIN_SIZE = (80, 24)
15
+
16
+
17
+ @dataclass
18
+ class Check:
19
+ name: str
20
+ passed: bool
21
+ detail: str
22
+ required: bool = True
23
+
24
+ @property
25
+ def mark(self) -> str:
26
+ return "✓" if self.passed else "✗" if self.required else "·"
27
+
28
+
29
+ def _nvim() -> list[Check]:
30
+ if not shutil.which("nvim"):
31
+ return [Check("Neovim", False, f"not found; install {MIN_NVIM[0]}.{MIN_NVIM[1]} or newer")]
32
+ out = subprocess.run(["nvim", "--version"], capture_output=True, text=True).stdout
33
+ found = re.search(r"v(\d+)\.(\d+)", out)
34
+ version = (int(found[1]), int(found[2])) if found else (0, 0)
35
+ good = version >= MIN_NVIM
36
+ checks = [Check("Neovim", good, f"{version[0]}.{version[1]}" + ("" if good else f"; {MIN_NVIM[0]}.{MIN_NVIM[1]} or newer needed"))]
37
+ try:
38
+ run = subprocess.run(["nvim", "--headless", "-i", "NONE", "+qa"], capture_output=True, text=True, timeout=30)
39
+ problem = next((line for line in run.stderr.splitlines() if re.search(r"(?i)error", line)), "")
40
+ checks.append(Check("Your config", run.returncode == 0 and not problem, problem[:90] or "starts cleanly"))
41
+ except subprocess.TimeoutExpired:
42
+ checks.append(Check("Your config", False, "Neovim didn't start within 30 seconds"))
43
+ return checks
44
+
45
+
46
+ def _terminal() -> Check:
47
+ size = shutil.get_terminal_size()
48
+ good = size.columns >= MIN_SIZE[0] and size.lines >= MIN_SIZE[1]
49
+ return Check("Terminal size", good, f"{size.columns}×{size.lines}" + ("" if good else f"; {MIN_SIZE[0]}×{MIN_SIZE[1]} or larger is needed"), required=False)
50
+
51
+
52
+ def _data() -> Check:
53
+ from vimniscient.progress import DATA_DIR
54
+
55
+ path = DATA_DIR / "progress.db"
56
+ try:
57
+ DATA_DIR.mkdir(parents=True, exist_ok=True)
58
+ sqlite3.connect(path).execute("SELECT 1")
59
+ return Check("Progress data", True, str(path))
60
+ except (OSError, sqlite3.Error) as error:
61
+ return Check("Progress data", False, f"{path}: {error}")
62
+
63
+
64
+ def checks() -> list[Check]:
65
+ optional = [
66
+ Check("LazyVim", has_lazyvim(), "week 5 lessons and the LazyVim cards" + ("" if has_lazyvim() else " stay hidden"), required=False),
67
+ Check("hardtime.nvim", has_plugin("hardtime.nvim"), "habit cards and drills", required=False),
68
+ Check("Hyprland", bool(os.environ.get("HYPRLAND_INSTANCE_SIGNATURE")), "Omarchy drills", required=False),
69
+ ]
70
+ return [*_nvim(), _data(), _terminal(), *optional]
@@ -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,136 @@
1
+ # The evaluation: forms a and b are the same eight skills on different texts,
2
+ # none of them from a lesson. `pre` uses a, `post` b, `delayed` a again, so a
3
+ # change can't just be remembering the test. Realistic on purpose. Never graded.
4
+ forms:
5
+ a:
6
+ - goal: 'Change the port to 9000.'
7
+ start: |-
8
+ ▮server = connect("db.local", 5432)
9
+ target: |-
10
+ server = connect("db.local", 9000)
11
+ ways: ['f5ciw9000<Esc>']
12
+ - goal: 'Delete the line with the password.'
13
+ start: |-
14
+ ▮user = "ana"
15
+ password = "hunter2"
16
+ host = "web1"
17
+ target: |-
18
+ user = "ana"
19
+ host = "web1"
20
+ ways: ['jdd']
21
+ - goal: 'Change "draft" to "final" in both places.'
22
+ start: |-
23
+ ▮status: draft
24
+ note: this is a draft
25
+ target: |-
26
+ status: final
27
+ note: this is a final
28
+ ways: [':%s/draft/final<CR>']
29
+ - goal: 'Put each name in quotes.'
30
+ start: |-
31
+ ▮anna
32
+ bert
33
+ carl
34
+ target: |-
35
+ "anna"
36
+ "bert"
37
+ "carl"
38
+ ways: [':%s/.*/"&"/<CR>']
39
+ - goal: 'Delete the arguments of the call.'
40
+ start: |-
41
+ ▮result = process(data, retries, timeout)
42
+ target: |-
43
+ result = process()
44
+ ways: ['f(di(']
45
+ - goal: 'Move the last line to the top.'
46
+ start: |-
47
+ one
48
+ two
49
+ ▮zero
50
+ target: |-
51
+ zero
52
+ one
53
+ two
54
+ ways: [':m0<CR>']
55
+ - goal: 'Sort the lines.'
56
+ start: |-
57
+ ▮pear
58
+ apple
59
+ mango
60
+ target: |-
61
+ apple
62
+ mango
63
+ pear
64
+ ways: [':sort<CR>']
65
+ - goal: 'Delete from here to the end of the line.'
66
+ start: |-
67
+ keep this▮, drop all of this
68
+ target: |-
69
+ keep this
70
+ ways: ['D']
71
+ b:
72
+ - goal: 'Change the timeout to 60.'
73
+ start: |-
74
+ ▮client = open("api.host", 30)
75
+ target: |-
76
+ client = open("api.host", 60)
77
+ ways: ['f3ciw60<Esc>']
78
+ - goal: 'Delete the line with the token.'
79
+ start: |-
80
+ ▮name = "bo"
81
+ token = "abc123"
82
+ path = "/srv"
83
+ target: |-
84
+ name = "bo"
85
+ path = "/srv"
86
+ ways: ['jdd']
87
+ - goal: 'Change "todo" to "done" in both places.'
88
+ start: |-
89
+ ▮state: todo
90
+ again: still todo
91
+ target: |-
92
+ state: done
93
+ again: still done
94
+ ways: [':%s/todo/done<CR>']
95
+ - goal: 'Put each word in quotes.'
96
+ start: |-
97
+ ▮red
98
+ green
99
+ blue
100
+ target: |-
101
+ "red"
102
+ "green"
103
+ "blue"
104
+ ways: [':%s/.*/"&"/<CR>']
105
+ - goal: 'Delete the arguments of the call.'
106
+ start: |-
107
+ ▮value = compute(left, right, scale)
108
+ target: |-
109
+ value = compute()
110
+ ways: ['f(di(']
111
+ - goal: 'Move the last line to the top.'
112
+ start: |-
113
+ beta
114
+ gamma
115
+ ▮alpha
116
+ target: |-
117
+ alpha
118
+ beta
119
+ gamma
120
+ ways: [':m0<CR>']
121
+ - goal: 'Sort the lines.'
122
+ start: |-
123
+ ▮walnut
124
+ almond
125
+ hazelnut
126
+ target: |-
127
+ almond
128
+ hazelnut
129
+ walnut
130
+ ways: [':sort<CR>']
131
+ - goal: 'Delete from here to the end of the line.'
132
+ start: |-
133
+ the good part▮, and the rest goes
134
+ target: |-
135
+ the good part
136
+ ways: ['D']