From c5d2624a06de504aff446490dbbd96ec82c1428c Mon Sep 17 00:00:00 2001 From: Rootiest Date: Wed, 9 Sep 2026 14:29:40 -0400 Subject: [PATCH 1/5] feat(config-settings): curses prototype front-end Add scripts/config-settings-tui.py, a stdlib-curses prototype of the config-settings interface, plus a gate that runs its --self-test. The backend is stubbed: values live in an in-memory dict and nothing is read from or written to fish variables. This is here to evaluate the render engine and the interaction model before committing to a rewrite. Why curses rather than more ANSI arithmetic: - No flicker, structurally. curses diffs its virtual screen against the physical one and emits only the changed cells, which is what __config_settings_diff_redraw.fish reimplements by hand. - Alternate screen plus absolute addressing. Stray output cannot desync the display, so the bug class behind 608b022 (fish's read prompt), 4210f3b (a shadow warning on stderr), 93fc5e0 and 3c4f720 (line wrap breaking the erase height) cannot occur at all. - Resize is a repaint rather than wrap-factor arithmetic. - Overlays, panes, live filtering and mouse input cost a few lines each. The layout departs from the current single panel: a page sidebar with a live filter on the left, a scrolling detail pane on the right, a help overlay on '?', and mouse selection. Sub-category drill-down, the tri-state badges and the Sponge/Paths value rows all carry over. Dependency note: python3 with the curses module. That is stdlib on Arch, Fedora and a full Debian/Ubuntu python3; python3-minimal alone does not carry _curses, so the test asserts the import. The prototype is not wired into config-settings and nothing existing changed. --- scripts/config-settings-tui.py | 632 ++++++++++++++++++++++++++++ tests/test-config-settings-tui.fish | 33 ++ 2 files changed, 665 insertions(+) create mode 100755 scripts/config-settings-tui.py create mode 100755 tests/test-config-settings-tui.fish diff --git a/scripts/config-settings-tui.py b/scripts/config-settings-tui.py new file mode 100755 index 0000000..b351fec --- /dev/null +++ b/scripts/config-settings-tui.py @@ -0,0 +1,632 @@ +#!/usr/bin/env python3 +# Copyright (C) 2026 Rootiest +# SPDX-License-Identifier: AGPL-3.0-or-later +# +# SYNOPSIS +# config-settings-tui.py [--self-test] +# +# DESCRIPTION +# PROTOTYPE front-end for `config-settings`, rendered with Python's stdlib +# `curses` instead of hand-rolled ANSI cursor arithmetic. The backend is +# stubbed: values live in an in-memory dict and nothing is read from or +# written to fish universal/global variables. The point is to evaluate the +# interaction model and the render engine, not to be functional. +# +# What curses buys over the fish implementation: +# - No flicker, structurally. curses diffs its virtual screen against the +# physical one and emits only the changed cells. The fish version +# reimplements this by hand in __config_settings_diff_redraw.fish. +# - Alternate screen + absolute addressing. Stray output cannot desync the +# display, so the whole class of bugs behind commits 608b022, 4210f3b, +# 93fc5e0 and 3c4f720 cannot occur. +# - Resize is a repaint, not wrap-factor arithmetic. +# - Overlays, panes, live filtering and mouse input are a few lines each. +# +# DEPENDENCIES +# python3 (stdlib only; the `curses` module ships with CPython on Linux -- +# Debian/Ubuntu users who installed python3-minimal alone may need the full +# `python3` package, which pulls in _curses). +# +# ARGUMENTS +# --self-test Exercise the pure state/filter logic without a terminal and +# exit non-zero on failure. Used by tests; needs no TTY. +# +# EXIT STATUS +# 0 Clean exit, or --self-test passed +# 1 --self-test failed +# +# EXAMPLE +# ./scripts/config-settings-tui.py +# ./scripts/config-settings-tui.py --self-test + +import os +import sys + +# ncurses reads ESCDELAY at init; 25ms makes bare Esc feel instant instead of +# the 1s default. Must be set before curses is initialised. +os.environ.setdefault("ESCDELAY", "25") + +import curses # noqa: E402 + +# ╭──────────────────────────────────────────────────────────────────────────╮ +# │ Stub model │ +# ╰──────────────────────────────────────────────────────────────────────────╯ + +TRI = ("DEFAULT", "ON", "OFF") + +# label, description, [(sub-label, sub-description), ...] +CATEGORIES = [ + ("Aliases", "shadows: ls→eza, cat→bat, cd→z, rm→trash", [ + ("Filesystem", "ls, cat, cd, du, mkdir, rm, mv, zoxide"), + ("Search", "rg"), + ("Network", "ping, ssh, yt-dlp"), + ("Monitor", "top"), + ("Shell-tools", "bash, less, help"), + ("Dev-tools", "claude, edit, agy"), + ]), + ("Auto-exec", "Fisher bootstrap, themes, py-venv activate", [ + ("Plugins", "Fisher bootstrap"), + ("Pkg-wrappers", "paru/yay wrapper generation"), + ("Venv", "Python auto-activation"), + ("Telemetry", "WakaTime hook bootstrap"), + ("Sync", "auto-pull, user-dots symlink"), + ]), + ("Overrides", "vi-mode, bang-bang, PAGER, CDPATH, starship", [ + ("Key-bindings", "vi-mode, autopair, puffer, bang-bang"), + ("Environment", "PATH, PAGER, EDITOR, CDPATH"), + ("Prompt", "Starship, right prompt, theme + FZF colors"), + ("Privacy", "DO_NOT_TRACK, DISABLE_TELEMETRY"), + ]), + ("Integrations", "Kitty/WezTerm tab/split fns, notifications", [ + ("Term-abbrs", "Kitty/WezTerm abbreviations"), + ("Window-mgmt", "spwin, tab, split"), + ("Notifications", "done, WakaTime hook"), + ("History-logs", "hist, logs"), + ("Pkg-upgrade", "upgrade"), + ]), + ("Logging", "scrollback capture & paru/yay AUR wrappers", [ + ("Term-capture", "Kitty watcher, smart_exit scrollback"), + ("Multiplexer", "tmux, zellij"), + ("Pkg-logs", "paru/yay AUR log wrappers"), + ]), + ("Greeting", "fish_greeting & first-run welcome banner", [ + ("First-run", "welcome banner"), + ("Greeting", "fish_greeting override"), + ]), + ("Master", "master off-switch: overrides all categories", []), +] + +# label, kind, default-hint, description +SPONGE = [ + ("Delay", "int", "2", "entries kept before a failed command is purged"), + ("Purge@exit", "bool", "false", "only purge history on shell exit"), + ("Allow prev", "bool", "true", "keep commands that previously succeeded"), + ("OK codes", "list", "0", "exit codes treated as success"), + ("Extra secret", "list", "(none)", "extra patterns scrubbed from history"), +] + +PATHS = [ + ("Log dir", "path", "~/.terminal_history", "scrollback capture directory"), + ("Log max", "int", "100", "max scrollback files retained"), + ("Dots path", "path", "(default)", "user-dots source directory"), + ("Dots link", "bool", "on", "symlink ~/.config/.user-dots/fish"), +] + +PAGES = ["Universal", "Session", "Sponge", "Paths"] + + +def initial_state(): + """Seed the in-memory stub backend. Keys are (page, row-label).""" + st = {} + for page in ("Universal", "Session"): + for label, _, _ in CATEGORIES: + st[(page, label)] = "DEFAULT" + st[(page, "Aliases")] = "ON" + st[(page, "Auto-exec")] = "OFF" + st[(page, "Integrations")] = "ON" + for row in SPONGE: + st[("Sponge", row[0])] = "" + for row in PATHS: + st[("Paths", row[0])] = "" + st[("Paths", "Log dir")] = "~/logs/term" + return st + + +def rows_for(page): + """Row tuples (label, kind, hint, description) for a page.""" + if page in ("Universal", "Session"): + return [(lbl, "tri", "DEFAULT", desc) for lbl, desc, _ in CATEGORIES] + return SPONGE if page == "Sponge" else PATHS + + +def subcats_for(label): + for lbl, _, subs in CATEGORIES: + if lbl == label: + return subs + return [] + + +def apply_filter(rows, needle): + """Case-insensitive match over label and description.""" + if not needle: + return rows + n = needle.lower() + return [r for r in rows if n in r[0].lower() or n in r[3].lower()] + + +def cycle(value, kind, forward): + """Advance a value one step. Non-toggle kinds are edited, not cycled.""" + if kind == "tri": + i = TRI.index(value) if value in TRI else 0 + return TRI[(i + (1 if forward else -1)) % len(TRI)] + if kind == "bool": + order = ("", "on", "off") + i = order.index(value) if value in order else 0 + return order[(i + (1 if forward else -1)) % len(order)] + return "" if not forward else value + + +# ╭──────────────────────────────────────────────────────────────────────────╮ +# │ Rendering │ +# ╰──────────────────────────────────────────────────────────────────────────╯ + +SIDEBAR_W = 16 +MIN_W, MIN_H = 60, 14 + +C_FRAME, C_TITLE, C_SEL, C_ON, C_OFF, C_DEF, C_DIM, C_HINT, C_BAR = range(1, 10) + + +def init_colors(): + curses.start_color() + try: + curses.use_default_colors() + bg = -1 + except curses.error: + bg = curses.COLOR_BLACK + pairs = { + C_FRAME: curses.COLOR_BLUE, + C_TITLE: curses.COLOR_MAGENTA, + C_SEL: curses.COLOR_CYAN, + C_ON: curses.COLOR_GREEN, + C_OFF: curses.COLOR_RED, + C_DEF: curses.COLOR_YELLOW, + C_DIM: curses.COLOR_BLACK, + C_HINT: curses.COLOR_CYAN, + C_BAR: curses.COLOR_WHITE, + } + for pair, fg in pairs.items(): + curses.init_pair(pair, fg, bg) + + +def put(win, y, x, text, attr=0, limit=None): + """Clipped addstr. + + curses cannot addstr() the bottom-right cell -- writing it would advance + the cursor off the window. Fill that one cell with insstr(), which does + not move the cursor, so the frame closes instead of losing its corner. + """ + h, w = win.getmaxyx() + if y < 0 or y >= h or x >= w: + return + room = (w - x) if limit is None else min(limit, w - x) + if room <= 0: + return + text = text[:room] + try: + if y == h - 1 and x + len(text) >= w: + if len(text) > 1: + win.addstr(y, x, text[:-1], attr) + win.insstr(y, w - 1, text[-1], attr) + else: + win.addstr(y, x, text, attr) + except curses.error: + pass + + +def ell(text, room): + """Truncate with an ellipsis so a clipped description reads as clipped.""" + if room <= 0: + return "" + return text if len(text) <= room else text[: room - 1] + "…" + + +def badge(value, kind, hint): + """Right-hand value cell plus its colour pair.""" + if kind == "tri": + return { + "ON": ("[ ON ]", C_ON), + "OFF": ("[ OFF ]", C_OFF), + }.get(value, ("[ DEFAULT ]", C_DEF)) + if kind == "bool": + return { + "on": ("[ ON ]", C_ON), + "off": ("[ OFF ]", C_OFF), + }.get(value, ("[ DEFAULT ]", C_DEF)) + return ((value, C_ON) if value else (hint, C_DIM)) + + +class App: + def __init__(self, stdscr): + self.scr = stdscr + self.state = initial_state() + self.page = 0 + self.row = 0 + self.filter = "" + self.mode = "nav" # nav | filter | edit | help + self.buf = "" # filter/edit scratch buffer + self.drill = None # category label when inside sub-categories + self.msg = "prototype — nothing is written to fish variables" + self.row_hits = [] # screen-row → row-index, for mouse clicks + + # ── data helpers ────────────────────────────────────────────────────── + @property + def page_name(self): + return PAGES[self.page] + + def visible_rows(self): + if self.drill is not None: + return [(lbl, "tri", "DEFAULT", desc) + for lbl, desc in subcats_for(self.drill)] + return apply_filter(rows_for(self.page_name), self.filter) + + def current(self): + rows = self.visible_rows() + return rows[self.row] if rows else None + + def value_of(self, label): + key = (self.drill or self.page_name, label) + return self.state.get(key, "DEFAULT" if self.drill else "") + + def set_value(self, label, value): + self.state[(self.drill or self.page_name, label)] = value + + # ── drawing ─────────────────────────────────────────────────────────── + def draw(self): + scr = self.scr + scr.erase() + h, w = scr.getmaxyx() + if h < MIN_H or w < MIN_W: + put(scr, 0, 0, f"terminal too small ({w}x{h}); need {MIN_W}x{MIN_H}") + scr.noutrefresh() + curses.doupdate() + return + + frame = curses.color_pair(C_FRAME) + title = " Opinionated Settings " + put(scr, 0, 0, "┌" + "─" * (w - 2) + "┐", frame) + put(scr, 0, 2, title, curses.color_pair(C_TITLE) | curses.A_BOLD) + body_top, body_bot = 1, h - 3 + for y in range(body_top, body_bot + 1): + put(scr, y, 0, "│", frame) + put(scr, y, SIDEBAR_W + 1, "│", frame) + put(scr, y, w - 1, "│", frame) + put(scr, h - 2, 0, "├" + "─" * SIDEBAR_W + "┴" + "─" * (w - SIDEBAR_W - 3) + "┤", frame) + put(scr, h - 1, 0, "│", frame) + put(scr, h - 1, w - 1, "│", frame) + + self.draw_sidebar(body_top, body_bot) + self.draw_detail(body_top, body_bot, w) + self.draw_status(h, w) + + scr.noutrefresh() + if self.mode == "help": + self.draw_help(h, w) + curses.doupdate() + + def draw_sidebar(self, top, bot): + scr = self.scr + put(scr, top, 2, "PAGES", curses.color_pair(C_DIM) | curses.A_BOLD) + for i, name in enumerate(PAGES): + y = top + 1 + i + if y > bot: + break + sel = i == self.page and self.drill is None + attr = curses.color_pair(C_SEL) | curses.A_BOLD if sel else 0 + put(scr, y, 2, ("▸ " if sel else " ") + name, attr, SIDEBAR_W - 2) + y = top + len(PAGES) + 2 + if y <= bot: + put(scr, y, 2, "FILTER", curses.color_pair(C_DIM) | curses.A_BOLD) + if self.mode == "filter": + shown = "/" + self.buf + "_" + attr = curses.color_pair(C_HINT) | curses.A_BOLD + elif self.filter: + shown, attr = "/" + self.filter, curses.color_pair(C_HINT) + else: + shown, attr = "(none)", curses.color_pair(C_DIM) + put(scr, y + 1, 2, shown, attr, SIDEBAR_W - 2) + + def draw_detail(self, top, bot, w): + scr = self.scr + left = SIDEBAR_W + 3 + width = w - left - 1 + rows = self.visible_rows() + self.row_hits = [] + + if self.drill is not None: + put(scr, top, left, f"{self.drill} ▸ sub-categories", + curses.color_pair(C_TITLE) | curses.A_BOLD, width) + top += 1 + + if not rows: + put(scr, top, left, "no rows match", curses.color_pair(C_DIM), width) + return + + # Two screen lines per row (value line + description), one blank between. + per = 3 + capacity = max(1, (bot - top + 1) // per) + first = max(0, min(self.row - capacity + 1, len(rows) - capacity)) + first = max(0, first) + + for i in range(first, min(len(rows), first + capacity)): + label, kind, hint, desc = rows[i] + y = top + (i - first) * per + sel = i == self.row + self.row_hits.append((y, i)) + self.row_hits.append((y + 1, i)) + + if self.mode == "edit" and sel: + cell, cattr = self.buf + "_", C_HINT + else: + cell, cattr = badge(self.value_of(label), kind, hint) + cell = cell[: max(0, width)] + + lattr = curses.color_pair(C_SEL) | curses.A_BOLD if sel else curses.A_BOLD + put(scr, y, left, ("▸ " if sel else " ") + label, lattr, width - len(cell) - 2) + put(scr, y, left + width - len(cell), cell, + curses.color_pair(cattr) | (curses.A_BOLD if sel else 0)) + put(scr, y + 1, left + 4, ell(desc, width - 4), curses.color_pair(C_DIM), width - 4) + if y + 2 <= bot and i + 1 < min(len(rows), first + capacity): + put(scr, y + 2, left, "─" * width, curses.color_pair(C_FRAME) | curses.A_DIM) + + def draw_status(self, h, w): + if self.mode == "edit": + keys = "⏎ commit Esc cancel" + elif self.mode == "filter": + keys = "⏎ apply Esc clear" + elif self.drill is not None: + keys = "↑↓ move ←→ set Esc back ? help q quit" + elif w >= 90: + keys = "↑↓ move ←→ set ⏎ drill/edit Tab page / filter ? help q quit" + else: + keys = "↑↓ ←→ ⏎ Tab pg / filter ? help q quit" + # Reserve the final column for the frame; put() refuses to write the + # bottom-right cell anyway, so anything past w-2 is silently lost. + avail = w - 4 + put(self.scr, h - 1, 2, keys, curses.color_pair(C_BAR) | curses.A_BOLD, avail) + room = avail - len(keys) - 3 + if self.msg and room > 8: + put(self.scr, h - 1, 2 + len(keys) + 3, ell(self.msg, room), + curses.color_pair(C_DIM), room) + + def draw_help(self, h, w): + lines = [ + "", + " Navigation", + " ↑ ↓ / k j move between rows", + " Tab / ⇧Tab next / previous page", + " ⏎ drill into sub-categories, or edit a value", + " Esc leave sub-categories / cancel", + "", + " Values", + " ← → / h l cycle DEFAULT → ON → OFF", + " ⏎ inline edit (int, list and path rows)", + "", + " Other", + " / live filter over label and description", + " mouse click a row to select, click its value to cycle", + " ? toggle this overlay", + " q quit", + "", + " Prototype: curses render engine, stubbed backend.", + "", + ] + bw = min(w - 4, max(len(x) for x in lines) + 4) + bh = min(h - 2, len(lines) + 2) + y0, x0 = (h - bh) // 2, (w - bw) // 2 + win = curses.newwin(bh, bw, y0, x0) + win.bkgd(" ", curses.color_pair(C_BAR)) + win.attrset(curses.color_pair(C_FRAME)) + win.box() + put(win, 0, 3, " Help ", curses.color_pair(C_TITLE) | curses.A_BOLD) + for i, line in enumerate(lines[: bh - 2], start=1): + attr = curses.A_BOLD if line.strip() and not line.startswith(" ") else 0 + put(win, i, 1, line, attr, bw - 2) + win.noutrefresh() + + # ── input ───────────────────────────────────────────────────────────── + def clamp(self): + n = len(self.visible_rows()) + self.row = 0 if n == 0 else max(0, min(self.row, n - 1)) + + def handle(self, ch): + """Return False to quit.""" + if ch == curses.KEY_RESIZE: + return True + if self.mode == "help": + self.mode = "nav" + return True + if self.mode in ("filter", "edit"): + return self.handle_text(ch) + return self.handle_nav(ch) + + def handle_text(self, ch): + if ch in (curses.KEY_ENTER, 10, 13): + if self.mode == "filter": + self.filter = self.buf + else: + row = self.current() + if row: + self.set_value(row[0], self.buf) + self.msg = f"{row[0]} = {self.buf or '(cleared)'}" + self.mode, self.buf = "nav", "" + self.clamp() + elif ch == 27: # Esc + if self.mode == "filter": + self.filter = "" + self.mode, self.buf = "nav", "" + self.clamp() + elif ch in (curses.KEY_BACKSPACE, 127, 8): + self.buf = self.buf[:-1] + if self.mode == "filter": + self.filter = self.buf + self.clamp() + elif 32 <= ch < 127: + self.buf += chr(ch) + if self.mode == "filter": + self.filter = self.buf + self.clamp() + return True + + def handle_nav(self, ch): + rows = self.visible_rows() + key = chr(ch) if 32 <= ch < 127 else "" + + if key == "q": + return False + if key == "?": + self.mode = "help" + elif ch in (curses.KEY_DOWN,) or key == "j": + self.row = (self.row + 1) % max(1, len(rows)) + elif ch in (curses.KEY_UP,) or key == "k": + self.row = (self.row - 1) % max(1, len(rows)) + elif ch in (curses.KEY_RIGHT,) or key == "l": + self.bump(True) + elif ch in (curses.KEY_LEFT,) or key == "h": + self.bump(False) + elif ch == 9: # Tab + if self.drill is None: + self.page = (self.page + 1) % len(PAGES) + self.row = 0 + elif ch == curses.KEY_BTAB: + if self.drill is None: + self.page = (self.page - 1) % len(PAGES) + self.row = 0 + elif key == "/": + if self.drill is None: + self.mode, self.buf = "filter", self.filter + elif ch in (curses.KEY_ENTER, 10, 13): + self.enter() + elif ch == 27: + if self.drill is not None: + self.drill, self.row = None, 0 + elif self.filter: + self.filter, self.row = "", 0 + elif ch == curses.KEY_MOUSE: + self.mouse() + self.clamp() + return True + + def enter(self): + row = self.current() + if not row: + return + label, kind = row[0], row[1] + if kind == "tri" and self.drill is None and subcats_for(label): + self.drill, self.row = label, 0 + for sub in subcats_for(label): + self.state.setdefault((label, sub[0]), "DEFAULT") + elif kind in ("int", "list", "path"): + self.mode, self.buf = "edit", self.value_of(label) + else: + self.bump(True) + + def bump(self, forward): + row = self.current() + if not row: + return + label, kind = row[0], row[1] + if kind in ("tri", "bool"): + new = cycle(self.value_of(label), kind, forward) + self.set_value(label, new) + self.msg = f"{label} = {new or 'DEFAULT'}" + elif not forward: + self.set_value(label, "") + self.msg = f"{label} cleared to default" + + def mouse(self): + try: + mouse = curses.getmouse() + except curses.error: + return + mx, my = mouse[1], mouse[2] + for y, idx in self.row_hits: + if y == my: + if idx == self.row and mx > self.scr.getmaxyx()[1] - 16: + self.bump(True) + else: + self.row = idx + return + + def run(self): + curses.curs_set(0) + self.scr.keypad(True) + curses.mousemask(curses.BUTTON1_CLICKED) + while True: + self.draw() + try: + ch = self.scr.getch() + except KeyboardInterrupt: + return + if not self.handle(ch): + return + + +# ╭──────────────────────────────────────────────────────────────────────────╮ +# │ Entry points │ +# ╰──────────────────────────────────────────────────────────────────────────╯ + +def self_test(): + """Exercise the pure logic. No terminal required.""" + st = initial_state() + assert st[("Universal", "Aliases")] == "ON" + assert st[("Universal", "Overrides")] == "DEFAULT" + + assert cycle("DEFAULT", "tri", True) == "ON" + assert cycle("ON", "tri", True) == "OFF" + assert cycle("OFF", "tri", True) == "DEFAULT" + assert cycle("DEFAULT", "tri", False) == "OFF" + assert cycle("", "bool", True) == "on" + + rows = rows_for("Universal") + assert len(rows) == len(CATEGORIES) + assert rows_for("Sponge") is SPONGE + + hits = apply_filter(rows, "log") + assert [r[0] for r in hits] == ["Logging"], hits + # Matches on description, not just label. + assert [r[0] for r in apply_filter(rows, "starship")] == ["Overrides"] + assert apply_filter(rows, "") == rows + assert apply_filter(rows, "zzz") == [] + + assert len(subcats_for("Aliases")) == 6 + assert subcats_for("Master") == [] + + assert badge("ON", "tri", "")[1] == C_ON + assert badge("", "path", "~/x")[0] == "~/x" + assert badge("/tmp", "path", "~/x")[0] == "/tmp" + + assert ell("abcdef", 10) == "abcdef" + assert ell("abcdef", 4) == "abc…" + assert ell("abcdef", 0) == "" + + print("self-test OK") + return 0 + + +def main(): + if "--self-test" in sys.argv: + return self_test() + if "-h" in sys.argv or "--help" in sys.argv: + print(__doc__ or "config-settings-tui.py [--self-test]") + return 0 + + def boot(stdscr): + init_colors() + App(stdscr).run() + + curses.wrapper(boot) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/test-config-settings-tui.fish b/tests/test-config-settings-tui.fish new file mode 100755 index 0000000..254c769 --- /dev/null +++ b/tests/test-config-settings-tui.fish @@ -0,0 +1,33 @@ +#!/usr/bin/env fish +# Copyright (C) 2026 Rootiest +# SPDX-License-Identifier: AGPL-3.0-or-later +# +# Gate for the curses config-settings PROTOTYPE (scripts/config-settings-tui.py). +# +# Runs isolated (no `# MODE:` marker). Two things are checked, and only two: +# +# 1. python3 can import `curses`. On Arch, Fedora and a full Debian/Ubuntu +# python3 this is stdlib and always true, but python3-minimal alone does +# not carry _curses -- the one portability claim the prototype makes. +# 2. The prototype's own --self-test passes. That covers the pure state +# machine (value cycling, filtering, sub-category lookup, badge and +# ellipsis rendering) with no TTY, which is all a test runner has. +# +# The drawing code is deliberately NOT golden-tested. That is the whole +# argument for the prototype: curses owns the cell arithmetic, so there is no +# hand-tuned width/pad/dash math to pin down the way +# test-config-settings-render.fish has to pin the fish renderer's. + +source (dirname (status filename))/lib.fish + +set -l tui $repo_root/scripts/config-settings-tui.py + +section "config-settings-tui: prerequisites" +check "scripts/config-settings-tui.py is executable" true (test -x $tui; and echo true; or echo false) +check "python3 is available" true (type -q python3; and echo true; or echo false) +check "python3 ships the curses module" 0 (python3 -c 'import curses' >/dev/null 2>&1; echo $status) + +section "config-settings-tui: self-test" +check "--self-test passes" 0 (python3 $tui --self-test >/dev/null 2>&1; echo $status) + +report -- 2.54.0 From 078d01666e987cb984ac1e12958d4b59d71ecf85 Mon Sep 17 00:00:00 2001 From: Rootiest Date: Wed, 9 Sep 2026 14:50:26 -0400 Subject: [PATCH 2/5] feat(config-settings): replace the ANSI renderer with a curses front-end MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit config-settings is now a launcher for scripts/config-settings-tui.py, drawn with Python's stdlib curses. The seven fish files that hand-rolled the ANSI renderer are gone, along with the golden harness that had to pin their byte-exact output. The TUI is a child process, so it can neither read the session's global variables nor write them. State goes in as a dump from the new __config_settings_state; the edits come back as a fish script that config-settings sources, which is what lets the Session page's `set -g` land in the caller's shell instead of in a child that is about to exit. Every edit is emitted as a call to __config_settings_apply or __config_settings_set_value, so list splitting, the SCROLLBACK_HISTORY_* export mirror and the shadow-warning suppression all stay in the fish layer that already owned them. The consequence, and the one behaviour change: edits are applied in one batch on exit rather than on each keypress. The status bar shows a pending count. New: `/` filters the current page, and on the Universal and Session pages it reaches into every category's sub-categories, listing hits as "Category › Sub" so a sub-category can be toggled without drilling into its parent first. Also a `?` help overlay, mouse selection, and a drill-down page that leads with the category's own toggle. Gone with the renderer: the four width tiers, the wrap-aware erase arithmetic, the stty/dd/od raw key reader, the panel-height bookkeeping and the hand-written redraw differ. curses owns all of it, and the alt screen plus absolute addressing makes the desync class behind 608b022, 4210f3b, 93fc5e0 and 3c4f720 unreachable. The sub-category taxonomy is NOT duplicated in Python: it travels in the state dump, still sourced from __config_settings_subcats. The category, Sponge and Paths row tables move into Python, consolidating the two copies the fish renderers kept. Dependency: python3 with curses. Stdlib on Arch, Fedora and a full Debian/Ubuntu python3; python3-minimal alone lacks _curses. The launcher checks for both and names what is missing. Called out in the README. Verified: 416/416 assertions, plus a live end-to-end in a sandbox HOME confirming the Universal page writes universal scope only (U1/G0) and the Session page global scope only (U0/G1). --- README.md | 4 +- functions/__config_settings_diff_redraw.fish | 47 - functions/__config_settings_draw.fish | 155 - functions/__config_settings_draw_subcat.fish | 120 - functions/__config_settings_draw_value.fish | 159 - functions/__config_settings_frame.fish | 155 - functions/__config_settings_pagetab.fish | 45 - functions/__config_settings_read_key.fish | 97 - functions/__config_settings_state.fish | 76 + functions/config-settings.fish | 459 +- scripts/config-settings-tui.py | 706 ++- tests/golden/config-settings-render.txt | 4994 ------------------ tests/test-config-settings-render.fish | 285 - tests/test-config-settings-tui.fish | 43 +- tests/test-session.fish | 138 +- 15 files changed, 745 insertions(+), 6738 deletions(-) delete mode 100644 functions/__config_settings_diff_redraw.fish delete mode 100644 functions/__config_settings_draw.fish delete mode 100644 functions/__config_settings_draw_subcat.fish delete mode 100644 functions/__config_settings_draw_value.fish delete mode 100644 functions/__config_settings_frame.fish delete mode 100644 functions/__config_settings_pagetab.fish delete mode 100644 functions/__config_settings_read_key.fish create mode 100644 functions/__config_settings_state.fish delete mode 100644 tests/golden/config-settings-render.txt delete mode 100644 tests/test-config-settings-render.fish diff --git a/README.md b/README.md index 22fdf30..7327b84 100644 --- a/README.md +++ b/README.md @@ -186,6 +186,8 @@ Then open a new Fish shell — Fisher will be installed automatically on first l A [chezmoi](https://www.chezmoi.io/) dotfile manager is also configured — secrets are kept in a private overlay directory (see [Personalization](#personalization)) and excluded from version control. +**External requirements.** Everything degrades gracefully if a tool is missing, with two exceptions worth calling out. `python3` is required by the documentation pipeline and by `config-settings`, whose TUI is drawn with the stdlib `curses` module. That module ships with CPython on Arch, Fedora and a full Debian/Ubuntu `python3`; `python3-minimal` alone does **not** carry `_curses`, so install the complete `python3` package there. `config-settings` checks for both and tells you which is missing rather than failing inside the renderer. + > [!IMPORTANT] > `config.fish` ends with a `return` sentinel guard. Any lines appended **after** it by a tool's setup command will silently have no effect. Many tools (starship, zoxide, mise, etc.) offer a setup command that appends an `init | source` line to your `config.fish` — all integrations are managed through `conf.d/` files instead. If you add a new tool and its shell integration appears to do nothing, check whether its setup command appended an init line to the bottom of `config.fish` and create a `conf.d/.fish` file for it instead. @@ -288,7 +290,7 @@ To opt out, set `__fish_user_dots_symlink` to a falsy value (or toggle **Dots li Everything opinionated in this config — command shadows, startup side-effects, key and environment overrides, terminal integrations, and the first-run greeting — is active by default but can be switched off. Logging (C5) is the exception: it is **opt-in**, off until you explicitly enable it. -> **The easy way — `config-settings`:** Run `config-settings` for an interactive TUI that manages settings across four pages — **Universal** and **Session** (the opinionated category toggles below, persistent or per-shell), **Sponge** (history-scrubbing: delay, exit codes, purge-on-exit, and extra sensitive variable names), and **Paths** (scrollback log dir, max files, and the user-dots path) — without typing a single variable name. Navigate with the arrow keys (or `h`/`j`/`k`/`l`); toggle rows step OFF ← DEFAULT → ON, value rows edit inline with `Enter` and clear with `←`. `Tab`/`Shift-Tab` cycle pages and `q` quits. Changes apply instantly. The panel auto-sizes to your terminal width (four tiers from 52- to 78-wide with a 6-column margin), centers itself horizontally, and redraws within ~0.3 s of a resize. +> **The easy way — `config-settings`:** Run `config-settings` for an interactive TUI that manages settings across four pages — **Universal** and **Session** (the opinionated category toggles below, persistent or per-shell), **Sponge** (history-scrubbing: delay, exit codes, purge-on-exit, and extra sensitive variable names), and **Paths** (scrollback log dir, max files, and the user-dots path) — without typing a single variable name. Navigate with the arrow keys (or `h`/`j`/`k`/`l`); toggle rows step OFF ← DEFAULT → ON, value rows edit inline with `Enter` and reset with `←`. `Enter` on a category opens its sub-categories, `/` filters the page — reaching into sub-categories too, listed as `Category › Sub` — `?` opens a help overlay, `Tab`/`Shift-Tab` cycle pages, and `q` applies your edits and quits. Edits are collected as you make them and written in one batch on exit; the status bar shows the pending count. The panel is drawn with Python's stdlib `curses`, so it resizes with the terminal and never flickers — see [Installation](#installation) for the `python3` requirement. If you'd rather set them by hand, each category is controlled by a universal variable. Six category toggles and one master switch are available: diff --git a/functions/__config_settings_diff_redraw.fish b/functions/__config_settings_diff_redraw.fish deleted file mode 100644 index f5d9621..0000000 --- a/functions/__config_settings_diff_redraw.fish +++ /dev/null @@ -1,47 +0,0 @@ -# Copyright (C) 2026 Rootiest -# SPDX-License-Identifier: AGPL-3.0-or-later - -# SYNOPSIS -# __config_settings_diff_redraw -# -# DESCRIPTION -# Rewrites an on-screen panel frame in place, touching only the lines -# that changed. Assumes the cursor is already positioned at the top-left -# of the frame (the caller moves it there with a plain \e[A -- no -# \e[J -- before calling this). Line count in old_joined and new_joined -# must be equal; a caller facing a height or width change should use the -# existing full erase+redraw path instead of calling this. -# -# Unchanged lines advance the cursor with a bare newline, leaving -# whatever is already on screen untouched. Changed lines clear just that -# line (\e[2K), return to its start (\r), print the new content, and -# advance (\n). This is what removes the erase-then-redraw flicker: the -# screen is never blanked, only the handful of lines that actually -# differ are ever touched, and each of those is cleared and rewritten in -# the same breath rather than blanked-then-paused-then-filled. -# -# ARGUMENTS -# old_joined Previous frame, lines joined with \n -# new_joined New frame, lines joined with \n (same line count as old) -# -# EXIT STATUS -# 0 Always -# -# RETURNS -# The ANSI sequence needed to turn the old frame into the new one, -# printed to stdout -# -# EXAMPLE -# __config_settings_diff_redraw (string join \n -- $prev_frame) \ -# (string join \n -- $new_frame) -function __config_settings_diff_redraw - set -l old (string split \n -- $argv[1]) - set -l new (string split \n -- $argv[2]) - for i in (seq (count $new)) - if test "$old[$i]" = "$new[$i]" - printf '\n' - else - printf '\e[2K\r%s\n' $new[$i] - end - end -end diff --git a/functions/__config_settings_draw.fish b/functions/__config_settings_draw.fish deleted file mode 100644 index f4d975b..0000000 --- a/functions/__config_settings_draw.fish +++ /dev/null @@ -1,155 +0,0 @@ -# Copyright (C) 2026 Rootiest -# SPDX-License-Identifier: AGPL-3.0-or-later - -# SYNOPSIS -# __config_settings_draw ... -# -# DESCRIPTION -# Renders the 16-line config-settings TUI panel to stdout. Panel width and -# horizontal position are chosen automatically from $COLUMNS each call, -# so a terminal resize takes effect on the next keypress without any -# extra bookkeeping. Four width tiers with a 6-col buffer per side: -# -# COLUMNS ≥ 90 → 78-wide (IW=76, desc=43 chars) -# COLUMNS ≥ 86 → 74-wide (IW=72, desc=39 chars) -# COLUMNS ≥ 82 → 70-wide (IW=68, desc=35 chars) -# COLUMNS < 82 → 52-wide (IW=50, desc=17 chars) ← default -# -# The box is horizontally centered via a left-padding prefix on every -# output line. \e[16A\e[J erases by line count so the horizontal offset -# does not interfere with the redraw loop. -# -# ARGUMENTS -# cur_row 0–6, the currently highlighted row -# cur_scope "universal" or "session" -# var1–var7 Variable names for rows 0–6 (6 categories + master) -# -# EXIT STATUS -# 0 Always -# -# EXAMPLE -# __config_settings_draw 0 universal \ -# __fish_config_op_aliases __fish_config_op_autoexec \ -# __fish_config_op_overrides __fish_config_op_integrations \ -# __fish_config_op_logging __fish_config_op_greeting \ -# __fish_config_opinionated -function __config_settings_draw - set -l cur_row $argv[1] - set -l cur_scope $argv[2] - set -l vars $argv[3..] - - __fish_palette - - set -l labels Aliases Auto-exec Overrides Integrations Logging Greeting Master - - # ── Width tier ──────────────────────────────────────────────────────── - # The tier thresholds live in __config_settings_frame; this file only - # chooses which hand-authored description set goes with the width. - # IW = inner width (chars between │ │); desc field = IW - 33. - # All four layouts are exactly 16 lines tall — panel_h in caller stays 16. - # Descriptions are authored to fit their field exactly at every tier - # (43/43, 39/39, 35/35, 17/17), which is why the rows below pass `pad` - # and not `cut` -- see the NOTES in __config_settings_frame. - set -l iw (__config_settings_frame width) - set -l descs \ - "cmd shadows" \ - startup \ - "keys/env/prompt" \ - "terminal coupling" \ - scrollback \ - fish_greeting \ - "disable all" - - switch $iw - case 76 - set descs \ - "shadows: ls→eza, cat→bat, cd→z, rm→trash" \ - "Fisher bootstrap, themes, py-venv activate" \ - "vi-mode, bang-bang, PAGER, CDPATH, starship" \ - "Kitty/WezTerm tab/split fns, notifications" \ - "scrollback capture & paru/yay AUR wrappers" \ - "fish_greeting & first-run welcome banner" \ - "master off-switch: overrides all categories" - case 72 - set descs \ - "ls→eza, cat→bat, cd→zoxide, rm→trash" \ - "Fisher bootstrap, themes, py-venv auto" \ - "vi-mode, bang-bang, PAGER, starship" \ - "Kitty/WezTerm fns, done notifications" \ - "scrollback capture & paru/yay wrappers" \ - "fish_greeting: first-run welcome banner" \ - "master off-switch for all categories" - case 68 - set descs \ - "ls→eza, cat→bat, cd→z, rm→trash" \ - "Fisher, themes, py-venv activate" \ - "vi-mode, bang-bang, PAGER, starship" \ - "Kitty/WezTerm, done notifications" \ - "scrollback & paru/yay log wrappers" \ - "fish_greeting & first-run banner" \ - "master disable for all categories" - end - - set -l HBR (string repeat -n $iw '─') - - # ── Center padding ──────────────────────────────────────────────────── - # ponytail: floor division — left margin may be 1 col less than right if gap is odd - set -l p (string repeat -n (math --scale=0 "max(0, ($COLUMNS - ($iw + 2)) / 2)") ' ') - - # ── Top border ──────────────────────────────────────────────────────── - # ┌─ Opinionated Settings (iw-23)×─ ┐ total = iw+2 - __config_settings_frame title $iw $p "$c_head Opinionated Settings $c_reset" - - # ── Page-tab header ─────────────────────────────────────────────────── - set -l active_idx 0 - if test $cur_scope = session - set active_idx 1 - end - printf '%s│%s│\n' $p (__config_settings_pagetab $active_idx $iw) - - # ── Top divider ─────────────────────────────────────────────────────── - printf '%s│%s│\n' $p $HBR - - # ── Category rows 0–5 ───────────────────────────────────────────────── - # Label field 12 wide; the description field falls out of it inside the - # frame (field_w = iw - 21 - label_w = iw - 33). `pad`, not `cut`: these - # descriptions are authored per tier to fit exactly, so truncating them - # would be a silent no-op that discards that property. - for i in (seq 0 5) - set -l idx (math $i + 1) - set -l val (__config_settings_get_val $vars[$idx] $cur_scope) - __config_settings_frame row $iw $p \ - (__config_settings_frame cursor $i $cur_row) \ - $labels[$idx] 12 \ - (__config_settings_frame badge $val) \ - $descs[$idx] pad - end - - # ── Separator before Master ─────────────────────────────────────────── - printf '%s│ %s │\n' $p (string repeat -n (math $iw - 6) '─') - - # ── Master row (index 6) ────────────────────────────────────────────── - set -l val (__config_settings_get_val $vars[7] $cur_scope) - __config_settings_frame row $iw $p \ - (__config_settings_frame cursor 6 $cur_row) \ - Master 12 \ - (__config_settings_frame badge $val) \ - $descs[7] pad - - # ── Filler (Dots Path moved to the Paths page) ──────────────────────── - printf '%s│ %s%s│\n' $p \ - "$c_dim→ Tab for Sponge & Path settings$c_reset" \ - (string repeat -n (math $iw - 34) ' ') - printf '%s│%s│\n' $p (string repeat -n $iw ' ') - - # ── Bottom divider ──────────────────────────────────────────────────── - printf '%s│%s│\n' $p $HBR - - # ── Keybind hint ────────────────────────────────────────────────────── - # string pad is width-aware (arrows count as 1 column) - set -l hint " ↑↓/kj move ←→/hl set Enter sub-cats Tab pg q quit" - printf '%s│%s%s%s│\n' $p $c_dim (string pad -r -w $iw -- $hint) $c_reset - - # ── Bottom border ───────────────────────────────────────────────────── - printf '%s└%s┘\n' $p $HBR -end diff --git a/functions/__config_settings_draw_subcat.fish b/functions/__config_settings_draw_subcat.fish deleted file mode 100644 index 6920ece..0000000 --- a/functions/__config_settings_draw_subcat.fish +++ /dev/null @@ -1,120 +0,0 @@ -# Copyright (C) 2026 Rootiest -# SPDX-License-Identifier: AGPL-3.0-or-later - -# SYNOPSIS -# __config_settings_draw_subcat -# -# DESCRIPTION -# Renders the sub-category drill-down page for one C1-C6 category: -# the category's own toggle at the top (still meaningful as the cascade -# default for its sub-categories), then one row per sub-category from -# __config_settings_subcats, sized dynamically instead of the fixed -# 6-row layout __config_settings_draw uses for the category list. -# Follows the same width-tier and center-padding conventions as -# __config_settings_draw so the panel doesn't visibly jump between the -# two pages. -# -# Label and description fields are defensively truncated to their field -# width before padding (string pad only ever grows a string, never -# shrinks it) -- sub-category labels/descriptions are static data from -# __config_settings_subcats, not authored per width-tier the way -# __config_settings_draw's own category descriptions are, so a couple of -# them are longer than the narrower tiers' fields (e.g. "Notifications" -# is 13 chars against a 12-char label field; several descriptions run -# well past the 17-char field at the narrowest tier). Truncating keeps -# the box perfectly rectangular in every case instead of only in the -# cases the static text happens to fit. -# -# ARGUMENTS -# cur_row 0-based highlighted row (0 = the category's own toggle; -# 1..N = sub-category rows) -# cur_scope "universal" or "session" -# category_var One of the six __fish_config_op_ names -# -# EXIT STATUS -# 0 Always -# -# EXAMPLE -# __config_settings_draw_subcat 1 universal __fish_config_op_aliases -function __config_settings_draw_subcat - set -l cur_row $argv[1] - set -l cur_scope $argv[2] - set -l category_var $argv[3] - - __fish_palette - - set -l rows (__config_settings_subcats $category_var) - set -l n (count $rows) - - # ── Width tier: matches __config_settings_draw's 6-col-per-side steps ── - set -l iw (__config_settings_frame width) - set -l HBR (string repeat -n $iw '─') - set -l p (string repeat -n (math --scale=0 "max(0, ($COLUMNS - ($iw + 2)) / 2)") ' ') - - # Label field is 13 wide (one wider than __config_settings_draw's 12) -- - # the longest real sub-category label ("Notifications") is 13 chars. The - # description field absorbs the difference inside the frame - # (field_w = iw - 21 - label_w = iw - 34), so every row still totals iw+2. - - set -l cat_label (string replace -r '^__fish_config_op_' '' -- $category_var) - # Scope indicator: toggling a row on this page writes -U (Universal, - # persistent) or -g (Session, this-shell-only) -- the title must say - # which, since it isn't otherwise visible anywhere on the page. - set -l scope_label Universal - test "$cur_scope" = session; and set scope_label Session - # Title layout is "┌─ Sub-categories: