feat(config-settings): replace the ANSI renderer with a curses front-end

config-settings is now a launcher for scripts/config-settings-tui.py,
drawn with Python's stdlib curses. The seven fish files that hand-rolled
the ANSI renderer are gone, along with the golden harness that had to pin
their byte-exact output.

The TUI is a child process, so it can neither read the session's global
variables nor write them. State goes in as a dump from the new
__config_settings_state; the edits come back as a fish script that
config-settings sources, which is what lets the Session page's `set -g`
land in the caller's shell instead of in a child that is about to exit.
Every edit is emitted as a call to __config_settings_apply or
__config_settings_set_value, so list splitting, the SCROLLBACK_HISTORY_*
export mirror and the shadow-warning suppression all stay in the fish
layer that already owned them.

The consequence, and the one behaviour change: edits are applied in one
batch on exit rather than on each keypress. The status bar shows a
pending count.

New: `/` filters the current page, and on the Universal and Session pages
it reaches into every category's sub-categories, listing hits as
"Category › Sub" so a sub-category can be toggled without drilling into
its parent first. Also a `?` help overlay, mouse selection, and a
drill-down page that leads with the category's own toggle.

Gone with the renderer: the four width tiers, the wrap-aware erase
arithmetic, the stty/dd/od raw key reader, the panel-height bookkeeping
and the hand-written redraw differ. curses owns all of it, and the alt
screen plus absolute addressing makes the desync class behind 608b022,
4210f3b, 93fc5e0 and 3c4f720 unreachable.

The sub-category taxonomy is NOT duplicated in Python: it travels in the
state dump, still sourced from __config_settings_subcats. The category,
Sponge and Paths row tables move into Python, consolidating the two
copies the fish renderers kept.

Dependency: python3 with curses. Stdlib on Arch, Fedora and a full
Debian/Ubuntu python3; python3-minimal alone lacks _curses. The launcher
checks for both and names what is missing. Called out in the README.

