vimniscient 0.2.0__py3-none-any.whl

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 (80) hide show
  1. vimniscient/__init__.py +3 -0
  2. vimniscient/__main__.py +3 -0
  3. vimniscient/answer.py +113 -0
  4. vimniscient/app.py +310 -0
  5. vimniscient/cards/foot.yaml +19 -0
  6. vimniscient/cards/lazyvim.yaml +80 -0
  7. vimniscient/cards/shell.yaml +22 -0
  8. vimniscient/cards/vim.yaml +95 -0
  9. vimniscient/cards/zathura.yaml +32 -0
  10. vimniscient/cards.py +200 -0
  11. vimniscient/cli.py +98 -0
  12. vimniscient/drills/omarchy.yaml +88 -0
  13. vimniscient/drills.py +198 -0
  14. vimniscient/endless.py +60 -0
  15. vimniscient/generate.py +102 -0
  16. vimniscient/habits.py +116 -0
  17. vimniscient/keys.py +14 -0
  18. vimniscient/lessons/sources.md +59 -0
  19. vimniscient/lessons/w1d1-words.yaml +223 -0
  20. vimniscient/lessons/w1d2-pairs.yaml +188 -0
  21. vimniscient/lessons/w1d3-sentences.yaml +247 -0
  22. vimniscient/lessons/w1d4-find.yaml +184 -0
  23. vimniscient/lessons/w1d5-lines.yaml +249 -0
  24. vimniscient/lessons/w1d6-counts.yaml +351 -0
  25. vimniscient/lessons/w1d7-review.yaml +222 -0
  26. vimniscient/lessons/w2d1-dot.yaml +195 -0
  27. vimniscient/lessons/w2d2-search.yaml +194 -0
  28. vimniscient/lessons/w2d3-cgn.yaml +196 -0
  29. vimniscient/lessons/w2d4-case-numbers.yaml +201 -0
  30. vimniscient/lessons/w2d5-insert.yaml +229 -0
  31. vimniscient/lessons/w2d6-visual.yaml +206 -0
  32. vimniscient/lessons/w2d7-review.yaml +197 -0
  33. vimniscient/lessons/w3d1-macros.yaml +240 -0
  34. vimniscient/lessons/w3d2-macros-more.yaml +170 -0
  35. vimniscient/lessons/w3d3-registers.yaml +230 -0
  36. vimniscient/lessons/w3d4-marks.yaml +188 -0
  37. vimniscient/lessons/w3d5-visual-block.yaml +201 -0
  38. vimniscient/lessons/w3d6-substitute.yaml +290 -0
  39. vimniscient/lessons/w3d7-review.yaml +204 -0
  40. vimniscient/lessons/w4d1-ranges.yaml +214 -0
  41. vimniscient/lessons/w4d2-sort-filter.yaml +243 -0
  42. vimniscient/lessons/w4d3-regex.yaml +167 -0
  43. vimniscient/lessons/w4d4-norm.yaml +158 -0
  44. vimniscient/lessons/w4d5-motions.yaml +149 -0
  45. vimniscient/lessons/w4d6-indent.yaml +143 -0
  46. vimniscient/lessons/w4d7-review.yaml +175 -0
  47. vimniscient/lessons/w5d1-arguments.yaml +133 -0
  48. vimniscient/lessons/w5d2-any-quote-bracket.yaml +136 -0
  49. vimniscient/lessons/w5d3-functions.yaml +193 -0
  50. vimniscient/lessons/w5d4-small-objects.yaml +185 -0
  51. vimniscient/lessons/w5d5-comments.yaml +161 -0
  52. vimniscient/lessons/w5d6-review.yaml +120 -0
  53. vimniscient/lessons/w6d1-code.yaml +157 -0
  54. vimniscient/lessons/w6d2-notes.yaml +163 -0
  55. vimniscient/lessons/w6d3-data.yaml +145 -0
  56. vimniscient/lessons.py +184 -0
  57. vimniscient/mnemonics.py +141 -0
  58. vimniscient/mnemonics.yaml +124 -0
  59. vimniscient/nvim/keymaps.lua +25 -0
  60. vimniscient/nvim/runner.lua +711 -0
  61. vimniscient/progress.py +496 -0
  62. vimniscient/reminders.py +220 -0
  63. vimniscient/session.py +152 -0
  64. vimniscient/settings.py +33 -0
  65. vimniscient/srs.py +56 -0
  66. vimniscient/today.py +189 -0
  67. vimniscient/ui/__init__.py +1 -0
  68. vimniscient/ui/builtin_lessons.py +99 -0
  69. vimniscient/ui/cards.py +160 -0
  70. vimniscient/ui/common.py +129 -0
  71. vimniscient/ui/drills.py +163 -0
  72. vimniscient/ui/lesson.py +193 -0
  73. vimniscient/ui/main.py +217 -0
  74. vimniscient/ui/stats.py +145 -0
  75. vimniscient/vimgolf.py +97 -0
  76. vimniscient-0.2.0.dist-info/METADATA +322 -0
  77. vimniscient-0.2.0.dist-info/RECORD +80 -0
  78. vimniscient-0.2.0.dist-info/WHEEL +4 -0
  79. vimniscient-0.2.0.dist-info/entry_points.txt +2 -0
  80. vimniscient-0.2.0.dist-info/licenses/LICENSE +661 -0
