feat(config-settings): replace the ANSI renderer with a curses front-end
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 behind608b022,4210f3b,93fc5e0and3c4f720unreachable. 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).
This commit is contained in:
+456
-250
@@ -3,167 +3,287 @@
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
#
|
||||
# SYNOPSIS
|
||||
# config-settings-tui.py [--self-test]
|
||||
# config-settings-tui.py [--state <file>] [--emit <file>] [--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.
|
||||
# Front-end for `config-settings`, rendered with Python's stdlib `curses`.
|
||||
# This process never touches fish state directly. It reads a state dump on
|
||||
# the way in and writes a fish script of the edits on the way out; the
|
||||
# `config-settings` function sources that script, so `set -g` lands in the
|
||||
# caller's shell rather than in a child that is about to exit.
|
||||
#
|
||||
# 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.
|
||||
# fish --(__config_settings_state)--> --state file --> this TUI
|
||||
# fish <--(source)------------------- --emit file <-- this TUI
|
||||
#
|
||||
# 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).
|
||||
# Every edit is emitted as a call to an existing helper --
|
||||
# __config_settings_apply for scope toggles, __config_settings_set_value for
|
||||
# the Sponge and Paths rows -- so list splitting, the SCROLLBACK_HISTORY_*
|
||||
# export mirror and the shadow-warning suppression all stay in the fish
|
||||
# layer that already owns them. Nothing is applied until the TUI exits.
|
||||
#
|
||||
# The sub-category taxonomy is NOT duplicated here: it arrives in the state
|
||||
# dump, sourced from __config_settings_subcats. The category, Sponge and
|
||||
# Paths row tables do live here, consolidated from the three fish renderers
|
||||
# this replaces.
|
||||
#
|
||||
# ARGUMENTS
|
||||
# --self-test Exercise the pure state/filter logic without a terminal and
|
||||
# exit non-zero on failure. Used by tests; needs no TTY.
|
||||
# --state <file> State dump to read. Without it, the dump is obtained by
|
||||
# running `fish -c __config_settings_state`.
|
||||
# --emit <file> Write the resulting fish script here. Without it, the
|
||||
# script is printed to stdout after the TUI exits, applying
|
||||
# nothing -- useful for inspecting a session by hand.
|
||||
# --self-test Exercise the pure logic with no terminal and exit non-zero
|
||||
# on failure. Needs no TTY and no fish.
|
||||
#
|
||||
# EXIT STATUS
|
||||
# 0 Clean exit, or --self-test passed
|
||||
# 1 --self-test failed
|
||||
# 1 --self-test failed, or the state dump could not be obtained
|
||||
#
|
||||
# EXAMPLE
|
||||
# ./scripts/config-settings-tui.py
|
||||
# config-settings # the normal entry point
|
||||
# ./scripts/config-settings-tui.py # standalone, dry run
|
||||
# ./scripts/config-settings-tui.py --self-test
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from typing import NamedTuple
|
||||
|
||||
# ncurses reads ESCDELAY at init; 25ms makes bare Esc feel instant instead of
|
||||
# the 1s default. Must be set before curses is initialised.
|
||||
# the 1s default. Must be set before curses is imported and initialised.
|
||||
os.environ.setdefault("ESCDELAY", "25")
|
||||
|
||||
import curses # noqa: E402
|
||||
|
||||
RS, US = "\x1e", "\x1f" # record / unit separators used by the state dump
|
||||
|
||||
|
||||
# ╭──────────────────────────────────────────────────────────────────────────╮
|
||||
# │ Stub model │
|
||||
# │ Row model │
|
||||
# ╰──────────────────────────────────────────────────────────────────────────╯
|
||||
|
||||
TRI = ("DEFAULT", "ON", "OFF")
|
||||
class Row(NamedTuple):
|
||||
label: str # display label
|
||||
kind: str # tri | bool | int | list | path
|
||||
hint: str # shown in the value cell while the variable is unset
|
||||
desc: str # one-line description under the label
|
||||
var: str # fish variable name this row edits
|
||||
parent: str # owning category label, "" for a top-level row
|
||||
default: str # what ← / a blank inline edit resets the row to
|
||||
|
||||
# label, description, [(sub-label, sub-description), ...]
|
||||
|
||||
TRI = ("", "on", "off") # DEFAULT → ON → OFF
|
||||
BOOLS = ("", "true", "false") # Sponge/Paths convention
|
||||
|
||||
# Category rows for the Universal and Session pages. Consolidated from
|
||||
# __config_settings_draw.fish (descriptions) and config-settings.fish
|
||||
# (variable names), which this file replaces.
|
||||
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", []),
|
||||
Row("Aliases", "tri", "", "shadows: ls→eza, cat→bat, cd→z, rm→trash",
|
||||
"__fish_config_op_aliases", "", ""),
|
||||
Row("Auto-exec", "tri", "", "Fisher bootstrap, themes, py-venv activate",
|
||||
"__fish_config_op_autoexec", "", ""),
|
||||
Row("Overrides", "tri", "", "vi-mode, bang-bang, PAGER, CDPATH, starship",
|
||||
"__fish_config_op_overrides", "", ""),
|
||||
Row("Integrations", "tri", "", "Kitty/WezTerm tab/split fns, notifications",
|
||||
"__fish_config_op_integrations", "", ""),
|
||||
Row("Logging", "tri", "", "scrollback capture & paru/yay AUR wrappers",
|
||||
"__fish_config_op_logging", "", ""),
|
||||
Row("Greeting", "tri", "", "fish_greeting & first-run welcome banner",
|
||||
"__fish_config_op_greeting", "", ""),
|
||||
Row("Master", "tri", "", "master off-switch: overrides all categories",
|
||||
"__fish_config_opinionated", "", ""),
|
||||
]
|
||||
|
||||
# label, kind, default-hint, description
|
||||
# Value rows. Variables, types and reset targets mirror the sponge_/paths_
|
||||
# tables that config-settings.fish carried; the reset targets are load-bearing
|
||||
# (sponge reads sponge_delay and sponge_successful_exit_codes with no fallback,
|
||||
# so those must never be left unset, while the path rows tolerate being unset).
|
||||
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"),
|
||||
Row("Delay", "int", "2", "entries kept before a failed command is purged",
|
||||
"sponge_delay", "", "2"),
|
||||
Row("Purge@exit", "bool", "false", "only purge history on shell exit",
|
||||
"sponge_purge_only_on_exit", "", ""),
|
||||
Row("Allow prev", "bool", "true", "keep commands that previously succeeded",
|
||||
"sponge_allow_previously_successful", "", ""),
|
||||
Row("OK codes", "list", "0", "exit codes treated as success",
|
||||
"sponge_successful_exit_codes", "", "0"),
|
||||
Row("Extra secret", "list", "(none)", "extra patterns scrubbed from history",
|
||||
"__fish_sponge_extra_sensitive", "", ""),
|
||||
]
|
||||
|
||||
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"),
|
||||
Row("Log dir", "path", "~/.terminal_history", "scrollback capture directory",
|
||||
"__fish_scrollback_history_dir", "", ""),
|
||||
Row("Log max", "int", "100", "max scrollback files retained",
|
||||
"__fish_scrollback_history_max_files", "", ""),
|
||||
Row("Dots path", "path", "(default)", "user-dots source directory",
|
||||
"__fish_user_dots_path", "", ""),
|
||||
Row("Dots link", "bool", "on", "symlink ~/.config/.user-dots/fish",
|
||||
"__fish_user_dots_symlink", "", ""),
|
||||
]
|
||||
|
||||
PAGES = ["Universal", "Session", "Sponge", "Paths"]
|
||||
SCOPES = {"Universal": "universal", "Session": "session",
|
||||
"Sponge": "universal", "Paths": "universal"}
|
||||
|
||||
# Changing this variable has to re-run the linker, exactly as the fish
|
||||
# implementation did on every ←/→ over the Dots link row.
|
||||
DOTS_SYMLINK = "__fish_user_dots_symlink"
|
||||
|
||||
|
||||
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 subcat_var(category_var, slug):
|
||||
"""Sub-category variable name, matching config-settings.fish's derivation."""
|
||||
return category_var + "_" + slug.replace("-", "_")
|
||||
|
||||
|
||||
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
|
||||
# ╭──────────────────────────────────────────────────────────────────────────╮
|
||||
# │ State dump │
|
||||
# ╰──────────────────────────────────────────────────────────────────────────╯
|
||||
|
||||
class State:
|
||||
"""Parsed __config_settings_state output plus the pending edit set.
|
||||
|
||||
Two record types, RS-separated, fields US-separated:
|
||||
|
||||
var <scope> <name> <value> a variable that is set
|
||||
sub <category_var> <slug> <label> <description>
|
||||
|
||||
Absent means unset, which is what DEFAULT renders as. The separators are
|
||||
ASCII control characters, so no value these variables can hold needs
|
||||
escaping on the way through.
|
||||
"""
|
||||
|
||||
def __init__(self, text=""):
|
||||
self.vals = {} # (scope, varname) -> value
|
||||
self.subs = {} # category_var -> [(slug, label, desc), ...]
|
||||
for rec in text.split(RS):
|
||||
if not rec.strip():
|
||||
continue
|
||||
f = rec.split(US)
|
||||
if f[0] == "var" and len(f) >= 4:
|
||||
self.vals[(f[1], f[2])] = f[3]
|
||||
elif f[0] == "sub" and len(f) >= 5:
|
||||
self.subs.setdefault(f[1], []).append((f[2], f[3], f[4]))
|
||||
self.orig = dict(self.vals)
|
||||
|
||||
def get(self, scope, var):
|
||||
return self.vals.get((scope, var), "")
|
||||
|
||||
def set(self, scope, var, value):
|
||||
if value == "":
|
||||
self.vals.pop((scope, var), None)
|
||||
else:
|
||||
self.vals[(scope, var)] = value
|
||||
|
||||
def changed(self):
|
||||
keys = set(self.vals) | set(self.orig)
|
||||
return sorted(k for k in keys
|
||||
if self.vals.get(k, "") != self.orig.get(k, ""))
|
||||
|
||||
|
||||
def subcats_for(label):
|
||||
for lbl, _, subs in CATEGORIES:
|
||||
if lbl == label:
|
||||
return subs
|
||||
return []
|
||||
def load_state(path):
|
||||
"""Read a dump from `path`, or ask fish for one when path is None."""
|
||||
if path:
|
||||
with open(path, encoding="utf-8") as fh:
|
||||
return State(fh.read())
|
||||
try:
|
||||
out = subprocess.run(["fish", "-c", "__config_settings_state"],
|
||||
capture_output=True, text=True, timeout=30)
|
||||
except (OSError, subprocess.SubprocessError) as exc:
|
||||
raise SystemExit(f"cannot run fish to read config state: {exc}")
|
||||
if out.returncode != 0:
|
||||
raise SystemExit("fish -c __config_settings_state failed: "
|
||||
+ (out.stderr.strip() or f"exit {out.returncode}"))
|
||||
return State(out.stdout)
|
||||
|
||||
|
||||
def apply_filter(rows, needle):
|
||||
"""Case-insensitive match over label and description."""
|
||||
# ╭──────────────────────────────────────────────────────────────────────────╮
|
||||
# │ Command emission │
|
||||
# ╰──────────────────────────────────────────────────────────────────────────╯
|
||||
|
||||
def fq(s):
|
||||
"""Single-quote a string for fish. Inside '', only \\' and \\\\ escape."""
|
||||
return "'" + s.replace("\\", "\\\\").replace("'", "\\'") + "'"
|
||||
|
||||
|
||||
def emit(state, rows_by_var):
|
||||
"""Render the pending edits as fish commands, one per changed variable."""
|
||||
lines = []
|
||||
relink = False
|
||||
for scope, var in state.changed():
|
||||
value = state.get(scope, var)
|
||||
row = rows_by_var.get(var)
|
||||
if row is not None and row.kind in ("int", "list", "path", "bool"):
|
||||
lines.append(f"__config_settings_set_value {var} {row.kind} {fq(value)}")
|
||||
relink = relink or var == DOTS_SYMLINK
|
||||
else:
|
||||
# Toggle rows, including every sub-category row. An empty value
|
||||
# means DEFAULT, which erases the variable in that scope.
|
||||
lines.append(
|
||||
f"__config_settings_apply {var} {scope} {value or 'DEFAULT'}")
|
||||
if relink:
|
||||
lines.append("__fish_user_dots_link")
|
||||
return "\n".join(lines) + ("\n" if lines else "")
|
||||
|
||||
|
||||
# ╭──────────────────────────────────────────────────────────────────────────╮
|
||||
# │ Pure view helpers │
|
||||
# ╰──────────────────────────────────────────────────────────────────────────╯
|
||||
|
||||
def matches(row, needle):
|
||||
return needle in row.label.lower() or needle in row.desc.lower()
|
||||
|
||||
|
||||
def sub_rows(state, cat):
|
||||
"""Sub-category rows for one category, from the taxonomy in the dump."""
|
||||
return [Row(label, "tri", "", desc, subcat_var(cat.var, slug), cat.label, "")
|
||||
for slug, label, desc in state.subs.get(cat.var, [])]
|
||||
|
||||
|
||||
def build_rows(state, page, drill, needle):
|
||||
"""The rows a page shows, given the drill-down and filter in effect.
|
||||
|
||||
An unfiltered category page lists categories only. Filtering flattens the
|
||||
tree: a matching category is listed, and so is any matching sub-category of
|
||||
any category, labelled "Category › Sub" so the row still says where it
|
||||
lives. That is the only way to reach a sub-category without drilling.
|
||||
"""
|
||||
if page in ("Sponge", "Paths"):
|
||||
rows = SPONGE if page == "Sponge" else PATHS
|
||||
return [r for r in rows if matches(r, needle)] if needle else list(rows)
|
||||
|
||||
if drill:
|
||||
cat = next((c for c in CATEGORIES if c.label == drill), None)
|
||||
if cat is None:
|
||||
return []
|
||||
return [cat] + sub_rows(state, cat)
|
||||
|
||||
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()]
|
||||
return list(CATEGORIES)
|
||||
|
||||
out = []
|
||||
for cat in CATEGORIES:
|
||||
if matches(cat, needle):
|
||||
out.append(cat)
|
||||
for sub in sub_rows(state, cat):
|
||||
if matches(sub, needle):
|
||||
out.append(sub._replace(label=f"{cat.label} › {sub.label}"))
|
||||
return out
|
||||
|
||||
|
||||
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
|
||||
"""Advance a toggle one step. Non-toggle kinds are edited, not cycled."""
|
||||
ring = TRI if kind == "tri" else BOOLS
|
||||
i = ring.index(value) if value in ring else 0
|
||||
return ring[(i + (1 if forward else -1)) % len(ring)]
|
||||
|
||||
|
||||
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] + "…"
|
||||
|
||||
|
||||
# ╭──────────────────────────────────────────────────────────────────────────╮
|
||||
@@ -183,7 +303,7 @@ def init_colors():
|
||||
bg = -1
|
||||
except curses.error:
|
||||
bg = curses.COLOR_BLACK
|
||||
pairs = {
|
||||
for pair, fg in {
|
||||
C_FRAME: curses.COLOR_BLUE,
|
||||
C_TITLE: curses.COLOR_MAGENTA,
|
||||
C_SEL: curses.COLOR_CYAN,
|
||||
@@ -193,8 +313,7 @@ def init_colors():
|
||||
C_DIM: curses.COLOR_BLACK,
|
||||
C_HINT: curses.COLOR_CYAN,
|
||||
C_BAR: curses.COLOR_WHITE,
|
||||
}
|
||||
for pair, fg in pairs.items():
|
||||
}.items():
|
||||
curses.init_pair(pair, fg, bg)
|
||||
|
||||
|
||||
@@ -212,6 +331,8 @@ def put(win, y, x, text, attr=0, limit=None):
|
||||
if room <= 0:
|
||||
return
|
||||
text = text[:room]
|
||||
if not text:
|
||||
return
|
||||
try:
|
||||
if y == h - 1 and x + len(text) >= w:
|
||||
if len(text) > 1:
|
||||
@@ -223,62 +344,51 @@ def put(win, y, x, text, attr=0, limit=None):
|
||||
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))
|
||||
"""The right-hand value cell and its colour pair."""
|
||||
if kind in ("tri", "bool"):
|
||||
on = "on" if kind == "tri" else "true"
|
||||
off = "off" if kind == "tri" else "false"
|
||||
if value == on:
|
||||
return "[ ON ]", C_ON
|
||||
if value == off:
|
||||
return "[ OFF ]", C_OFF
|
||||
return "[ DEFAULT ]", C_DEF
|
||||
return (value, C_ON) if value else (hint, C_DIM)
|
||||
|
||||
|
||||
class App:
|
||||
def __init__(self, stdscr):
|
||||
def __init__(self, stdscr, state):
|
||||
self.scr = stdscr
|
||||
self.state = initial_state()
|
||||
self.state = 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
|
||||
self.buf = "" # filter / inline-edit scratch buffer
|
||||
self.drill = None # category label while drilled in
|
||||
self.msg = ""
|
||||
self.row_hits = [] # screen row -> row index, for mouse clicks
|
||||
|
||||
# ── data helpers ──────────────────────────────────────────────────────
|
||||
@property
|
||||
def page_name(self):
|
||||
return PAGES[self.page]
|
||||
|
||||
@property
|
||||
def scope(self):
|
||||
return SCOPES[self.page_name]
|
||||
|
||||
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)
|
||||
return build_rows(self.state, self.page_name, self.drill,
|
||||
self.filter.lower())
|
||||
|
||||
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
|
||||
def value_of(self, row):
|
||||
return self.state.get(self.scope, row.var)
|
||||
|
||||
# ── drawing ───────────────────────────────────────────────────────────
|
||||
def draw(self):
|
||||
@@ -292,20 +402,21 @@ class App:
|
||||
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, 0, 2, " Opinionated Settings ",
|
||||
curses.color_pair(C_TITLE) | curses.A_BOLD)
|
||||
top, bot = 1, h - 3
|
||||
for y in range(top, 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 - 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_sidebar(top, bot)
|
||||
self.draw_detail(top, bot, w)
|
||||
self.draw_status(h, w)
|
||||
|
||||
scr.noutrefresh()
|
||||
@@ -327,13 +438,12 @@ class App:
|
||||
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
|
||||
shown, attr = "/" + self.buf + "_", 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)
|
||||
put(scr, y + 1, 2, ell(shown, SIDEBAR_W - 2), attr, SIDEBAR_W - 2)
|
||||
|
||||
def draw_detail(self, top, bot, w):
|
||||
scr = self.scr
|
||||
@@ -343,7 +453,7 @@ class App:
|
||||
self.row_hits = []
|
||||
|
||||
if self.drill is not None:
|
||||
put(scr, top, left, f"{self.drill} ▸ sub-categories",
|
||||
put(scr, top, left, ell(f"{self.drill} ▸ sub-categories", width),
|
||||
curses.color_pair(C_TITLE) | curses.A_BOLD, width)
|
||||
top += 1
|
||||
|
||||
@@ -351,52 +461,55 @@ class App:
|
||||
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
|
||||
per = 3 # value line, description line, separator
|
||||
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]
|
||||
row = 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
|
||||
cell, cattr = ell(self.buf + "_", width - 4), C_HINT
|
||||
else:
|
||||
cell, cattr = badge(self.value_of(label), kind, hint)
|
||||
cell = cell[: max(0, width)]
|
||||
cell, cattr = badge(self.value_of(row), row.kind, row.hint)
|
||||
cell = ell(cell, width - 4)
|
||||
|
||||
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, ("▸ " if sel else " ") + row.label, lattr,
|
||||
max(0, 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)
|
||||
put(scr, y + 1, left + 4, ell(row.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)
|
||||
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"
|
||||
keys = "⏎ commit Esc cancel (blank resets to default)"
|
||||
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"
|
||||
keys = "↑↓ move ←→ set Esc back ? help q apply & quit"
|
||||
elif w >= 96:
|
||||
keys = "↑↓ move ←→ set ⏎ drill/edit Tab page / filter ? help q apply & 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)
|
||||
|
||||
n = len(self.state.changed())
|
||||
note = self.msg or (f"{n} pending" if n else "")
|
||||
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)
|
||||
if note and room > 8:
|
||||
attr = C_DEF if not self.msg and n else C_DIM
|
||||
put(self.scr, h - 1, 2 + len(keys) + 3, ell(note, room),
|
||||
curses.color_pair(attr), room)
|
||||
|
||||
def draw_help(self, h, w):
|
||||
lines = [
|
||||
@@ -405,25 +518,29 @@ class App:
|
||||
" ↑ ↓ / k j move between rows",
|
||||
" Tab / ⇧Tab next / previous page",
|
||||
" ⏎ drill into sub-categories, or edit a value",
|
||||
" Esc leave sub-categories / cancel",
|
||||
" Esc leave sub-categories / clear filter",
|
||||
"",
|
||||
" Values",
|
||||
" ← → / h l cycle DEFAULT → ON → OFF",
|
||||
" ⏎ inline edit (int, list and path rows)",
|
||||
" blank + ⏎ reset the row to its default",
|
||||
"",
|
||||
" Filter",
|
||||
" / match label and description",
|
||||
" on a category page the filter also reaches sub-categories,",
|
||||
" listed as \"Category › Sub\" -- toggle them without drilling",
|
||||
"",
|
||||
" Other",
|
||||
" / live filter over label and description",
|
||||
" mouse click a row to select, click its value to cycle",
|
||||
" ? toggle this overlay",
|
||||
" q quit",
|
||||
" q apply pending edits and quit",
|
||||
"",
|
||||
" Prototype: curses render engine, stubbed backend.",
|
||||
" Edits are written when the TUI exits, not as you make them.",
|
||||
"",
|
||||
]
|
||||
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 = curses.newwin(bh, bw, (h - bh) // 2, (w - bw) // 2)
|
||||
win.bkgd(" ", curses.color_pair(C_BAR))
|
||||
win.attrset(curses.color_pair(C_FRAME))
|
||||
win.box()
|
||||
@@ -454,10 +571,7 @@ class App:
|
||||
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.commit_edit()
|
||||
self.mode, self.buf = "nav", ""
|
||||
self.clamp()
|
||||
elif ch == 27: # Esc
|
||||
@@ -480,30 +594,26 @@ class App:
|
||||
def handle_nav(self, ch):
|
||||
rows = self.visible_rows()
|
||||
key = chr(ch) if 32 <= ch < 127 else ""
|
||||
self.msg = ""
|
||||
|
||||
if key == "q":
|
||||
return False
|
||||
if key == "?":
|
||||
self.mode = "help"
|
||||
elif ch in (curses.KEY_DOWN,) or key == "j":
|
||||
elif ch == curses.KEY_DOWN or key == "j":
|
||||
self.row = (self.row + 1) % max(1, len(rows))
|
||||
elif ch in (curses.KEY_UP,) or key == "k":
|
||||
elif ch == curses.KEY_UP or key == "k":
|
||||
self.row = (self.row - 1) % max(1, len(rows))
|
||||
elif ch in (curses.KEY_RIGHT,) or key == "l":
|
||||
elif ch == curses.KEY_RIGHT or key == "l":
|
||||
self.bump(True)
|
||||
elif ch in (curses.KEY_LEFT,) or key == "h":
|
||||
elif ch == 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 == 9 and self.drill is None: # Tab
|
||||
self.page, self.row = (self.page + 1) % len(PAGES), 0
|
||||
elif ch == curses.KEY_BTAB and self.drill is None:
|
||||
self.page, self.row = (self.page - 1) % len(PAGES), 0
|
||||
elif key == "/" and self.drill is None:
|
||||
self.mode, self.buf = "filter", self.filter
|
||||
elif ch in (curses.KEY_ENTER, 10, 13):
|
||||
self.enter()
|
||||
elif ch == 27:
|
||||
@@ -520,28 +630,37 @@ class App:
|
||||
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)
|
||||
# A category row drills in, but only from the unfiltered category list.
|
||||
# A filtered hit is already the row it names, sub-categories included,
|
||||
# so Enter there would be a surprise navigation.
|
||||
drillable = (row.kind == "tri" and self.drill is None and not self.filter
|
||||
and self.state.subs.get(row.var))
|
||||
if drillable:
|
||||
self.drill, self.row = row.label, 0
|
||||
elif row.kind in ("int", "list", "path"):
|
||||
self.mode, self.buf = "edit", self.value_of(row)
|
||||
else:
|
||||
self.bump(True)
|
||||
|
||||
def commit_edit(self):
|
||||
row = self.current()
|
||||
if not row:
|
||||
return
|
||||
value = self.buf.strip() or row.default
|
||||
self.state.set(self.scope, row.var, value)
|
||||
self.msg = f"{row.label} = {value or 'default'}"
|
||||
|
||||
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'}"
|
||||
if row.kind in ("tri", "bool"):
|
||||
new = cycle(self.value_of(row), row.kind, forward)
|
||||
self.state.set(self.scope, row.var, new)
|
||||
self.msg = f"{row.label} = {new or 'DEFAULT'}"
|
||||
elif not forward:
|
||||
self.set_value(label, "")
|
||||
self.msg = f"{label} cleared to default"
|
||||
self.state.set(self.scope, row.var, row.default)
|
||||
self.msg = f"{row.label} reset to default"
|
||||
|
||||
def mouse(self):
|
||||
try:
|
||||
@@ -566,7 +685,7 @@ class App:
|
||||
try:
|
||||
ch = self.scr.getch()
|
||||
except KeyboardInterrupt:
|
||||
return
|
||||
return # Ctrl-C keeps the edits made so far, like q
|
||||
if not self.handle(ch):
|
||||
return
|
||||
|
||||
@@ -575,36 +694,108 @@ class App:
|
||||
# │ Entry points │
|
||||
# ╰──────────────────────────────────────────────────────────────────────────╯
|
||||
|
||||
def rows_by_var():
|
||||
"""Every value row, keyed by variable, for emit()'s type lookup."""
|
||||
return {r.var: r for r in SPONGE + PATHS}
|
||||
|
||||
|
||||
def self_test():
|
||||
"""Exercise the pure logic. No terminal required."""
|
||||
st = initial_state()
|
||||
assert st[("Universal", "Aliases")] == "ON"
|
||||
assert st[("Universal", "Overrides")] == "DEFAULT"
|
||||
"""Exercise the pure logic. No terminal, no fish."""
|
||||
dump = RS.join([
|
||||
US.join(["var", "universal", "__fish_config_op_aliases", "on"]),
|
||||
US.join(["var", "session", "__fish_config_op_aliases", "off"]),
|
||||
US.join(["var", "universal", "sponge_delay", "5"]),
|
||||
US.join(["sub", "__fish_config_op_aliases", "filesystem",
|
||||
"Filesystem", "ls, cat, cd, du, mkdir, rm, mv, zoxide"]),
|
||||
US.join(["sub", "__fish_config_op_aliases", "shell-tools",
|
||||
"Shell-tools", "bash, less, help"]),
|
||||
US.join(["sub", "__fish_config_op_logging", "pkg-logs",
|
||||
"Pkg-logs", "paru/yay AUR log wrappers"]),
|
||||
])
|
||||
st = State(dump)
|
||||
|
||||
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"
|
||||
# -- dump parsing ---------------------------------------------------
|
||||
assert st.get("universal", "__fish_config_op_aliases") == "on"
|
||||
assert st.get("session", "__fish_config_op_aliases") == "off"
|
||||
assert st.get("universal", "__fish_config_op_greeting") == ""
|
||||
assert len(st.subs["__fish_config_op_aliases"]) == 2
|
||||
assert st.changed() == []
|
||||
|
||||
rows = rows_for("Universal")
|
||||
assert len(rows) == len(CATEGORIES)
|
||||
assert rows_for("Sponge") is SPONGE
|
||||
# -- variable naming matches config-settings.fish --------------------
|
||||
assert subcat_var("__fish_config_op_aliases", "shell-tools") \
|
||||
== "__fish_config_op_aliases_shell_tools"
|
||||
|
||||
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") == []
|
||||
# -- toggle cycling --------------------------------------------------
|
||||
assert cycle("", "tri", True) == "on"
|
||||
assert cycle("on", "tri", True) == "off"
|
||||
assert cycle("off", "tri", True) == ""
|
||||
assert cycle("", "tri", False) == "off"
|
||||
assert cycle("", "bool", True) == "true"
|
||||
|
||||
assert len(subcats_for("Aliases")) == 6
|
||||
assert subcats_for("Master") == []
|
||||
# -- unfiltered pages ------------------------------------------------
|
||||
assert build_rows(st, "Universal", None, "") == CATEGORIES
|
||||
assert build_rows(st, "Sponge", None, "") == SPONGE
|
||||
|
||||
assert badge("ON", "tri", "")[1] == C_ON
|
||||
# -- drill-down leads with the category's own toggle ------------------
|
||||
drilled = build_rows(st, "Universal", "Aliases", "")
|
||||
assert [r.label for r in drilled] == ["Aliases", "Filesystem", "Shell-tools"]
|
||||
assert drilled[1].var == "__fish_config_op_aliases_filesystem"
|
||||
assert build_rows(st, "Universal", "Nonexistent", "") == []
|
||||
|
||||
# -- the filter reaches sub-categories -------------------------------
|
||||
hits = build_rows(st, "Universal", None, "log")
|
||||
assert [r.label for r in hits] == ["Logging", "Logging › Pkg-logs"], hits
|
||||
assert hits[1].var == "__fish_config_op_logging_pkg_logs"
|
||||
# A sub-category match with no matching parent still surfaces.
|
||||
hits = build_rows(st, "Universal", None, "zoxide")
|
||||
assert [r.label for r in hits] == ["Aliases › Filesystem"], hits
|
||||
# Matching on description, not just label.
|
||||
assert [r.label for r in build_rows(st, "Universal", None, "starship")] \
|
||||
== ["Overrides"]
|
||||
assert build_rows(st, "Universal", None, "zzz") == []
|
||||
assert [r.label for r in build_rows(st, "Sponge", None, "exit")] \
|
||||
== ["Purge@exit", "OK codes"]
|
||||
|
||||
# -- fish quoting ----------------------------------------------------
|
||||
assert fq("plain") == "'plain'"
|
||||
assert fq("it's") == "'it\\'s'"
|
||||
assert fq("a\\b") == "'a\\\\b'"
|
||||
|
||||
# -- emission --------------------------------------------------------
|
||||
by_var = rows_by_var()
|
||||
st.set("universal", "__fish_config_op_aliases", "off")
|
||||
st.set("session", "__fish_config_op_aliases", "") # -> DEFAULT
|
||||
st.set("universal", "__fish_config_op_logging_pkg_logs", "on")
|
||||
st.set("universal", "sponge_delay", "9")
|
||||
st.set("universal", "__fish_sponge_extra_sensitive", "TOKEN, KEY")
|
||||
out = emit(st, by_var).splitlines()
|
||||
assert "__config_settings_apply __fish_config_op_aliases universal off" in out
|
||||
assert "__config_settings_apply __fish_config_op_aliases session DEFAULT" in out
|
||||
assert ("__config_settings_apply __fish_config_op_logging_pkg_logs "
|
||||
"universal on") in out
|
||||
assert "__config_settings_set_value sponge_delay int '9'" in out
|
||||
assert ("__config_settings_set_value __fish_sponge_extra_sensitive list "
|
||||
"'TOKEN, KEY'") in out
|
||||
assert "__fish_user_dots_link" not in out
|
||||
assert len(out) == 5, out
|
||||
|
||||
# -- the dots symlink re-runs the linker, and only then ---------------
|
||||
st2 = State(dump)
|
||||
st2.set("universal", DOTS_SYMLINK, "false")
|
||||
out2 = emit(st2, by_var).splitlines()
|
||||
assert out2 == [f"__config_settings_set_value {DOTS_SYMLINK} bool 'false'",
|
||||
"__fish_user_dots_link"], out2
|
||||
|
||||
# -- nothing changed emits nothing -----------------------------------
|
||||
assert emit(State(dump), by_var) == ""
|
||||
|
||||
# -- badges and clipping ---------------------------------------------
|
||||
assert badge("on", "tri", "")[1] == C_ON
|
||||
assert badge("true", "bool", "")[1] == C_ON
|
||||
assert badge("false", "bool", "")[1] == C_OFF
|
||||
assert badge("", "bool", "x")[1] == C_DEF
|
||||
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) == ""
|
||||
@@ -613,20 +804,35 @@ def self_test():
|
||||
return 0
|
||||
|
||||
|
||||
def main():
|
||||
if "--self-test" in sys.argv:
|
||||
def main(argv):
|
||||
if "--self-test" in argv:
|
||||
return self_test()
|
||||
if "-h" in sys.argv or "--help" in sys.argv:
|
||||
print(__doc__ or "config-settings-tui.py [--self-test]")
|
||||
if "-h" in argv or "--help" in argv:
|
||||
print("usage: config-settings-tui.py [--state <file>] [--emit <file>] "
|
||||
"[--self-test]\n\nNormally launched by the `config-settings` "
|
||||
"fish function.")
|
||||
return 0
|
||||
|
||||
def opt(name):
|
||||
return argv[argv.index(name) + 1] if name in argv else None
|
||||
|
||||
state = load_state(opt("--state"))
|
||||
|
||||
def boot(stdscr):
|
||||
init_colors()
|
||||
App(stdscr).run()
|
||||
App(stdscr, state).run()
|
||||
|
||||
curses.wrapper(boot)
|
||||
|
||||
script = emit(state, rows_by_var())
|
||||
out = opt("--emit")
|
||||
if out:
|
||||
with open(out, "w", encoding="utf-8") as fh:
|
||||
fh.write(script)
|
||||
elif script:
|
||||
sys.stdout.write(script)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
sys.exit(main(sys.argv[1:]))
|
||||
|
||||
Reference in New Issue
Block a user