2025-06-07 11:43:44 +02:00
|
|
|
|
|
2025-06-07 11:35:05 +02:00
|
|
|
|
"""
|
|
|
|
|
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
|
|
|
|
|
|
2025-06-07 11:43:44 +02:00
|
|
|
|
|
2025-06-07 11:35:05 +02:00
|
|
|
|
|
|
|
|
|
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"
|
|
|
|
|
|
2025-06-07 11:43:44 +02:00
|
|
|
|
|
2025-06-07 11:35:05 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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)
|
|
|
|
|
|
|
|
|
|
|
2025-06-07 11:43:44 +02:00
|
|
|
|
|
2025-06-07 11:35:05 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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")
|
|
|
|
|
|
|
|
|
|
|
2025-06-07 11:43:44 +02:00
|
|
|
|
|
2025-06-07 11:35:05 +02:00
|
|
|
|
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:
|
2025-06-07 11:43:44 +02:00
|
|
|
|
q = sanitize_question(q)
|
2025-06-07 11:35:05 +02:00
|
|
|
|
stdscr.clear()
|
|
|
|
|
h, w = stdscr.getmaxyx()
|
|
|
|
|
|
2025-06-07 11:43:44 +02:00
|
|
|
|
|
2025-06-07 11:35:05 +02:00
|
|
|
|
header = f"Question {idx}/{total} – Type the command:"
|
|
|
|
|
draw_centered(stdscr, 2, header, bold=True)
|
|
|
|
|
|
2025-06-07 11:43:44 +02:00
|
|
|
|
|
2025-06-07 11:35:05 +02:00
|
|
|
|
wrapped = textwrap.fill(q["task"], width=min(70, w - 4))
|
|
|
|
|
for i, line in enumerate(wrapped.splitlines(), start=4):
|
|
|
|
|
draw_centered(stdscr, i, line)
|
|
|
|
|
|
2025-06-07 11:43:44 +02:00
|
|
|
|
|
2025-06-07 11:35:05 +02:00
|
|
|
|
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
|
|
|
|
|
|
2025-06-07 11:43:44 +02:00
|
|
|
|
|
2025-06-07 11:35:05 +02:00
|
|
|
|
stdscr.timeout(100)
|
|
|
|
|
ch = stdscr.getch()
|
|
|
|
|
if ch == -1:
|
2025-06-07 11:43:44 +02:00
|
|
|
|
continue
|
2025-06-07 11:35:05 +02:00
|
|
|
|
if ch in (curses.KEY_BACKSPACE, 127, 8):
|
|
|
|
|
buffer = buffer[:-1]
|
|
|
|
|
continue
|
|
|
|
|
if ch in (10, 13):
|
2025-06-07 11:43:44 +02:00
|
|
|
|
|
2025-06-07 11:35:05 +02:00
|
|
|
|
break
|
|
|
|
|
if 0 <= ch <= 255:
|
|
|
|
|
buffer += chr(ch)
|
|
|
|
|
|
2025-06-07 11:43:44 +02:00
|
|
|
|
|
2025-06-07 11:35:05 +02:00
|
|
|
|
norm = " ".join(buffer.split())
|
|
|
|
|
if norm in valid:
|
2025-06-07 11:43:44 +02:00
|
|
|
|
|
2025-06-07 11:35:05 +02:00
|
|
|
|
curses.curs_set(0)
|
|
|
|
|
stdscr.addstr(input_y + 1, 4, "✅ Correct!", curses.A_BOLD)
|
|
|
|
|
stdscr.refresh()
|
|
|
|
|
time.sleep(0.4)
|
|
|
|
|
return True
|
2025-06-07 11:43:44 +02:00
|
|
|
|
|
2025-06-07 11:35:05 +02:00
|
|
|
|
if len(buffer) > longest + 5:
|
|
|
|
|
buffer = buffer[-(longest + 5) :]
|
|
|
|
|
|
2025-06-07 11:43:44 +02:00
|
|
|
|
|
2025-06-07 11:35:05 +02:00
|
|
|
|
norm = " ".join(buffer.split())
|
|
|
|
|
return norm in valid
|
|
|
|
|
|
|
|
|
|
|
2025-06-07 11:43:44 +02:00
|
|
|
|
|
2025-06-07 11:35:05 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def play_game(stdscr: "curses._CursesWindow", pool: List[Dict[str, object]]) -> None:
|
|
|
|
|
score_data = load_score()
|
|
|
|
|
curses.curs_set(0)
|
|
|
|
|
|
2025-06-07 11:43:44 +02:00
|
|
|
|
|
2025-06-07 11:35:05 +02:00
|
|
|
|
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)))
|
|
|
|
|
|
2025-06-07 11:43:44 +02:00
|
|
|
|
|
2025-06-07 11:35:05 +02:00
|
|
|
|
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
|
|
|
|
|
|
2025-06-07 11:43:44 +02:00
|
|
|
|
|
2025-06-07 11:35:05 +02:00
|
|
|
|
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()
|
|
|
|
|
|
|
|
|
|
|
2025-06-07 11:43:44 +02:00
|
|
|
|
|
2025-06-07 11:35:05 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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!")
|