vimniscient 0.2.2__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 (112) hide show
  1. {vimniscient-0.2.2 → vimniscient-0.3.0}/PKG-INFO +22 -7
  2. {vimniscient-0.2.2 → vimniscient-0.3.0}/README.md +21 -6
  3. {vimniscient-0.2.2 → vimniscient-0.3.0}/pyproject.toml +1 -1
  4. {vimniscient-0.2.2 → vimniscient-0.3.0}/src/vimniscient/app.py +68 -9
  5. {vimniscient-0.2.2 → vimniscient-0.3.0}/src/vimniscient/cli.py +24 -0
  6. vimniscient-0.3.0/src/vimniscient/doctor.py +70 -0
  7. vimniscient-0.3.0/src/vimniscient/eval.yaml +136 -0
  8. vimniscient-0.3.0/src/vimniscient/evaluation.py +58 -0
  9. {vimniscient-0.2.2 → vimniscient-0.3.0}/src/vimniscient/generate.py +90 -0
  10. {vimniscient-0.2.2 → vimniscient-0.3.0}/src/vimniscient/lessons/w2d1-dot.yaml +2 -0
  11. {vimniscient-0.2.2 → vimniscient-0.3.0}/src/vimniscient/lessons/w2d6-visual.yaml +3 -0
  12. {vimniscient-0.2.2 → vimniscient-0.3.0}/src/vimniscient/lessons/w3d1-macros.yaml +2 -0
  13. {vimniscient-0.2.2 → vimniscient-0.3.0}/src/vimniscient/lessons/w3d5-visual-block.yaml +2 -0
  14. {vimniscient-0.2.2 → vimniscient-0.3.0}/src/vimniscient/lessons/w3d6-substitute.yaml +1 -0
  15. {vimniscient-0.2.2 → vimniscient-0.3.0}/src/vimniscient/lessons/w4d1-ranges.yaml +4 -0
  16. {vimniscient-0.2.2 → vimniscient-0.3.0}/src/vimniscient/lessons/w4d2-sort-filter.yaml +2 -0
  17. {vimniscient-0.2.2 → vimniscient-0.3.0}/src/vimniscient/lessons/w4d3-regex.yaml +2 -0
  18. {vimniscient-0.2.2 → vimniscient-0.3.0}/src/vimniscient/lessons/w4d4-norm.yaml +1 -0
  19. {vimniscient-0.2.2 → vimniscient-0.3.0}/src/vimniscient/lessons/w4d5-motions.yaml +1 -0
  20. {vimniscient-0.2.2 → vimniscient-0.3.0}/src/vimniscient/lessons.py +5 -0
  21. vimniscient-0.3.0/src/vimniscient/missions.py +44 -0
  22. vimniscient-0.3.0/src/vimniscient/missions.yaml +121 -0
  23. vimniscient-0.3.0/src/vimniscient/nvim/mission.lua +84 -0
  24. vimniscient-0.3.0/src/vimniscient/placement.py +63 -0
  25. {vimniscient-0.2.2 → vimniscient-0.3.0}/src/vimniscient/progress.py +18 -10
  26. vimniscient-0.3.0/src/vimniscient/quick.py +49 -0
  27. {vimniscient-0.2.2 → vimniscient-0.3.0}/src/vimniscient/session.py +27 -0
  28. {vimniscient-0.2.2 → vimniscient-0.3.0}/src/vimniscient/today.py +1 -0
  29. {vimniscient-0.2.2 → vimniscient-0.3.0}/src/vimniscient/transfer.py +1 -1
  30. {vimniscient-0.2.2 → vimniscient-0.3.0}/src/vimniscient/ui/builtin_lessons.py +15 -8
  31. {vimniscient-0.2.2 → vimniscient-0.3.0}/src/vimniscient/ui/main.py +4 -1
  32. vimniscient-0.3.0/src/vimniscient/ui/quick.py +56 -0
  33. {vimniscient-0.2.2 → vimniscient-0.3.0}/tests/check_app.py +7 -0
  34. vimniscient-0.3.0/tests/check_missions.py +49 -0
  35. vimniscient-0.3.0/tests/check_placement.py +78 -0
  36. vimniscient-0.3.0/tests/check_quick.py +47 -0
  37. {vimniscient-0.2.2 → vimniscient-0.3.0}/tests/check_transfer.py +1 -1
  38. vimniscient-0.3.0/tests/live_mission.py +45 -0
  39. {vimniscient-0.2.2 → vimniscient-0.3.0}/.gitignore +0 -0
  40. {vimniscient-0.2.2 → vimniscient-0.3.0}/LICENSE +0 -0
  41. {vimniscient-0.2.2 → vimniscient-0.3.0}/extras/nvim/vimniscient.lua +0 -0
  42. {vimniscient-0.2.2 → vimniscient-0.3.0}/src/vimniscient/__init__.py +0 -0
  43. {vimniscient-0.2.2 → vimniscient-0.3.0}/src/vimniscient/__main__.py +0 -0
  44. {vimniscient-0.2.2 → vimniscient-0.3.0}/src/vimniscient/answer.py +0 -0
  45. {vimniscient-0.2.2 → vimniscient-0.3.0}/src/vimniscient/cards/foot.yaml +0 -0
  46. {vimniscient-0.2.2 → vimniscient-0.3.0}/src/vimniscient/cards/lazyvim.yaml +0 -0
  47. {vimniscient-0.2.2 → vimniscient-0.3.0}/src/vimniscient/cards/shell.yaml +0 -0
  48. {vimniscient-0.2.2 → vimniscient-0.3.0}/src/vimniscient/cards/vim.yaml +0 -0
  49. {vimniscient-0.2.2 → vimniscient-0.3.0}/src/vimniscient/cards/zathura.yaml +0 -0
  50. {vimniscient-0.2.2 → vimniscient-0.3.0}/src/vimniscient/cards.py +0 -0
  51. {vimniscient-0.2.2 → vimniscient-0.3.0}/src/vimniscient/diagnose.py +0 -0
  52. {vimniscient-0.2.2 → vimniscient-0.3.0}/src/vimniscient/drills/omarchy.yaml +0 -0
  53. {vimniscient-0.2.2 → vimniscient-0.3.0}/src/vimniscient/drills.py +0 -0
  54. {vimniscient-0.2.2 → vimniscient-0.3.0}/src/vimniscient/endless.py +0 -0
  55. {vimniscient-0.2.2 → vimniscient-0.3.0}/src/vimniscient/habits.py +0 -0
  56. {vimniscient-0.2.2 → vimniscient-0.3.0}/src/vimniscient/keys.py +0 -0
  57. {vimniscient-0.2.2 → vimniscient-0.3.0}/src/vimniscient/lessons/sources.md +0 -0
  58. {vimniscient-0.2.2 → vimniscient-0.3.0}/src/vimniscient/lessons/w1d1-words.yaml +0 -0
  59. {vimniscient-0.2.2 → vimniscient-0.3.0}/src/vimniscient/lessons/w1d2-pairs.yaml +0 -0
  60. {vimniscient-0.2.2 → vimniscient-0.3.0}/src/vimniscient/lessons/w1d3-sentences.yaml +0 -0
  61. {vimniscient-0.2.2 → vimniscient-0.3.0}/src/vimniscient/lessons/w1d4-find.yaml +0 -0
  62. {vimniscient-0.2.2 → vimniscient-0.3.0}/src/vimniscient/lessons/w1d5-lines.yaml +0 -0
  63. {vimniscient-0.2.2 → vimniscient-0.3.0}/src/vimniscient/lessons/w1d6-counts.yaml +0 -0
  64. {vimniscient-0.2.2 → vimniscient-0.3.0}/src/vimniscient/lessons/w1d7-review.yaml +0 -0
  65. {vimniscient-0.2.2 → vimniscient-0.3.0}/src/vimniscient/lessons/w2d2-search.yaml +0 -0
  66. {vimniscient-0.2.2 → vimniscient-0.3.0}/src/vimniscient/lessons/w2d3-cgn.yaml +0 -0
  67. {vimniscient-0.2.2 → vimniscient-0.3.0}/src/vimniscient/lessons/w2d4-case-numbers.yaml +0 -0
  68. {vimniscient-0.2.2 → vimniscient-0.3.0}/src/vimniscient/lessons/w2d5-insert.yaml +0 -0
  69. {vimniscient-0.2.2 → vimniscient-0.3.0}/src/vimniscient/lessons/w2d7-review.yaml +0 -0
  70. {vimniscient-0.2.2 → vimniscient-0.3.0}/src/vimniscient/lessons/w3d2-macros-more.yaml +0 -0
  71. {vimniscient-0.2.2 → vimniscient-0.3.0}/src/vimniscient/lessons/w3d3-registers.yaml +0 -0
  72. {vimniscient-0.2.2 → vimniscient-0.3.0}/src/vimniscient/lessons/w3d4-marks.yaml +0 -0
  73. {vimniscient-0.2.2 → vimniscient-0.3.0}/src/vimniscient/lessons/w3d7-review.yaml +0 -0
  74. {vimniscient-0.2.2 → vimniscient-0.3.0}/src/vimniscient/lessons/w4d6-indent.yaml +0 -0
  75. {vimniscient-0.2.2 → vimniscient-0.3.0}/src/vimniscient/lessons/w4d7-review.yaml +0 -0
  76. {vimniscient-0.2.2 → vimniscient-0.3.0}/src/vimniscient/lessons/w5d1-arguments.yaml +0 -0
  77. {vimniscient-0.2.2 → vimniscient-0.3.0}/src/vimniscient/lessons/w5d2-any-quote-bracket.yaml +0 -0
  78. {vimniscient-0.2.2 → vimniscient-0.3.0}/src/vimniscient/lessons/w5d3-functions.yaml +0 -0
  79. {vimniscient-0.2.2 → vimniscient-0.3.0}/src/vimniscient/lessons/w5d4-small-objects.yaml +0 -0
  80. {vimniscient-0.2.2 → vimniscient-0.3.0}/src/vimniscient/lessons/w5d5-comments.yaml +0 -0
  81. {vimniscient-0.2.2 → vimniscient-0.3.0}/src/vimniscient/lessons/w5d6-review.yaml +0 -0
  82. {vimniscient-0.2.2 → vimniscient-0.3.0}/src/vimniscient/lessons/w6d1-code.yaml +0 -0
  83. {vimniscient-0.2.2 → vimniscient-0.3.0}/src/vimniscient/lessons/w6d2-notes.yaml +0 -0
  84. {vimniscient-0.2.2 → vimniscient-0.3.0}/src/vimniscient/lessons/w6d3-data.yaml +0 -0
  85. {vimniscient-0.2.2 → vimniscient-0.3.0}/src/vimniscient/mnemonics.py +0 -0
  86. {vimniscient-0.2.2 → vimniscient-0.3.0}/src/vimniscient/mnemonics.yaml +0 -0
  87. {vimniscient-0.2.2 → vimniscient-0.3.0}/src/vimniscient/nvim/keymaps.lua +0 -0
  88. {vimniscient-0.2.2 → vimniscient-0.3.0}/src/vimniscient/nvim/runner.lua +0 -0
  89. {vimniscient-0.2.2 → vimniscient-0.3.0}/src/vimniscient/reminders.py +0 -0
  90. {vimniscient-0.2.2 → vimniscient-0.3.0}/src/vimniscient/settings.py +0 -0
  91. {vimniscient-0.2.2 → vimniscient-0.3.0}/src/vimniscient/srs.py +0 -0
  92. {vimniscient-0.2.2 → vimniscient-0.3.0}/src/vimniscient/transfer.yaml +0 -0
  93. {vimniscient-0.2.2 → vimniscient-0.3.0}/src/vimniscient/ui/__init__.py +0 -0
  94. {vimniscient-0.2.2 → vimniscient-0.3.0}/src/vimniscient/ui/cards.py +0 -0
  95. {vimniscient-0.2.2 → vimniscient-0.3.0}/src/vimniscient/ui/common.py +0 -0
  96. {vimniscient-0.2.2 → vimniscient-0.3.0}/src/vimniscient/ui/drills.py +0 -0
  97. {vimniscient-0.2.2 → vimniscient-0.3.0}/src/vimniscient/ui/lesson.py +0 -0
  98. {vimniscient-0.2.2 → vimniscient-0.3.0}/src/vimniscient/ui/stats.py +0 -0
  99. {vimniscient-0.2.2 → vimniscient-0.3.0}/src/vimniscient/vimgolf.py +0 -0
  100. {vimniscient-0.2.2 → vimniscient-0.3.0}/tests/check_answers.py +0 -0
  101. {vimniscient-0.2.2 → vimniscient-0.3.0}/tests/check_diagnose.py +0 -0
  102. {vimniscient-0.2.2 → vimniscient-0.3.0}/tests/check_drills.py +0 -0
  103. {vimniscient-0.2.2 → vimniscient-0.3.0}/tests/check_generate.py +0 -0
  104. {vimniscient-0.2.2 → vimniscient-0.3.0}/tests/check_lessons.lua +0 -0
  105. {vimniscient-0.2.2 → vimniscient-0.3.0}/tests/check_lessons.py +0 -0
  106. {vimniscient-0.2.2 → vimniscient-0.3.0}/tests/check_timing.py +0 -0
  107. {vimniscient-0.2.2 → vimniscient-0.3.0}/tests/check_today.py +0 -0
  108. {vimniscient-0.2.2 → vimniscient-0.3.0}/tests/live_drills.py +0 -0
  109. {vimniscient-0.2.2 → vimniscient-0.3.0}/tests/live_endless.py +0 -0
  110. {vimniscient-0.2.2 → vimniscient-0.3.0}/tests/live_lessons.py +0 -0
  111. {vimniscient-0.2.2 → vimniscient-0.3.0}/tests/live_retry.py +0 -0
  112. {vimniscient-0.2.2 → 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.2
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
 
@@ -303,6 +314,10 @@ uv run python tests/check_timing.py # task times: old progress datab
303
314
  uv run python tests/check_generate.py # generated texts: reproducible, and they solve in Neovim
304
315
  uv run python tests/check_diagnose.py # strategy feedback: no reference solution sets it off
305
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
306
321
  uv run python tests/check_today.py # the daily budget, SRS scheduling, card introductions, hardtime
307
322
  uv run python tests/check_app.py # every screen, driven headless
308
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
 
@@ -280,6 +291,10 @@ uv run python tests/check_timing.py # task times: old progress datab
280
291
  uv run python tests/check_generate.py # generated texts: reproducible, and they solve in Neovim
281
292
  uv run python tests/check_diagnose.py # strategy feedback: no reference solution sets it off
282
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
283
298
  uv run python tests/check_today.py # the daily budget, SRS scheduling, card introductions, hardtime
284
299
  uv run python tests/check_app.py # every screen, driven headless
285
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.2"
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,22 +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
14
15
  from textual.content import Content
15
16
 
16
- from vimniscient import drills, endless, reminders, today, transfer, vimgolf
17
+ from vimniscient import drills, endless, missions, placement, quick, reminders, today, transfer, vimgolf
17
18
  from vimniscient.cards import has_lazyvim, load_decks
18
19
  from vimniscient.lessons import Lesson, load_lessons, two_rounds
19
20
  from vimniscient.progress import Progress
20
- from vimniscient.session import COLORS, TaskResult, mouse_wheel, run_endless, run_lesson
21
- from vimniscient.ui.builtin_lessons import endless_lesson, placement_lesson, review_lesson, transfer_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
22
23
  from vimniscient.ui.cards import CardScreen
23
24
  from vimniscient.ui.common import CATPPUCCIN, DEFAULT_THEME, omarchy_theme, plural
24
25
  from vimniscient.ui.drills import DrillScreen
25
26
  from vimniscient.ui.lesson import LessonScreen, ResultsScreen
26
27
  from vimniscient.ui.main import MainScreen
28
+ from vimniscient.ui.quick import QuickScreen
27
29
  from vimniscient.ui.stats import StatsScreen
28
30
 
29
31
 
@@ -45,7 +47,7 @@ class VimniscientApp(App):
45
47
 
46
48
  #main { height: 1fr; }
47
49
  #left { width: 46; max-width: 45%; }
48
- #plan { height: 9; }
50
+ #plan { height: 10; }
49
51
  #lesson-list { height: 1fr; }
50
52
  #right { width: 1fr; }
51
53
  #detail-pane { height: 1fr; }
@@ -189,6 +191,8 @@ class VimniscientApp(App):
189
191
  elif step_id == "vimgolf":
190
192
  if not progress.vimgolf_done_today():
191
193
  return self.start_vimgolf
194
+ elif step_id == "quick":
195
+ return self.start_quick
192
196
  elif step_id == "transfer":
193
197
  picks = transfer.pick(self.lessons, progress)
194
198
  if picks:
@@ -245,24 +249,79 @@ class VimniscientApp(App):
245
249
  self.notify("No cards due. Come back tomorrow.")
246
250
 
247
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
+
248
258
  def on_results(results: list[TaskResult]) -> str:
249
259
  self.progress.record(results)
250
- placed = self.progress.place(self.lessons, results)
251
- 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:
252
267
  return "No lessons skipped. Start from Week 1 · Day 1."
253
- 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)
254
269
 