@@ -0,0 +1,32 @@
1
+ deck: zathura
2
+ title: Zathura (PDF)
3
+ requires: zathura
4
+ style: keys
5
+ case_sensitive: true
6
+ # Defaults from zathura(1); ~/.config/zathura/zathurarc doesn't change any keys.
7
+ cards:
8
+ - {prompt: Next page, answer: [J, <PageDown>]}
9
+ - {prompt: Previous page, answer: [K, <PageUp>]}
10
+ - {prompt: Half a page down, answer: [<C-d>]}
11
+ - {prompt: Go to the first page, answer: [gg]}
12
+ - {prompt: Go to the last page, answer: [G]}
13
+ - {prompt: Go to page 12, answer: [12G]}
14
+ - {prompt: Search the text, answer: [/]}
15
+ - {prompt: Fit the page width, answer: [s]}
16
+ - {prompt: Fit the whole page, answer: [a]}
17
+ - {prompt: Zoom in, answer: ['+', zI]}
18
+ - {prompt: Zoom out, answer: ['-', zO]}
19
+ - {prompt: Zoom back to the original size, answer: ['=', z0]}
20
+ - {prompt: Dark mode (recolor), answer: [<C-r>]}
21
+ - {prompt: Show the table of contents, answer: [<Tab>]}
22
+ - {prompt: Toggle two pages side by side, answer: [d]}
23
+ - {prompt: Follow a link with the keyboard, answer: [f]}
24
+ - {prompt: Rotate the page, answer: [r]}
25
+ - {prompt: Reload the document, answer: [R]}
26
+ - {prompt: Jump back after following a link, answer: [<C-o>]}
27
+ - {prompt: Set quickmark a, answer: [ma]}
28
+ - {prompt: Go to quickmark a, answer: ["'a"]}
29
+ - {prompt: Presentation mode, answer: [<F5>]}
30
+ - {prompt: Fullscreen, answer: [<F11>]}
31
+ - {prompt: Toggle the status bar, answer: [<C-n>]}
32
+ - {prompt: Open another document, answer: [o]}
vimniscient/cards.py ADDED
@@ -0,0 +1,200 @@
1
+ """Flashcards: static decks from `cards/*.yaml` plus decks read live from the
2
+ user's setup (Omarchy, tmux and LazyVim keybindings)."""
3
+
4
+ import json
5
+ import os
6
+ import re
7
+ import shutil
8
+ import subprocess
9
+ from dataclasses import dataclass, field
10
+ from importlib import resources
11
+ from pathlib import Path
12
+
13
+ import yaml
14
+
15
+ from vimniscient.answer import pretty_keys
16
+
17
+ NVIM_DATA = Path(os.environ.get("XDG_DATA_HOME", Path.home() / ".local/share")) / "nvim"
18
+ CACHE_DIR = Path(os.environ.get("XDG_CACHE_HOME", Path.home() / ".cache")) / "vimniscient"
19
+ NVIM_CONFIG = Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config")) / "nvim"
20
+
21
+ # New cards are introduced round-robin in this order.
22
+ DECK_ORDER = ["habits", "vim", "lazyvim", "omarchy", "tmux", "shell", "foot", "zathura"]
23
+
24
+
25
+ @dataclass
26
+ class Card:
27
+ deck: str
28
+ id: str
29
+ prompt: str
30
+ answers: list[str]
31
+ style: str # keys | combo
32
+ case_sensitive: bool
33
+ week: int = 0
34
+ day: int = 0 # the lesson (in `week`) that teaches it; 0 for none
35
+ note: str = ""
36
+ order: int = 0 # position within the deck for introducing new cards
37
+
38
+ @property
39
+ def shown(self) -> str:
40
+ return pretty_keys(self.answers[0])
41
+
42
+ @property
43
+ def also(self) -> list[str]:
44
+ return [pretty_keys(a) for a in self.answers[1:]]
45
+
46
+
47
+ @dataclass
48
+ class Deck:
49
+ id: str
50
+ title: str
51
+ cards: list[Card] = field(default_factory=list)
52
+
53
+
54
+ def has_plugin(name: str) -> bool:
55
+ """A Neovim plugin installed through lazy.nvim."""
56
+ return (NVIM_DATA / "lazy" / name).is_dir()
57
+
58
+
59
+ def has_lazyvim() -> bool:
60
+ """LazyVim's lessons and cards need it."""
61
+ return has_plugin("LazyVim")
62
+
63
+
64
+ def _static_decks() -> dict[str, Deck]:
65
+ decks = {}
66
+ for f in (resources.files("vimniscient") / "cards").iterdir():
67
+ if not f.name.endswith(".yaml"):
68
+ continue
69
+ data = yaml.safe_load(f.read_text(encoding="utf-8"))
70
+ # a deck about a program only shows up when that program is installed
71
+ if data.get("requires") == "lazyvim" and not has_lazyvim():
72
+ continue
73
+ if data.get("requires") not in (None, "lazyvim") and not shutil.which(data["requires"]):
74
+ continue
75
+ deck = Deck(data["deck"], data["title"])
76
+ for i, c in enumerate(data["cards"]):
77
+ deck.cards.append(
78
+ Card(
79
+ deck=deck.id,
80
+ id=f"{deck.id}:{c['prompt']}",
81
+ prompt=c["prompt"],
82
+ answers=[str(a) for a in c["answer"]],
83
+ style=data.get("style", "keys"),
84
+ case_sensitive=data.get("case_sensitive", True),
85
+ week=c.get("week", 0),
86
+ day=c.get("day", 0),
87
+ note=c.get("note", data.get("note", "")),
88
+ order=i,
89
+ )
90
+ )
91
+ decks[deck.id] = deck
92
+ return decks
93
+
94
+
95
+ def _run(cmd: list[str]) -> str:
96
+ try:
97
+ return subprocess.run(cmd, capture_output=True, text=True, timeout=20).stdout
98
+ except (OSError, subprocess.TimeoutExpired):
99
+ return ""
100
+
101
+
102
+ def _arrow_list(deck_id: str, text: str, case_sensitive: bool) -> list[Card]:
103
+ """Cards from `KEYS → description` lines (the Omarchy keybinding menus)."""
104
+ by_desc: dict[str, list[str]] = {}
105
+ for line in text.splitlines():
106
+ if "→" not in line:
107
+ continue
108
+ keys, desc = (s.strip() for s in line.split("→", 1))
109
+ if not keys or not desc or re.search(r"MOUSE|SCROLL|SWITCH:|XF86", keys, re.I):
110
+ continue
111
+ by_desc.setdefault(desc, []).append(keys)
112
+ cards = []
113
+ for i, (desc, keys) in enumerate(by_desc.items()):
114
+ # "Switch to workspace 1..10" is one thing to learn: keep the 1.
115
+ m = re.fullmatch(r"(.*\D)(\d+)", desc)
116
+ if m and m.group(2) != "1" and f"{m.group(1)}1" in by_desc:
117
+ continue
118
+ if keys == ["PREFIX"]: # tmux: "PREFIX → CTRL + SPACE / CTRL + B"
119
+ desc, keys = "The tmux prefix", [k.strip() for k in desc.split("/")]
120
+ cards.append(
121
+ Card(deck_id, f"{deck_id}:{desc}", desc, keys, "combo", case_sensitive, order=i)
122
+ )
123
+ return cards
124
+
125
+
126
+ def omarchy_cards() -> list[Card]:
127
+ return _arrow_list("omarchy", _run(["omarchy", "menu", "keybindings", "--print"]), False)
128
+
129
+
130
+ def tmux_cards() -> list[Card]:
131
+ return _arrow_list("tmux", _run(["omarchy-menu-tmux-keybindings", "--print"]), True)
132
+
133
+
134
+ def _nvim_signature() -> str:
135
+ newest = 0.0
136
+ for root, _, files in os.walk(NVIM_CONFIG):
137
+ for name in files:
138
+ newest = max(newest, os.path.getmtime(os.path.join(root, name)))
139
+ return str(newest)
140
+
141
+
142
+ def nvim_keymaps() -> list[dict]:
143
+ """Every described keymap in the user's Neovim, cached until the config changes."""
144
+ CACHE_DIR.mkdir(parents=True, exist_ok=True)
145
+ cache = CACHE_DIR / "nvim-keymaps.json"
146
+ signature = _nvim_signature()
147
+ if cache.exists():
148
+ data = json.loads(cache.read_text())
149
+ if data.get("signature") == signature:
150
+ return data["maps"]
151
+ out = CACHE_DIR / "nvim-keymaps.tmp.json"
152
+ script = resources.files("vimniscient") / "nvim" / "keymaps.lua"
153
+ with resources.as_file(script) as script_path:
154
+ try:
155
+ subprocess.run(
156
+ ["nvim", "--headless", "-i", "NONE", "-n", "-S", str(script_path)],
157
+ env=os.environ | {"VIMNISCIENT_KEYMAPS": str(out)},
158
+ capture_output=True,
159
+ timeout=60,
160
+ cwd=CACHE_DIR,
161
+ )
162
+ except (OSError, subprocess.TimeoutExpired):
163
+ return []
164
+ if not out.exists():
165
+ return []
166
+ maps = json.loads(out.read_text())
167
+ out.unlink()
168
+ cache.write_text(json.dumps({"signature": signature, "maps": maps}))
169
+ return maps
170
+
171
+
172
+ def lazyvim_extra_cards(core: list[Card]) -> list[Card]:
173
+ """The `from_config` keymaps of cards/lazyvim.yaml, as named in the user's config."""
174
+ data = yaml.safe_load((resources.files("vimniscient") / "cards" / "lazyvim.yaml").read_text(encoding="utf-8"))
175
+ wanted = [pretty_keys(k) for k in data.get("from_config", [])]
176
+ known = {a for card in core for a in card.answers}
177
+ by_lhs = {
178
+ pretty_keys(m["lhs"]): m["desc"]
179
+ for m in nvim_keymaps()
180
+ if m["mode"] == "n" and not m["desc"].startswith("+")
181
+ }
182
+ start = len(core)
183
+ return [
184
+ Card("lazyvim", f"lazyvim:{by_lhs[lhs]}", by_lhs[lhs], [lhs], "keys", True, order=start + i)
185
+ for i, lhs in enumerate(lhs for lhs in wanted if lhs in by_lhs and lhs not in known)
186
+ ]
187
+
188
+
189
+ def load_decks(live: bool = True) -> dict[str, Deck]:
190
+ decks = _static_decks()
191
+ if live:
192
+ for deck in (Deck("omarchy", "Omarchy", omarchy_cards()), Deck("tmux", "tmux", tmux_cards())):
193
+ if deck.cards:
194
+ decks[deck.id] = deck
195
+ if "lazyvim" in decks:
196
+ decks["lazyvim"].cards += lazyvim_extra_cards(decks["lazyvim"].cards)
197
+ from vimniscient.habits import habit_deck
198
+
199
+ decks["habits"] = habit_deck()
200
+ return {d: decks[d] for d in DECK_ORDER if d in decks} | {d: v for d, v in decks.items() if d not in DECK_ORDER}
vimniscient/cli.py ADDED
@@ -0,0 +1,98 @@
1
+ """`vimniscient` opens the menu; `vimniscient list` / `vimniscient run <id>` skip it."""
2
+
3
+ import argparse
4
+
5
+ from vimniscient.lessons import load_lessons
6
+
7
+
8
+ def main() -> None:
9
+ parser = argparse.ArgumentParser(prog="vimniscient", description="Absolute vim: a daily trainer.")
10
+ sub = parser.add_subparsers(dest="command")
11
+ sub.add_parser("list", help="list the lessons")
12
+ endless_cmd = sub.add_parser("endless", help="endless mode without the menu")
13
+ endless_cmd.add_argument("--seed", help="the same tasks every time (for testing)")
14
+ run = sub.add_parser("run", help="run one lesson without the menu")
15
+ run.add_argument("lesson", help="lesson id, e.g. w1d1-words (see `list`)")
16
+ remind = sub.add_parser("remind", help="notify about today's practice if any is left (for timers and hooks)")
17
+ remind.add_argument("--login", action="store_true", help="you just started using the computer")
18
+ remind.add_argument("--check", action="store_true", help="a follow-up reminder firing")
19
+ budget = sub.add_parser("minutes", help="how long today's routine is meant to take (15 by default)")
20
+ budget.add_argument("minutes", type=int, nargs="?", help="set it; leave it out to see it")
21
+ setup = sub.add_parser("reminders", help="daily practice reminders through Omarchy")
22
+ setup.add_argument("action", choices=["install", "uninstall", "status"], nargs="?", default="status")
23
+ args = parser.parse_args()
24
+
25
+ if args.command is None:
26
+ from vimniscient.app import VimniscientApp
27
+ from vimniscient.session import mouse_wheel
28
+
29
+ try:
30
+ VimniscientApp().run(mouse=False) # keyboard only: nothing in it reacts to clicks or the wheel
31
+ finally:
32
+ mouse_wheel(True)
33
+ return
34
+
35
+ if args.command == "remind":
36
+ from vimniscient import reminders
37
+
38
+ print(reminders.remind("check" if args.check else "login"))
39
+ return
40
+ if args.command == "minutes":
41
+ from vimniscient import settings
42
+
43
+ if args.minutes is not None:
44
+ if args.minutes < 1:
45
+ parser.error("at least one minute")
46
+ settings.set_minutes(args.minutes)
47
+ print(f"Today's routine takes about {settings.minutes()} minutes. ({settings.CONFIG})")
48
+ return
49
+ if args.command == "reminders":
50
+ from vimniscient import reminders
51
+
52
+ if args.action == "install":
53
+ for path in reminders.install():
54
+ print(f"wrote {path}")
55
+ print(f"Reminders on: at {reminders.MORNING} or when you start using the computer, then every "
56
+ f"{reminders.EVERY // 60} hours until {reminders.DAY_END:%H:%M} while practice is left.")
57
+ elif args.action == "uninstall":
58
+ reminders.uninstall()
59
+ print("Reminders off.")
60
+ print(reminders.status())
61
+ return
62
+
63
+ lessons = load_lessons()
64
+ if args.command == "list":
65
+ for lesson in lessons:
66
+ print(f"{lesson.id:28} {lesson.label}: {lesson.title} ({len(lesson.tasks)} tasks)")
67
+ return
68
+
69
+ from vimniscient.progress import Progress
70
+ from vimniscient.session import run_endless, run_lesson
71
+
72
+ if args.command == "endless":
73
+ from vimniscient import endless
74
+ from vimniscient.cards import has_lazyvim
75
+
76
+ progress = Progress()
77
+ start = progress.endless_start(endless.RESTART_BELOW)
78
+ rungs = endless.ladder([lesson for lesson in lessons if not lesson.lazyvim or has_lazyvim()], start, seed=args.seed)
79
+ results = run_endless({t.item_id: t for t, _ in rungs}, [(t.item_id, v) for t, v in rungs],
80
+ start, endless.RAMP, endless.GAP)
81
+ run = endless.summary(results, start)
82
+ progress.record_endless(results, run)
83
+ for r in results:
84
+ print(f"{r.task.phase:6} {r.level:4} {r.status:8} {r.count:>3}/{r.budget or '-'} keys {r.task.goal}")
85
+ print(f"Level {run['start']} -> {run['level']}: {run['missed']} missed, {run['fixed']} recalled afterwards")
86
+ return
87
+
88
+ lesson = next((lesson for lesson in lessons if lesson.id == args.lesson), None)
89
+ if lesson is None:
90
+ parser.error(f"no lesson {args.lesson!r}; try `vimniscient list`")
91
+ from dataclasses import replace
92
+
93
+ from vimniscient.lessons import two_rounds
94
+
95
+ results = run_lesson(replace(lesson, tasks=two_rounds(lesson)), retry=True)
96
+ Progress().record(results)
97
+ for i, r in enumerate(results, 1):
98
+ print(f"{i:2}. {r.task.phase:6} {r.status:8} {r.count if r.status == 'solved' else '-':>3} keys (ref {r.task.ref}) {r.task.goal}")
@@ -0,0 +1,88 @@
1
+ # Live Omarchy drills: the trainer watches Hyprland's event socket and ticks a
2
+ # step off when it really happened.
3
+ #
4
+ # A step matches an event by name and arguments (the part after `>>`, split on
5
+ # commas). Argument patterns:
6
+ # $drill the drill window's address $home the trainer's workspace
7
+ # $away workspace 9 (8 if you're on 9)
8
+ # $new any other window; the first match is remembered for later steps
9
+ # !drill any window but the drill window
10
+ # * anything other text must match exactly
11
+ # `active: $drill` also requires the drill window to have focus (for events
12
+ # like `fullscreen` that don't name a window).
13
+ # `bindings` are descriptions from `omarchy menu keybindings --print`; the hint
14
+ # shows their keys live.
15
+ drills:
16
+ - id: focus
17
+ title: Focus the drill window
18
+ bindings: [Focus on left window, Focus on right window, Focus on next window]
19
+ prompt: Without the mouse, move the focus off the drill window, then back onto it.
20
+ steps:
21
+ - {event: activewindowv2, args: ["!drill"], done: focus moved away}
22
+ - {event: activewindowv2, args: [$drill], done: drill window focused}
23
+
24
+ - id: float
25
+ title: Float and tile
26
+ bindings: [Toggle window floating/tiling]
27
+ prompt: Make the drill window float, then tile it again.
28
+ steps:
29
+ - {event: changefloatingmode, args: [$drill, "1"], done: floating}
30
+ - {event: changefloatingmode, args: [$drill, "0"], done: tiled again}
31
+
32
+ - id: fullscreen
33
+ title: Full screen
34
+ bindings: [Full screen]
35
+ prompt: Make the drill window full screen, then leave full screen.
36
+ steps:
37
+ - {event: fullscreen, args: ["1"], active: $drill, done: full screen}
38
+ - {event: fullscreen, args: ["0"], active: $drill, done: back to normal}
39
+
40
+ - id: full-width
41
+ title: Full width
42
+ bindings: [Full width]
43
+ prompt: Give the drill window the full width (not full screen), then undo it.
44
+ steps:
45
+ - {event: fullscreen, args: ["1"], active: $drill, done: full width}
46
+ - {event: fullscreen, args: ["0"], active: $drill, done: back to normal}
47
+
48
+ - id: pop
49
+ title: Pop a window out
50
+ bindings: [Pop window out (float & pin)]
51
+ prompt: Pop the drill window out (float and pin it), then put it back.
52
+ steps:
53
+ - {event: pin, args: [$drill, "1"], done: popped out}
54
+ - {event: pin, args: [$drill, "0"], done: back in place}
55
+
56
+ - id: workspace-visit
57
+ title: Visit a workspace and come back
58
+ bindings: [Switch to workspace 1, Former workspace]
59
+ prompt: Go to workspace {away}, then come straight back here (workspace {home}). There's a key for "back".
60
+ steps:
61
+ - {event: workspacev2, args: [$away, "*"], done: "on workspace {away}"}
62
+ - {event: workspacev2, args: [$home, "*"], done: "back on workspace {home}"}
63
+
64
+ - id: workspace-move
65
+ title: Send a window away and fetch it
66
+ bindings: [Move window silently to workspace 1, Switch to workspace 1, Move window to workspace 1]
67
+ prompt: >-
68
+ Send the drill window to workspace {away} without following it. Then go to
69
+ workspace {away} and take it back to workspace {home} with you.
70
+ steps:
71
+ - {event: movewindowv2, args: [$drill, $away, "*"], done: "sent to workspace {away}"}
72
+ - {event: workspacev2, args: [$away, "*"], done: "on workspace {away}"}
73
+ - {event: movewindowv2, args: [$drill, $home, "*"], done: brought back}
74
+
75
+ - id: terminal
76
+ title: Open and close a terminal
77
+ bindings: [Terminal, Close window]
78
+ prompt: Open a new terminal, then close it again with the keyboard.
79
+ steps:
80
+ - {event: openwindow, args: [$new, "*", "*", "*"], done: terminal open}
81
+ - {event: closewindow, args: [$new], done: closed}
82
+
83
+ - id: close
84
+ title: Close a window
85
+ bindings: [Close window]
86
+ prompt: Close the drill window with the keyboard. (A new one opens for the next drill.)
87
+ steps:
88
+ - {event: closewindow, args: [$drill], done: closed}
vimniscient/drills.py ADDED
@@ -0,0 +1,198 @@
1
+ """Live Omarchy drills, checked against Hyprland's event socket (socket2).
2
+
3
+ The matching (`DrillRun.feed`) is plain and testable; `Hyprland` holds the
4
+ parts that touch the desktop: the drill window and the sockets.
5
+ """
6
+
7
+ import asyncio
8
+ import json
9
+ import os
10
+ import subprocess
11
+ import time
12
+ from dataclasses import dataclass, field
13
+ from importlib import resources
14
+
15
+ import yaml
16
+
17
+ DRILL_CLASS = "vimniscient-drill"
18
+ DRILL_TITLE = "Vimniscient drill"
19
+
20
+
21
+ @dataclass
22
+ class Step:
23
+ event: str
24
+ args: list[str]
25
+ done: str
26
+ active: str | None = None
27
+
28
+
29
+ @dataclass
30
+ class Drill:
31
+ id: str
32
+ title: str
33
+ prompt: str
34
+ bindings: list[str]
35
+ steps: list[Step]
36
+
37
+ @property
38
+ def item_id(self) -> str:
39
+ return f"drill:{self.id}"
40
+
41
+
42
+ def load_drills() -> list[Drill]:
43
+ data = yaml.safe_load((resources.files("vimniscient") / "drills" / "omarchy.yaml").read_text(encoding="utf-8"))
44
+ return [
45
+ Drill(
46
+ id=d["id"],
47
+ title=d["title"],
48
+ prompt=d["prompt"],
49
+ bindings=d.get("bindings", []),
50
+ steps=[Step(s["event"], [str(a) for a in s["args"]], s["done"], s.get("active")) for s in d["steps"]],
51
+ )
52
+ for d in data["drills"]
53
+ ]
54
+
55
+
56
+ @dataclass
57
+ class DrillRun:
58
+ """Walks one drill's steps as events come in."""
59
+
60
+ drill: Drill
61
+ drill_addr: str # without 0x, as the events write it
62
+ home: str # workspace id
63
+ step: int = 0
64
+ active: str = "" # address of the focused window
65
+ new: str | None = None
66
+ started: float = field(default_factory=time.monotonic)
67
+
68
+ @property
69
+ def away(self) -> str:
70
+ return "8" if self.home == "9" else "9"
71
+
72
+ def text(self, template: str) -> str:
73
+ return template.replace("{home}", self.home).replace("{away}", self.away)
74
+
75
+ @property
76
+ def finished(self) -> bool:
77
+ return self.step >= len(self.drill.steps)
78
+
79
+ def _arg_ok(self, pattern: str, value: str) -> bool:
80
+ if pattern == "*":
81
+ return True
82
+ if pattern == "$drill":
83
+ return value == self.drill_addr
84
+ if pattern == "$home":
85
+ return value == self.home
86
+ if pattern == "$away":
87
+ return value == self.away
88
+ if pattern == "!drill":
89
+ return bool(value) and value != self.drill_addr
90
+ if pattern == "$new":
91
+ if self.new is None:
92
+ if value and value != self.drill_addr:
93
+ self.new = value
94
+ return True
95
+ return False
96
+ return value == self.new
97
+ return value == pattern
98
+
99
+ def feed(self, line: str) -> Step | None:
100
+ """Take one `event>>data` line; returns the step it completed, if any."""
101
+ name, _, data = line.partition(">>")
102
+ if name == "activewindowv2":
103
+ self.active = data.removeprefix("0x")
104
+ if self.finished:
105
+ return None
106
+ step = self.drill.steps[self.step]
107
+ if name != step.event:
108
+ return None
109
+ args = data.split(",", len(step.args) - 1) if step.args else []
110
+ if len(args) != len(step.args):
111
+ return None
112
+ if step.active == "$drill" and self.active != self.drill_addr:
113
+ return None
114
+ new_before = self.new
115
+ if not all(self._arg_ok(p, v) for p, v in zip(step.args, args)):
116
+ self.new = new_before
117
+ return None
118
+ self.step += 1
119
+ return step
120
+
121
+
122
+ def grade(seconds: float, hinted: bool, skipped: bool) -> int:
123
+ from vimniscient import srs
124
+
125
+ if skipped:
126
+ return srs.AGAIN
127
+ if hinted:
128
+ return srs.HARD
129
+ return srs.EASY if seconds <= 10 else srs.GOOD if seconds <= 30 else srs.HARD
130
+
131
+
132
+ class Hyprland:
133
+ """The live desktop: the drill window and the event stream."""
134
+
135
+ def __init__(self) -> None:
136
+ sig = os.environ.get("HYPRLAND_INSTANCE_SIGNATURE")
137
+ runtime = os.environ.get("XDG_RUNTIME_DIR", "")
138
+ self.socket2 = os.path.join(runtime, "hypr", sig, ".socket2.sock") if sig else ""
139
+ self.drill_window: subprocess.Popen | None = None
140
+
141
+ @property
142
+ def available(self) -> bool:
143
+ return bool(self.socket2) and os.path.exists(self.socket2)
144
+
145
+ def _json(self, what: str):
146
+ out = subprocess.run(["hyprctl", what, "-j"], capture_output=True, text=True, timeout=5).stdout
147
+ return json.loads(out or "null")
148
+
149
+ def home(self) -> str:
150
+ return str(self._json("activeworkspace")["id"])
151
+
152
+ def drill_address(self) -> str | None:
153
+ for client in self._json("clients") or []:
154
+ if client.get("class") == DRILL_CLASS:
155
+ return client["address"].removeprefix("0x")
156
+ return None
157
+
158
+ def ensure_drill_window(self) -> str | None:
159
+ """Open the drill window if it isn't there; returns its address."""
160
+ addr = self.drill_address()
161
+ if addr:
162
+ return addr
163
+ text = "This is the drill window.\\nThe drills happen to it, so your own windows are left alone."
164
+ self.drill_window = subprocess.Popen(
165
+ [
166
+ "xdg-terminal-exec", f"--app-id={DRILL_CLASS}", f"--title={DRILL_TITLE}",
167
+ # it closes itself once the trainer is gone, even if that crashed
168
+ "-e", "sh", "-c", f'printf "\\n {text}\\n"; while kill -0 {os.getpid()} 2>/dev/null; do sleep 2; done',
169
+ ],
170
+ stdout=subprocess.DEVNULL,
171
+ stderr=subprocess.DEVNULL,
172
+ )
173
+ for _ in range(50):
174
+ time.sleep(0.1)
175
+ addr = self.drill_address()
176
+ if addr:
177
+ return addr
178
+ return None
179
+
180
+ def close_drill_window(self) -> None:
181
+ addr = self.drill_address()
182
+ if addr:
183
+ subprocess.run(
184
+ ["hyprctl", "dispatch", f'hl.dsp.window.close({{ window = "address:0x{addr}" }})'],
185
+ capture_output=True,
186
+ timeout=5,
187
+ )
188
+ if self.drill_window and self.drill_window.poll() is None:
189
+ self.drill_window.terminate()
190
+
191
+ async def events(self):
192
+ """Yield `event>>data` lines from socket2 forever."""
193
+ reader, writer = await asyncio.open_unix_connection(self.socket2)
194
+ try:
195
+ while line := await reader.readline():
196
+ yield line.decode(errors="replace").rstrip("\n")
197
+ finally:
198
+ writer.close()
vimniscient/endless.py ADDED
@@ -0,0 +1,60 @@
1
+ """Endless mode: tasks from every lesson, one after another, getting harder.
2
+
3
+ Each task you solve climbs a level. Two things make the climb harder:
4
+ - the mix: it starts with the first lesson and takes in the next one every
5
+ LEVELS_PER_LESSON levels, half the time picking from the newest lesson;
6
+ - the key budget: about twice the reference at level 1, shrinking to exactly
7
+ the reference by level RAMP (the runner works it out, see runner.lua).
8
+
9
+ Going over the budget, :Skip, or asking for the answer counts as a miss. A
10
+ miss shows the answer, gives the task again with the keys shown (Learn), and
11
+ GAP tasks later brings it back on another text, from memory (Recall).
12
+ """
13
+
14
+ import math
15
+ import random
16
+ from collections import deque
17
+
18
+ from vimniscient.lessons import Lesson, Task
19
+
20
+ LEVELS_PER_LESSON = 4
21
+ RAMP = 200
22
+ GAP = 2
23
+ RUNGS = 1000 # more than anyone climbs in a sitting; the runner wraps around after
24
+ RESTART_BELOW = 10 # a new run starts this many levels below where the last one ended
25
+
26
+
27
+ def unlocked(level: int, lessons: int) -> int:
28
+ """How many lessons are in the mix at `level`."""
29
+ return max(1, min(lessons, 1 + (level - 1) // LEVELS_PER_LESSON))
30
+
31
+
32
+ def budget(ref: int, level: int) -> int:
33
+ """The most keys allowed at `level`; mirrors budget() in runner.lua."""
34
+ slack = max(0.0, 1 - (level - 1) / RAMP)
35
+ return ref + math.ceil(ref * slack) + (1 if slack > 0 else 0)
36
+
37
+
38
+ def ladder(lessons: list[Lesson], start: int, length: int = RUNGS, seed: object = None) -> list[tuple[Task, int]]:
39
+ """The tasks to climb from level `start` on, as (task, version)."""
40
+ rng = random.Random(seed)
41
+ recent: deque[str] = deque(maxlen=8)
42
+ rungs = []
43
+ for level in range(start, start + length):
44
+ mix = lessons[: unlocked(level, len(lessons))]
45
+ lesson = mix[-1] if rng.random() < 0.5 else rng.choice(mix)
46
+ task = rng.choice([t for t in lesson.tasks if t.item_id not in recent] or lesson.tasks)
47
+ recent.append(task.item_id)
48
+ rungs.append((task, rng.randrange(task.versions)))
49
+ return rungs
50
+
51
+
52
+ def summary(results: list, start: int) -> dict[str, int]:
53
+ climbed = sum(r.task.phase == "climb" and r.status == "solved" for r in results)
54
+ return {
55
+ "start": start,
56
+ "level": start + climbed,
57
+ "climbed": climbed,
58
+ "missed": sum(r.status in ("failed", "skipped") and r.task.phase != "learn" for r in results),
59
+ "fixed": sum(r.task.phase == "recall" and r.status == "solved" for r in results),
60
+ }