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.
This commit is contained in:
Executable
+632
@@ -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())
|
||||||
Executable
+33
@@ -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
|
||||||
Reference in New Issue
Block a user