Verified: 416/416 assertions, plus a live end-to-end in a sandbox HOME
confirming the Universal page writes universal scope only (U1/G0) and the
Session page global scope only (U0/G1).
This commit is contained in:
2026-09-09 14:50:26 -04:00
parent c5d2624a06
commit 078d01666e
15 changed files with 745 additions and 6738 deletions
@@ -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 0–6, the currently highlighted row
# cur_scope "universal" or "session"
# var1–var7 Variable names for rows 0–6 (6 categories + master)
#
# EXIT STATUS
# 0 Always
#
# EXAMPLE
# __config_settings_draw 0 universal \
# __fish_config_op_aliases __fish_config_op_autoexec \
# __fish_config_op_overrides __fish_config_op_integrations \
# __fish_config_op_logging __fish_config_op_greeting \
# __fish_config_opinionated
function __config_settings_draw
set -l cur_row $argv[1]
set -l cur_scope $argv[2]
set -l vars $argv[3..]
__fish_palette
set -l labels Aliases Auto-exec Overrides Integrations Logging Greeting Master
# ── Width tier ────────────────────────────────────────────────────────
# The tier thresholds live in __config_settings_frame; this file only
# chooses which hand-authored description set goes with the width.
# IW = inner width (chars between │ │); desc field = IW - 33.
# All four layouts are exactly 16 lines tall — panel_h in caller stays 16.
# Descriptions are authored to fit their field exactly at every tier
# (43/43, 39/39, 35/35, 17/17), which is why the rows below pass `pad`
# and not `cut` -- see the NOTES in __config_settings_frame.
set -l iw (__config_settings_frame width)
set -l descs \
"cmd shadows" \
startup \
"keys/env/prompt" \
"terminal coupling" \
scrollback \
fish_greeting \
"disable all"
switch $iw
case 76
set descs \
"shadows: ls→eza, cat→bat, cd→z, rm→trash" \
"Fisher bootstrap, themes, py-venv activate" \
"vi-mode, bang-bang, PAGER, CDPATH, starship" \
"Kitty/WezTerm tab/split fns, notifications" \
"scrollback capture & paru/yay AUR wrappers" \
"fish_greeting & first-run welcome banner" \
"master off-switch: overrides all categories"
case 72
set descs \
"ls→eza, cat→bat, cd→zoxide, rm→trash" \
"Fisher bootstrap, themes, py-venv auto" \
"vi-mode, bang-bang, PAGER, starship" \
"Kitty/WezTerm fns, done notifications" \
"scrollback capture & paru/yay wrappers" \
"fish_greeting: first-run welcome banner" \
"master off-switch for all categories"
case 68
set descs \
"ls→eza, cat→bat, cd→z, rm→trash" \
"Fisher, themes, py-venv activate" \
"vi-mode, bang-bang, PAGER, starship" \
"Kitty/WezTerm, done notifications" \
"scrollback & paru/yay log wrappers" \
"fish_greeting & first-run banner" \
"master disable for all categories"
end
set -l HBR (string repeat -n $iw '─')
# ── Center padding ────────────────────────────────────────────────────
# ponytail: floor division — left margin may be 1 col less than right if gap is odd
set -l p (string repeat -n (math --scale=0 "max(0, ($COLUMNS - ($iw + 2)) / 2)") ' ')
# ── Top border ────────────────────────────────────────────────────────
# ┌─ Opinionated Settings (iw-23)×─ ┐ total = iw+2
__config_settings_frame title $iw $p "$c_head Opinionated Settings $c_reset"
# ── Page-tab header ───────────────────────────────────────────────────
set -l active_idx 0
if test $cur_scope = session
set active_idx 1
end
printf '%s│%s│\n' $p (__config_settings_pagetab $active_idx $iw)
# ── Top divider ───────────────────────────────────────────────────────
printf '%s│%s│\n' $p $HBR
# ── Category rows 0–5 ─────────────────────────────────────────────────
# Label field 12 wide; the description field falls out of it inside the
# frame (field_w = iw - 21 - label_w = iw - 33). `pad`, not `cut`: these
# descriptions are authored per tier to fit exactly, so truncating them
# would be a silent no-op that discards that property.
for i in (seq 0 5)
set -l idx (math $i + 1)
set -l val (__config_settings_get_val $vars[$idx] $cur_scope)
__config_settings_frame row $iw $p \
(__config_settings_frame cursor $i $cur_row) \
$labels[$idx] 12 \
(__config_settings_frame badge $val) \
$descs[$idx] pad
end
# ── Separator before Master ───────────────────────────────────────────
printf '%s│ %s │\n' $p (string repeat -n (math $iw - 6) '─')
# ── Master row (index 6) ──────────────────────────────────────────────
set -l val (__config_settings_get_val $vars[7] $cur_scope)
__config_settings_frame row $iw $p \
(__config_settings_frame cursor 6 $cur_row) \
Master 12 \
(__config_settings_frame badge $val) \
$descs[7] pad
# ── Filler (Dots Path moved to the Paths page) ────────────────────────
printf '%s│ %s%s│\n' $p \
"$c_dim→ Tab for Sponge & Path settings$c_reset" \
(string repeat -n (math $iw - 34) ' ')
printf '%s│%s│\n' $p (string repeat -n $iw ' ')
# ── Bottom divider ────────────────────────────────────────────────────
printf '%s│%s│\n' $p $HBR
# ── Keybind hint ──────────────────────────────────────────────────────
# string pad is width-aware (arrows count as 1 column)
set -l hint " ↑↓/kj move ←→/hl set Enter sub-cats Tab pg q quit"
printf '%s│%s%s%s│\n' $p $c_dim (string pad -r -w $iw -- $hint) $c_reset
# ── Bottom border ─────────────────────────────────────────────────────
printf '%s└%s┘\n' $p $HBR
end
@@ -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 0–3, 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
+82 -377
View File
@@ -4,6 +4,10 @@
# CATEGORY
# 14-miscellaneous
#
# DEPENDENCIES
# __fish_palette, __config_settings_state, __config_settings_apply,
# __config_settings_set_value, python3
#
# SYNOPSIS
# config-settings [-h | --help]
#
@@ -22,49 +26,55 @@
# 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
# Universal/Session pages, Enter on a category row (C1–C6) opens that
# category's sub-category drill-down page for finer-grained toggles;
# Escape backs out to the category list. Value rows
# (Sponge, Paths) use Enter to edit inline; ← / h clears to default. List rows
# (e.g. Extra secret, OK codes) accept values separated by commas and/or
# whitespace — "A, B", "A,B" and "A B" all yield the same two entries.
# Tab / Shift-Tab cycle forward / backward through pages.
# Changes apply immediately — no confirm step. Always available regardless of
# __fish_config_opinionated state.
# category's sub-category drill-down page, which leads with the category's
# own toggle; Escape backs out. Value rows (Sponge, Paths) use Enter to edit
# inline and ← / h to reset to the row default; committing a blank edit does
# the same. List rows (e.g. Extra secret, OK codes) accept values separated
# by commas and/or whitespace — "A, B", "A,B" and "A B" all yield the same
# two entries. Tab / Shift-Tab cycle through pages.
#
# `/` 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 -g`, 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
# persistent, set-and-forget settings with no per-session scope. Editing a
# scrollback row updates both the __fish_scrollback_history_* source-of-truth
# variables and the exported SCROLLBACK_HISTORY_* mirrors, so the AUR/tmux/
# 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
# layout tiers (with a 6-column buffer on each side before stepping up to the
# next tier) and horizontally centering the box. The panel redraws within
# ~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)
# The panel is drawn by scripts/config-settings-tui.py using Python's stdlib
# `curses`, which owns the cell arithmetic, the alternate screen and the
# redraw diffing. It resizes with the terminal and needs no width tiers.
#
# Navigation:
# ↑ ↓ / k j Move cursor
# ← → / h l Toggle rows: OFF ← DEFAULT → ON
# ← / h Value rows: clear to default
# Enter Category rows (Universal/Session): open sub-category
# drill-down page. Value rows: edit inline (Sponge /
# Paths pages)
# Escape Sub-category page: back out to the category list
# ← / h Value rows: reset to default
# Enter Category rows: open the sub-category page.
# Value rows: edit inline
# / Filter, sub-categories included
# Escape Back out of a sub-category page, or clear the filter
# Tab / S-Tab Next / previous page
# q / Escape Exit
# ? Help overlay
# q Apply pending edits and exit
#
# ARGUMENTS
# -h, --help Print usage and exit
#
# EXIT STATUS
# 0 Exited normally (q or Escape pressed)
# 1 Unknown flag passed
# 0 Exited normally
# 1 Unknown flag, no TTY, or python3/curses unavailable
#
# EXAMPLE
# 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
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 "$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""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 " 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""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 "$c_head""Pages:$c_reset"
echo " $c_flag""Universal$c_reset Toggles, persistent ($c_dim""set -U$c_reset)"
@@ -102,354 +115,46 @@ function config-settings --description 'Interactive TUI for managing fish config
end
end
# ── Toggle-page variables (rows 0–6: 6 categories + master) ───────────
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
# ── 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
case 1
if test $in_subcat -eq 1
set new_frame (__config_settings_draw_subcat $subcat_row session $toggle_vars[(math $cur_row + 1)])
else
set new_frame (__config_settings_draw $cur_row session $toggle_vars)
end
case 2
set new_frame (__config_settings_draw_value $cur_row sponge)
case 3
set new_frame (__config_settings_draw_value $cur_row paths)
end
set panel_h (count $new_frame)
# ── Dependencies ──────────────────────────────────────
# python3 with the curses module. That is stdlib on Arch, Fedora and a
# full Debian/Ubuntu python3; python3-minimal alone does not carry
# _curses, so both are checked rather than assumed from `type -q`.
if not type -q python3
echo "$c_err""config-settings requires python3.$c_reset" >&2
return 1
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
set -l key (__config_settings_read_key)
or break # not a TTY — exit instead of spinning
set -l did_redraw 0
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
set prev_edit_frame $edit_frame
set edit_frame (__config_settings_draw_value $cur_row $page edit "$buf")
if test (count $edit_frame) -eq (count $prev_edit_frame) -a "$COLUMNS" = "$last_cols" -a $COLUMNS -ge 52
# `| string collect` is required on each join --
# see the identical note in the main loop's
# diff-path call.
printf '\e[%dA' (count $edit_frame)
__config_settings_diff_redraw (string join \n -- $prev_edit_frame | string collect) (string join \n -- $edit_frame | string collect)
else
set -l ph (count $prev_edit_frame)
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
# Skip redraw entirely when the key reader timed out with no resize
if test -z "$key" -a "$COLUMNS" = "$last_cols"
continue
end
# Skip redraw if the Enter handler already redrew (e.g. after path edit)
if test $did_redraw -eq 1
continue
end
set -l old_h $panel_h
set -l prev_frame $new_frame
__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
if not python3 -c 'import curses' 2>/dev/null
echo "$c_err""config-settings requires python3 with the curses module.$c_reset" >&2
echo " Debian/Ubuntu: install the full $c_cmd""python3$c_reset package." >&2
return 1
end
if not isatty stdout
echo "$c_err""config-settings needs a terminal.$c_reset" >&2
return 1
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
set -l tui (dirname (status filename))/../scripts/config-settings-tui.py
if not test -f $tui
echo "$c_err""config-settings: missing $tui$c_reset" >&2
return 1
end
# ── Run the TUI ───────────────────────────────────────
# The TUI is a child process, so it can neither read the session's global
# variables nor write them. State goes in as a dump; the edits come back
# as a fish script this function sources, which is what lets the Session
# page's `set -g` land in the caller's shell instead of in a child that is
# about to exit. `command` throughout: this repo's own aliases shadow rm.
set -l work (command mktemp -d)
__config_settings_state >$work/state
python3 $tui --state $work/state --emit $work/edits
set -l rc $status
if test $rc -eq 0 -a -s $work/edits
source $work/edits
end
command rm -rf $work
return $rc
end