diff --git a/README.md b/README.md index ed6aa7e..c67c14e 100644 --- a/README.md +++ b/README.md @@ -79,9 +79,11 @@ a new terminal and just type `cain`. The repo can live anywhere: cain remembers ### Usage ``` -cain asks: Collect (C) or Install (I)? +cain menu: Install or Collect cain -c collect: copy this machine's configs into the repo, commit, push -cain -i install everything +cain -i menu: pick what to install (see below) +cain -i -y install everything, no menu +cain install light pick a preset without the menu: full light dotfiles keybindings server cain install keys only some steps: packages git files bin shell keys desktop cain keys re-apply keybindings after editing cain.json cain generate macos print the keyd / AutoHotkey / Karabiner config for any OS @@ -91,6 +93,22 @@ cain booklister pick a book from ~/Desktop/books and open it ($CAIN_BOOKS_ cain listlauncher edit list.html on the website, then commit and push ($CAIN_SITE_DIR) ``` +`cain -i` opens a menu (arrow keys or j/k, Enter to pick, q to quit): + +``` +What should cain install? + + > Full programs, dotfiles, keybindings and desktop settings + Light dotfiles and keybindings; no programs, no desktop changes + Dotfiles just configs, shell aliases and git settings + Keybindings just key remaps and shortcuts + Server terminal programs and dotfiles, nothing graphical (Linux) + Custom tick each part yourself +``` + +Custom shows a checklist of the install steps (Space ticks, `a` toggles all). When cain isn't +run from a terminal (a script, CI) it skips the menu and installs everything, like `-y`. + Files that install overwrites are backed up to `~/.cain-backup//` first. ### Per-OS notes @@ -132,7 +150,7 @@ make build ./cain make test check the generated configs against tests/expected/ make windows cross-compile cain.exe with MinGW ``` -The code is in `src/`: `main.c` (commands and install steps), `keys.c` (key names for +The code is in `src/`: `main.c` (commands, install steps and presets), `tui.c` (the menu), `keys.c` (key names for every backend), `keyd.c` / `ahk.c` / `karabiner.c` / `linux_desktop.c` (the backends), `packages.c`, `desktop.c`, `tools.c` (booklister, listlauncher), `json.c` and `util.c`. GitHub Actions builds and tests on all three OSes. Push a `v*` tag to publish binaries. diff --git a/src/cain.h b/src/cain.h index 51729f8..c7d1520 100644 --- a/src/cain.h +++ b/src/cain.h @@ -197,6 +197,13 @@ void packages_install(const Manifest *m, const char *os, int terminal_only); void desktop_apply(const char *os, const char *desktop, const Manifest *m); int collect_cinnamon_settings(void); +/* ------------------------------------------------------------------- tui.c */ + +typedef struct { const char *label, *desc; } MenuItem; +/* Arrow-key menu. Returns the chosen index, or -1 if cancelled / stdin isn't a terminal. + * With `checked` (one int per item) it's a checklist: Space ticks, Enter confirms. */ +int tui_menu(const char *title, const MenuItem *items, int n, int start, int *checked); + /* ----------------------------------------------------------------- tools.c */ int booklister_main(void); diff --git a/src/main.c b/src/main.c index 157ac7c..6e2dfc3 100644 --- a/src/main.c +++ b/src/main.c @@ -18,9 +18,10 @@ static const char *USAGE = "\n" " cain ask whether to collect or install\n" " cain -c | collect copy this machine's configs into the repo, commit and push\n" - " cain -i | install install programs, configs, keybindings and desktop settings\n" - " cain install keys run only some install steps:\n" - " packages git files bin shell keys desktop\n" + " cain -i | install pick what to install from a menu (full, light, dotfiles, ...)\n" + " cain install light skip the menu: full light dotfiles keybindings server\n" + " cain install keys or name install steps: packages git files bin shell keys desktop\n" + " -y, --yes install everything without the menu\n" " cain keys shorthand for `cain install keys`\n" " cain vps set up a Linux server: terminal programs, configs and shell, no desktop\n" " cain vps shell run only some vps steps: packages git files bin shell\n" @@ -44,6 +45,80 @@ static const char *const STEPS[] = {"packages", "git", "files", "bin", "shell", static const char *const VPS_STEPS[] = {"packages", "git", "files", "bin", "shell", NULL}; static int g_vps = 0; +/* What the install menu offers. `cain install ` picks one without the menu. */ +typedef struct { const char *name, *label, *desc, *steps; int vps; } Preset; +static const Preset PRESETS[] = { + {"full", "Full", "programs, dotfiles, keybindings and desktop settings", "packages git files bin shell keys desktop", 0}, + {"light", "Light", "dotfiles and keybindings; no programs, no desktop changes", "git files bin shell keys", 0}, + {"dotfiles", "Dotfiles", "just configs, shell aliases and git settings", "git files bin shell", 0}, + {"keybindings", "Keybindings", "just key remaps and shortcuts", "keys", 0}, + {"server", "Server", "terminal programs and dotfiles, nothing graphical", "packages git files bin shell", 1}, + {NULL, NULL, NULL, NULL, 0}, +}; + +static const char *const STEP_DESCS[] = { + "programs from cain.json", + "your git name and email", + "dotfiles: micro, nano, shell aliases...", + "put the cain command on your PATH", + "load the aliases from your shell's startup file", + "key remaps and keyboard shortcuts", + "wallpaper, dark mode, desktop theme", +}; + +static void push_words(StrList *l, const char *words) +{ + char *copy = xstrdup(words); + for (char *t = strtok(copy, " "); t; t = strtok(NULL, " ")) + if (!sl_has(l, t)) sl_push(l, t); + free(copy); +} + +static void use_preset(const Preset *p, StrList *steps) +{ + if (p->vps) { + if (!streq(OS_NATIVE, "linux")) die("the server install is for Linux"); + g_vps = 1; + } + push_words(steps, p->steps); +} + +/* The install menu. 0 if the user backed out. */ +static int pick_install(StrList *steps) +{ + MenuItem items[16]; + const Preset *map[16]; + int n = 0; + for (int i = 0; PRESETS[i].name; i++) { + if (PRESETS[i].vps && !streq(OS_NATIVE, "linux")) continue; + items[n].label = PRESETS[i].label; + items[n].desc = PRESETS[i].desc; + map[n++] = &PRESETS[i]; + } + items[n].label = "Custom"; + items[n].desc = "tick each part yourself"; + int custom = n++; + + int choice = tui_menu("What should cain install?", items, n, 0, NULL); + if (choice < 0) return 0; + if (choice != custom) { + use_preset(map[choice], steps); + return 1; + } + + MenuItem parts[8]; + int checked[8], nsteps = 0; + for (; STEPS[nsteps]; nsteps++) { + parts[nsteps].label = STEPS[nsteps]; + parts[nsteps].desc = STEP_DESCS[nsteps]; + checked[nsteps] = 1; + } + if (tui_menu("Pick what to install", parts, nsteps, 0, checked) < 0) return 0; + for (int i = 0; i < nsteps; i++) + if (checked[i]) sl_push(steps, STEPS[i]); + return steps->n > 0; +} + /* ------------------------------------------------------------------ finding the repo */ static int has_manifest(const char *dir) @@ -374,11 +449,13 @@ int main(int argc, char **argv) { const char *command = NULL; StrList args = {0}; + int yes = 0; for (int i = 1; i < argc; i++) { const char *a = argv[i]; if (streq(a, "-n") || streq(a, "--dry-run")) g_dry_run = 1; else if (streq(a, "-c") || streq(a, "--collect")) command = "collect"; else if (streq(a, "-i") || streq(a, "--install")) command = "install"; + else if (streq(a, "-y") || streq(a, "--yes") || streq(a, "--all")) yes = 1; else if (streq(a, "-h") || streq(a, "--help") || streq(a, "help")) { fputs(USAGE, stdout); return 0; } else if (a[0] == '-') { fprintf(stderr, "cain: unknown option %s\n\n%s", a, USAGE); return 2; } else if (!command) command = a; @@ -402,6 +479,15 @@ int main(int argc, char **argv) fputs(ASCII, stdout); putchar('\n'); + if (!command && stdin_tty()) { + const MenuItem items[] = { + {"Install", "put my programs, configs and keybindings on this machine"}, + {"Collect", "copy this machine's configs into the repo, commit and push"}, + }; + int choice = tui_menu("What do you want to do?", items, 2, 0, NULL); + if (choice < 0) return 0; + command = choice == 0 ? "install" : "collect"; + } while (!command) { printf("Would you like to Collect (C) or Install (I)?: "); fflush(stdout); @@ -421,11 +507,22 @@ int main(int argc, char **argv) if (streq(command, "collect") || streq(command, "c")) { collect(m); } else if (streq(command, "install") || streq(command, "i") || streq(command, "keys") || g_vps) { - const char *const *allowed = g_vps ? VPS_STEPS : STEPS; StrList steps = {0}; - if (args.n) steps = args; - else if (streq(command, "keys")) sl_push(&steps, "keys"); - else for (int i = 0; allowed[i]; i++) sl_push(&steps, allowed[i]); + for (int i = 0; i < args.n; i++) { /* preset names expand to their steps */ + const Preset *p = PRESETS; + while (p->name && (g_vps || !streq(p->name, args.v[i]))) p++; + if (p->name) use_preset(p, &steps); + else if (!sl_has(&steps, args.v[i])) sl_push(&steps, args.v[i]); + } + int menu = !steps.n && !streq(command, "keys") && !g_vps && !yes && stdin_tty(); + if (menu && !pick_install(&steps)) { + info("Nothing installed."); + return 0; + } + const char *const *allowed = g_vps ? VPS_STEPS : STEPS; + if (!steps.n && streq(command, "keys")) sl_push(&steps, "keys"); + if (!steps.n) + for (int i = 0; allowed[i]; i++) sl_push(&steps, allowed[i]); for (int i = 0; i < steps.n; i++) { int known = 0; for (int k = 0; allowed[k]; k++) known |= streq(allowed[k], steps.v[i]); diff --git a/src/tui.c b/src/tui.c new file mode 100644 index 0000000..3419949 --- /dev/null +++ b/src/tui.c @@ -0,0 +1,228 @@ +/* A tiny terminal menu: arrow keys (or j/k) to move, Enter to pick, Space to tick. + * Falls back to a numbered prompt when the terminal can't do it (no ANSI, piped output). */ +#include "cain.h" + +#include +#include +#include + +#ifdef _WIN32 +#include +#include +#include +#define stdout_tty() _isatty(_fileno(stdout)) +#else +#define stdout_tty() isatty(1) +#include +#include +#include +#include +#include +#endif + +enum { K_NONE, K_UP, K_DOWN, K_ENTER, K_SPACE, K_QUIT, K_ALL, K_HOME, K_END }; + +/* ------------------------------------------------------------------ raw keyboard */ + +#ifdef _WIN32 +static int raw_on(void) +{ + HANDLE out = GetStdHandle(STD_OUTPUT_HANDLE); + DWORD mode; + if (!GetConsoleMode(out, &mode)) return 0; + return SetConsoleMode(out, mode | 0x0004 /* ENABLE_VIRTUAL_TERMINAL_PROCESSING */) != 0; +} +static void raw_off(void) {} + +static int read_key(int *ch) +{ + int c = _getch(); + *ch = c; + if (c == 0 || c == 0xE0) { /* arrows and friends come as two codes */ + int d = _getch(); + return d == 72 ? K_UP : d == 80 ? K_DOWN : d == 71 ? K_HOME : d == 79 ? K_END : K_NONE; + } + if (c == '\r' || c == '\n') return K_ENTER; + if (c == 27 || c == 3) return K_QUIT; /* Esc, Ctrl+C */ + return K_NONE; +} +#else +static struct termios saved; +static int raw_active = 0; + +static void raw_off(void) +{ + if (!raw_active) return; + tcsetattr(0, TCSAFLUSH, &saved); + fputs("\033[?25h", stdout); /* show the cursor again */ + fflush(stdout); + raw_active = 0; +} + +static void on_signal(int sig) +{ + raw_off(); + signal(sig, SIG_DFL); + raise(sig); +} + +static int raw_on(void) +{ + if (tcgetattr(0, &saved) != 0) return 0; + struct termios raw = saved; + raw.c_lflag &= ~(tcflag_t)(ICANON | ECHO); + raw.c_cc[VMIN] = 1; + raw.c_cc[VTIME] = 0; + if (tcsetattr(0, TCSAFLUSH, &raw) != 0) return 0; + raw_active = 1; + signal(SIGINT, on_signal); + signal(SIGTERM, on_signal); + fputs("\033[?25l", stdout); /* hide the cursor while the menu is up */ + return 1; +} + +static int byte_ready(void) +{ + struct pollfd p = {0, POLLIN, 0}; + return poll(&p, 1, 40) > 0; +} + +static int read_key(int *ch) +{ + unsigned char c; + if (read(0, &c, 1) != 1) return K_QUIT; + *ch = c; + if (c == '\r' || c == '\n') return K_ENTER; + if (c == 3 || c == 4) return K_QUIT; /* Ctrl+C, Ctrl+D */ + if (c != 27) return K_NONE; + if (!byte_ready()) return K_QUIT; /* a lone Esc */ + unsigned char seq[3] = {0}; + if (read(0, &seq[0], 1) != 1 || (seq[0] != '[' && seq[0] != 'O')) return K_NONE; + if (read(0, &seq[1], 1) != 1) return K_NONE; + switch (seq[1]) { + case 'A': return K_UP; + case 'B': return K_DOWN; + case 'H': return K_HOME; + case 'F': return K_END; + default: + if (seq[1] >= '0' && seq[1] <= '9' && byte_ready() && read(0, &seq[2], 1) == 1) {} /* eat the ~ of ESC[3~ */ + return K_NONE; + } +} +#endif + +static int keyboard_key(int *ch) +{ + int k = read_key(ch); + if (k != K_NONE) return k; + switch (*ch) { + case 'k': case 'K': case 'w': return K_UP; + case 'j': case 'J': case 's': return K_DOWN; + case ' ': case 'x': return K_SPACE; + case 'q': case 'Q': return K_QUIT; + case 'a': case 'A': return K_ALL; + case 'g': return K_HOME; + case 'G': return K_END; + } + return K_NONE; +} + +/* ------------------------------------------------------------------ drawing */ + +static int term_cols(void) +{ +#ifdef _WIN32 + CONSOLE_SCREEN_BUFFER_INFO info; + if (GetConsoleScreenBufferInfo(GetStdHandle(STD_OUTPUT_HANDLE), &info)) + return info.srWindow.Right - info.srWindow.Left + 1; +#else + struct winsize ws; + if (ioctl(1, TIOCGWINSZ, &ws) == 0 && ws.ws_col > 0) return ws.ws_col; +#endif + return 80; +} + +static int g_drawn = 0; /* lines printed by the last draw, so the next one can overwrite them */ + +static void draw(const char *title, const char *hint, const MenuItem *items, int n, int cur, const int *checked) +{ + if (g_drawn) printf("\033[%dA", g_drawn); + int width = 0; + for (int i = 0; i < n; i++) { + int w = (int)strlen(items[i].label); + if (w > width) width = w; + } + /* every line must fit: a wrapped line would throw off the redraw */ + int cols = term_cols() - 1, room = cols - 3 - (checked ? 4 : 0) - width - 2; + if (room < 0) room = 0; + printf("\r\033[2K\033[1m%.*s\033[0m\n", cols, title); + printf("\r\033[2K\033[2m%.*s\033[0m\n\r\033[2K\n", cols, hint); + for (int i = 0; i < n; i++) { + const char *box = !checked ? "" : checked[i] ? "[x] " : "[ ] ", *desc = items[i].desc ? items[i].desc : ""; + if (i == cur) + printf("\r\033[2K \033[1;36m> %s%-*s\033[0m %.*s\n", box, width, items[i].label, room, desc); + else + printf("\r\033[2K %s%-*s \033[2m%.*s\033[0m\n", box, width, items[i].label, room, desc); + } + fflush(stdout); + g_drawn = n + 3; +} + +/* Numbered fallback for terminals that can't do the arrow-key menu. */ +static int plain_menu(const char *title, const MenuItem *items, int n, int *checked) +{ + printf("%s\n\n", title); + for (int i = 0; i < n; i++) + printf(" %d) %s%-12s %s\n", i + 1, !checked ? "" : checked[i] ? "[x] " : "[ ] ", items[i].label, + items[i].desc ? items[i].desc : ""); + printf(checked ? "\nNumbers to install, separated by spaces (Enter keeps the [x] ones, q quits): " + : "\nPick a number (q quits): "); + fflush(stdout); + char line[256]; + if (!fgets(line, sizeof line, stdin)) return -1; + char *p = line; + while (*p == ' ' || *p == '\t') p++; + if (*p == 'q' || *p == 'Q') return -1; + if (!checked) { + int v = atoi(p); + return v >= 1 && v <= n ? v - 1 : plain_menu(title, items, n, checked); + } + if (*p == '\n' || !*p) return 0; + for (int i = 0; i < n; i++) checked[i] = 0; + for (char *t = strtok(p, " ,\t\n"); t; t = strtok(NULL, " ,\t\n")) { + int v = atoi(t); + if (v >= 1 && v <= n) checked[v - 1] = 1; + } + return 0; +} + +int tui_menu(const char *title, const MenuItem *items, int n, int start, int *checked) +{ + if (!stdin_tty()) return -1; + if (!stdout_tty() || !raw_on()) return plain_menu(title, items, n, checked); + const char *hint = checked ? "up/down move space tick a all/none enter go q quit" + : "up/down move enter pick q quit"; + int cur = start >= 0 && start < n ? start : 0, result = -1; + g_drawn = 0; + putchar('\n'); + for (;;) { + draw(title, hint, items, n, cur, checked); + int ch = 0, k = keyboard_key(&ch); + if (k == K_UP) cur = (cur + n - 1) % n; + else if (k == K_DOWN) cur = (cur + 1) % n; + else if (k == K_HOME) cur = 0; + else if (k == K_END) cur = n - 1; + else if (k == K_SPACE && checked) checked[cur] = !checked[cur]; + else if (k == K_ALL && checked) { + int all = 1; + for (int i = 0; i < n; i++) all &= checked[i] != 0; + for (int i = 0; i < n; i++) checked[i] = !all; + } else if (k == K_ENTER) { result = cur; break; } + else if (k == K_QUIT) break; + else if (ch >= '1' && ch <= '9' && ch - '1' < n) cur = ch - '1'; + } + raw_off(); + putchar('\n'); + fflush(stdout); + return result; +} diff --git a/tests/run.sh b/tests/run.sh index faa88f0..85307c5 100644 --- a/tests/run.sh +++ b/tests/run.sh @@ -31,7 +31,17 @@ expect_error "broken JSON" "line 2" '{"remaps": [ expect_error "unknown action" "unknown shortcut" '{"shortcuts": [{"action": "fly", "keys": ["alt+f"]}]}' generate windows expect_error "unknown step" "unknown install step" '{}' install nope -expect_error "unknown vps step" "unknown vps step" '{}' vps keys + +# install presets run exactly these steps (no menu: stdin isn't a terminal) +expect_steps() { # expect_steps + got=$(./cain $1 --dry-run < /dev/null 2>&1 | sed -n 's/^== \(.*\) ==$/\1/p' | tr '\n' ' ') + if [ "$got" = "$2 " ]; then echo "ok cain $1"; else echo "FAIL cain $1 (wanted: $2, got: $got)"; fail=1; fi +} +expect_steps "install light" "git files bin shell keys" +expect_steps "install dotfiles" "git files bin shell" +expect_steps "install keybindings" "keys" +expect_steps "install dotfiles keys" "git files bin shell keys" +[ "$(uname -s)" = Linux ] && expect_steps "install server" "packages git files bin shell" # `cain vps` (Linux only): terminal packages only, with the right name for each distro expect_output() { # expect_output ... @@ -46,6 +56,7 @@ expect_output() { # expect_output ... echo "ok $desc" } if [ "$(uname -s)" = Linux ]; then + expect_error "unknown vps step" "unknown vps step" '{}' vps keys expect_output "vps apt" apt "apt-get install -y python3-venv" "install -y openssh-client" "!inkscape" "!xclip" "!apk" expect_output "vps dnf" dnf "dnf install -y ffmpeg-free" "install -y openssh-clients" "install -y iproute\$" "!python3-venv" expect_output "vps pacman" pacman "pacman -Syu" "noconfirm python-pip" "noconfirm openssh" "!virt-manager"