"""
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
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 "
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 )
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 " )
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 )
stdscr . clear ( )
h , w = stdscr . getmaxyx ( )
header = f " Question { idx } / { total } – Type the command: "
draw_centered ( stdscr , 2 , header , bold = True )
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_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
stdscr . timeout ( 100 )
ch = stdscr . getch ( )
if ch == - 1 :
continue
if ch in ( curses . KEY_BACKSPACE , 127 , 8 ) :
buffer = buffer [ : - 1 ]
continue
if ch in ( 10 , 13 ) :
break
if 0 < = ch < = 255 :
buffer + = chr ( ch )
norm = " " . join ( buffer . split ( ) )
if norm in valid :
curses . curs_set ( 0 )
stdscr . addstr ( input_y + 1 , 4 , " ✅ Correct! " , curses . A_BOLD )
stdscr . refresh ( )
time . sleep ( 0.4 )
return True
if len ( buffer ) > longest + 5 :
buffer = buffer [ - ( longest + 5 ) : ]
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 )
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 ) ) )
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
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 ( )
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 ( " \n Select: " ) . 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 ( " \n Enter 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 ( " \n Goodbye! " )