Vibe coded short cuts trainer.
This commit is contained in:
parent
cd9422355a
commit
daf77f0ba2
693
shortcuts.py
Normal file
693
shortcuts.py
Normal file
@ -0,0 +1,693 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
vim_terminal_trainer.py – *v2*
|
||||
================================
|
||||
**New feature:** you no longer have to press **Return** when you know the
|
||||
command. As soon as the characters you type exactly match a valid answer, the
|
||||
trainer auto‑advances to the next question. This is now implemented with the
|
||||
standard‑library **curses** module, so it should run on any Unix‑like terminal
|
||||
(macOS, Linux, WSL, etc.).
|
||||
|
||||
Key changes
|
||||
-----------
|
||||
* Real‑time character capture with `curses` – no blocking `input()` inside the
|
||||
quiz loop.
|
||||
* Timer bar counts down in the top‑right corner.
|
||||
* Still stores a persistent high‑score in `~/.vim_terminal_trainer/score.json`.
|
||||
* Backspace works while answering.
|
||||
|
||||
Run it like before:
|
||||
|
||||
```bash
|
||||
chmod +x vim_terminal_trainer.py
|
||||
./vim_terminal_trainer.py
|
||||
```
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import curses
|
||||
import json
|
||||
import os
|
||||
import random
|
||||
import textwrap
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Tuple
|
||||
|
||||
# ------------------- DATA & CONFIG -----------------------------------------
|
||||
|
||||
DEFAULT_QUESTIONS: List[Dict[str, object]] = [
|
||||
{"task": "Delete (cut) the current line in Vim", "answers": ["dd"]},
|
||||
{"task": "Save file and quit Vim (one command)", "answers": [":wq", ":x"]},
|
||||
{"task": "Quit Vim **without** saving", "answers": [":q!", "ZQ"]},
|
||||
{"task": "Yank (copy) three lines including the current", "answers": ["3yy"]},
|
||||
{"task": "Paste yanked text **below** current line", "answers": ["p"]},
|
||||
{"task": "Move to first non-blank character of the line", "answers": ["^"]},
|
||||
{"task": "Jump to end of file", "answers": ["G"]},
|
||||
{
|
||||
"task": "List *all* files, including hidden, one entry per line",
|
||||
"answers": ["ls -a1", "ls -a -1"],
|
||||
},
|
||||
{
|
||||
"task": "Recursively search for the string ‘TODO’ in *.py files (GNU grep)",
|
||||
"answers": [
|
||||
"grep -R --include='*.py' 'TODO' .",
|
||||
"grep -R --include=*.py TODO .",
|
||||
],
|
||||
},
|
||||
{
|
||||
"task": "Show the last 20 lines of the file `log.txt` *as they grow*",
|
||||
"answers": ["tail -f -n 20 log.txt", "tail -20f log.txt"],
|
||||
},
|
||||
{
|
||||
"task": "Change word under cursor to UPPERCASE in Vim (normal‑mode, 1 cmd)",
|
||||
"answers": ["gUiw"],
|
||||
},
|
||||
{"task": "Switch to the previous buffer in Vim", "answers": [":b#", "<C-^>", "^"]},
|
||||
|
||||
{
|
||||
"task": "Undo the last change in Vim (normal-mode, one command)",
|
||||
"answers": ["u"],
|
||||
},
|
||||
{"task": "Redo the last undone change in Vim", "answers": ["<C-r>"]},
|
||||
{"task": "Delete (cut) from cursor to end of line in Vim", "answers": ["D"]},
|
||||
{
|
||||
"task": "Select the entire file in Vim (visual mode, 1 keystroke)",
|
||||
"answers": ["ggVG"],
|
||||
},
|
||||
{
|
||||
"task": "Search forward for the exact word UNDER the cursor in Vim",
|
||||
"answers": ["*"],
|
||||
},
|
||||
{
|
||||
"task": "Replace all occurrences of “foo” with “bar” in the whole file (Vim, one ex command)",
|
||||
"answers": [":%s/foo/bar/g"],
|
||||
},
|
||||
{
|
||||
"task": "Split the current Vim window horizontally",
|
||||
"answers": [":split", ":sp"],
|
||||
},
|
||||
{
|
||||
"task": "Delete the word under cursor without yanking in Vim",
|
||||
"answers": ['"_dw'],
|
||||
},
|
||||
{
|
||||
"task": "Open a file named README.md in a new tab from inside Vim",
|
||||
"answers": [":tabe README.md", ":tabedit README.md"],
|
||||
},
|
||||
{
|
||||
"task": "Show numbered lines in Vim (toggle on)",
|
||||
"answers": [":set number", ":set nu"],
|
||||
},
|
||||
{"task": "Move to beginning of file in less(1)", "answers": ["g"]},
|
||||
{"task": "Jump to the bottom of file in less(1)", "answers": ["G"]},
|
||||
{
|
||||
"task": "Change directory to the previous directory (bash/zsh built-in)",
|
||||
"answers": ["cd -"],
|
||||
},
|
||||
{
|
||||
"task": "Recursively remove an empty directory tree named build using one POSIX command",
|
||||
"answers": ["rmdir -p build"],
|
||||
},
|
||||
{
|
||||
"task": "Copy the directory src recursively to dst preserving attributes (GNU cp)",
|
||||
"answers": ["cp -a src dst", "cp -R --preserve=all src dst"],
|
||||
},
|
||||
{
|
||||
"task": "Count the number of lines in all *.py files in the current directory (bash, one pipeline)",
|
||||
"answers": ["wc -l *.py", "cat *.py | wc -l"],
|
||||
},
|
||||
{
|
||||
"task": "Find files larger than 10 MiB anywhere under the current directory (GNU find)",
|
||||
"answers": ["find . -type f -size +10M"],
|
||||
},
|
||||
{
|
||||
"task": "Archive the directory project into a compressed tarball project.tar.gz",
|
||||
"answers": [
|
||||
"tar czf project.tar.gz project",
|
||||
"tar -czf project.tar.gz project",
|
||||
],
|
||||
},
|
||||
{
|
||||
"task": "Extract project.tar.gz into the current directory (tar autodetect)",
|
||||
"answers": ["tar xf project.tar.gz"],
|
||||
},
|
||||
{
|
||||
"task": "Show the five most recently modified files in the current dir (GNU ls)",
|
||||
"answers": ["ls -1t | head -n 5", "ls -t | head -5"],
|
||||
},
|
||||
{
|
||||
"task": "Print the third column of a CSV file data.csv (awk)",
|
||||
"answers": ["awk -F, '{print $3}' data.csv"],
|
||||
},
|
||||
{
|
||||
"task": "Remove carriage-return characters from file win.txt in-place (sed, GNU)",
|
||||
"answers": ["sed -i 's/\\r$//' win.txt"],
|
||||
},
|
||||
{
|
||||
"task": "List running processes whose command contains “python” (POSIX)",
|
||||
"answers": ["ps aux | grep python | grep -v grep", "pgrep -af python"],
|
||||
},
|
||||
{
|
||||
"task": "Show only the IPv4 addresses on all interfaces (iproute2)",
|
||||
"answers": ["ip -4 addr show | grep -oP '(?<=inet\\s)\\d+(\\.\\d+){3}'"],
|
||||
},
|
||||
{
|
||||
"task": "Create an empty file called temp.txt or update its mtime if it exists (shell)",
|
||||
"answers": ["touch temp.txt"],
|
||||
},
|
||||
{
|
||||
"task": "Stage **all** changes (new, modified, deleted) in Git",
|
||||
"answers": ["git add -A", "git add ."],
|
||||
},
|
||||
{
|
||||
"task": "Amend the most recent Git commit message without changing its contents",
|
||||
"answers": ["git commit --amend --no-edit"],
|
||||
},
|
||||
{
|
||||
"task": "Show a concise, one-line-per-commit log with graph and decorations (Git)",
|
||||
"answers": ["git log --oneline --graph --decorate"],
|
||||
},
|
||||
{
|
||||
"task": "Create and switch to a new branch named feature/api (Git)",
|
||||
"answers": ["git checkout -b feature/api", "git switch -c feature/api"],
|
||||
},
|
||||
{
|
||||
"task": "Delete a local branch named hotfix quickly (Git)",
|
||||
"answers": ["git branch -d hotfix", "git branch -D hotfix"],
|
||||
},
|
||||
{
|
||||
"task": "Temporarily stash uncommitted changes, including untracked files (Git)",
|
||||
"answers": ["git stash -u", "git stash --include-untracked"],
|
||||
},
|
||||
{
|
||||
"task": "Pull the current branch and rebase instead of merge (Git)",
|
||||
"answers": ["git pull --rebase"],
|
||||
},
|
||||
{
|
||||
"task": "Show the diff of what is **staged** vs HEAD in Git",
|
||||
"answers": ["git diff --cached", "git diff --staged"],
|
||||
},
|
||||
{
|
||||
"task": "Reset the current branch to the previous commit, keeping changes unstaged (Git)",
|
||||
"answers": ["git reset --soft HEAD~1"],
|
||||
},
|
||||
{
|
||||
"task": "List Docker containers that are currently running",
|
||||
"answers": ["docker ps"],
|
||||
},
|
||||
{
|
||||
"task": "Remove all stopped Docker containers in one command",
|
||||
"answers": ["docker container prune -f", "docker rm $(docker ps -aq)"],
|
||||
},
|
||||
{
|
||||
"task": "Display real-time memory usage every 2 s in human-readable units (GNU watch / free)",
|
||||
"answers": ["watch -n2 free -h"],
|
||||
},
|
||||
{
|
||||
"task": "Run a Python one-liner that starts an HTTP server on port 8000",
|
||||
"answers": ["python -m http.server 8000", "python3 -m http.server"],
|
||||
},
|
||||
{
|
||||
"task": "Show the environment variable PATH in zsh/bash",
|
||||
"answers": ["echo $PATH", "printf '%s\\n' \"$PATH\""],
|
||||
},
|
||||
{
|
||||
"task": "Replace the first occurrence of word “foo” with “bar” on the **current** line in Vim (normal mode)",
|
||||
"answers": [":s/foo/bar/", "ciwbar<Esc>"],
|
||||
},
|
||||
{
|
||||
"task": "Transpose two characters around the cursor in Vim",
|
||||
"answers": ["xp"],
|
||||
},
|
||||
{
|
||||
"task": "Increment the number under the cursor by 1 in Vim",
|
||||
"answers": ["<C-a>"],
|
||||
},
|
||||
{
|
||||
"task": "Decrease the number under the cursor by 1 in Vim",
|
||||
"answers": ["<C-x>"],
|
||||
},
|
||||
{
|
||||
"task": "Open the man page for grep using a single terminal command",
|
||||
"answers": ["man grep"],
|
||||
},
|
||||
{
|
||||
"task": "Download a file silently via HTTP to out.bin using curl",
|
||||
"answers": [
|
||||
"curl -s -o out.bin http://example.com/file",
|
||||
"curl -so out.bin http://example.com/file",
|
||||
],
|
||||
},
|
||||
{
|
||||
"task": "Generate an SSH key pair using Ed25519 algorithm with default filename",
|
||||
"answers": ["ssh-keygen -t ed25519"],
|
||||
},
|
||||
{
|
||||
"task": "Show disk usage of each directory in the current path, depth=1 (GNU du)",
|
||||
"answers": ["du -h --max-depth=1", "du -h --summarize */"],
|
||||
},
|
||||
{
|
||||
"task": "Kill the process listening on TCP port 3000 (Linux, lsof + awk + kill)",
|
||||
"answers": ["kill -9 $(lsof -t -i:3000)", "fuser -k 3000/tcp"],
|
||||
},
|
||||
{
|
||||
"task": "Print lines 10 through 20 of file notes.txt (sed)",
|
||||
"answers": ["sed -n '10,20p' notes.txt"],
|
||||
},
|
||||
{
|
||||
"task": "Show only unique lines of file list.txt preserving order (GNU awk)",
|
||||
"answers": ["awk '!seen[$0]++' list.txt"],
|
||||
},
|
||||
{
|
||||
"task": "Create a symbolic link named latest pointing to build-2025-06-07",
|
||||
"answers": ["ln -s build-2025-06-07 latest"],
|
||||
},
|
||||
{
|
||||
"task": "Generate a SHA-256 checksum of file iso.img (GNU coreutils)",
|
||||
"answers": ["sha256sum iso.img"],
|
||||
},
|
||||
{
|
||||
"task": "Display the calendar for June 2025 (util-linux cal)",
|
||||
"answers": ["cal 6 2025"],
|
||||
},
|
||||
{"task": "Move cursor to line 42 in Vim", "answers": [":42", "42G"]},
|
||||
{"task": "Repeat last command-line in Vim", "answers": [":@:", "<C-p>"]},
|
||||
{"task": "Join next line with current in Vim (normal mode)", "answers": ["J"]},
|
||||
{"task": "Toggle case of character under cursor in Vim", "answers": ["~"]},
|
||||
{"task": "Enter insert mode after current character in Vim", "answers": ["a"]},
|
||||
{"task": "Delete 5 lines starting from current in Vim", "answers": ["5dd"]},
|
||||
{"task": "Search backward for word in Vim", "answers": ["#"]},
|
||||
{"task": "Replace current character in Vim (normal mode)", "answers": ["r"]},
|
||||
{"task": "Go to next tab in Vim", "answers": [":tabn", "gt"]},
|
||||
{"task": "Split Vim window vertically", "answers": [":vsplit", ":vs"]},
|
||||
{"task": "List all buffers in Vim", "answers": [":ls", ":buffers"]},
|
||||
{"task": "Save file without quitting in Vim", "answers": [":w"]},
|
||||
{"task": "Move to next word in Vim", "answers": ["w"]},
|
||||
{"task": "Move to previous word in Vim", "answers": ["b"]},
|
||||
{"task": "Delete until end of word in Vim", "answers": ["dw"]},
|
||||
{"task": "Yank current line in Vim", "answers": ["yy"]},
|
||||
{"task": "Paste yanked text above current line in Vim", "answers": ["P"]},
|
||||
{"task": "Indent current line in Vim (normal mode)", "answers": [">>"]},
|
||||
{"task": "Unindent current line in Vim (normal mode)", "answers": ["<<"]},
|
||||
{"task": "Go to first line in Vim", "answers": ["gg", ":1"]},
|
||||
{
|
||||
"task": "Show file permissions in current directory (ls)",
|
||||
"answers": ["ls -l"],
|
||||
},
|
||||
{"task": "Create directory named docs (mkdir)", "answers": ["mkdir docs"]},
|
||||
{"task": "Remove file temp.txt (rm)", "answers": ["rm temp.txt"]},
|
||||
{"task": "List files sorted by size (GNU ls)", "answers": ["ls -lS"]},
|
||||
{"task": "Show current working directory (bash)", "answers": ["pwd"]},
|
||||
{"task": "Copy file a.txt to b.txt (cp)", "answers": ["cp a.txt b.txt"]},
|
||||
{"task": "Move file a.txt to dir/ (mv)", "answers": ["mv a.txt dir/"]},
|
||||
{
|
||||
"task": "Show first 10 lines of file.txt (head)",
|
||||
"answers": ["head file.txt"],
|
||||
},
|
||||
{"task": "Count words in file.txt (wc)", "answers": ["wc -w file.txt"]},
|
||||
{"task": "Sort lines of file.txt (sort)", "answers": ["sort file.txt"]},
|
||||
{
|
||||
"task": "Find files modified in last 7 days (GNU find)",
|
||||
"answers": ["find . -type f -mtime -7"],
|
||||
},
|
||||
{"task": "List all running processes (POSIX ps)", "answers": ["ps aux"]},
|
||||
{"task": "Kill process by PID 1234 (kill)", "answers": ["kill 1234"]},
|
||||
{"task": "Show disk usage of current directory (du)", "answers": ["du -sh ."]},
|
||||
{
|
||||
"task": "Compress file.txt to file.txt.gz (gzip)",
|
||||
"answers": ["gzip file.txt"],
|
||||
},
|
||||
{"task": "Decompress file.txt.gz (gunzip)", "answers": ["gunzip file.txt.gz"]},
|
||||
{"task": "Show current user (whoami)", "answers": ["whoami"]},
|
||||
{
|
||||
"task": "Change file permissions to 644 (chmod)",
|
||||
"answers": ["chmod 644 file.txt"],
|
||||
},
|
||||
{
|
||||
"task": "Change file owner to user (chown)",
|
||||
"answers": ["chown user file.txt"],
|
||||
},
|
||||
{"task": "Show system uptime (uptime)", "answers": ["uptime"]},
|
||||
{"task": "Create empty git repository (git)", "answers": ["git init"]},
|
||||
{"task": "Show git branch status (git)", "answers": ["git status"]},
|
||||
{
|
||||
"task": "Commit staged changes with message (git)",
|
||||
"answers": ["git commit -m 'message'"],
|
||||
},
|
||||
{
|
||||
"task": "Push current branch to origin (git)",
|
||||
"answers": ["git push origin HEAD"],
|
||||
},
|
||||
{"task": "Pull current branch from origin (git)", "answers": ["git pull"]},
|
||||
{"task": "Show git branches (git)", "answers": ["git branch"]},
|
||||
{
|
||||
"task": "Switch to branch main (git)",
|
||||
"answers": ["git checkout main", "git switch main"],
|
||||
},
|
||||
{
|
||||
"task": "Merge branch feature into current (git)",
|
||||
"answers": ["git merge feature"],
|
||||
},
|
||||
{"task": "Show git diff of unstaged changes (git)", "answers": ["git diff"]},
|
||||
{"task": "List all Docker images", "answers": ["docker images"]},
|
||||
{
|
||||
"task": "Stop Docker container by ID (docker)",
|
||||
"answers": ["docker stop <container_id>"],
|
||||
},
|
||||
{
|
||||
"task": "Run container from image nginx (docker)",
|
||||
"answers": ["docker run nginx"],
|
||||
},
|
||||
{
|
||||
"task": "Show Docker container logs (docker)",
|
||||
"answers": ["docker logs <container_id>"],
|
||||
},
|
||||
{
|
||||
"task": "Remove Docker image by ID (docker)",
|
||||
"answers": ["docker rmi <image_id>"],
|
||||
},
|
||||
{
|
||||
"task": "Search for pattern in files recursively (grep)",
|
||||
"answers": ["grep -r 'pattern' ."],
|
||||
},
|
||||
{
|
||||
"task": "Show line numbers in grep output",
|
||||
"answers": ["grep -n 'pattern' file.txt"],
|
||||
},
|
||||
{
|
||||
"task": "Print first column of file.txt (awk)",
|
||||
"answers": ["awk '{print $1}' file.txt"],
|
||||
},
|
||||
{
|
||||
"task": "Replace tabs with spaces in file.txt (sed)",
|
||||
"answers": ["sed 's/\t/ /g' file.txt"],
|
||||
},
|
||||
{"task": "Show current shell (bash/zsh)", "answers": ["echo $SHELL"]},
|
||||
{
|
||||
"task": "Set environment variable FOO=bar (bash)",
|
||||
"answers": ["export FOO=bar"],
|
||||
},
|
||||
{
|
||||
"task": "Append text to file.txt (bash)",
|
||||
"answers": ["echo 'text' >> file.txt"],
|
||||
},
|
||||
{"task": "Show file type of file.txt (file)", "answers": ["file file.txt"]},
|
||||
{"task": "List network interfaces (ip)", "answers": ["ip link show"]},
|
||||
{"task": "Ping host 8.8.8.8 4 times (ping)", "answers": ["ping -c 4 8.8.8.8"]},
|
||||
{"task": "Show open ports (netstat)", "answers": ["netstat -tuln"]},
|
||||
{
|
||||
"task": "Download file with wget (wget)",
|
||||
"answers": ["wget http://example.com/file"],
|
||||
},
|
||||
{"task": "Extract tar file.tar (tar)", "answers": ["tar xf file.tar"]},
|
||||
{"task": "Show calendar for 2025 (cal)", "answers": ["cal 2025"]},
|
||||
{"task": "Show current date and time (date)", "answers": ["date"]},
|
||||
{
|
||||
"task": "List cron jobs for current user (crontab)",
|
||||
"answers": ["crontab -l"],
|
||||
},
|
||||
{
|
||||
"task": "Copy text to clipboard (xclip)",
|
||||
"answers": ["echo 'text' | xclip -sel clip"],
|
||||
},
|
||||
{"task": "Show CPU info (lscpu)", "answers": ["lscpu"]},
|
||||
{"task": "Monitor system processes in real-time (top)", "answers": ["top"]},
|
||||
{"task": "Show memory usage (free)", "answers": ["free -h"]},
|
||||
{
|
||||
"task": "List all installed packages (apt)",
|
||||
"answers": ["apt list --installed"],
|
||||
},
|
||||
{"task": "Update package lists (apt)", "answers": ["sudo apt update"]},
|
||||
{"task": "Install package vim (apt)", "answers": ["sudo apt install vim"]},
|
||||
{"task": "Search for package vim (apt)", "answers": ["apt search vim"]},
|
||||
{"task": "Show manual for ls (man)", "answers": ["man ls"]},
|
||||
{"task": "Check disk space (df)", "answers": ["df -h"]},
|
||||
{
|
||||
"task": "Create zip archive of dir (zip)",
|
||||
"answers": ["zip -r archive.zip dir"],
|
||||
},
|
||||
{"task": "Extract zip archive (unzip)", "answers": ["unzip archive.zip"]},
|
||||
{
|
||||
"task": "Show last 50 lines of file.txt (tail)",
|
||||
"answers": ["tail -n 50 file.txt"],
|
||||
},
|
||||
{
|
||||
"task": "List files modified today (find)",
|
||||
"answers": ["find . -type f -mtime 0"],
|
||||
},
|
||||
{
|
||||
"task": "Count files in current directory (find)",
|
||||
"answers": ["find . -type f | wc -l"],
|
||||
},
|
||||
{"task": "Show git remote repositories (git)", "answers": ["git remote -v"]},
|
||||
{
|
||||
"task": "Fetch all branches from origin (git)",
|
||||
"answers": ["git fetch origin"],
|
||||
},
|
||||
{"task": "Show git commit history (git)", "answers": ["git log"]},
|
||||
{
|
||||
"task": "Revert last commit, keeping changes (git)",
|
||||
"answers": ["git revert HEAD"],
|
||||
},
|
||||
{
|
||||
"task": "Show staged files in git (git)",
|
||||
"answers": ["git diff --name-only --cached"],
|
||||
},
|
||||
|
||||
]
|
||||
random.shuffle(DEFAULT_QUESTIONS)
|
||||
TIME_LIMIT_SEC = 15
|
||||
DATA_DIR = Path.home() / ".vim_terminal_trainer"
|
||||
SCORE_FILE = DATA_DIR / "score.json"
|
||||
|
||||
# ------------------- PERSISTENCE -------------------------------------------
|
||||
|
||||
|
||||
def load_score() -> Dict[str, int]:
|
||||
if not SCORE_FILE.exists():
|
||||
return {"high_score": 0, "games_played": 0}
|
||||
try:
|
||||
with SCORE_FILE.open("r", encoding="utf-8") as f:
|
||||
d = json.load(f)
|
||||
return {
|
||||
"high_score": int(d.get("high_score", 0)),
|
||||
"games_played": int(d.get("games_played", 0)),
|
||||
}
|
||||
except Exception:
|
||||
return {"high_score": 0, "games_played": 0}
|
||||
|
||||
|
||||
def save_score(high: int, played: int) -> None:
|
||||
DATA_DIR.mkdir(exist_ok=True)
|
||||
with SCORE_FILE.open("w", encoding="utf-8") as f:
|
||||
json.dump({"high_score": high, "games_played": played}, f)
|
||||
|
||||
|
||||
# ------------------- CURSES UI HELPERS -------------------------------------
|
||||
|
||||
|
||||
def draw_centered(
|
||||
stdscr: "curses._CursesWindow", y: int, text: str, bold: bool = False
|
||||
) -> None:
|
||||
h, w = stdscr.getmaxyx()
|
||||
x = max(0, (w - len(text)) // 2)
|
||||
if bold:
|
||||
stdscr.addstr(y, x, text, curses.A_BOLD)
|
||||
else:
|
||||
stdscr.addstr(y, x, text)
|
||||
|
||||
|
||||
def render_timer(stdscr: "curses._CursesWindow", remaining: float) -> None:
|
||||
bar_len = 20
|
||||
filled = int(bar_len * remaining / TIME_LIMIT_SEC)
|
||||
bar = "█" * filled + " " * (bar_len - filled)
|
||||
stdscr.addstr(0, 0, f"⏰[{bar}] {remaining:4.1f}s")
|
||||
|
||||
|
||||
# ------------------- GAMEPLAY ----------------------------------------------
|
||||
def sanitize_question(q):
|
||||
"""Return a dict with keys 'task' and 'answers' no matter what comes in."""
|
||||
if isinstance(q, dict):
|
||||
return q
|
||||
if isinstance(q, list):
|
||||
task, *rest = q
|
||||
answers = rest if rest else []
|
||||
return {"task": str(task), "answers": [str(a) for a in answers]}
|
||||
return {"task": str(q), "answers": []}
|
||||
|
||||
|
||||
def ask_question_curses(
|
||||
stdscr: "curses._CursesWindow",
|
||||
q: Dict[str, object],
|
||||
idx: int,
|
||||
total: int,
|
||||
) -> bool:
|
||||
q = sanitize_question(q) # ← new – normalise input
|
||||
stdscr.clear()
|
||||
h, w = stdscr.getmaxyx()
|
||||
|
||||
# Header
|
||||
header = f"Question {idx}/{total} – Type the command:"
|
||||
draw_centered(stdscr, 2, header, bold=True)
|
||||
|
||||
# Task text (wrapped)
|
||||
wrapped = textwrap.fill(q["task"], width=min(70, w - 4))
|
||||
for i, line in enumerate(wrapped.splitlines(), start=4):
|
||||
draw_centered(stdscr, i, line)
|
||||
|
||||
# Input prompt area
|
||||
input_y = 6 + wrapped.count("\n")
|
||||
stdscr.addstr(input_y, 2, "› ")
|
||||
stdscr.refresh()
|
||||
|
||||
buffer = ""
|
||||
valid = [" ".join(a.split()) for a in q["answers"]]
|
||||
longest = max(len(a) for a in valid)
|
||||
|
||||
start = time.monotonic()
|
||||
curses.curs_set(1)
|
||||
while True:
|
||||
remaining = TIME_LIMIT_SEC - (time.monotonic() - start)
|
||||
render_timer(stdscr, max(0, remaining))
|
||||
stdscr.addstr(input_y, 4, buffer + " " * (longest - len(buffer)))
|
||||
stdscr.move(input_y, 4 + len(buffer))
|
||||
stdscr.refresh()
|
||||
|
||||
if remaining <= 0:
|
||||
return False
|
||||
|
||||
# Non‑blocking read (100 ms chunks)
|
||||
stdscr.timeout(100)
|
||||
ch = stdscr.getch()
|
||||
if ch == -1:
|
||||
continue # no keypress yet
|
||||
if ch in (curses.KEY_BACKSPACE, 127, 8):
|
||||
buffer = buffer[:-1]
|
||||
continue
|
||||
if ch in (10, 13):
|
||||
# User pressed Return anyway – treat as submission
|
||||
break
|
||||
if 0 <= ch <= 255:
|
||||
buffer += chr(ch)
|
||||
|
||||
# Auto‑check
|
||||
norm = " ".join(buffer.split())
|
||||
if norm in valid:
|
||||
# Flash feedback
|
||||
curses.curs_set(0)
|
||||
stdscr.addstr(input_y + 1, 4, "✅ Correct!", curses.A_BOLD)
|
||||
stdscr.refresh()
|
||||
time.sleep(0.4)
|
||||
return True
|
||||
# Trim oversized buffer to keep UI tidy
|
||||
if len(buffer) > longest + 5:
|
||||
buffer = buffer[-(longest + 5) :]
|
||||
|
||||
# Manual submission (Return) or timeout
|
||||
norm = " ".join(buffer.split())
|
||||
return norm in valid
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
|
||||
def play_game(stdscr: "curses._CursesWindow", pool: List[Dict[str, object]]) -> None:
|
||||
score_data = load_score()
|
||||
curses.curs_set(0)
|
||||
|
||||
# Ask number of questions (simple prompt outside curses because of text input)
|
||||
curses.endwin()
|
||||
try:
|
||||
q_default = 10 if len(pool) >= 10 else len(pool)
|
||||
num_q = int(input(f"How many questions? [{q_default}]: ") or q_default)
|
||||
except ValueError:
|
||||
num_q = q_default
|
||||
num_q = max(1, min(num_q, len(pool)))
|
||||
|
||||
# Back into curses
|
||||
stdscr = curses.initscr()
|
||||
curses.noecho()
|
||||
curses.cbreak()
|
||||
stdscr.keypad(True)
|
||||
|
||||
questions = random.sample(pool, k=num_q)
|
||||
correct = 0
|
||||
|
||||
for idx, q in enumerate(questions, 1):
|
||||
if ask_question_curses(stdscr, q, idx, num_q):
|
||||
correct += 1
|
||||
|
||||
# Final screen
|
||||
stdscr.clear()
|
||||
result_text = f"You scored {correct}/{num_q}!"
|
||||
draw_centered(stdscr, 3, result_text, bold=True)
|
||||
|
||||
if correct > score_data["high_score"]:
|
||||
draw_centered(stdscr, 5, "🎉 NEW PERSONAL BEST! 🏆", bold=True)
|
||||
score_data["high_score"] = correct
|
||||
score_data["games_played"] += 1
|
||||
save_score(score_data["high_score"], score_data["games_played"])
|
||||
|
||||
draw_centered(
|
||||
stdscr,
|
||||
7,
|
||||
f"High‑score: {score_data['high_score']} | Games played: {score_data['games_played']}",
|
||||
)
|
||||
draw_centered(stdscr, 9, "Press any key to return to menu…")
|
||||
stdscr.refresh()
|
||||
stdscr.getch()
|
||||
|
||||
|
||||
# ------------------- MAIN ---------------------------------------------------
|
||||
|
||||
|
||||
def main_menu() -> None:
|
||||
pool = DEFAULT_QUESTIONS.copy()
|
||||
|
||||
while True:
|
||||
os.system("clear")
|
||||
print("=" * 60)
|
||||
print(" VIM / TERMINAL TRAINER ".center(60))
|
||||
print("=" * 60)
|
||||
sc = load_score()
|
||||
print(
|
||||
f"High‑score: {sc['high_score']} – Games played: {sc['games_played']}\n"
|
||||
)
|
||||
print("1) Play")
|
||||
print("2) List questions")
|
||||
print("3) Add question")
|
||||
print("4) Reset high‑score")
|
||||
print("5) Quit")
|
||||
choice = input("\nSelect: ").strip()
|
||||
if choice == "1":
|
||||
curses.wrapper(play_game, pool)
|
||||
elif choice == "2":
|
||||
for i, q in enumerate(pool, 1):
|
||||
print(f"{i:>3}. {q['task']}")
|
||||
print(" → " + ", ".join(q["answers"]))
|
||||
input("\nEnter to continue …")
|
||||
elif choice == "3":
|
||||
task = input("Task description (blank to cancel): ").strip()
|
||||
if not task:
|
||||
continue
|
||||
answers = [
|
||||
a.strip()
|
||||
for a in input("Accepted answers (comma‑sep): ").split(",")
|
||||
if a.strip()
|
||||
]
|
||||
if answers:
|
||||
pool.append({"task": task, "answers": answers})
|
||||
elif choice == "4":
|
||||
if input("Really reset scores? [y/N]: ").lower() == "y":
|
||||
save_score(0, 0)
|
||||
elif choice == "5":
|
||||
break
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
main_menu()
|
||||
except KeyboardInterrupt:
|
||||
print("\nGoodbye!")
|
Loading…
Reference in New Issue
Block a user