255
- self.push_screen(LessonScreen(placement_lesson(self.lessons), on_results))
270
+ def after() -> None:
271
+ self.placement_round(search, done) if not search.finished else done()
272
+
273
+ self.start_lesson(placement_lesson(tasks), on_results, after)
256
274
 
257
275
  def action_endless(self) -> None:
258
276
  self.run_step("endless", lambda: None)
259
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
+
260
319
  def action_transfer(self) -> None:
261
320
  self.run_step("transfer", lambda: None)
262
321
 
263
322
  def start_transfer(self, picks: list[transfer.Challenge], done: Callable[[], None]) -> None:
264
323
  def on_results(results: list[TaskResult]) -> Content:
265
- self.progress.record_transfer(results)
324
+ self.progress.record_ungraded(results, "transfer")
266
325
  by_goal = {c.task.goal: c for c in picks}
267
326
  lines = [f"{r.task.goal} {' or '.join(ways)}" for r in results
268
327
  if (ways := transfer.another_way(by_goal[r.task.goal], r.keys, r.status == "solved"))]
@@ -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,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]
@@ -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']
@@ -0,0 +1,58 @@
1
+ """`vimniscient eval`: does the training work? Three sittings of the same eight
2
+ skills, on texts from no lesson, with no hints: `pre` now, `post` after some
3
+ weeks of training, `delayed` a few weeks after that. Stored locally, never graded.
4
+ """
5
+
6
+ from functools import cache
7
+ from importlib import resources
8
+ from statistics import median
9
+
10
+ import yaml
11
+
12
+ from vimniscient.keys import count_keys
13
+ from vimniscient.lessons import Lesson, Task, _parse_start
14
+
15
+ LESSON_ID = "eval"
16
+ HINT = "No hints in the test: :Skip what you don't know."
17
+ FORM = {"pre": "a", "post": "b", "delayed": "a"}
18
+
19
+
20
+ @cache
21
+ def forms() -> dict[str, tuple[dict, ...]]:
22
+ data = yaml.safe_load((resources.files("vimniscient") / "eval.yaml").read_text(encoding="utf-8"))
23
+ return {name: tuple(tasks) for name, tasks in data["forms"].items()}
24
+
25
+
26
+ def task(t: dict, index: int) -> Task:
27
+ start, cursor = _parse_start(t["start"])
28
+ return Task(goal=t["goal"], start=start, cursor=cursor, target=t["target"].split("\n"), solution="—", hint=HINT, ft="",
29
+ lesson_id=LESSON_ID, index=index, ref_override=min(count_keys(way) for way in t["ways"]))
30
+
31
+
32
+ def lesson(stage: str) -> Lesson:
33
+ tasks = [task(t, i) for i, t in enumerate(forms()[FORM[stage]])]
34
+ return Lesson(id=LESSON_ID, week=0, day=0, title=f"Evaluation: {stage}", intro="", keys=[], tasks=tasks)
35
+
36
+
37
+ def summary(rows: list[tuple[str, float | None, int, int]]) -> tuple[int, float | None, float | None]:
38
+ """(solved, median seconds, keys against the reference) from (status, seconds, count, ref) rows."""
39
+ solved = [row for row in rows if row[0] == "solved"]
40
+ seconds = [row[1] for row in solved if row[1] is not None]
41
+ keys = sum(row[2] for row in solved) / sum(row[3] for row in solved) if solved else None
42
+ return len(solved), median(seconds) if seconds else None, keys
43
+
44
+
45
+ def report(progress) -> str:
46
+ lines = [f"{'':8}{'date':12}{'solved':8}{'median':9}keys/ref"]
47
+ for stage in FORM:
48
+ rows = progress.db.execute(
49
+ "SELECT status, seconds, count, ref, ts FROM attempts WHERE phase = ? ORDER BY id DESC LIMIT ?",
50
+ (f"eval-{stage}", len(forms()[FORM[stage]]))).fetchall()
51
+ if not rows:
52
+ lines.append(f"{stage:8}not taken yet: vimniscient eval {stage}")
53
+ continue
54
+ solved, seconds, keys = summary([row[:4] for row in rows])
55
+ time = f"{seconds:.1f}s" if seconds is not None else "-"
56
+ ratio = f"{keys:.2f}" if keys is not None else "-"
57
+ lines.append(f"{stage:8}{rows[0][4][:10]:12}{f'{solved}/{len(rows)}':8}{time:9}{ratio}")
58
+ return "\n".join(lines)