Merge pull request 'feat(config-settings): replace the ANSI renderer with a curses front-end' (#141) from feat/config-settings-curses-tui into main
CI / github-mirror (push) Skipped
CI / test (push) Successful in 1m47s
CI / build-docs (push) Successful in 3m45s

Reviewed-on: #141
This commit was merged in pull request #141.
This commit is contained in:
2026-09-09 19:54:11 +00:00
15 changed files with 1239 additions and 6480 deletions
+7 -3
View File
@@ -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. 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] > [!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/<tool>.fish` file for it instead. > `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/<tool>.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. 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: 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:
@@ -329,8 +331,10 @@ Each category further sub-divides into two to six sub-categories with
their own `__fish_config_op_<category>_<subcategory>` toggles (e.g. their own `__fish_config_op_<category>_<subcategory>` toggles (e.g.
`__fish_config_op_aliases_filesystem`), following the exact same `__fish_config_op_aliases_filesystem`), following the exact same
truthy/falsy/unset cascade one level deeper. Run `config-settings` and truthy/falsy/unset cascade one level deeper. Run `config-settings` and
press Enter on a category row to browse and toggle its sub-categories, or press Enter on a category row to browse and toggle its sub-categories
see the [Components Reference](https://fish.rootiest.fyi/08-components-reference/) or press `/` and type, which searches sub-categories across every
category at once and lists the hits as `Category Sub`. Or see the
[Components Reference](https://fish.rootiest.fyi/08-components-reference/)
for the full sub-category list per category. for the full sub-category list per category.
--- ---
@@ -1,47 +0,0 @@
# Copyright (C) 2026 Rootiest
# SPDX-License-Identifier: AGPL-3.0-or-later
# SYNOPSIS
# __config_settings_diff_redraw <old_joined> <new_joined>
#
# 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[<n>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
-155
View File
@@ -1,155 +0,0 @@
# Copyright (C) 2026 Rootiest
# SPDX-License-Identifier: AGPL-3.0-or-later
# SYNOPSIS
# __config_settings_draw <cur_row> <cur_scope> <var1> ... <var7>
#
# 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 06, the currently highlighted row
# cur_scope "universal" or "session"
# var1var7 Variable names for rows 06 (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
@@ -1,120 +0,0 @@
# Copyright (C) 2026 Rootiest
# SPDX-License-Identifier: AGPL-3.0-or-later
# SYNOPSIS
# __config_settings_draw_subcat <cur_row> <cur_scope> <category_var>
#
# 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_<category> 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: <label> (<scope>) ───┐"; the
# dash count absorbs every visible char added around cat_label so the
# line still totals iw+2, matching the surrounding box exactly. The
# frame derives it from the segment's visible width, so it no longer
# has to be hand-verified here.
__config_settings_frame title $iw $p \
"$c_head Sub-categories: $cat_label ($scope_label)$c_reset "
printf '%s│%s│\n' $p $HBR
# Row 0: the category's own toggle, still meaningful as the cascade
# default any DEFAULT-valued sub-category below falls back to.
set -l cat_val (__config_settings_get_val $category_var $cur_scope)
set -l cat_desc "cascade default"
if test $iw -ge 68
set cat_desc "default for all sub-cats below"
end
if test $iw -ge 72
set cat_desc "default for all sub-categories below"
end
# `cut` for the same reason as the sub-category rows below. It also
# truncates the label, which the old code did not -- provably inert here,
# since "(category)" is a 10-char literal against a 13-wide field.
__config_settings_frame row $iw $p \
(__config_settings_frame cursor 0 $cur_row) \
"(category)" 13 \
(__config_settings_frame badge $cat_val) \
$cat_desc cut
printf '%s│ %s │\n' $p (string repeat -n (math $iw - 6) '─')
for i in (seq 1 $n)
set -l fields (string split -- \t $rows[$i])
set -l label $fields[2]
set -l desc $fields[3]
set -l subcat_var "$category_var"_(string replace -a -- '-' '_' $fields[1])
set -l val (__config_settings_get_val $subcat_var $cur_scope)
# `cut`: these labels and descriptions are static data from
# __config_settings_subcats, not authored per width tier the way
# __config_settings_draw's are, and several run well past the
# narrower tiers' fields. `string pad` only ever grows a string, so
# they must be truncated before padding or the box stops being
# rectangular. This is the divergence the DESCRIPTION block above
# documents -- do not "simplify" it to `pad`.
__config_settings_frame row $iw $p \
(__config_settings_frame cursor $i $cur_row) \
$label 13 \
(__config_settings_frame badge $val) \
$desc cut
end
printf '%s│%s│\n' $p $HBR
set -l hint " ↑↓/kj move ←→/hl set Esc back q quit"
printf '%s│%s%s%s│\n' $p $c_dim (string pad -r -w $iw -- $hint) $c_reset
printf '%s└%s┘\n' $p $HBR
end
-159
View File
@@ -1,159 +0,0 @@
# Copyright (C) 2026 Rootiest
# SPDX-License-Identifier: AGPL-3.0-or-later
# SYNOPSIS
# __config_settings_draw_value <cur_row> <page>
#
# DESCRIPTION
# Renders a config-settings value page (Sponge or Paths) as exactly 16
# lines, matching the box geometry of the opinionated toggle page so the
# caller's wrap-aware erase (panel_h=16) is unchanged. Each row shows a
# label, a badge, and the variable's current value (or its default hint).
# Toggle-type rows (the two sponge booleans) reuse the ON/OFF/DEFAULT badge;
# value rows (path/int/list/string) show a type badge and the live value.
#
# Page width follows the same $COLUMNS tiers as the toggle page for a
# consistent look; exact width is not required for the erase (the erase
# over-clears to end of screen using the 78-col worst case).
#
# ARGUMENTS
# cur_row 0-based highlighted row within the page
# page "sponge" or "paths"
#
# EXIT STATUS
# 0 Always
#
# EXAMPLE
# __config_settings_draw_value 0 sponge
function __config_settings_draw_value
set -l cur_row $argv[1]
set -l page $argv[2]
# Inline-edit state: when argv[3] is "edit", the cur_row field renders the
# live input buffer (argv[4]) with a caret instead of its stored value.
set -l edit_mode $argv[3]
set -l edit_buf $argv[4]
__fish_palette
# ── Page row metadata (parallel lists) ────────────────────────────────
set -l title
set -l vars
set -l labels
set -l types
set -l hints # default hint shown when unset
set -l active_idx
if test $page = sponge
set title "Sponge Settings"
set active_idx 2
set vars sponge_delay sponge_purge_only_on_exit sponge_allow_previously_successful sponge_successful_exit_codes __fish_sponge_extra_sensitive
set labels Delay "Purge@exit" "Allow prev" "OK codes" "Extra secret"
set types int bool bool list list
set hints 2 false true 0 "(none)"
else
set title "Path Settings"
set active_idx 3
set vars __fish_scrollback_history_dir __fish_scrollback_history_max_files __fish_user_dots_path __fish_user_dots_symlink
set labels "Log dir" "Log max" "Dots path" "Dots link"
set types path int path bool
set hints "~/.terminal_history" 100 "(default)" on
end
set -l nrows (count $vars)
# ── Width tier (same thresholds as the toggle page) ───────────────────
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)") ' ')
# ── Line 1: top border with title ─────────────────────────────────────
__config_settings_frame title $iw $p "$c_head $title$c_reset "
# ── Line 2: page-tab header ───────────────────────────────────────────
printf '%s│%s│\n' $p (__config_settings_pagetab $active_idx $iw)
# ── Line 3: divider ───────────────────────────────────────────────────
printf '%s│%s│\n' $p $HBR
# ── Value rows ────────────────────────────────────────────────────────
for i in (seq 0 (math $nrows - 1))
set -l idx (math $i + 1)
set -l var $vars[$idx]
set -l label $labels[$idx]
set -l type $types[$idx]
set -l hint $hints[$idx]
# Badge (7 visible cols) + value field
set -l badge
set -l field
if test $type = bool
# Booleans store true/false (sponge convention); unset = DEFAULT.
# The frame is told this page's vocabulary rather than merging
# true/on: a hand-set "on" here must keep rendering DEFAULT.
set badge (__config_settings_frame badge (__config_settings_get_raw $var) true false)
set field "default: $hint"
else
set -l raw (__config_settings_get_raw $var)
if test "$raw" = DEFAULT
set badge "$c_dim""DEFAULT$c_reset"
set field "$hint"
else
switch $type
case path
set badge "$c_ok"" PATH $c_reset"
case int
set badge "$c_ok"" INT $c_reset"
case list
set badge "$c_ok"" LIST $c_reset"
case '*'
set badge "$c_ok"" STR $c_reset"
end
set field "$raw"
end
end
# Inline edit: render the active row's field as the live buffer with a
# block caret, tail-anchored so the caret stays visible as text grows.
if test "$edit_mode" = edit -a $i -eq $cur_row
set -l fw (math $iw - 33)
set -l avail (math $fw - 1)
set -l shown "$edit_buf"
set -l blen (string length -- "$edit_buf")
if test $blen -gt $avail
set shown (string sub -s (math $blen - $avail + 1) -- "$edit_buf")
end
set badge "$c_head"" EDIT $c_reset"
set field "$shown"(set_color --reverse)" "(set_color normal)
end
# `shorten` ellipsises the value: unlike the toggle page's per-tier
# descriptions, these fields hold arbitrary user values. The edit row
# is the exception -- its field is already length-constrained above
# and carries a reverse-video caret whose escapes `string shorten`
# miscounts, so it pads directly.
set -l fit shorten
if test "$edit_mode" = edit -a $i -eq $cur_row
set fit pad
end
__config_settings_frame row $iw $p \
(__config_settings_frame cursor $i $cur_row) \
$label 12 $badge $field $fit
end
# ── Pad blank rows so chrome(6) + nrows + blanks = 16 ─────────────────
set -l blanks (math 10 - $nrows)
for i in (seq 1 $blanks)
printf '%s│%s│\n' $p (string repeat -n $iw ' ')
end
# ── Bottom divider ────────────────────────────────────────────────────
printf '%s│%s│\n' $p $HBR
# ── Hint line (changes while editing) ─────────────────────────────────
set -l hint_line " ↑↓ move Enter edit ←/h clear Tab page q quit"
if test "$edit_mode" = edit
set hint_line " type value Enter save Esc cancel ⌫ delete"
end
printf '%s│%s%s%s│\n' $p $c_dim (string pad -r -w $iw -- $hint_line) $c_reset
# ── Bottom border ─────────────────────────────────────────────────────
printf '%s└%s┘\n' $p $HBR
end
-155
View File
@@ -1,155 +0,0 @@
# Copyright (C) 2026 Rootiest
# SPDX-License-Identifier: AGPL-3.0-or-later
# SYNOPSIS
# __config_settings_frame width
# __config_settings_frame title <iw> <p> <segment>
# __config_settings_frame badge <value> [<on_word> <off_word>]
# __config_settings_frame cursor <row> <cur_row>
# __config_settings_frame row <iw> <p> <curs> <label> <label_w> <badge> <field> <fit>
#
# DESCRIPTION
# The pieces of the config-settings panel that every page draws identically:
# the width tier, the title border, the ON/OFF/DEFAULT badge, the cursor cell
# and one table row. __config_settings_draw, __config_settings_draw_subcat
# and __config_settings_draw_value each keep their own sequence of lines and
# their own per-tier text; only these shared computations live here.
#
# Deliberately owns no page height. Every verb prints exactly one line or one
# fragment, so how tall a page is remains a property of its caller's line
# sequence: __config_settings_draw and __config_settings_draw_value are a
# fixed 16 lines, __config_settings_draw_subcat is 7 + <sub-category count>,
# and config-settings.fish's wrap-aware erase (\e[<N>A\e[J, sized from
# panel_h) depends on that difference surviving exactly as it is.
#
# Row geometry. A row is
# │ + 2 spaces + cursor(2) + label(label_w) + " [ " + badge(7) + " ] "
# + field(field_w) + 3 spaces + │
# which totals 23 + label_w + field_w and must equal iw + 2. So
# field_w = iw - 21 - label_w
# and that single sum reproduces both hand-maintained constants: label_w 12
# gives iw-33 (the toggle and value pages), label_w 13 gives iw-34 (the
# sub-category page, whose description field absorbs its wider label).
#
# Title geometry. "┌─" + segment + dashes + "┐" totals iw + 2, so
# dashes = iw - visible(segment) - 1
# The segment arrives already coloured because the three pages place their
# set_color reset at different byte offsets around the same visible text
# (__config_settings_draw resets after the trailing space, the other two
# before it). That difference is invisible on screen and visible to cmp, so
# each page keeps its own bytes while sharing the arithmetic; --visible
# discounts the escapes.
#
# ARGUMENTS
# width No arguments. Prints the inner width for the current $COLUMNS:
# >= 90 -> 76, >= 86 -> 72, >= 82 -> 68, otherwise 50.
# title iw, the centering prefix, and the pre-coloured title segment.
# badge The stored value, then optionally the words this page uses for true
# and false (default on/off; the value pages store true/false).
# Anything else renders DEFAULT.
# cursor This row's index and the highlighted row's index.
# row iw, centering prefix, cursor cell, label, label field width,
# rendered badge, field text, and the fit policy:
# pad pad label and field, never shrink either
# cut truncate both to their field width, then pad
# shorten pad the label, ellipsise the field with string shorten
#
# EXIT STATUS
# 0 Verb recognised
# 1 Unknown verb
#
# RETURNS
# The requested panel line or fragment, printed to stdout
#
# NOTES
# `string pad` only ever grows a string, never shrinks it. That is the whole
# reason `cut` and `shorten` exist: a caller whose text can exceed its field
# must shrink it first, or the box stops being rectangular.
#
# `pad` is not "the default when you don't care". __config_settings_draw's
# descriptions are authored per width tier to fit their field exactly -- at
# every tier the longest is precisely the field width -- so applying `cut`
# there would be a byte-for-byte no-op that silently discards that property
# and passes the render golden. Which policy a page uses is a real decision,
# named at each call site. See tests/config-settings-render.fish.
#
# EXAMPLE
# set -l iw (__config_settings_frame width)
# __config_settings_frame row $iw $p (__config_settings_frame cursor 0 0) \
# Aliases 12 (__config_settings_frame badge on) "cmd shadows" pad
function __config_settings_frame
switch $argv[1]
# ── Width tier: 6-col buffer per side before stepping up ──────────
case width
if test "$COLUMNS" -ge 90
echo 76
else if test "$COLUMNS" -ge 86
echo 72
else if test "$COLUMNS" -ge 82
echo 68
else
echo 50
end
# ── Title border: ┌─<segment><dashes>┐ ────────────────────────────
case title
set -l iw $argv[2]
set -l vis (string length --visible -- "$argv[4]")
set -l dashes (math "max(0, $iw - $vis - 1)")
printf '%s┌─%s%s┐\n' $argv[3] "$argv[4]" (string repeat -n $dashes '─')
# ── Badge: 7 visible columns, coloured ────────────────────────────
# The truthy/falsy words are arguments rather than one merged
# vocabulary: merging would make a hand-set "on" in a true/false
# variable render as ON where it renders DEFAULT today.
case badge
set -l on_word on
set -l off_word off
if test (count $argv) -ge 4
set on_word $argv[3]
set off_word $argv[4]
end
switch "$argv[2]"
case $on_word
printf '%s ON%s' (set_color green) (set_color normal)
case $off_word
printf '%sOFF %s' (set_color red) (set_color normal)
case '*'
printf '%sDEFAULT%s' (set_color brblack) (set_color normal)
end
# ── Cursor cell: 2 visible columns ────────────────────────────────
case cursor
if test $argv[2] -eq $argv[3]
printf '%s▶%s ' (set_color --bold magenta) (set_color normal)
else
printf ' '
end
# ── One table row ─────────────────────────────────────────────────
case row
set -l iw $argv[2]
set -l p $argv[3]
set -l curs $argv[4]
set -l label "$argv[5]"
set -l label_w $argv[6]
set -l badge $argv[7]
set -l field "$argv[8]"
set -l fit $argv[9]
set -l field_w (math $iw - 21 - $label_w)
switch $fit
case cut
set label (string sub -l $label_w -- "$label")
set field (string sub -l $field_w -- "$field")
case shorten
set field (string shorten -m $field_w -- "$field")
end
printf '%s│ %s%s [ %s ] %s │\n' $p $curs \
(string pad -r -w $label_w -- "$label") $badge \
(string pad -r -w $field_w -- "$field")
case '*'
echo "__config_settings_frame: unknown verb '$argv[1]'" >&2
return 1
end
end
-45
View File
@@ -1,45 +0,0 @@
# Copyright (C) 2026 Rootiest
# SPDX-License-Identifier: AGPL-3.0-or-later
# SYNOPSIS
# __config_settings_pagetab <active_idx> <iw>
#
# DESCRIPTION
# Renders the four-page tab strip used as the header line of every
# config-settings page. Pages: 0 Universal, 1 Session, 2 Sponge, 3 Paths.
# The active page is marked with a filled bullet and bold text; the others
# with a hollow bullet. The returned string is padded to exactly <iw>
# printable columns (the caller adds the │ │ border and center offset).
#
# ARGUMENTS
# active_idx 03, the active page index
# iw inner width in columns to pad the strip to
#
# EXIT STATUS
# 0 Always
#
# RETURNS
# The rendered tab strip, printed to stdout (no trailing newline beyond printf's)
#
# EXAMPLE
# set strip (__config_settings_pagetab 2 76)
function __config_settings_pagetab
set -l active $argv[1]
set -l iw $argv[2]
__fish_palette
set -l names Universal Session Sponge Paths
set -l strip ' '
for i in (seq 0 3)
set -l idx (math $i + 1)
if test $i -eq $active
set strip "$strip$c_hi$names[$idx]$c_reset "
else
set strip "$strip$names[$idx] "
end
end
# string pad is width-aware: color escapes count as 0 columns, the bullets
# and letters as their printable width.
string pad -r -w $iw -- $strip
end
-97
View File
@@ -1,97 +0,0 @@
# Copyright (C) 2026 Rootiest
# SPDX-License-Identifier: AGPL-3.0-or-later
# SYNOPSIS
# __config_settings_read_key
#
# DESCRIPTION
# Reads a single keypress directly from the controlling terminal in raw
# mode and echoes a normalized token naming the key. Bypasses fish's
# read builtin, whose interactive line editor swallows Tab and arrow
# keys (and prints a "read> " prompt) — none of which is usable for a TUI.
#
# The terminal is put into raw, no-echo mode with a 0.1s inter-byte timer
# (stty raw -echo min 1 time 1) so a multi-byte escape sequence (e.g.
# an arrow key, ESC [ A) is captured in one read while a lone key returns
# promptly. Original terminal settings are always restored before return.
#
# In raw mode Ctrl-C does not raise SIGINT; it arrives as byte 3 (ETX),
# which is reported as the token "quit".
#
# ARGUMENTS
# (none)
#
# EXIT STATUS
# 0 A key was read
# 1 The terminal could not be put into raw mode (stdin is not a TTY)
#
# RETURNS
# One token, printed to stdout:
# up down left right arrow keys
# space tab backtab enter escape backspace
# quit Ctrl-C (byte 3) in raw mode
# <char> any other single printable character
# "" nothing decodable was read
#
# EXAMPLE
# set -l key (__config_settings_read_key)
# or return # not a TTY — bail
# switch $key
# case up; echo "moved up"
# case space; echo "toggled"
# end
function __config_settings_read_key
# Snapshot current terminal settings; failure means stdin is not a TTY.
set -l saved (stty -g </dev/tty 2>/dev/null)
or return 1
# Raw, no-echo. min 0 / time 3: return after 0.3s even with no bytes (poll
# interval for resize detection), or immediately when any bytes arrive.
# Escape sequences (e.g. arrow keys) arrive fast enough to land in one read.
stty raw -echo min 0 time 3 </dev/tty 2>/dev/null
# One read() of up to 3 bytes — covers ESC [ A style sequences. od emits
# the bytes as space-separated decimal codes.
set -l codes (dd if=/dev/tty bs=3 count=1 2>/dev/null \
| od -An -tu1 2>/dev/null | string trim | string split -n ' ')
# Restore the terminal before doing anything else.
stty $saved </dev/tty 2>/dev/null
switch (string join ' ' $codes)
case '27 91 65'
echo up
case '27 91 66'
echo down
case '27 91 67'
echo right
case '27 91 68'
echo left
case '27 91 90'
echo backtab
case 27
echo escape
case 9
echo tab
case 32
echo space
case 10 13
echo enter
case 8 127
echo backspace
case 3
echo quit
case ''
echo ''
case '*'
# Single printable byte → emit its character; ignore stray
# multi-byte sequences we do not recognise. The two-step octal
# form avoids fish mangling a one-shot '\\%03o' format string.
if test (count $codes) -eq 1
set -l oct (printf '%03o' $codes[1])
printf '%b\n' "\\$oct"
else
echo ''
end
end
end
+76
View File
@@ -0,0 +1,76 @@
# Copyright (C) 2026 Rootiest
# SPDX-License-Identifier: AGPL-3.0-or-later
# DEPENDENCIES
# __config_settings_get_val, __config_settings_get_raw,
# __config_settings_subcats
#
# SYNOPSIS
# __config_settings_state
#
# DESCRIPTION
# Dumps everything scripts/config-settings-tui.py needs to render the
# settings TUI: the current value of every opinionated-component,
# sponge, scrollback and user-dots variable, plus the sub-category
# taxonomy from __config_settings_subcats.
#
# Two record types, separated by RS (0x1e), fields separated by US
# (0x1f). Both are ASCII control characters, so no value these variables
# can hold needs escaping on the way through:
#
# var<US><scope><US><name><US><value>
# sub<US><category_var><US><slug><US><label><US><description>
#
# Only variables that are actually set are emitted; the TUI renders an
# absent variable as DEFAULT. That is what keeps the variable list out of
# this function -- names are discovered with `set --names` and filtered by
# prefix, so a new sub-category needs no edit here, only in
# __config_settings_subcats.
#
# Toggle variables are dumped once per scope (universal and session), since
# the TUI edits those scopes independently. Sponge and Paths variables are
# universal-only and dumped as the value the running shell resolves,
# space-joined for list variables.
#
# EXIT STATUS
# 0 Always
#
# RETURNS
# The RS/US-separated state dump, printed to stdout
#
# EXAMPLE
# __config_settings_state | string split \x1e
function __config_settings_state --description 'Dump config-settings state for the curses TUI'
# ── Variable values ───────────────────────────────────────────────────
for name in (set --names)
switch $name
case '__fish_config_op_registry_*'
# conf.d data table, not a setting -- shares the op_ prefix.
continue
case '__fish_config_op_*' __fish_config_opinionated
# Toggles: both scopes, independently editable.
for scope in universal session
set -l val (__config_settings_get_val $name $scope)
test "$val" = DEFAULT; and continue
printf '%s\x1f%s\x1f%s\x1f%s\x1e' var $scope $name $val
end
case 'sponge_*' __fish_sponge_extra_sensitive \
'__fish_scrollback_history_*' __fish_user_dots_path \
__fish_user_dots_symlink
# Value rows: universal-only, list variables space-joined.
set -l val (__config_settings_get_raw $name)
test "$val" = DEFAULT; and continue
printf '%s\x1f%s\x1f%s\x1f%s\x1e' var universal $name $val
end
end
# ── Sub-category taxonomy ─────────────────────────────────────────────
for cvar in __fish_config_op_aliases __fish_config_op_autoexec \
__fish_config_op_overrides __fish_config_op_integrations \
__fish_config_op_logging __fish_config_op_greeting
for row in (__config_settings_subcats $cvar)
set -l f (string split -- \t $row)
printf '%s\x1f%s\x1f%s\x1f%s\x1f%s\x1e' sub $cvar $f[1] $f[2] $f[3]
end
end
end
+87 -368
View File
@@ -4,6 +4,10 @@
# CATEGORY # CATEGORY
# 14-miscellaneous # 14-miscellaneous
# #
# DEPENDENCIES
# __fish_palette, __config_settings_state, __config_settings_apply,
# __config_settings_set_value, python3
#
# SYNOPSIS # SYNOPSIS
# config-settings [-h | --help] # config-settings [-h | --help]
# #
@@ -22,49 +26,55 @@
# Toggle rows use ← / → (or h / l) to step OFF ← DEFAULT → ON; DEFAULT erases # Toggle rows use ← / → (or h / l) to step OFF ← DEFAULT → ON; DEFAULT erases
# the variable so the master switch / built-in default applies. On the # the variable so the master switch / built-in default applies. On the
# Universal/Session pages, Enter on a category row (C1C6) opens that # Universal/Session pages, Enter on a category row (C1C6) opens that
# category's sub-category drill-down page for finer-grained toggles; # category's sub-category drill-down page, which leads with the category's
# Escape backs out to the category list. Value rows # own toggle; Escape backs out. Value rows (Sponge, Paths) use Enter to edit
# (Sponge, Paths) use Enter to edit inline; ← / h clears to default. List rows # inline and ← / h to reset to the row default; committing a blank edit does
# (e.g. Extra secret, OK codes) accept values separated by commas and/or # the same. List rows (e.g. Extra secret, OK codes) accept values separated
# whitespace — "A, B", "A,B" and "A B" all yield the same two entries. # by commas and/or whitespace — "A, B", "A,B" and "A B" all yield the same
# Tab / Shift-Tab cycle forward / backward through pages. # two entries. Tab / Shift-Tab cycle through pages.
# Changes apply immediately — no confirm step. Always available regardless of #
# __fish_config_opinionated state. # / filters the current page on label and description. On the Universal and
# Session pages the filter also 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.
#
# Edits are collected while the TUI runs and applied in one batch when it
# exits, via __config_settings_apply and __config_settings_set_value. The
# status bar shows a pending count. This is a deliberate consequence of the
# renderer being a child process: a child cannot reach into its parent shell
# to set a global, so the Session page's edits come back as a fish script the
# function sources on exit, and the Universal page rides the same path for
# consistency. Always available regardless of __fish_config_opinionated state.
# #
# The Sponge and Paths pages always write universal variables — these are # The Sponge and Paths pages always write universal variables — these are
# persistent, set-and-forget settings with no per-session scope. Editing a # persistent, set-and-forget settings with no per-session scope. Editing a
# scrollback row updates both the __fish_scrollback_history_* source-of-truth # scrollback row updates both the __fish_scrollback_history_* source-of-truth
# variables and the exported SCROLLBACK_HISTORY_* mirrors, so the AUR/tmux/ # variables and the exported SCROLLBACK_HISTORY_* mirrors, so the AUR/tmux/
# zellij log wrappers (which read the exported names) see the change in the # zellij log wrappers (which read the exported names) see the change in the
# running session. # running session. Editing Dots link re-runs __fish_user_dots_link.
# #
# The panel adapts to the terminal width automatically, selecting from four # The panel is drawn by scripts/config-settings-tui.py using Python's stdlib
# layout tiers (with a 6-column buffer on each side before stepping up to the # curses module, which owns the cell arithmetic, the alternate screen and the
# next tier) and horizontally centering the box. The panel redraws within # redraw diffing. It resizes with the terminal and needs no width tiers.
# ~0.3 s of a terminal resize with no keypress required.
#
# COLUMNS >= 90 → 78-wide panel (most detail)
# COLUMNS >= 86 → 74-wide panel
# COLUMNS >= 82 → 70-wide panel
# COLUMNS < 82 → 52-wide panel (default)
# #
# Navigation: # Navigation:
# ↑ ↓ / k j Move cursor # ↑ ↓ / k j Move cursor
# ← → / h l Toggle rows: OFF ← DEFAULT → ON # ← → / h l Toggle rows: OFF ← DEFAULT → ON
# ← / h Value rows: clear to default # ← / h Value rows: reset to default
# Enter Category rows (Universal/Session): open sub-category # Enter Category rows: open the sub-category page.
# drill-down page. Value rows: edit inline (Sponge / # Value rows: edit inline
# Paths pages) # / Filter, sub-categories included
# Escape Sub-category page: back out to the category list # Escape Back out of a sub-category page, or clear the filter
# Tab / S-Tab Next / previous page # Tab / S-Tab Next / previous page
# q / Escape Exit # ? Help overlay
# q Apply pending edits and exit
# #
# ARGUMENTS # ARGUMENTS
# -h, --help Print usage and exit # -h, --help Print usage and exit
# #
# EXIT STATUS # EXIT STATUS
# 0 Exited normally (q or Escape pressed) # 0 Exited normally
# 1 Unknown flag passed # 1 Unknown flag, no TTY, or python3/curses unavailable
# #
# EXAMPLE # EXAMPLE
# config-settings # config-settings
@@ -78,16 +88,19 @@ function config-settings --description 'Interactive TUI for managing fish config
echo "$c_head""Usage:$c_reset $c_cmd""config-settings$c_reset $c_flag""[-h]$c_reset" echo "$c_head""Usage:$c_reset $c_cmd""config-settings$c_reset $c_flag""[-h]$c_reset"
echo echo
echo " Interactive TUI for managing fish config settings." echo " Interactive TUI for managing fish config settings."
echo " Changes apply immediately — no confirm step required." echo " Edits are applied in one batch when the TUI exits."
echo echo
echo "$c_head""Navigation:$c_reset" echo "$c_head""Navigation:$c_reset"
echo " $c_flag↑ ↓$c_reset or $c_flag""k j$c_reset Move cursor up / down" echo " $c_flag↑ ↓$c_reset or $c_flag""k j$c_reset Move cursor up / down"
echo " $c_flag← →$c_reset or $c_flag""h l$c_reset Toggle pages: OFF ← DEFAULT → ON" echo " $c_flag← →$c_reset or $c_flag""h l$c_reset Toggle rows: OFF ← DEFAULT → ON"
echo " $c_flag""Enter$c_reset Open sub-category page (Universal / Session);" echo " $c_flag""Enter$c_reset Open sub-category page (Universal / Session);"
echo " edit value (Sponge / Paths pages)" echo " edit value (Sponge / Paths pages)"
echo " $c_flag← / h$c_reset Clear value to default (value rows)" echo " $c_flag← / h$c_reset Reset value to default (value rows)"
echo " $c_flag/$c_reset Filter rows, sub-categories included"
echo " $c_flag""Tab / S-Tab$c_reset Next / previous page" echo " $c_flag""Tab / S-Tab$c_reset Next / previous page"
echo " $c_flag""q$c_reset / $c_flag""Esc$c_reset Exit" echo " $c_flag""Esc$c_reset Leave sub-category page / clear filter"
echo " $c_flag?$c_reset Help overlay"
echo " $c_flag""q$c_reset Apply pending edits and exit"
echo echo
echo "$c_head""Pages:$c_reset" echo "$c_head""Pages:$c_reset"
echo " $c_flag""Universal$c_reset Toggles, persistent ($c_dim""set -U$c_reset)" echo " $c_flag""Universal$c_reset Toggles, persistent ($c_dim""set -U$c_reset)"
@@ -102,354 +115,60 @@ function config-settings --description 'Interactive TUI for managing fish config
end end
end end
# ── Toggle-page variables (rows 06: 6 categories + master) ─────────── # ── Dependencies ──────────────────────────────────────
set -l toggle_vars \ # python3 with the curses module. That is stdlib on Arch, Fedora and a
__fish_config_op_aliases \ # full Debian/Ubuntu python3; python3-minimal alone does not carry
__fish_config_op_autoexec \ # _curses, so both are checked rather than assumed from `type -q`.
__fish_config_op_overrides \ if not type -q python3
__fish_config_op_integrations \ echo "$c_err""config-settings requires python3.$c_reset" >&2
__fish_config_op_logging \ return 1
__fish_config_op_greeting \
__fish_config_opinionated
# ── Drill-down navigation state (Universal/Session pages only) ────────
# in_subcat: 1 while viewing a category's sub-category page (Enter to
# open, Escape to back out). subcat_row: cursor row within that page.
set -l in_subcat 0
set -l subcat_row 0
# ── Value-page row metadata (parallel: var / type) ────────────────────
set -l sponge_vars sponge_delay sponge_purge_only_on_exit sponge_allow_previously_successful sponge_successful_exit_codes __fish_sponge_extra_sensitive
set -l sponge_types int bool bool list list
set -l sponge_labels Delay "Purge@exit" "Allow prev" "OK codes" "Extra secret"
set -l paths_vars __fish_scrollback_history_dir __fish_scrollback_history_max_files __fish_user_dots_path __fish_user_dots_symlink
set -l paths_types path int path bool
set -l paths_labels "Log dir" "Log max" "Dots path" "Dots link"
# Reset/blank-edit target for each value row. A non-empty entry is written
# verbatim (sponge reads sponge_delay / sponge_successful_exit_codes with no
# fallback, so they must never be left unset); an empty entry erases the var
# so its own built-in default applies (scrollback/dots paths and the
# extra-sensitive list all tolerate being unset). Bool rows are not reset
# through this path — they are a 2-state true/false with no unset state.
set -l sponge_defaults 2 '' '' 0 ''
set -l paths_defaults '' '' '' ''
# Rows per page index 0..3
set -l page_rows 7 7 5 4
set -l cur_page 0 # 0=Universal 1=Session 2=Sponge 3=Paths
set -l cur_row 0
set -l panel_h 0 # real value set by the first dispatch call below
set -l new_frame # captured by __cs_dispatch_draw
set -l last_cols $COLUMNS
# ── Terminal setup ────────────────────────────────────
printf '\e[?25l' # hide cursor
trap 'printf "\e[?25h"; set -g __config_settings_exit 1' INT
# ── Draw dispatch (page 0/1 = toggle table; 2/3 = value page) ─────────
# Captures the page's rendered lines into new_frame and derives panel_h
# from their count. panel_h is never hand-set again: the sub-category
# page is n+7 lines (2-6 sub-categories: 9-13 lines), never the
# category list's fixed 16, and deriving it from the real output means
# that fact can no longer drift out of sync with what got drawn.
function __cs_dispatch_draw --no-scope-shadowing
switch $cur_page
case 0
if test $in_subcat -eq 1
set new_frame (__config_settings_draw_subcat $subcat_row universal $toggle_vars[(math $cur_row + 1)])
else
set new_frame (__config_settings_draw $cur_row universal $toggle_vars)
end end
case 1 if not python3 -c 'import curses' 2>/dev/null
if test $in_subcat -eq 1 echo "$c_err""config-settings requires python3 with the curses module.$c_reset" >&2
set new_frame (__config_settings_draw_subcat $subcat_row session $toggle_vars[(math $cur_row + 1)]) echo " Debian/Ubuntu: install the full $c_cmd""python3$c_reset package." >&2
else return 1
set new_frame (__config_settings_draw $cur_row session $toggle_vars)
end end
case 2 if not isatty stdout
set new_frame (__config_settings_draw_value $cur_row sponge) echo "$c_err""config-settings needs a terminal.$c_reset" >&2
case 3 return 1
set new_frame (__config_settings_draw_value $cur_row paths)
end
set panel_h (count $new_frame)
end
__cs_dispatch_draw
printf '%s\n' $new_frame
# ── Event loop ────────────────────────────────────────
# __config_settings_read_key reads a single keypress from /dev/tty in raw
# mode and returns a normalized token (up/down/tab/space/escape/quit or a
# literal char). It bypasses fish's `read`, whose line editor swallows Tab
# and arrow keys and prints a `read> ` prompt — unusable for a TUI.
while true
# Check for Ctrl-C signal (trap sets this flag during redraw, when the
# terminal is briefly back in cooked mode and SIGINT can fire).
if set -q __config_settings_exit
set -eg __config_settings_exit
break
end end
set -l key (__config_settings_read_key) set -l tui (dirname (status filename))/../scripts/config-settings-tui.py
or break # not a TTY — exit instead of spinning if not test -f $tui
echo "$c_err""config-settings: missing $tui$c_reset" >&2
set -l did_redraw 0 return 1
switch $key
case up k
if test $in_subcat -eq 1
set subcat_row (math "max(0, $subcat_row - 1)")
else
set cur_row (math "max(0, $cur_row - 1)")
end
case down j
if test $in_subcat -eq 1
set -l n (count (__config_settings_subcats $toggle_vars[(math $cur_row + 1)]))
set subcat_row (math "min($n, $subcat_row + 1)")
else
# Hoist the page index: fish cannot expand a command-substitution
# index inside a quoted math string.
set -l pidx (math $cur_page + 1)
set cur_row (math "min($page_rows[$pidx] - 1, $cur_row + 1)")
end
case tab
set cur_page (math "($cur_page + 1) % 4")
set cur_row 0
set in_subcat 0
case backtab
set cur_page (math "($cur_page + 3) % 4")
set cur_row 0
set in_subcat 0
case right l
if test $cur_page -le 1
# Toggle page: step toward ON
set -l scope universal
test $cur_page -eq 1; and set scope session
# Default: the category variable itself -- correct both
# when not in a sub-category page at all, and when in
# one but sitting on its row 0 (the category's own
# toggle). Only row >= 1 of a sub-category page
# resolves to a different, sub-category variable.
# Hoist the category variable into a plain local first --
# fish cannot expand a command-substitution index
# ("$toggle_vars[(math ...)]") inside a quoted string
# (same reason the down/j case above hoists $pidx).
set -l cvar $toggle_vars[(math $cur_row + 1)]
set -l varname $cvar
if test $in_subcat -eq 1 -a $subcat_row -ne 0
set -l rows (__config_settings_subcats $cvar)
set -l fields (string split -- \t $rows[$subcat_row])
set varname "$cvar"_(string replace -a -- '-' '_' $fields[1])
end
set -l cur_val (__config_settings_get_val $varname $scope)
set -l next_val on
test "$cur_val" = off; and set next_val DEFAULT
__config_settings_apply $varname $scope $next_val
else
# Value pages: bool rows are 2-state (true/false). → sets
# true. Sponge reads its bools with no fallback; the Paths
# "Dots link" bool drives __fish_user_dots_link on change.
set -l v_vars $sponge_vars
set -l v_types $sponge_types
if test $cur_page -eq 3
set v_vars $paths_vars
set v_types $paths_types
end
set -l ridx (math $cur_row + 1)
if test "$v_types[$ridx]" = bool
set -U $v_vars[$ridx] true 2>/dev/null
test "$v_vars[$ridx]" = __fish_user_dots_symlink
and __fish_user_dots_link
end
end
case left h
if test $cur_page -le 1
set -l scope universal
test $cur_page -eq 1; and set scope session
# Same varname resolution as the right/l case above
# (hoisted local -- see the comment there for why the
# command-substitution index can't be inlined into the
# quoted string directly).
set -l cvar $toggle_vars[(math $cur_row + 1)]
set -l varname $cvar
if test $in_subcat -eq 1 -a $subcat_row -ne 0
set -l rows (__config_settings_subcats $cvar)
set -l fields (string split -- \t $rows[$subcat_row])
set varname "$cvar"_(string replace -a -- '-' '_' $fields[1])
end
set -l cur_val (__config_settings_get_val $varname $scope)
set -l next_val off
test "$cur_val" = on; and set next_val DEFAULT
__config_settings_apply $varname $scope $next_val
else
# Value pages: bool rows set false; other value rows reset to
# their default (a literal value, or erase when the var
# tolerates being unset — see sponge_defaults/paths_defaults).
set -l v_vars $sponge_vars
set -l v_types $sponge_types
set -l v_defaults $sponge_defaults
if test $cur_page -eq 3
set v_vars $paths_vars
set v_types $paths_types
set v_defaults $paths_defaults
end
set -l ridx (math $cur_row + 1)
set -l varname $v_vars[$ridx]
set -l vtype $v_types[$ridx]
if test "$vtype" = bool
set -U $varname false 2>/dev/null
test "$varname" = __fish_user_dots_symlink
and __fish_user_dots_link
else
__config_settings_set_value $varname $vtype "$v_defaults[$ridx]"
end
end
case enter
if test $cur_page -le 1
if test $in_subcat -eq 0 -a $cur_row -le 5
set in_subcat 1
set subcat_row 0
end
else if test $cur_page -ge 2
set -l v_vars $sponge_vars
set -l v_types $sponge_types
set -l v_defaults $sponge_defaults
if test $cur_page -eq 3
set v_vars $paths_vars
set v_types $paths_types
set v_defaults $paths_defaults
end
set -l ridx (math $cur_row + 1)
set -l varname $v_vars[$ridx]
set -l vtype $v_types[$ridx]
# Only path/int/list rows are editable; bool rows toggle with ←/→.
if test "$vtype" != toggle -a "$vtype" != bool
# Inline editor: edit in-place in the row's value field
# using the raw key reader — no fish `read` / `read>`
# prompt, and the panel cleans itself up on exit. The
# buffer is pre-filled with the current value; clearing it
# and pressing Enter reverts to the row's default.
set -l page sponge
test $cur_page -eq 3; and set page paths
set -l buf (__config_settings_get_raw $varname)
test "$buf" = DEFAULT; and set buf ""
set -l committed 0
# Full erase once to enter edit mode; per-keystroke
# redraws below diff against the previous edit frame.
set -l edit_frame (__config_settings_draw_value $cur_row $page edit "$buf")
set -l prev_edit_frame
set -l pml (math --scale=0 "($last_cols + 78) / 2")
set -l eh (math --scale=0 "$panel_h * max(1, ceil($pml / $COLUMNS))")
printf '\e[%dA\e[J' $eh
printf '%s\n' $edit_frame
set last_cols $COLUMNS
while true
set -l ek (__config_settings_read_key)
or break
switch $ek
case enter
set committed 1
break
case escape
break
case backspace
set buf (string sub -s 1 -e -1 -- "$buf")
case space
set buf "$buf "
case up down left right tab backtab quit ''
# ignored while editing
case '*'
set buf "$buf$ek"
end end
set prev_edit_frame $edit_frame # ── Run the TUI ───────────────────────────────────────
set edit_frame (__config_settings_draw_value $cur_row $page edit "$buf") # The TUI is a child process, so it can neither read the session's global
if test (count $edit_frame) -eq (count $prev_edit_frame) -a "$COLUMNS" = "$last_cols" -a $COLUMNS -ge 52 # variables nor write them. State goes in as a dump; the edits come back
# `| string collect` is required on each join -- # as a fish script this function sources, which is what lets the Session
# see the identical note in the main loop's # page's `set -g` land in the caller's shell instead of in a child that is
# diff-path call. # about to exit. `command` throughout: this repo's own aliases shadow rm.
printf '\e[%dA' (count $edit_frame) set -l work (command mktemp -d)
__config_settings_diff_redraw (string join \n -- $prev_edit_frame | string collect) (string join \n -- $edit_frame | string collect) if test -z "$work" -o ! -d "$work"
else echo "$c_err""config-settings: could not create a temporary directory.$c_reset" >&2
set -l ph (count $prev_edit_frame) return 1
set -l pml (math --scale=0 "($last_cols + 78) / 2")
set -l eh (math --scale=0 "$ph * max(1, ceil($pml / $COLUMNS))")
printf '\e[%dA\e[J' $eh
printf '%s\n' $edit_frame
end
set last_cols $COLUMNS
end
if test $committed -eq 1
# Empty buffer reverts to the row default (a value, or
# erase when the var tolerates being unset).
if test -n "$buf"
__config_settings_set_value $varname $vtype "$buf"
else
__config_settings_set_value $varname $vtype "$v_defaults[$ridx]"
end
end
# Redraw the normal panel in place of the editor.
set -l pml (math --scale=0 "($last_cols + 78) / 2")
set -l eh (math --scale=0 "$panel_h * max(1, ceil($pml / $COLUMNS))")
printf '\e[%dA\e[J' $eh
set last_cols $COLUMNS
__cs_dispatch_draw
printf '%s\n' $new_frame
set did_redraw 1
end
end
case q Q quit
break
case escape
if test $in_subcat -eq 1
set in_subcat 0
else
break
end
end end
# Skip redraw entirely when the key reader timed out with no resize # An empty dump would not fail loudly -- the TUI would simply render every
if test -z "$key" -a "$COLUMNS" = "$last_cols" # row as DEFAULT, which is indistinguishable from a config where nothing is
continue # set. That is a wrong answer, not a missing one, so refuse instead. The
# taxonomy alone guarantees a non-empty dump on any working checkout.
__config_settings_state >$work/state
if not test -s $work/state
echo "$c_err""config-settings: __config_settings_state produced no output.$c_reset" >&2
command rm -rf $work
return 1
end end
# Skip redraw if the Enter handler already redrew (e.g. after path edit) python3 $tui --state $work/state --emit $work/edits
if test $did_redraw -eq 1 set -l rc $status
continue
if test $rc -eq 0 -a -s $work/edits
source $work/edits
end end
set -l old_h $panel_h command rm -rf $work
set -l prev_frame $new_frame return $rc
__cs_dispatch_draw
if test $panel_h -eq $old_h -a "$COLUMNS" = "$last_cols" -a $COLUMNS -ge 52
# Diff path: geometry and width unchanged since the last frame --
# move up without erasing, rewrite only the lines that changed.
# `| string collect` is required on each join: command
# substitution always re-splits on newlines, so without it
# __config_settings_diff_redraw would receive many positional
# arguments instead of the two joined strings it expects.
printf '\e[%dA' $panel_h
__config_settings_diff_redraw (string join \n -- $prev_frame | string collect) (string join \n -- $new_frame | string collect)
else
# Full-redraw path: resize, page switch, or subcat enter/exit --
# same wrap-aware erase math as before, unchanged. 78 = widest
# box (IW=76+2); the formula gives the worst-case old line width
# for any tier drawn at last_cols.
set -l prev_max_lw (math --scale=0 "($last_cols + 78) / 2")
set -l erase_h (math --scale=0 "$old_h * max(1, ceil($prev_max_lw / $COLUMNS))")
printf '\e[%dA\e[J' $erase_h
printf '%s\n' $new_frame
end
set last_cols $COLUMNS
end
# ── Cleanup ───────────────────────────────────────────
trap - INT # remove the signal handler
set -l prev_max_lw (math --scale=0 "($last_cols + 78) / 2")
set -l erase_h (math --scale=0 "$panel_h * max(1, ceil($prev_max_lw / $COLUMNS))")
printf '\e[%dA\e[J' $erase_h # erase the panel (wrap-aware)
printf '\e[?25h' # restore cursor
functions --erase __cs_dispatch_draw
end end
+838
View File
@@ -0,0 +1,838 @@
#!/usr/bin/env python3
# Copyright (C) 2026 Rootiest
# SPDX-License-Identifier: AGPL-3.0-or-later
#
# SYNOPSIS
# config-settings-tui.py [--state <file>] [--emit <file>] [--self-test]
#
# DESCRIPTION
# 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.
#
# fish --(__config_settings_state)--> --state file --> this TUI
# fish <--(source)------------------- --emit file <-- this TUI
#
# 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
# --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, or the state dump could not be obtained
#
# EXAMPLE
# 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 imported and initialised.
os.environ.setdefault("ESCDELAY", "25")
import curses # noqa: E402
RS, US = "\x1e", "\x1f" # record / unit separators used by the state dump
# ╭──────────────────────────────────────────────────────────────────────────╮
# │ Row model │
# ╰──────────────────────────────────────────────────────────────────────────╯
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
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 = [
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", "", ""),
]
# 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 = [
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 = [
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 subcat_var(category_var, slug):
"""Sub-category variable name, matching config-settings.fish's derivation."""
return category_var + "_" + slug.replace("-", "_")
# ╭──────────────────────────────────────────────────────────────────────────╮
# │ 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 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)
# ╭──────────────────────────────────────────────────────────────────────────╮
# │ 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 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 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] + ""
# ╭──────────────────────────────────────────────────────────────────────────╮
# │ 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
for pair, fg in {
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,
}.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]
if not text:
return
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 badge(value, kind, hint):
"""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, state):
self.scr = stdscr
self.state = state
self.page = 0
self.row = 0
self.filter = ""
self.mode = "nav" # nav | filter | edit | help
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):
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, row):
return self.state.get(self.scope, row.var)
# ── 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)
put(scr, 0, 0, "" + "" * (w - 2) + "", frame)
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 - 1, 0, "", frame)
put(scr, h - 1, w - 1, "", frame)
self.draw_sidebar(top, bot)
self.draw_detail(top, 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, 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, ell(shown, SIDEBAR_W - 2), 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, ell(f"{self.drill} ▸ sub-categories", width),
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
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))
for i in range(first, min(len(rows), first + capacity)):
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 = ell(self.buf + "_", width - 4), C_HINT
else:
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 " ") + 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(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)
def draw_status(self, h, w):
if self.mode == "edit":
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 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"
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 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 = [
"",
" Navigation",
" ↑ ↓ / k j move between rows",
" Tab / ⇧Tab next / previous page",
" ⏎ drill into sub-categories, or edit a value",
" 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",
" mouse click a row to select, click its value to cycle",
" ? toggle this overlay",
" q apply pending edits and quit",
"",
" 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)
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()
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:
self.commit_edit()
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 ""
self.msg = ""
if key == "q":
return False
if key == "?":
self.mode = "help"
elif ch == curses.KEY_DOWN or key == "j":
self.row = (self.row + 1) % max(1, len(rows))
elif ch == curses.KEY_UP or key == "k":
self.row = (self.row - 1) % max(1, len(rows))
elif ch == curses.KEY_RIGHT or key == "l":
self.bump(True)
elif ch == curses.KEY_LEFT or key == "h":
self.bump(False)
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:
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
# 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
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.state.set(self.scope, row.var, row.default)
self.msg = f"{row.label} reset 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 # Ctrl-C keeps the edits made so far, like q
if not self.handle(ch):
return
# ╭──────────────────────────────────────────────────────────────────────────╮
# │ 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, 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)
# -- 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() == []
# -- variable naming matches config-settings.fish --------------------
assert subcat_var("__fish_config_op_aliases", "shell-tools") \
== "__fish_config_op_aliases_shell_tools"
# -- 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"
# -- unfiltered pages ------------------------------------------------
assert build_rows(st, "Universal", None, "") == CATEGORIES
assert build_rows(st, "Sponge", None, "") == SPONGE
# -- 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) == ""
print("self-test OK")
return 0
def main(argv):
if "--self-test" in argv:
return 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, 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.argv[1:]))
File diff suppressed because it is too large Load Diff
-285
View File
@@ -1,285 +0,0 @@
#!/usr/bin/env fish
# Copyright (C) 2026 Rootiest
# SPDX-License-Identifier: AGPL-3.0-or-later
#
# Golden-output harness for the config-settings TUI renderer.
#
# fish tests/config-settings-render.fish compare against the golden
# fish tests/config-settings-render.fish --update rewrite the golden
#
# __config_settings_draw, __config_settings_draw_subcat and
# __config_settings_draw_value are hand-tuned layout code: field widths, dash
# counts and pad targets are arithmetic on the width tier, verified by eye once
# and never since. Any refactor of them has to be byte-identical, so this
# renders every page at every width tier, in both scopes, with the cursor on
# every row, and byte-compares the result against a committed baseline.
#
# The golden holds RAW output: the set_color escapes and box drawing exactly as
# the draw functions emit them, plus the \e[<N>A\e[J erase config-settings.fish
# would emit for that panel. Nothing is normalized, folded or pretty-printed --
# the gate is cmp(1) over the bytes, and a one-space change anywhere fails it.
# `diff` is only used to *display* a failure, through cat -v.
#
# Everything runs inside a throwaway HOME/XDG_CONFIG_HOME sandbox. The fixtures
# have to be real universal variables (the Universal page reads universal scope
# through `set --show`), and this repo doubles as a live ~/.config/fish whose
# fish_variables must never be touched by a test run. `fish --no-config` is not
# an option here: under -N, `set -U` silently degrades to global scope, which
# would render the Universal page as all-DEFAULT and prove nothing.
#
# Every external utility below is called through `command`. The outer pass runs
# under the user's real config, which shadows rm (-> trash), cat (-> bat) and
# mkdir, and aliases cp to `cp -i` -- a bare `cp` over an existing golden would
# sit waiting for a confirmation that never comes.
set -l self (command realpath (status filename))
set -l repo (command realpath (command dirname $self)/..)
set -l golden $repo/tests/golden/config-settings-render.txt
# ╭──────────────────────────────────────────────────────────────────────────╮
# │ Outer pass: sandbox, run the render, compare or update │
# ╰──────────────────────────────────────────────────────────────────────────╯
if not set -q CS_RENDER_OUT
set -l sandbox (command mktemp -d)
command mkdir -p $sandbox/home $sandbox/xdg/fish
set -l out (command mktemp)
set -l errf (command mktemp)
# TERM is pinned so set_color emits a fixed sequence set regardless of the
# terminal this runs from; COLUMNS is set per case inside the render pass.
command env -i \
HOME=$sandbox/home \
XDG_CONFIG_HOME=$sandbox/xdg \
PATH="$PATH" \
TERM=xterm-256color \
CS_RENDER_OUT=$out \
fish $self >/dev/null 2>$errf
set -l render_status $status
command rm -rf $sandbox
if test $render_status -ne 0
echo " FAIL render pass exited $render_status"
command cat $errf >&2
command rm -f $out $errf
exit 1
end
set -l cases (command grep -c '^### ' $out)
if contains -- --update $argv
command mkdir -p (command dirname $golden)
command cp $out $golden
echo "golden updated: $cases cases, "(command wc -l <$golden | string trim)" lines, "(command wc -c <$golden | string trim)" bytes"
command rm -f $out $errf
exit 0
end
if not test -f $golden
echo " FAIL no golden at $golden -- run with --update to create it"
command rm -f $out $errf
exit 1
end
if command cmp -s $out $golden
echo "$cases/$cases config-settings render cases byte-identical"
command rm -f $out $errf
exit 0
end
echo " FAIL rendering differs from $golden"
# cat -v only to make the escapes readable in the report; the gate above is
# a raw byte compare, never this.
command diff (command cat -v $golden | psub) (command cat -v $out | psub) | command head -40
command rm -f $out $errf
exit 1
end
# ╭──────────────────────────────────────────────────────────────────────────╮
# │ Render pass (inside the sandbox) │
# ╰──────────────────────────────────────────────────────────────────────────╯
# Sourced, not autoloaded: XDG_CONFIG_HOME points at the empty sandbox. Only
# the draw path is ever called -- __config_settings_apply and
# __config_settings_set_value are defined here and never invoked.
# __fish_palette is sourced too: the draw functions call it (instead of
# declaring $c_head/$c_dim/etc. inline) and it doesn't match the
# __config_settings_* glob, so without this line every draw function's
# colors silently unset and the golden loses all its escape sequences.
source $repo/functions/__fish_palette.fish
for f in $repo/functions/__config_settings_*.fish
source $f
end
set -l toggle_vars \
__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
set -l categories $toggle_vars[1..6]
# ── Fixtures ──────────────────────────────────────────────────────────────
# Chosen so every badge branch is live somewhere in the golden: an explicit
# truthy (ON), an explicit falsy (OFF), and an unset variable (DEFAULT). The
# session values deliberately differ from the universal ones so the two pages
# cannot render identically by accident.
set -U __fish_config_op_aliases on
set -U __fish_config_op_autoexec off
set -U __fish_config_op_integrations on
set -U __fish_config_op_logging off
set -U __fish_config_opinionated off
# __fish_config_op_overrides, __fish_config_op_greeting: unset -> DEFAULT
set -g __fish_config_op_aliases off
set -g __fish_config_op_overrides on
set -g __fish_config_op_greeting off
# Sub-category fixtures. "Notifications" is the 13-char label the subcat page's
# label field was widened for; multiplexer-capture exercises the slug's '-'->'_'
# rewrite into a variable name.
set -U __fish_config_op_aliases_filesystem on
set -U __fish_config_op_aliases_search off
set -U __fish_config_op_integrations_notifications on
set -U __fish_config_op_logging_multiplexer_capture off
set -g __fish_config_op_aliases_search on
# Value-page fixtures: one per type badge (INT / LIST / PATH) plus unset rows
# for DEFAULT, plus a value long enough to force `string shorten`'s ellipsis at
# every tier.
set -U sponge_delay 5
set -U sponge_purge_only_on_exit true
set -U sponge_allow_previously_successful false
set -U __fish_sponge_extra_sensitive KOPIA_PASSWORD MY_CORP_AUTH
# sponge_successful_exit_codes: unset -> DEFAULT
set -U __fish_scrollback_history_dir /home/tester/very/long/scrollback/history/directory
set -U __fish_user_dots_path /home/tester/.config/.user-dots/fish
set -U __fish_user_dots_symlink false
# __fish_scrollback_history_max_files: unset -> DEFAULT
# ── Case emitter ──────────────────────────────────────────────────────────
# panel_h is passed in by the caller, independently declaring the expected
# height for each page so it can be cross-checked against the draw functions'
# real output: the category list and both value pages are a fixed 16 lines, a
# sub-category page is 7 + <sub-category count>. The golden records the
# declared height, the measured line count, and the erase sequence derived
# from the declared height -- so flattening the fixed/dynamic divergence, or
# changing a page's height at all, breaks the compare three ways.
function _cs_render_case --argument-names label panel_h
set -l cmd $argv[3..]
set -l t (command mktemp)
$cmd >$t
set -l lines (command wc -l <$t | string trim)
# Wrap-aware erase, byte-for-byte what config-settings.fish emits for a
# panel of this height at this width in the steady state (last_cols ==
# COLUMNS). 78 is the widest box (IW=76 + 2 borders).
set -l pml (math --scale=0 "($COLUMNS + 78) / 2")
set -l eh (math --scale=0 "$panel_h * max(1, ceil($pml / $COLUMNS))")
printf '### %s COLUMNS=%d PANEL_H=%d LINES=%d ERASE=' $label $COLUMNS $panel_h $lines
printf '\e[%dA\e[J' $eh
printf '\n'
command cat $t
command rm -f $t
end
# ── Frame-verb fragments ──────────────────────────────────────────────────
# The shared computations pinned directly, not only through the pages that use
# them. <END> marks the end of each fragment so trailing padding -- which is
# the entire point of a 7-column badge or a 2-column cursor cell -- shows up in
# a diff instead of being invisible whitespace.
function _cs_frame_case --argument-names label
printf '### frame %s\n' $label
$argv[2..]
printf '<END>\n'
end
# ── Cases ─────────────────────────────────────────────────────────────────
# 100 -> IW 76, 88 -> IW 72, 84 -> IW 68, 70 -> IW 50: one COLUMNS value per
# width tier, each a few columns above its threshold.
begin
for cols in 100 88 84 70
set -g COLUMNS $cols
for scope in universal session
for row in (seq 0 6)
_cs_render_case "toggle scope=$scope row=$row" 16 \
__config_settings_draw $row $scope $toggle_vars
end
end
for category in $categories
set -l n (count (__config_settings_subcats $category))
for scope in universal session
for row in (seq 0 $n)
_cs_render_case "subcat cat=$category scope=$scope row=$row" (math 7 + $n) \
__config_settings_draw_subcat $row $scope $category
end
end
end
for row in (seq 0 4)
_cs_render_case "value page=sponge row=$row" 16 \
__config_settings_draw_value $row sponge
end
for row in (seq 0 3)
_cs_render_case "value page=paths row=$row" 16 \
__config_settings_draw_value $row paths
end
# Inline editor: only reachable on non-bool rows. Short buffer, a
# buffer long enough to tail-anchor against the caret, and an empty one.
_cs_render_case "edit page=sponge row=0 buf=short" 16 \
__config_settings_draw_value 0 sponge edit 12
_cs_render_case "edit page=sponge row=4 buf=long" 16 \
__config_settings_draw_value 4 sponge edit "KOPIA_PASSWORD MY_CORP_AUTH ANOTHER_SECRET_NAME"
_cs_render_case "edit page=paths row=0 buf=long" 16 \
__config_settings_draw_value 0 paths edit /home/tester/very/long/scrollback/history/directory
_cs_render_case "edit page=paths row=2 buf=empty" 16 \
__config_settings_draw_value 2 paths edit ""
end
# Emitted last on purpose: everything above is page output, so the page
# section's byte offsets never move when this section grows.
for cols in 100 88 84 70
set -g COLUMNS $cols
_cs_frame_case "width cols=$cols" __config_settings_frame width
end
set -g COLUMNS 100
set -l head (set_color --bold cyan)
set -l rst (set_color normal)
set -l p_test (string repeat -n 11 ' ')
for v in on off DEFAULT ''
_cs_frame_case "badge onoff val=$v" __config_settings_frame badge $v
end
for v in true false DEFAULT
_cs_frame_case "badge boolean val=$v" __config_settings_frame badge $v true false
end
_cs_frame_case "cursor hit" __config_settings_frame cursor 3 3
_cs_frame_case "cursor miss" __config_settings_frame cursor 3 4
_cs_frame_case "title toggle-page" __config_settings_frame title 76 $p_test \
"$head Opinionated Settings $rst"
_cs_frame_case "title subcat-page" __config_settings_frame title 76 $p_test \
"$head Sub-categories: aliases (Universal)$rst "
_cs_frame_case "title value-page" __config_settings_frame title 76 $p_test \
"$head Sponge Settings$rst "
set -l badge_on (__config_settings_frame badge on)
_cs_frame_case "row pad lw=12" __config_settings_frame row 76 $p_test \
(__config_settings_frame cursor 0 0) Aliases 12 $badge_on "cmd shadows" pad
_cs_frame_case "row cut lw=13" __config_settings_frame row 76 $p_test \
(__config_settings_frame cursor 0 1) Notifications 13 $badge_on "done, WakaTime hook" cut
_cs_frame_case "row cut lw=13 overlong" __config_settings_frame row 50 $p_test \
(__config_settings_frame cursor 0 0) Notifications 13 $badge_on \
"ls, cat, cd, du, mkdir, rm, mv, zoxide" cut
_cs_frame_case "row shorten lw=12" __config_settings_frame row 76 $p_test \
(__config_settings_frame cursor 0 0) "Log dir" 12 $badge_on \
/home/tester/very/long/scrollback/history/directory shorten
_cs_frame_case "row shorten lw=12 empty" __config_settings_frame row 50 $p_test \
(__config_settings_frame cursor 1 0) "Log max" 12 $badge_on "" shorten
end >$CS_RENDER_OUT
+127
View File
@@ -0,0 +1,127 @@
#!/usr/bin/env fish
# Copyright (C) 2026 Rootiest
# SPDX-License-Identifier: AGPL-3.0-or-later
#
# Gate for the curses config-settings front-end (scripts/config-settings-tui.py).
#
# Runs isolated (no `# MODE:` marker). Three things are checked here:
#
# 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 config-settings makes,
# and the reason the function checks the import rather than just `type -q`.
# 2. The TUI's own --self-test passes. That covers the state-dump parser,
# variable-name derivation, toggle cycling, the sub-category-aware filter,
# fish quoting and command emission, with no TTY.
# 3. config-settings still degrades cleanly with no terminal, rather than
# leaving a half-initialised curses screen behind.
#
# The drawing code is deliberately NOT golden-tested, and the golden harness
# the ANSI renderer needed is gone. That is the point of the rewrite: curses
# owns the cell arithmetic, so there is no hand-tuned width/pad/dash maths left
# to pin down. The seam that does need pinning -- dump in, fish script out --
# is covered here and, for session scope, in tests/test-session.fish.
source (dirname (status filename))/lib.fish
# Isolated suites run under `fish --no-config`, which autoloads nothing from
# this repo. The launcher cases below call config-settings for real, so put the
# repo's functions on the autoload path -- and only that, so nothing in conf.d
# runs and the guard variables stay unset.
set -p fish_function_path $repo_root/functions
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)
section "config-settings: launcher"
check "--help exits 0" 0 (config-settings --help >/dev/null 2>&1; echo $status)
check "an unknown flag exits 1" 1 (config-settings --nope >/dev/null 2>&1; echo $status)
# stdout is a pipe here, so the isatty guard fires before curses ever starts.
# Without it the TUI would fail deep inside setupterm and leave the terminal
# in whatever state it got to.
function test_no_tty_is_refused_cleanly
set -l out (config-settings 2>&1 >/dev/null | string collect)
string match -q '*needs a terminal*' -- $out
end
check "no TTY is refused with a message, not a curses crash" true (test_no_tty_is_refused_cleanly; and echo true; or echo false)
# An empty state dump is the dangerous failure, because it is not one: the TUI
# would render every row as DEFAULT, which is indistinguishable from a config
# where nothing is set. The user would be looking at ON rows reported as OFF's
# neighbour and toggling from a false baseline. The launcher must refuse.
#
# Reaching that guard needs a real terminal -- the isatty check sits in front
# of it -- so this runs fish under a pty. python3 is already a hard
# prerequisite of this suite (see the header), so its stdlib pty module costs
# no new dependency; `script` would.
#
# The deadline is load-bearing, not belt-and-braces. If the guard regresses,
# config-settings does not fail -- it opens the TUI and blocks on getch(),
# so an unbounded read here would hang the suite instead of failing it.
set -l pty_runner '
import os, pty, select, signal, sys, time
pid, fd = pty.fork()
if pid == 0:
os.execvp("fish", ["fish", "--no-config", "-c", sys.argv[1]])
deadline, out = time.monotonic() + 15, b""
while time.monotonic() < deadline:
if not select.select([fd], [], [], deadline - time.monotonic())[0]:
break
try:
chunk = os.read(fd, 65536)
except OSError:
break
if not chunk:
break
out += chunk
else:
out += b"\nTIMEOUT: the TUI opened and blocked on input\n"
try:
os.kill(pid, signal.SIGKILL)
except ProcessLookupError:
pass
os.waitpid(pid, 0)
sys.stdout.write(out.decode("utf-8", "replace"))
'
function test_empty_state_dump_is_refused --argument-names runner
# Shadowing the autoloaded __config_settings_state with an empty function
# is what fakes the failure; a real dump always carries the taxonomy.
set -l cmd "set -p fish_function_path $repo_root/functions;
function __config_settings_state; end;
config-settings;
echo RC=\$status"
set -l out (python3 -c "$runner" "$cmd" | string collect)
set -l failed 0
if not string match -q '*produced no output*' -- $out
# A regression here means the TUI drew itself, so $out is a screenful
# of escape sequences. Strip them and keep a usable excerpt.
set -l seen (string replace -ra '\e\[[0-9;?]*[a-zA-Z]|\e[()][A-Z]' '' -- $out \
| string join ' ' | string sub -l 120)
echo " expected the empty-dump refusal, got: $seen"
set failed 1
end
if not string match -q '*RC=1*' -- $out
echo " expected exit status 1 from the refusal"
set failed 1
end
# Guard the guard: if the pty were not a terminal we would be watching the
# isatty check fire and would learn nothing about the dump.
if string match -q '*needs a terminal*' -- $out
echo " the isatty guard fired -- the pty did not present a terminal"
set failed 1
end
test $failed -eq 0
end
check "an empty state dump is refused, not rendered as all-DEFAULT" true (test_empty_state_dump_is_refused "$pty_runner"; and echo true; or echo false)
report
+93 -41
View File
@@ -164,56 +164,108 @@ function test_functions_keep_their_palette
end end
check "colored --help output keeps its escape sequences" true (test_functions_keep_their_palette; and echo true; or echo false) check "colored --help output keeps its escape sequences" true (test_functions_keep_their_palette; and echo true; or echo false)
section "session: config-settings diff redraw" section "session: config-settings state dump"
# Locks in the invariant the diff-redraw renderer depends on: each draw # The curses TUI is a child process: it can neither read the session's global
# function's real line count must match the height config-settings.fish's # variables nor write them, so everything it knows arrives through
# dispatch derives from it (count $new_frame) -- see # __config_settings_state. These cases run here, in a real loaded session,
# __cs_dispatch_draw in functions/config-settings.fish. # because session scope is exactly what an isolated suite cannot produce.
function test_draw_line_count_matches_panel_h
set -l toggle_vars \ # Every sub-category __config_settings_subcats knows must reach the dump --
__fish_config_op_aliases __fish_config_op_autoexec \ # the TUI does not carry a second copy of the taxonomy, so a category missing
# here is a category the TUI silently cannot show.
function test_state_dump_carries_the_whole_taxonomy
# US/RS have to come from printf: fish does not expand \x escapes inside
# double quotes, so a literal "\x1f" in a pattern matches backslash-x-1-f.
set -l US (printf '\x1f')
set -l dump (__config_settings_state | string split (printf '\x1e'))
set -l want 0
for cvar in __fish_config_op_aliases __fish_config_op_autoexec \
__fish_config_op_overrides __fish_config_op_integrations \ __fish_config_op_overrides __fish_config_op_integrations \
__fish_config_op_logging __fish_config_op_greeting \ __fish_config_op_logging __fish_config_op_greeting
__fish_config_opinionated set -l n (count (__config_settings_subcats $cvar))
set want (math $want + $n)
set -l lines (__config_settings_draw 0 universal $toggle_vars) set -l got (count (string match -- "sub$US$cvar$US*" $dump))
if test (count $lines) -ne 16 if test $got -ne $n
echo " __config_settings_draw: expected 16 lines, got "(count $lines) echo " $cvar: expected $n sub records, got $got"
return 1 return 1
end end
set -l vlines (__config_settings_draw_value 0 sponge)
if test (count $vlines) -ne 16
echo " __config_settings_draw_value: expected 16 lines, got "(count $vlines)
return 1
end end
set -l subs (count (string match -- "sub$US*" $dump))
set -l n (count (__config_settings_subcats __fish_config_op_aliases)) if test $subs -ne $want
set -l slines (__config_settings_draw_subcat 0 universal __fish_config_op_aliases) echo " expected $want sub records in total, got $subs"
set -l want (math 7 + $n)
if test (count $slines) -ne $want
echo " __config_settings_draw_subcat: expected $want lines, got "(count $slines)
return 1 return 1
end end
return 0 return 0
end end
check "draw functions' line counts match their panel heights" true (test_draw_line_count_matches_panel_h; and echo true; or echo false) check "state dump carries every sub-category" true (test_state_dump_carries_the_whole_taxonomy; and echo true; or echo false)
function test_diff_redraw_unchanged_lines_are_bare_newlines # Toggles are dumped per scope; value rows are universal-only. A variable that
functions -q __config_settings_diff_redraw; or return 1 # is unset must not appear at all -- absence is how the TUI renders DEFAULT.
set -l old (string join \n -- AAA BBB CCC | string collect) function test_state_dump_records_both_scopes
set -l new (string join \n -- AAA BBB CCC | string collect) set -l US (printf '\x1f')
set -l out (__config_settings_diff_redraw "$old" "$new" | string collect -N) set -g __fish_config_op_overrides off
test "$out" = \n\n\n set -g sponge_delay 7
end set -l dump (__config_settings_state | string split (printf '\x1e'))
check "diff_redraw: unchanged lines are bare newlines" true (test_diff_redraw_unchanged_lines_are_bare_newlines; and echo true; or echo false) set -e __fish_config_op_overrides
set -e sponge_delay
function test_diff_redraw_changed_line_is_cleared_and_rewritten set -l failed 0
functions -q __config_settings_diff_redraw; or return 1 if test (count (string match -- "var$US""session$US""__fish_config_op_overrides$US""off" $dump)) -ne 1
set -l old (string join \n -- AAA BBB CCC | string collect) echo " session toggle missing from the dump"
set -l new (string join \n -- AAA XYZ CCC | string collect) set failed 1
set -l out (__config_settings_diff_redraw "$old" "$new" | string collect -N) end
test "$out" = \n\e\[2K\rXYZ\n\n if test (count (string match -- "var$US""universal$US""sponge_delay$US""7" $dump)) -ne 1
echo " value row missing from the dump"
set failed 1
end
if test (count (string match -- "var$US*$US""__fish_config_op_greeting$US*" $dump)) -ne 0
echo " an unset toggle was emitted; absence is what renders DEFAULT"
set failed 1
end
# The conf.d registry data table shares the op_ prefix and is not a setting.
if test (count (string match -- "var$US*$US""__fish_config_op_registry_*" $dump)) -ne 0
echo " the registry data table leaked into the dump"
set failed 1
end
test $failed -eq 0
end end
check "diff_redraw: a changed line is cleared and rewritten" true (test_diff_redraw_changed_line_is_cleared_and_rewritten; and echo true; or echo false) check "state dump records both scopes and omits unset variables" true (test_state_dump_records_both_scopes; and echo true; or echo false)
# The other half of the seam: what the TUI emits has to be runnable fish that
# actually moves the variable. Emitting is Python's job, applying is fish's.
function test_emitted_script_applies
set -l tui $repo_root/scripts/config-settings-tui.py
type -q python3; or return 1
set -l script (python3 $tui --self-test >/dev/null 2>&1; and python3 -c '
import importlib.util, sys
spec = importlib.util.spec_from_file_location("cst", sys.argv[1])
m = importlib.util.module_from_spec(spec); spec.loader.exec_module(m)
st = m.State("")
st.set("session", "__fish_config_op_greeting", "off")
st.set("universal", "sponge_delay", "11")
sys.stdout.write(m.emit(st, m.rows_by_var()))
' $tui)
or return 1
set -l tmp (command mktemp)
printf '%s\n' $script >$tmp
source $tmp
command rm -f $tmp
set -l failed 0
test "$__fish_config_op_greeting" = off
or begin
echo " sourcing the emitted script did not set the session toggle"
set failed 1
end
test "$sponge_delay" = 11
or begin
echo " sourcing the emitted script did not set the value row"
set failed 1
end
set -e __fish_config_op_greeting
set -Ue sponge_delay 2>/dev/null
test $failed -eq 0
end
check "an emitted script applies when sourced" true (test_emitted_script_applies; and echo true; or echo false)