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.
- vimniscient/__init__.py +3 -0
- vimniscient/__main__.py +3 -0
- vimniscient/answer.py +113 -0
- vimniscient/app.py +310 -0
- vimniscient/cards/foot.yaml +19 -0
- vimniscient/cards/lazyvim.yaml +80 -0
- vimniscient/cards/shell.yaml +22 -0
- vimniscient/cards/vim.yaml +95 -0
- vimniscient/cards/zathura.yaml +32 -0
- vimniscient/cards.py +200 -0
- vimniscient/cli.py +98 -0
- vimniscient/drills/omarchy.yaml +88 -0
- vimniscient/drills.py +198 -0
- vimniscient/endless.py +60 -0
- vimniscient/generate.py +102 -0
- vimniscient/habits.py +116 -0
- vimniscient/keys.py +14 -0
- vimniscient/lessons/sources.md +59 -0
- vimniscient/lessons/w1d1-words.yaml +223 -0
- vimniscient/lessons/w1d2-pairs.yaml +188 -0
- vimniscient/lessons/w1d3-sentences.yaml +247 -0
- vimniscient/lessons/w1d4-find.yaml +184 -0
- vimniscient/lessons/w1d5-lines.yaml +249 -0
- vimniscient/lessons/w1d6-counts.yaml +351 -0
- vimniscient/lessons/w1d7-review.yaml +222 -0
- vimniscient/lessons/w2d1-dot.yaml +195 -0
- vimniscient/lessons/w2d2-search.yaml +194 -0
- vimniscient/lessons/w2d3-cgn.yaml +196 -0
- vimniscient/lessons/w2d4-case-numbers.yaml +201 -0
- vimniscient/lessons/w2d5-insert.yaml +229 -0
- vimniscient/lessons/w2d6-visual.yaml +206 -0
- vimniscient/lessons/w2d7-review.yaml +197 -0
- vimniscient/lessons/w3d1-macros.yaml +240 -0
- vimniscient/lessons/w3d2-macros-more.yaml +170 -0
- vimniscient/lessons/w3d3-registers.yaml +230 -0
- vimniscient/lessons/w3d4-marks.yaml +188 -0
- vimniscient/lessons/w3d5-visual-block.yaml +201 -0
- vimniscient/lessons/w3d6-substitute.yaml +290 -0
- vimniscient/lessons/w3d7-review.yaml +204 -0
- vimniscient/lessons/w4d1-ranges.yaml +214 -0
- vimniscient/lessons/w4d2-sort-filter.yaml +243 -0
- vimniscient/lessons/w4d3-regex.yaml +167 -0
- vimniscient/lessons/w4d4-norm.yaml +158 -0
- vimniscient/lessons/w4d5-motions.yaml +149 -0
- vimniscient/lessons/w4d6-indent.yaml +143 -0
- vimniscient/lessons/w4d7-review.yaml +175 -0
- vimniscient/lessons/w5d1-arguments.yaml +133 -0
- vimniscient/lessons/w5d2-any-quote-bracket.yaml +136 -0
- vimniscient/lessons/w5d3-functions.yaml +193 -0
- vimniscient/lessons/w5d4-small-objects.yaml +185 -0
- vimniscient/lessons/w5d5-comments.yaml +161 -0
- vimniscient/lessons/w5d6-review.yaml +120 -0
- vimniscient/lessons/w6d1-code.yaml +157 -0
- vimniscient/lessons/w6d2-notes.yaml +163 -0
- vimniscient/lessons/w6d3-data.yaml +145 -0
- vimniscient/lessons.py +184 -0
- vimniscient/mnemonics.py +141 -0
- vimniscient/mnemonics.yaml +124 -0
- vimniscient/nvim/keymaps.lua +25 -0
- vimniscient/nvim/runner.lua +711 -0
- vimniscient/progress.py +496 -0
- vimniscient/reminders.py +220 -0
- vimniscient/session.py +152 -0
- vimniscient/settings.py +33 -0
- vimniscient/srs.py +56 -0
- vimniscient/today.py +189 -0
- vimniscient/ui/__init__.py +1 -0
- vimniscient/ui/builtin_lessons.py +99 -0
- vimniscient/ui/cards.py +160 -0
- vimniscient/ui/common.py +129 -0
- vimniscient/ui/drills.py +163 -0
- vimniscient/ui/lesson.py +193 -0
- vimniscient/ui/main.py +217 -0
- vimniscient/ui/stats.py +145 -0
- vimniscient/vimgolf.py +97 -0
- vimniscient-0.2.0.dist-info/METADATA +322 -0
- vimniscient-0.2.0.dist-info/RECORD +80 -0
- vimniscient-0.2.0.dist-info/WHEEL +4 -0
- vimniscient-0.2.0.dist-info/entry_points.txt +2 -0
- 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
|
+
}
|