#!/usr/bin/env python3
# fed - the fed-field-editor suite, consolidated into one file.
# GENERATED by local/scripts/build-fed.py -- do not edit by hand.
# Develop the tools in public/fed*.py; rebuild to regenerate this file.
# Bundle b2.1F7kY0  |  fed <tool> [options]  |  fed --versions

import atexit
import curses
import json
import locale
import os
import re
import signal
import sys
import termios
import tty

# ================================================================
# fedshared  (shared base; names un-prefixed)
# ================================================================
# fedshared.py - shared helpers for fed-field-editor components
#
# Provides:
#   - Color spec parsing and curses color pair allocation (formerly fedcolors)
#   - Terminal cursor position query (for '@' row/col arguments)
#   - Curses session bootstrap and teardown
#   - --help / -h / --version dispatch
#   - Final stdout/stderr emission with exit
#
# This module is library code only. Running it directly prints a notice
# and exits 2.


# Build string (build.ts6), bumped in-code before commit. fedshared is
# library-only and never prints --version; this records its build in-project.
__version__ = '14.1F6tiN'


# ============================================================
# Colors
# ============================================================

_NAMES = {
    'default':       -1,
    'black':          0,
    'red':            1,
    'green':          2,
    'yellow':         3,
    'blue':           4,
    'magenta':        5,
    'cyan':           6,
    'white':          7,
    'bright-black':   8,
    'bright-red':     9,
    'bright-green':  10,
    'bright-yellow': 11,
    'bright-blue':   12,
    'bright-magenta':13,
    'bright-cyan':   14,
    'bright-white':  15,
}

_pair_cache = {}
_next_pair = [1]
_initialized = [False]


def color_names():
    return list(_NAMES.keys())


def parse_color(spec):
    """Parse 'fg,bg' string into (fg_code, bg_code). Returns None if spec is None or empty."""
    if spec is None or spec == '':
        return None
    parts = spec.split(',')
    if len(parts) != 2:
        raise ValueError(f"color spec must be 'fg,bg', got: {spec!r}")
    fg_name = parts[0].strip().lower()
    bg_name = parts[1].strip().lower()
    if fg_name not in _NAMES:
        raise ValueError(f"unknown color name: {fg_name!r}")
    if bg_name not in _NAMES:
        raise ValueError(f"unknown color name: {bg_name!r}")
    return (_NAMES[fg_name], _NAMES[bg_name])


def swap_color(spec):
    """Return 'bg,fg' from 'fg,bg'. None passes through."""
    if spec is None or spec == '':
        return None
    parts = spec.split(',')
    if len(parts) != 2:
        return spec
    return f"{parts[1].strip()},{parts[0].strip()}"


def init_colors():
    """Idempotent. Call once after curses.initscr()."""
    if _initialized[0]:
        return
    try:
        curses.start_color()
        curses.use_default_colors()
    except curses.error:
        return
    _initialized[0] = True


def attr_for(spec):
    """Return a curses attribute for color spec 'fg,bg', or 0 if spec is None/empty.
    Allocates a color pair on first use. Safe before init_colors() (returns 0)."""
    if spec is None or spec == '':
        return 0
    if not _initialized[0]:
        init_colors()
    if not _initialized[0]:
        return 0
    fg, bg = parse_color(spec) if isinstance(spec, str) else spec
    key = (fg, bg)
    if key in _pair_cache:
        return curses.color_pair(_pair_cache[key])
    try:
        max_pairs = curses.COLOR_PAIRS - 1
    except curses.error:
        max_pairs = 63
    if _next_pair[0] > max_pairs:
        return 0
    n = _next_pair[0]
    try:
        curses.init_pair(n, fg, bg)
    except curses.error:
        return 0
    _pair_cache[key] = n
    _next_pair[0] += 1
    return curses.color_pair(n)


def _sgr_fg(code):
    """ANSI SGR foreground parameter for a _NAMES color code."""
    if code < 0:
        return '39'              # default
    if code < 8:
        return str(30 + code)    # base
    return str(90 + (code - 8))  # bright


def _sgr_bg(code):
    """ANSI SGR background parameter for a _NAMES color code."""
    if code < 0:
        return '49'              # default
    if code < 8:
        return str(40 + code)    # base
    return str(100 + (code - 8))  # bright


def sgr_for(spec, reverse=False):
    """Return an ANSI SGR escape sequence for color spec 'fg,bg' (fed color
    names, e.g. 'bright-white,blue') plus optional reverse video. Returns ''
    when there is nothing to set (spec None/empty and not reverse).

    This is the non-curses sibling of attr_for(): fedsay speaks the same color
    vocabulary as the editors' --color, but emits raw ANSI rather than
    allocating curses color pairs. Single-sourced through parse_color() so the
    names can never drift between a field and its label."""
    params = []
    if spec:
        fg, bg = parse_color(spec) if isinstance(spec, str) else spec
        params.append(_sgr_fg(fg))
        params.append(_sgr_bg(bg))
    if reverse:
        params.append('7')
    if not params:
        return ''
    return '\033[' + ';'.join(params) + 'm'


# ============================================================
# Vertical scrollbar
# ============================================================

def draw_scrollbar(win, rows, width, vy, total, attr=0):
    """Draw a vertical scrollbar in the rightmost column of `win`: a '│' track
    with a '█' thumb sized/positioned to the viewport (`rows` of `total` lines
    scrolled to offset `vy`). Drawn in reverse-of-field video so it stands out
    from field data. No-op when it won't fit or nothing scrolls."""
    if rows < 3 or width < 2 or total <= rows:
        return
    bar_attr = attr ^ curses.A_REVERSE
    thumb_size = min(rows, max(1, (rows * rows) // total))
    denom = max(1, total - rows)
    thumb_start = (vy * (rows - thumb_size)) // denom
    for r in range(rows):
        ch = '█' if thumb_start <= r < thumb_start + thumb_size else '│'
        try:
            win.addstr(r, width - 1, ch, bar_attr)
        except curses.error:
            pass


def render_options_horizontal(win, width, segments, focus, base_attr, sel_attr, gap=2):
    """Render option segments (e.g. '[X] AI', '(*) Yes') left-to-right on row 0,
    separated by `gap` spaces, highlighting the focused one. Used by fedcheck /
    fedradio in horizontal orientation. Truncates at `width`."""
    try:
        win.addstr(0, 0, ' ' * width, base_attr)
    except curses.error:
        pass
    c = 0
    for i, seg in enumerate(segments):
        if c >= width:
            break
        attr = sel_attr if i == focus else base_attr
        try:
            win.addstr(0, c, seg[:max(0, width - c)], attr)
        except curses.error:
            pass
        c += len(seg) + gap


# ============================================================
# Terminal cursor position query
# ============================================================

def query_cursor_pos():
    """Read current terminal cursor row/col via DSR (CSI 6n). Returns 0-indexed (row, col)."""
    fd = sys.stdin.fileno()
    old = termios.tcgetattr(fd)
    try:
        tty.setcbreak(fd)
        tty_fd = os.open('/dev/tty', os.O_RDWR)
        os.write(tty_fd, b'\033[6n')
        resp = b''
        while True:
            ch = os.read(tty_fd, 1)
            resp += ch
            if ch == b'R':
                break
        os.close(tty_fd)
        resp = resp.decode()
        parts = resp[2:-1].split(';')
        return int(parts[0]) - 1, int(parts[1]) - 1
    finally:
        termios.tcsetattr(fd, termios.TCSADRAIN, old)


def resolve_pos(raw_row, raw_col):
    """Resolve '@' tokens against query_cursor_pos. Numeric strings convert directly."""
    if raw_row == '@' or raw_col == '@':
        cur_row, cur_col = query_cursor_pos()
    else:
        cur_row = cur_col = None
    start_row = cur_row if raw_row == '@' else int(raw_row)
    start_col = cur_col if raw_col == '@' else int(raw_col)
    return start_row, start_col


# ============================================================
# Argument dispatch (-h / --help / --version)
# ============================================================

def early_dispatch(argv, name, version, usage, help_text):
    """Handle -h, --help, --version and exit if any is present. Otherwise returns.

    `version` is the tool's build string (build.ts6), passed as a
    literal from the call site and bumped in-code before commit."""
    if '-h' in argv:
        print(usage)
        sys.exit(0)
    if '--help' in argv:
        print(help_text)
        sys.exit(0)
    if '--version' in argv:
        print(f"{name} (C) 2026 smisco\nfed@smisco.biz\nb{version}")
        sys.exit(0)


# ============================================================
# Curses session lifecycle
# ============================================================

# Real-tty cooked termios saved by the --no-restore path so curses_close() can
# restore it. The --no-restore path must run initscr() against /dev/null (see
# below), which makes ncurses' own shell-mode snapshot useless, so we save/restore
# the terminal modes ourselves. One curses session per process, so a module-level
# slot is safe.
_saved_tty_termios = None

# Live-session state for the emergency-teardown safety nets. curses_open() sets
# this when a session opens; curses_close() clears it. If the session is instead
# torn down abnormally — an exception during curses_open(), an uncaught exception
# in the editor's main loop, or a SIGTERM/SIGHUP — the atexit/signal handlers use
# this to leave the terminal usable instead of stuck in raw/cbreak with the
# alternate screen up. Without it a single bad terminal (e.g. no color support
# making start_color() raise) would wedge the caller's shell.
_session = None
_atexit_registered = False


def _safe_start_color():
    """start_color() raises curses.error on a terminal with no color support.
    Color is optional here (attr_for degrades to 0), so swallow it and continue
    rather than abort curses_open() and wedge the terminal."""
    try:
        curses.start_color()
    except curses.error:
        pass


def _emergency_teardown():
    """Best-effort, idempotent terminal restore for the atexit/signal safety nets.
    No-op once curses_close() has run (it clears _session). Every step is guarded
    because this runs on failure paths where the terminal may be in any state."""
    global _session
    sess = _session
    if not sess:
        return
    _session = None
    try:
        curses.curs_set(1)
    except Exception:
        pass
    try:
        if sess['restore']:
            curses.endwin()
        else:
            curses.reset_shell_mode()
    except Exception:
        pass
    if _saved_tty_termios is not None:
        try:
            fd = os.open('/dev/tty', os.O_RDWR)
            termios.tcsetattr(fd, termios.TCSANOW, _saved_tty_termios)
            os.close(fd)
        except (OSError, termios.error):
            pass
    # Put the caller's original stdout/stderr back so any traceback is visible
    # and the shell's stdout keeps working after we exit.
    try:
        so, se = sess['saved_fds']
        os.dup2(so, 1)
        os.dup2(se, 2)
    except (OSError, KeyError):
        pass


def _signal_teardown(signum, frame):
    """SIGTERM/SIGHUP handler: restore the terminal, then re-raise with the
    default disposition so the process dies with the expected status."""
    _emergency_teardown()
    try:
        signal.signal(signum, signal.SIG_DFL)
        os.kill(os.getpid(), signum)
    except Exception:
        os._exit(128 + signum)


def curses_open(restore=True):
    """Open a curses session, rendering to /dev/tty so stdout/stderr stay free
    for the caller's captured value. Returns (stdscr, saved_stdout, saved_stderr).

    restore=True (DEFAULT): clean alt-screen ncurses. initscr() runs against the
    REAL tty so the shell-mode termios snapshot is correct (ONLCR on); the first
    refresh emits smcup (+ clear) and curses_close() calls endwin() (rmcup) to
    restore the caller's screen on exit. No capability hacks, no /dev/null.

    restore=False (--no-restore): stay on the MAIN screen so the field draws
    inline and its result persists in place. ncurses emits smcup (enter alt
    screen) during initscr/first-refresh, and on this ncurses it goes out the
    moment initscr() runs — so the ONLY reliable way to keep it off the real
    screen is to run initscr() while fd 1 points at /dev/null. That, however,
    makes ncurses snapshot /dev/null's termios (OPOST/ONLCR off) as its shell
    mode, which reset_shell_mode() would later stamp onto the real tty (the
    "stairstepping" / bare-LF bug). So we snapshot the tty's real termios up
    front and restore it ourselves in curses_close().
    """
    global _saved_tty_termios, _session, _atexit_registered
    _saved_tty_termios = None
    # Honor the user's locale so curses can emit UTF-8 glyphs (scrollbars,
    # overflow arrows, check/radio markers). Safe no-op on a C locale.
    try:
        locale.setlocale(locale.LC_ALL, '')
    except locale.Error:
        pass
    saved_stdout = os.dup(1)
    saved_stderr = os.dup(2)
    # Arm the safety nets: record the session and install signal handlers so any
    # abnormal teardown (exception below, uncaught exception in the caller, or a
    # SIGTERM/SIGHUP) restores the terminal instead of wedging it.
    if not _atexit_registered:
        atexit.register(_emergency_teardown)
        _atexit_registered = True
    _session = {'restore': restore,
                'saved_fds': (saved_stdout, saved_stderr),
                'old_signals': {}}
    for sig in (signal.SIGTERM, signal.SIGHUP):
        try:
            _session['old_signals'][sig] = signal.signal(sig, _signal_teardown)
        except (OSError, ValueError):
            pass

    try:
        if restore:
            # Point fd 1/2 at the real tty BEFORE initscr() so the shell-mode
            # snapshot is the tty's cooked termios; the first refresh enters the
            # alt screen.
            tty_fd = os.open('/dev/tty', os.O_RDWR)
            os.dup2(tty_fd, 1)
            os.dup2(tty_fd, 2)
            os.close(tty_fd)
            stdscr = curses.initscr()
            _safe_start_color()
            curses.noecho()
            # raw() (not cbreak) on this real-tty-initialized screen: the editors
            # bind control keys (Ctrl+Z undo, Ctrl+Y clear, …). With a real tty,
            # cbreak leaves the tty's signal chars active, so Ctrl+Z -> SIGTSTP
            # instead of reaching getch(). raw() passes them through and is stable
            # here. (raw() must NOT be used on the inline /dev/null screen — see
            # the branch below.)
            curses.raw()
            stdscr.keypad(True)
            stdscr.refresh()
            return stdscr, saved_stdout, saved_stderr

        # --no-restore: snapshot the real tty's cooked termios so we can restore
        # it by hand (ncurses' snapshot will be taken against /dev/null below).
        tty_pre = os.open('/dev/tty', os.O_RDWR)
        try:
            _saved_tty_termios = termios.tcgetattr(tty_pre)
        except termios.error:
            _saved_tty_termios = None
        os.close(tty_pre)
        # Run initscr() with fd 1 at /dev/null so smcup/clear go there, not the tty.
        devnull = os.open(os.devnull, os.O_WRONLY)
        os.dup2(devnull, 1)
        os.close(devnull)
        stdscr = curses.initscr()
        _disable_inline_clear()
        stdscr.refresh()  # first paint -> /dev/null (swallows the initial smcup/clear)
        # Reattach the real tty for actual rendering; ncurses won't re-emit smcup,
        # so later refreshes draw inline over the caller's content.
        tty_fd = os.open('/dev/tty', os.O_RDWR)
        os.dup2(tty_fd, 1)
        os.dup2(tty_fd, 2)
        os.close(tty_fd)
        _safe_start_color()
        curses.noecho()
        # cbreak(), NOT raw(): on this /dev/null-initialized screen raw() makes
        # curses_close()'s reset_shell_mode() return ERR and raise _curses.error,
        # so the editor dies in teardown BEFORE emit_and_exit (empty stdout/stderr,
        # exit 1). cbreak() tears down cleanly. Control keys (Ctrl+Z undo, etc.)
        # still reach getch() because the /dev/null-initialized program mode
        # already has ISIG off. The real tty's cooked termios is restored on exit.
        curses.cbreak()
        stdscr.keypad(True)
        return stdscr, saved_stdout, saved_stderr
    except BaseException:
        # Any failure mid-setup (e.g. /dev/tty unavailable, initscr error): leave
        # the terminal usable and the traceback visible, then propagate.
        _emergency_teardown()
        raise


def _disable_inline_clear():
    """Null ncurses' enter_ca_mode / exit_ca_mode / clear_screen capabilities on
    the already-loaded ncurses (symbols are global once _curses imported), so
    curses neither switches to the alternate screen nor clears the whole screen.
    Must run after initscr() (setupterm has populated the cap pointers) and
    before the first refresh. Best-effort: on any failure we simply keep the
    default (screen-clearing) behavior rather than break."""
    try:
        import ctypes
        lib = ctypes.CDLL(None)
        for sym in ('enter_ca_mode', 'exit_ca_mode', 'clear_screen'):
            try:
                ctypes.c_char_p.in_dll(lib, sym).value = None
            except ValueError:
                pass
    except Exception:
        pass


def curses_close(stdscr, restore=True):
    """Reverse of curses_open. `restore` must match the value passed to it.

    restore=True (DEFAULT): endwin() does the proper teardown — it emits rmcup
    (restoring the caller's screen from the alternate screen) and restores the
    shell-mode termios snapshot. Because initscr() ran against the real tty, that
    restore is correct (ONLCR/OPOST intact).

    restore=False: the alternate screen was suppressed at open, so endwin()'s
    rmcup would garble the main screen. Reset by hand instead:
      - keypad(False): stop interpreting keypad escapes
      - flushinp(): drop type-ahead so the caller's next read() blocks normally
      - emit rmkx/sgr0/cnorm to /dev/tty: leave application-keypad mode, reset
        attributes, show a normal cursor
      - reset_shell_mode() then restore the tty termios we saved in curses_open()
        (reset_shell_mode's snapshot was taken against /dev/null, so it leaves
        OPOST/ONLCR off — our saved cooked termios is the correct one).
    """
    global _session
    stdscr.keypad(False)
    curses.curs_set(1)
    curses.flushinp()
    if restore:
        curses.endwin()
    else:
        resets = b''
        for cap in ('rmkx', 'sgr0', 'cnorm'):
            try:
                seq = curses.tigetstr(cap)
            except curses.error:
                seq = None
            if seq:
                resets += seq
        curses.reset_shell_mode()
        if resets:
            try:
                fd = os.open('/dev/tty', os.O_WRONLY)
                os.write(fd, resets)
                os.close(fd)
            except OSError:
                pass
        # Restore the real tty's cooked termios (ONLCR/OPOST on). reset_shell_mode()
        # above restored the /dev/null-poisoned snapshot, so override it here.
        if _saved_tty_termios is not None:
            try:
                fd = os.open('/dev/tty', os.O_RDWR)
                termios.tcsetattr(fd, termios.TCSADRAIN, _saved_tty_termios)
                os.close(fd)
            except (OSError, termios.error):
                pass
    # Normal teardown succeeded: restore the prior signal handlers and disarm the
    # safety nets so the atexit handler is a no-op.
    if _session:
        for sig, old in _session.get('old_signals', {}).items():
            if old is None:
                continue
            try:
                signal.signal(sig, old)
            except (OSError, ValueError):
                pass
    _session = None


def emit_and_exit(saved_stdout, saved_stderr, text, exit_reason, exit_code, quiet=False):
    """Write the editor's result and exit. Closes the saved fds."""
    os.write(saved_stdout, text.encode())
    if not quiet and exit_reason:
        os.write(saved_stderr, exit_reason.encode())
    os.close(saved_stdout)
    os.close(saved_stderr)
    sys.exit(exit_code)

fedshared = sys.modules[__name__]  # self-alias: fedshared.X -> top-level X

# ================================================================
# fedline  ->  fed line   (top-level names prefixed fedline_)
# ================================================================


fedline_USAGE = """\
Usage: fed line <row> <col> <width> <maxlen> [options]
Try --help for full documentation."""

fedline_HELP = """\
fedline - Single-line terminal text editor

Usage: fed line <row> <col> <width> <maxlen> [options]

Arguments:
  row         Screen row (@ for current cursor row)
  col         Screen column (@ for current cursor column)
  width       Viewport width (visible columns)
  maxlen      Maximum input length (characters)

Options:
  -v, --val TEXT       Initial text value (default: empty)
  -f, --fill CHAR      Fill character for unused positions
  -e, --exit KEYS      Comma-separated exit key mnemonics (default: all)
  -q, --quiet          Suppress exit reason on stderr
  -c, --cursor POS     Initial cursor: start or end (default: end)
  -m, --mode MODE      Input mode: ins or ovr (default: ins)
  -r, --reverse        Display field in reverse video
  --color FG,BG        Field colors (ANSI names: black/red/green/yellow/blue/
                       magenta/cyan/white, optional 'bright-' prefix, or 'default')
  -n, --noop           Display field and exit immediately (code 0)

Exit Keys (mnemonic -> code):
  ESC (1)          Escape key pressed
  ENTER (2)        Enter key pressed
  TAB (3)          Tab key pressed
  SHIFT-TAB (4)    Shift+Tab pressed
  F10 (5)          F10 key pressed
  UP_TOP (6)       Up arrow pressed (single-line, always at top)
  DOWN_BOTTOM (7)  Down arrow pressed (single-line, always at bottom)
  PGUP (8)         PgUp pressed (single-line, always at top)
  PGDN (9)         PgDn pressed (single-line, always at bottom)

  When exit_keys is provided, only listed keys trigger an exit.
  Unlisted keys are silently ignored.

Editing Keys:
  Insert      Toggle insert/overwrite mode
  Ctrl+E      Delete to end of line
  Ctrl+T      Delete to start of line
  Ctrl+Y      Clear all text
  Ctrl+Z      Undo
  Backspace   Delete character before cursor
  Delete      Delete character at cursor
  Home/End    Move to start/end of text
  Left/Right  Move cursor

Terminal:
  This field draws INLINE: it renders only within its own area, over
  whatever is already on the screen. It does not clear the screen and does
  not restore it on exit — the calling script manages the surrounding
  display. (fedform is different: it owns and clears the screen and restores
  it on exit by default.) While active, the field runs in raw mode, so
  Ctrl+C does NOT interrupt it (and Ctrl+S/Ctrl+Q do not pause output);
  leave the field with a configured exit key.

Output:
  stdout      Final edited text
  stderr      Exit reason mnemonic (unless --quiet)
  exit code   Numeric exit code (0 for --noop, 1-9 for exit keys)

Examples:
  fed line 0 0 40 120 -v "hello"
  fed line 0 0 40 120 -v "hello" -f "." -e TAB,SHIFT-TAB
  fed line @ @ 40 120 -v "hello" -c start -m ovr -r
  fed line 5 10 30 50 -v "readonly" -n"""

def fedline_parse_args(argv):
    positional = []
    flags = {}
    i = 0
    while i < len(argv):
        a = argv[i]
        if a in ('-v', '--val') and i + 1 < len(argv):
            flags['val'] = argv[i + 1]; i += 2
        elif a in ('-f', '--fill') and i + 1 < len(argv):
            flags['fill'] = argv[i + 1][:1]; i += 2
        elif a in ('-e', '--exit') and i + 1 < len(argv):
            flags['exit'] = set(argv[i + 1].split(',')); i += 2
        elif a in ('-q', '--quiet'):
            flags['quiet'] = True; i += 1
        elif a in ('-c', '--cursor') and i + 1 < len(argv):
            flags['cursor'] = argv[i + 1]; i += 2
        elif a in ('-m', '--mode') and i + 1 < len(argv):
            flags['mode'] = argv[i + 1]; i += 2
        elif a in ('-r', '--reverse'):
            flags['reverse'] = True; i += 1
        elif a == '--color' and i + 1 < len(argv):
            flags['color'] = argv[i + 1]; i += 2
        elif a in ('-n', '--noop'):
            flags['noop'] = True; i += 1
        else:
            positional.append(a); i += 1
    return positional, flags

def fedline_main(stdscr, start_row, start_col, width, maxlen, exit_keys, fill_char, text, insert_mode, reverse, noop, cursor_pos, color=None):
    fedshared.init_colors()
    curses.curs_set(1 if insert_mode else 2)

    win = curses.newwin(1, width, start_row, start_col)
    win.keypad(True)

    color_attr = fedshared.attr_for(color)
    render_attr = color_attr | (curses.A_REVERSE if reverse else 0)
    if render_attr:
        win.bkgd(' ', render_attr)

    pos = 0 if cursor_pos == 'start' else len(text)
    vx = 0
    undo_stack = []

    def push_undo():
        undo_stack.append((text, pos))

    def can_exit(name):
        return exit_keys is None or name in exit_keys

    def draw_scroll_arrows():
        if width < 5:
            return
        # Reverse-of-field video so the arrows read as chrome, not field data.
        arrow_attr = render_attr ^ curses.A_REVERSE
        if vx > 0:
            try:
                win.addstr(0, 0, '◀', arrow_attr)
            except curses.error:
                pass
        if vx + width < len(text):
            try:
                win.addstr(0, width - 1, '▶', arrow_attr)
            except curses.error:
                pass

    if noop:
        # render once
        visible = text[vx:vx + width]
        pad_char = fill_char if fill_char else ' '
        visible = visible.ljust(width, pad_char)
        try:
            win.addstr(0, 0, visible, render_attr)
        except curses.error:
            pass
        draw_scroll_arrows()
        win.move(0, min(pos - vx, width - 1))
        win.refresh()
        return "NOOP", 0, text

    while True:
        if pos < vx:
            vx = pos
        if pos >= vx + width:
            vx = pos - width + 1

        visible = text[vx:vx + width]
        pad_char = fill_char if fill_char else ' '
        visible = visible.ljust(width, pad_char)
        try:
            win.addstr(0, 0, visible, render_attr)
        except curses.error:
            pass

        draw_scroll_arrows()
        win.move(0, pos - vx)
        win.refresh()

        ch = win.getch()

        # === EXIT TRIGGERS ===
        if ch == 27 and can_exit("ESC"):
            return "ESC", 1, text
        elif ch in (10, 13, curses.KEY_ENTER) and can_exit("ENTER"):
            return "ENTER", 2, text
        elif ch == 9 and can_exit("TAB"):
            return "TAB", 3, text
        elif ch == curses.KEY_BTAB and can_exit("SHIFT-TAB"):
            return "SHIFT-TAB", 4, text
        elif ch == curses.KEY_F10 and can_exit("F10"):
            return "F10", 5, text
        elif ch == curses.KEY_UP and can_exit("UP_TOP"):
            return "UP_TOP", 6, text
        elif ch == curses.KEY_DOWN and can_exit("DOWN_BOTTOM"):
            return "DOWN_BOTTOM", 7, text
        elif ch == curses.KEY_PPAGE and can_exit("PGUP"):
            return "PGUP", 8, text
        elif ch == curses.KEY_NPAGE and can_exit("PGDN"):
            return "PGDN", 9, text

        # === NAVIGATION ===
        elif ch == curses.KEY_LEFT:
            pos = max(0, pos - 1)
        elif ch == curses.KEY_RIGHT:
            pos = min(len(text), pos + 1)
        elif ch == curses.KEY_HOME:
            pos = 0
        elif ch == curses.KEY_END:
            pos = len(text)

        # === EDITING ===
        elif ch in (8, 127, curses.KEY_BACKSPACE):
            if pos > 0:
                push_undo()
                text = text[:pos - 1] + text[pos:]
                pos -= 1
        elif ch == curses.KEY_DC:
            if pos < len(text):
                push_undo()
                text = text[:pos] + text[pos + 1:]

        # Toggle insert/overwrite
        elif ch == curses.KEY_IC:
            insert_mode = not insert_mode
            curses.curs_set(1 if insert_mode else 2)

        # Ctrl+E: Delete to end of string
        elif ch == 5:
            if pos < len(text):
                push_undo()
                text = text[:pos]

        # Ctrl+T: Delete to start of string
        elif ch == 20:
            if pos > 0:
                push_undo()
                text = text[pos:]
                pos = 0

        # Ctrl+Y: Clear all text
        elif ch == 25:
            if text:
                push_undo()
                text = ""
                pos = 0

        # Ctrl+Z: Undo
        elif ch == 26:
            if undo_stack:
                text, pos = undo_stack.pop()

        # Standard character input
        elif 32 <= ch <= 126:
            if insert_mode:
                if len(text) < maxlen:
                    push_undo()
                    text = text[:pos] + chr(ch) + text[pos:]
                    pos += 1
            else:
                push_undo()
                if pos < len(text):
                    text = text[:pos] + chr(ch) + text[pos + 1:]
                    pos += 1
                elif len(text) < maxlen:
                    text = text[:pos] + chr(ch) + text[pos:]
                    pos += 1

def fedline_run():
    args = sys.argv[1:]
    fedshared.early_dispatch(args, 'fedline', '15.1ETwS6', fedline_USAGE, fedline_HELP)

    positional, flags = fedline_parse_args(args)
    if len(positional) != 4:
        print(fedline_USAGE, file=sys.stderr); sys.exit(1)

    start_row, start_col = fedshared.resolve_pos(positional[0], positional[1])
    width = int(positional[2])
    maxlen = int(positional[3])
    text = flags.get('val', '')[:maxlen]
    fill_char = flags.get('fill', '')
    exit_keys = flags.get('exit')
    quiet = flags.get('quiet', False)
    cursor_pos = flags.get('cursor', 'end')
    insert_mode = flags.get('mode', 'ins') != 'ovr'
    reverse = flags.get('reverse', False)
    noop = flags.get('noop', False)
    color = flags.get('color')

    stdscr, saved_stdout, saved_stderr = fedshared.curses_open(restore=False)
    try:
        exit_reason, exit_code, final_text = fedline_main(
            stdscr, start_row, start_col, width, maxlen, exit_keys, fill_char,
            text, insert_mode, reverse, noop, cursor_pos, color
        )
    finally:
        fedshared.curses_close(stdscr, restore=False)

    fedshared.emit_and_exit(saved_stdout, saved_stderr, final_text, exit_reason, exit_code, quiet)

# ================================================================
# fedarea  ->  fed area   (top-level names prefixed fedarea_)
# ================================================================


fedarea_USAGE = """\
Usage: fed area <row> <col> <width> <rows> <maxlen> <v_scroll> <word_wrap> [options]
Try --help for full documentation."""

fedarea_HELP = """\
fedarea - Multi-line terminal text editor with word wrap

Usage: fed area <row> <col> <width> <rows> <maxlen> <v_scroll> <word_wrap> [options]

Arguments:
  row         Screen row (@ for current cursor row)
  col         Screen column (@ for current cursor column)
  width       Viewport width (visible columns)
  rows        Viewport height (visible rows)
  maxlen      Maximum input length (characters)
  v_scroll    Vertical scrolling (1=enabled, 0=disabled)
  word_wrap   Word wrap mode (1=word wrap, 0=character grid)

Options:
  -v, --val TEXT       Initial text value (default: empty)
  -e, --exit KEYS      Comma-separated exit key mnemonics (default: all)
  -q, --quiet          Suppress exit reason on stderr
  -c, --cursor POS     Initial cursor: start or end (default: end)
  -m, --mode MODE      Input mode: ins or ovr (default: ins)
  -r, --reverse        Display field in reverse video
  --color FG,BG        Field colors (ANSI names, optional 'bright-' prefix)
  -n, --noop           Display field and exit immediately (code 0)

Exit Keys (mnemonic -> code):
  ESC (1)          Escape key pressed
  CTRL-X (2)       Ctrl+X pressed
  TAB (3)          Tab key pressed
  SHIFT-TAB (4)    Shift+Tab pressed
  F10 (5)          F10 key pressed
  UP_TOP (6)       Up arrow pressed while on the top line
  DOWN_BOTTOM (7)  Down arrow pressed while on the bottom line
  PGUP (8)         PgUp pressed while on the top line
  PGDN (9)         PgDn pressed while on the bottom line

  When exit_keys is provided, only listed keys trigger an exit.
  Unlisted keys are silently ignored. Up/Down/PgUp/PgDn still
  navigate between lines normally; only the boundary exit is
  suppressed.

Editing Keys:
  Insert      Toggle insert/overwrite mode
  Ctrl+E      Delete to end of text
  Ctrl+T      Delete to start of text
  Ctrl+K      Clear all text
  Ctrl+Y      Delete current line
  Ctrl+W      Toggle word wrap
  Ctrl+Z      Undo
  Enter       Insert newline
  Backspace   Delete character before cursor
  Delete      Delete character at cursor
  Home/End    Move to start/end of current line
  Ctrl+Home   Move to start of text
  Ctrl+End    Move to end of text
  Up/Down     Move between lines (exit at boundary if allowed)
  PgUp/PgDn   Move by page (exit at boundary if allowed)
  Left/Right  Move cursor

Terminal:
  This field draws INLINE: it renders only within its own area, over
  whatever is already on the screen. It does not clear the screen and does
  not restore it on exit — the calling script manages the surrounding
  display. (fedform is different: it owns and clears the screen and restores
  it on exit by default.) While active, the field runs in raw mode, so
  Ctrl+C does NOT interrupt it (and Ctrl+S/Ctrl+Q do not pause output);
  leave the field with a configured exit key.

Output:
  stdout      Final edited text
  stderr      Exit reason mnemonic (unless --quiet)
  exit code   Numeric exit code (0 for --noop, 1-9 for exit keys)

Examples:
  fed area 0 0 40 5 500 1 1 -v "hello world"
  fed area 2 4 60 10 2000 1 1 -v "" -e ESC,CTRL-X
  fed area @ @ 40 5 500 1 1 -v "edit me" -c start -m ovr -r"""

def fedarea_layout_text(text, width, word_wrap):
    lines = []
    idx_to_rc = {}
    current_line = []
    r, c = 0, 0
    i = 0

    while i < len(text):
        if text[i] == '\n':
            idx_to_rc[i] = (r, c)
            lines.append("".join(current_line))
            current_line = []
            r += 1
            c = 0
            i += 1
            continue

        if word_wrap:
            word_end = i
            while word_end < len(text) and text[word_end] not in (' ', '\n'):
                word_end += 1

            word = text[i:word_end]

            if c + len(word) > width and c > 0:
                lines.append("".join(current_line))
                current_line = []
                r += 1
                c = 0

            for j in range(i, word_end):
                if c >= width:
                    lines.append("".join(current_line))
                    current_line = []
                    r += 1
                    c = 0
                idx_to_rc[j] = (r, c)
                current_line.append(text[j])
                c += 1

            i = word_end

            while i < len(text) and text[i] == ' ':
                if c >= width:
                    lines.append("".join(current_line))
                    current_line = []
                    r += 1
                    c = 0
                    # Consume remaining spaces at soft-wrap break
                    while i < len(text) and text[i] == ' ':
                        idx_to_rc[i] = (r, c)
                        i += 1
                    break
                idx_to_rc[i] = (r, c)
                current_line.append(text[i])
                c += 1
                i += 1
        else:
            if c >= width:
                lines.append("".join(current_line))
                current_line = []
                r += 1
                c = 0
            idx_to_rc[i] = (r, c)
            current_line.append(text[i])
            c += 1
            i += 1

    # If the text exactly fills the final visual line, the end-of-text cursor
    # belongs at column 0 of the next line, not at column `width` (which is off
    # the window and makes win.move() raise wmove() ERR). Wrap it explicitly.
    if c >= width:
        lines.append("".join(current_line))
        current_line = []
        r += 1
        c = 0
    idx_to_rc[len(text)] = (r, c)
    lines.append("".join(current_line))
    return lines, idx_to_rc

def fedarea_move_vertically(pos, direction, idx_to_rc, lines):
    curr_r, curr_c = idx_to_rc[pos]
    target_r = curr_r + direction

    if target_r < 0 or target_r >= len(lines):
        return pos

    best_pos = pos
    min_c_diff = 999

    for i, (r, c) in idx_to_rc.items():
        if r == target_r:
            diff = abs(c - curr_c)
            if diff < min_c_diff:
                min_c_diff = diff
                best_pos = i
            elif diff == min_c_diff and c <= curr_c:
                best_pos = i
    return best_pos

def fedarea_parse_args(argv):
    positional = []
    flags = {}
    i = 0
    while i < len(argv):
        a = argv[i]
        if a in ('-v', '--val') and i + 1 < len(argv):
            flags['val'] = argv[i + 1]; i += 2
        elif a in ('-e', '--exit') and i + 1 < len(argv):
            flags['exit'] = set(argv[i + 1].split(',')); i += 2
        elif a in ('-q', '--quiet'):
            flags['quiet'] = True; i += 1
        elif a in ('-c', '--cursor') and i + 1 < len(argv):
            flags['cursor'] = argv[i + 1]; i += 2
        elif a in ('-m', '--mode') and i + 1 < len(argv):
            flags['mode'] = argv[i + 1]; i += 2
        elif a in ('-r', '--reverse'):
            flags['reverse'] = True; i += 1
        elif a == '--color' and i + 1 < len(argv):
            flags['color'] = argv[i + 1]; i += 2
        elif a in ('-n', '--noop'):
            flags['noop'] = True; i += 1
        else:
            positional.append(a); i += 1
    return positional, flags

def fedarea_main(stdscr, start_row, start_col, width, rows, maxlen, v_scroll, word_wrap, exit_keys, text, insert_mode, reverse, noop, cursor_pos, color=None):
    fedshared.init_colors()
    curses.curs_set(1 if insert_mode else 2)

    win = curses.newwin(rows, width, start_row, start_col)
    win.keypad(True)

    color_attr = fedshared.attr_for(color)
    render_attr = color_attr | (curses.A_REVERSE if reverse else 0)
    if render_attr:
        win.bkgd(' ', render_attr)

    pos = 0 if cursor_pos == 'start' else len(text)
    vy = 0
    undo_stack = []

    def push_undo():
        undo_stack.append((text, pos))

    def can_exit(name):
        return exit_keys is None or name in exit_keys

    def draw_scrollbar(total_lines):
        fedshared.draw_scrollbar(win, rows, width, vy, total_lines, render_attr)

    if noop:
        lines, idx_to_rc = fedarea_layout_text(text, width, word_wrap)
        cr, cc = idx_to_rc[pos]
        if cr >= rows:
            vy = cr - rows + 1
        for r_idx in range(rows):
            doc_y = vy + r_idx
            if doc_y < len(lines):
                try:
                    win.addstr(r_idx, 0, lines[doc_y].ljust(width), render_attr)
                except curses.error:
                    pass
            else:
                try:
                    win.addstr(r_idx, 0, ' ' * width, render_attr)
                except curses.error:
                    pass
        draw_scrollbar(len(lines))
        win.move(cr - vy, cc)
        win.refresh()
        return "NOOP", 0, text

    while True:
        lines, idx_to_rc = fedarea_layout_text(text, width, word_wrap)
        cr, cc = idx_to_rc[pos]

        if cr < vy:
            vy = cr
        if cr >= vy + rows:
            vy = cr - rows + 1

        # Clear the field area
        win.erase()

        for r in range(rows):
            doc_y = vy + r
            if doc_y < len(lines):
                try:
                    win.addstr(r, 0, lines[doc_y].ljust(width), render_attr)
                except curses.error:
                    pass
            else:
                try:
                    win.addstr(r, 0, ' ' * width, render_attr)
                except curses.error:
                    pass

        draw_scrollbar(len(lines))
        win.move(cr - vy, cc)
        win.refresh()

        ch = win.getch()

        # === EXIT TRIGGERS ===
        if ch == 27 and can_exit("ESC"):
            return "ESC", 1, text
        elif ch == 24 and can_exit("CTRL-X"):
            return "CTRL-X", 2, text
        elif ch == 9 and can_exit("TAB"):
            return "TAB", 3, text
        elif ch == curses.KEY_BTAB and can_exit("SHIFT-TAB"):
            return "SHIFT-TAB", 4, text
        elif ch == curses.KEY_F10 and can_exit("F10"):
            return "F10", 5, text
        elif ch == curses.KEY_UP:
            if cr == 0:
                if can_exit("UP_TOP"):
                    return "UP_TOP", 6, text
            else:
                pos = fedarea_move_vertically(pos, -1, idx_to_rc, lines)
        elif ch == curses.KEY_DOWN:
            if cr == len(lines) - 1:
                if can_exit("DOWN_BOTTOM"):
                    return "DOWN_BOTTOM", 7, text
            else:
                pos = fedarea_move_vertically(pos, 1, idx_to_rc, lines)
        elif ch == curses.KEY_PPAGE:
            if cr == 0:
                if can_exit("PGUP"):
                    return "PGUP", 8, text
            else:
                for _ in range(rows):
                    pos = fedarea_move_vertically(pos, -1, idx_to_rc, lines)
        elif ch == curses.KEY_NPAGE:
            if cr == len(lines) - 1:
                if can_exit("PGDN"):
                    return "PGDN", 9, text
            else:
                for _ in range(rows):
                    pos = fedarea_move_vertically(pos, 1, idx_to_rc, lines)

        # === NAVIGATION ===
        elif ch == curses.KEY_LEFT:
            pos = max(0, pos - 1)
        elif ch == curses.KEY_RIGHT:
            pos = min(len(text), pos + 1)
        elif ch == curses.KEY_HOME:
            pos = min(i for i, (r, c) in idx_to_rc.items() if r == cr)
        elif ch == curses.KEY_END:
            pos = max(i for i, (r, c) in idx_to_rc.items() if r == cr)
        elif ch == 535:
            pos = 0
        elif ch == 530:
            pos = len(text)

        # === EDITING ===
        elif ch in (8, 127, curses.KEY_BACKSPACE):
            if pos > 0:
                push_undo()
                text = text[:pos - 1] + text[pos:]
                pos -= 1
        elif ch == curses.KEY_DC:
            if pos < len(text):
                push_undo()
                text = text[:pos] + text[pos + 1:]

        elif ch in (10, 13, curses.KEY_ENTER):
            if len(text) < maxlen:
                if not v_scroll:
                    test_lines, _ = fedarea_layout_text(text[:pos] + '\n' + text[pos:], width, word_wrap)
                    if len(test_lines) > rows:
                        continue
                push_undo()
                text = text[:pos] + '\n' + text[pos:]
                pos += 1

        # Insert key: toggle insert/overwrite mode
        elif ch == curses.KEY_IC:
            insert_mode = not insert_mode
            curses.curs_set(1 if insert_mode else 2)

        # Ctrl+Y: Delete current line
        elif ch == 25:
            prev_nl = text.rfind('\n', 0, pos)
            next_nl = text.find('\n', pos)
            if prev_nl == -1 and next_nl == -1:
                if text:
                    push_undo()
                    text = ""
                    pos = 0
            elif prev_nl == -1:
                push_undo()
                text = text[next_nl + 1:]
                pos = 0
            elif next_nl == -1:
                push_undo()
                text = text[:prev_nl]
                pos = len(text)
            else:
                push_undo()
                text = text[:prev_nl + 1] + text[next_nl + 1:]
                pos = prev_nl + 1

        # Ctrl+K: Clear all text
        elif ch == 11:
            if text:
                push_undo()
                text = ""
                pos = 0

        # Ctrl+E: Delete to end of string
        elif ch == 5:
            if pos < len(text):
                push_undo()
                text = text[:pos]

        # Ctrl+T: Delete to start of string
        elif ch == 20:
            if pos > 0:
                push_undo()
                text = text[pos:]
                pos = 0

        # Ctrl+W: Toggle word wrap
        elif ch == 23:
            word_wrap = not word_wrap

        # Ctrl+Z: Undo
        elif ch == 26:
            if undo_stack:
                text, pos = undo_stack.pop()

        # Standard character input
        elif 32 <= ch <= 126:
            if insert_mode:
                if len(text) < maxlen:
                    if not v_scroll:
                        test_lines, _ = fedarea_layout_text(text[:pos] + chr(ch) + text[pos:], width, word_wrap)
                        if len(test_lines) > rows:
                            continue
                    push_undo()
                    text = text[:pos] + chr(ch) + text[pos:]
                    pos += 1
            else:
                if pos < len(text) and text[pos] != '\n':
                    push_undo()
                    text = text[:pos] + chr(ch) + text[pos + 1:]
                    pos += 1
                elif len(text) < maxlen:
                    if not v_scroll:
                        test_lines, _ = fedarea_layout_text(text[:pos] + chr(ch) + text[pos:], width, word_wrap)
                        if len(test_lines) > rows:
                            continue
                    push_undo()
                    text = text[:pos] + chr(ch) + text[pos:]
                    pos += 1

def fedarea_run():
    args = sys.argv[1:]
    fedshared.early_dispatch(args, 'fedarea', '17.1ETwS6', fedarea_USAGE, fedarea_HELP)

    positional, flags = fedarea_parse_args(args)
    if len(positional) != 7:
        print(fedarea_USAGE, file=sys.stderr)
        sys.exit(1)

    start_row, start_col = fedshared.resolve_pos(positional[0], positional[1])
    width = int(positional[2])
    rows = int(positional[3])
    maxlen = int(positional[4])
    v_scroll = bool(int(positional[5]))
    word_wrap = bool(int(positional[6]))
    text = flags.get('val', '')[:maxlen]
    exit_keys = flags.get('exit')
    quiet = flags.get('quiet', False)
    cursor_pos = flags.get('cursor', 'end')
    insert_mode = flags.get('mode', 'ins') != 'ovr'
    reverse = flags.get('reverse', False)
    noop = flags.get('noop', False)
    color = flags.get('color')

    stdscr, saved_stdout, saved_stderr = fedshared.curses_open(restore=False)
    try:
        exit_reason, exit_code, final_text = fedarea_main(
            stdscr, start_row, start_col, width, rows, maxlen, v_scroll, word_wrap,
            exit_keys, text, insert_mode, reverse, noop, cursor_pos, color
        )
    finally:
        fedshared.curses_close(stdscr, restore=False)

    fedshared.emit_and_exit(saved_stdout, saved_stderr, final_text, exit_reason, exit_code, quiet)

# ================================================================
# fedmask  ->  fed mask   (top-level names prefixed fedmask_)
# ================================================================


fedmask_USAGE = """\
Usage: fed mask <row> <col> <mask> [options]
Try --help for full documentation."""

fedmask_HELP = """\
fedmask - Masked terminal text input

Usage: fed mask <row> <col> <mask> [options]

Arguments:
  row         Screen row for editor placement
  col         Screen column for editor placement
  mask        Mask specification (pattern, named shortcut, or filter type)

Options:
  -v, --val TEXT       Initial text value (formatted or raw for patterns)
  -f, --fill CHAR      Fill character for unfilled positions (default: _ for patterns)
  -e, --exit KEYS      Comma-separated exit key mnemonics (default: all)
  -q, --quiet          Suppress exit reason on stderr
  -c, --cursor POS     Initial cursor: start or end (default: end)
  -m, --mode MODE      Input mode for filter types: ins or ovr (default: ins)
  -r, --reverse        Display field in reverse video
  --color FG,BG        Field colors (ANSI names, optional 'bright-' prefix)
  -n, --noop           Display field and exit immediately (code 0)

Pattern Masks:
  A pattern string defines fixed-length input with literals and fill slots.
  Cursor skips over literal positions. Each fill slot accepts one character
  matching its class. Typing overwrites the current slot and advances.

  Fill characters:
    #   Digit (0-9)
    @   Letter (A-Z, a-z)
    &   Alphanumeric (digit or letter)
    *   Any printable character
    \\x  Escape (next character treated as literal)
    All other characters are literals (auto-filled, cursor skips)

  Example: "(###) ###-####"    phone input
  Example: "####-##-##"        ISO date
  Example: "SKU ####-@@-**:#"  custom format

Named Masks:
  phone:classic    (###) ###-####
  phone:hyphen     ###-###-####
  phone:period     ###.###.####
  phone:digits     ##########
  date:mdy         ##/##/####
  date:ymd         ####-##-##
  date:dmy         ##/##/####
  time:24          ##:##
  time:12          ##:## @@
  ssn              ###-##-####
  zip              #####
  zip4             #####-####
  ein              ##-#######
  cc               ####-####-####-####

Filter Types:
  Variable-length input with character filtering. Behaves like fedline
  but only accepts characters matching the type. N is the max field width.

  int:N            Integer (digits, optional leading -), max N chars
  uint:N           Unsigned integer (digits only), max N chars
  float:N          Signed float (digits, one decimal), max N chars
  float:N.P        Signed float, max P decimal places
  decimal:N        Unsigned decimal, N decimal places
  decimal:W.N      Unsigned decimal, W total width, N decimal places
  hex:N            Hexadecimal (0-9, A-F), max N chars
  alpha:N          Letters only, max N chars
  alnum:N          Alphanumeric, max N chars
  upper:N          Letters, auto-uppercased, max N chars
  lower:N          Letters, auto-lowercased, max N chars

Exit Keys (mnemonic -> code):
  ESC (1)          Escape key pressed
  ENTER (2)        Enter key pressed
  TAB (3)          Tab key pressed
  SHIFT-TAB (4)    Shift+Tab pressed
  F10 (5)          F10 key pressed
  UP_TOP (6)       Up arrow pressed
  DOWN_BOTTOM (7)  Down arrow pressed
  PGUP (8)         PgUp pressed
  PGDN (9)         PgDn pressed

  When exit_keys is provided, only listed keys trigger an exit.
  Unlisted keys are silently ignored.

Editing Keys:
  Pattern mode:
    Backspace   Move to previous fill slot and clear it
    Delete      Clear current fill slot
    Ctrl+E      Clear from cursor to end
    Ctrl+T      Clear from start to cursor
    Ctrl+Y      Clear all fill slots
    Ctrl+Z      Undo

  Filter mode:
    Backspace   Delete character before cursor
    Delete      Delete character at cursor
    Insert      Toggle insert/overwrite mode
    Ctrl+E      Delete to end of text
    Ctrl+T      Delete to start of text
    Ctrl+Y      Clear all text
    Ctrl+Z      Undo

  Both modes:
    Home/End    Move to first/last fill position
    Left/Right  Move cursor (skips literals in pattern mode)

Terminal:
  This field draws INLINE: it renders only within its own area, over
  whatever is already on the screen. It does not clear the screen and does
  not restore it on exit — the calling script manages the surrounding
  display. (fedform is different: it owns and clears the screen and restores
  it on exit by default.) While active, the field runs in raw mode, so
  Ctrl+C does NOT interrupt it (and Ctrl+S/Ctrl+Q do not pause output);
  leave the field with a configured exit key.

Output:
  stdout      Final text (formatted with literals for patterns, raw for filters)
  stderr      Exit reason mnemonic
  exit code   Numeric exit code (1-9)

Examples:
  fed mask 0 0 phone:classic -v "5551234567"
  fed mask 0 0 date:mdy -f "."
  fed mask 0 0 "SKU ####-@@-**:#" -v "2354AN2A7"
  fed mask 0 0 int:8 -v "42" -e TAB,SHIFT-TAB
  fed mask 0 0 float:8.2 -v "99.95"
  fed mask 0 0 hex:6 -f "0" -r
  fed mask 5 10 phone:classic -v "5551234567" -n"""

# --- Named mask shortcuts ---

fedmask_NAMED_MASKS = {
    'phone:classic': '(###) ###-####',
    'phone:hyphen': '###-###-####',
    'phone:period': '###.###.####',
    'phone:digits': '##########',
    'date:mdy': '##/##/####',
    'date:ymd': '####-##-##',
    'date:dmy': '##/##/####',
    'time:24': '##:##',
    'time:12': '##:## @@',
    'ssn': '###-##-####',
    'zip': '#####',
    'zip4': '#####-####',
    'ein': '##-#######',
    'cc': '####-####-####-####',
}

fedmask_MASK_FILL_CHARS = {'#', '@', '*', '&'}
fedmask_FILTER_TYPES = {'int', 'uint', 'decimal', 'float', 'hex', 'alpha', 'alnum', 'upper', 'lower'}


def fedmask_validate_fill(ch, char_class):
    if char_class == '#':
        return ch.isdigit()
    elif char_class == '@':
        return ch.isalpha()
    elif char_class == '&':
        return ch.isalnum()
    elif char_class == '*':
        return 32 <= ord(ch) <= 126
    return False


def fedmask_parse_pattern(pattern_str):
    slots = []
    fill_positions = []
    i = 0
    while i < len(pattern_str):
        ch = pattern_str[i]
        if ch == '\\' and i + 1 < len(pattern_str):
            slots.append(('literal', pattern_str[i + 1]))
            i += 2
        elif ch in fedmask_MASK_FILL_CHARS:
            fill_positions.append(len(slots))
            slots.append(('fill', ch))
            i += 1
        else:
            slots.append(('literal', ch))
            i += 1
    return {'slots': slots, 'fill_positions': fill_positions, 'width': len(slots)}


def fedmask_parse_filter(type_name, param_str):
    config = {'type': type_name, 'max_len': 20, 'max_decimals': None, 'allow_negative': False}

    if type_name == 'int':
        config['allow_negative'] = True
        if param_str:
            config['max_len'] = int(param_str)
    elif type_name == 'uint':
        if param_str:
            config['max_len'] = int(param_str)
    elif type_name == 'float':
        config['allow_negative'] = True
        if param_str and '.' in param_str:
            w, n = param_str.split('.', 1)
            config['max_len'] = int(w)
            config['max_decimals'] = int(n)
        elif param_str:
            config['max_len'] = int(param_str)
    elif type_name == 'decimal':
        if param_str and '.' in param_str:
            w, n = param_str.split('.', 1)
            config['max_len'] = int(w)
            config['max_decimals'] = int(n)
        elif param_str:
            config['max_decimals'] = int(param_str)
            config['max_len'] = int(param_str) + 10
    elif type_name in ('hex', 'alpha', 'alnum', 'upper', 'lower'):
        if param_str:
            config['max_len'] = int(param_str)

    return config


def fedmask_parse_mask(mask_str):
    if mask_str in fedmask_NAMED_MASKS:
        return 'pattern', fedmask_parse_pattern(fedmask_NAMED_MASKS[mask_str])

    parts = mask_str.split(':', 1)
    if parts[0] in fedmask_FILTER_TYPES:
        return 'filter', fedmask_parse_filter(parts[0], parts[1] if len(parts) > 1 else None)

    return 'pattern', fedmask_parse_pattern(mask_str)


def fedmask_load_pattern_value(val, slots, fill_positions):
    values = [''] * len(fill_positions)
    if not val:
        return values

    if len(val) == len(slots):
        extracted = []
        valid = True
        for i, fp in enumerate(fill_positions):
            ch = val[fp]
            if fedmask_validate_fill(ch, slots[fp][1]):
                extracted.append(ch)
            elif ch == ' ':
                extracted.append('')
            else:
                valid = False
                break
        if valid:
            return extracted

    fi = 0
    for ch in val:
        if fi >= len(fill_positions):
            break
        if fedmask_validate_fill(ch, slots[fill_positions[fi]][1]):
            values[fi] = ch
            fi += 1
    return values


def fedmask_compose_pattern_output(slots, values):
    result = []
    fi = 0
    for stype, schar in slots:
        if stype == 'literal':
            result.append(schar)
        else:
            if fi < len(values) and values[fi]:
                result.append(values[fi])
            else:
                result.append(' ')
            fi += 1
    return ''.join(result)


def fedmask_filter_accept(ch, pos, text, config):
    ftype = config['type']

    if ftype in ('int', 'uint'):
        candidate = text[:pos] + ch + text[pos:]
        if config['allow_negative']:
            if not re.match(r'^-?\d*$', candidate):
                return None
        else:
            if not ch.isdigit():
                return None
        return ch

    elif ftype in ('float', 'decimal'):
        candidate = text[:pos] + ch + text[pos:]
        if config.get('max_decimals') is not None and '.' in candidate:
            dot_pos = candidate.index('.')
            decimals = len(candidate) - dot_pos - 1
            if decimals > config['max_decimals']:
                return None
        if config['allow_negative']:
            if not re.match(r'^-?\d*\.?\d*$', candidate):
                return None
        else:
            if not re.match(r'^\d*\.?\d*$', candidate):
                return None
        return ch

    elif ftype == 'hex':
        if ch in '0123456789abcdefABCDEF':
            return ch
    elif ftype == 'alpha':
        if ch.isalpha():
            return ch
    elif ftype == 'alnum':
        if ch.isalnum():
            return ch
    elif ftype == 'upper':
        if ch.isalpha():
            return ch.upper()
    elif ftype == 'lower':
        if ch.isalpha():
            return ch.lower()

    return None


def fedmask_main_pattern(stdscr, row, col, config, exit_keys, fill_char, val, reverse, noop, cursor_pos, color=None):
    fedshared.init_colors()
    curses.curs_set(1)

    slots = config['slots']
    fill_positions = config['fill_positions']

    if not fill_char:
        fill_char = '_'

    values = fedmask_load_pattern_value(val, slots, fill_positions)

    if not fill_positions:
        return "ESC", 1, fedmask_compose_pattern_output(slots, values)

    if cursor_pos == 'start':
        cursor = 0
    else:
        # First empty position
        cursor = 0
        for i, v in enumerate(values):
            if not v:
                cursor = i
                break
        else:
            cursor = len(values) - 1

    undo_stack = []

    def push_undo():
        undo_stack.append((values[:], cursor))

    def can_exit(name):
        return exit_keys is None or name in exit_keys

    def output():
        return fedmask_compose_pattern_output(slots, values)

    win = curses.newwin(1, config['width'], row, col)
    win.keypad(True)

    color_attr = fedshared.attr_for(color)
    render_attr = color_attr | (curses.A_REVERSE if reverse else 0)
    if render_attr:
        win.bkgd(' ', render_attr)

    if noop:
        display = []
        fi = 0
        for stype, schar in slots:
            if stype == 'literal':
                display.append(schar)
            else:
                if fi < len(values) and values[fi]:
                    display.append(values[fi])
                else:
                    display.append(fill_char)
                fi += 1
        try:
            win.addstr(0, 0, ''.join(display), render_attr)
        except curses.error:
            pass
        win.move(0, fill_positions[cursor])
        win.refresh()
        return "NOOP", 0, fedmask_compose_pattern_output(slots, values)

    while True:
        display = []
        fi = 0
        for stype, schar in slots:
            if stype == 'literal':
                display.append(schar)
            else:
                if fi < len(values) and values[fi]:
                    display.append(values[fi])
                else:
                    display.append(fill_char)
                fi += 1

        display_str = ''.join(display)
        try:
            win.addstr(0, 0, display_str, render_attr)
        except curses.error:
            pass

        win.move(0, fill_positions[cursor])
        win.refresh()

        ch = win.getch()

        # === EXIT TRIGGERS ===
        if ch == 27 and can_exit("ESC"):
            return "ESC", 1, output()
        elif ch in (10, 13, curses.KEY_ENTER) and can_exit("ENTER"):
            return "ENTER", 2, output()
        elif ch == 9 and can_exit("TAB"):
            return "TAB", 3, output()
        elif ch == curses.KEY_BTAB and can_exit("SHIFT-TAB"):
            return "SHIFT-TAB", 4, output()
        elif ch == curses.KEY_F10 and can_exit("F10"):
            return "F10", 5, output()
        elif ch == curses.KEY_UP and can_exit("UP_TOP"):
            return "UP_TOP", 6, output()
        elif ch == curses.KEY_DOWN and can_exit("DOWN_BOTTOM"):
            return "DOWN_BOTTOM", 7, output()
        elif ch == curses.KEY_PPAGE and can_exit("PGUP"):
            return "PGUP", 8, output()
        elif ch == curses.KEY_NPAGE and can_exit("PGDN"):
            return "PGDN", 9, output()

        # === NAVIGATION ===
        elif ch == curses.KEY_LEFT:
            if cursor > 0:
                cursor -= 1
        elif ch == curses.KEY_RIGHT:
            if cursor < len(fill_positions) - 1:
                cursor += 1
        elif ch == curses.KEY_HOME:
            cursor = 0
        elif ch == curses.KEY_END:
            cursor = len(fill_positions) - 1

        # === EDITING ===
        elif ch in (8, 127, curses.KEY_BACKSPACE):
            if cursor > 0:
                push_undo()
                cursor -= 1
                values[cursor] = ''
        elif ch == curses.KEY_DC:
            if values[cursor]:
                push_undo()
                values[cursor] = ''

        # Ctrl+E: Clear from cursor to end
        elif ch == 5:
            if any(values[cursor:]):
                push_undo()
                for i in range(cursor, len(values)):
                    values[i] = ''

        # Ctrl+T: Clear from start to cursor
        elif ch == 20:
            if any(values[:cursor]):
                push_undo()
                for i in range(cursor):
                    values[i] = ''
                cursor = 0

        # Ctrl+Y: Clear all
        elif ch == 25:
            if any(values):
                push_undo()
                values = [''] * len(fill_positions)
                cursor = 0

        # Ctrl+Z: Undo
        elif ch == 26:
            if undo_stack:
                values, cursor = undo_stack.pop()

        # Character input (overwrite at cursor)
        elif 32 <= ch <= 126:
            char = chr(ch)
            char_class = slots[fill_positions[cursor]][1]
            if fedmask_validate_fill(char, char_class):
                push_undo()
                values[cursor] = char
                if cursor < len(fill_positions) - 1:
                    cursor += 1


def fedmask_main_filter(stdscr, row, col, config, exit_keys, fill_char, val, insert_mode, reverse, noop, cursor_pos, color=None):
    fedshared.init_colors()
    curses.curs_set(1 if insert_mode else 2)

    max_len = config['max_len']
    width = max_len

    text = ''
    for ch in val:
        if len(text) >= max_len:
            break
        result = fedmask_filter_accept(ch, len(text), text, config)
        if result is not None:
            text += result

    pos = 0 if cursor_pos == 'start' else len(text)
    vx = 0
    undo_stack = []

    def push_undo():
        undo_stack.append((text, pos))

    def can_exit(name):
        return exit_keys is None or name in exit_keys

    win = curses.newwin(1, width, row, col)
    win.keypad(True)

    color_attr = fedshared.attr_for(color)
    render_attr = color_attr | (curses.A_REVERSE if reverse else 0)
    if render_attr:
        win.bkgd(' ', render_attr)

    if noop:
        visible = text[:width]
        visible = visible.ljust(width, fill_char if fill_char else ' ')
        try:
            win.addstr(0, 0, visible, render_attr)
        except curses.error:
            pass
        win.move(0, min(pos, width - 1))
        win.refresh()
        return "NOOP", 0, text

    while True:
        if pos < vx:
            vx = pos
        if pos >= vx + width:
            vx = pos - width + 1

        visible = text[vx:vx + width]
        visible = visible.ljust(width, fill_char if fill_char else ' ')
        try:
            win.addstr(0, 0, visible, render_attr)
        except curses.error:
            pass

        win.move(0, pos - vx)
        win.refresh()

        ch = win.getch()

        # === EXIT TRIGGERS ===
        if ch == 27 and can_exit("ESC"):
            return "ESC", 1, text
        elif ch in (10, 13, curses.KEY_ENTER) and can_exit("ENTER"):
            return "ENTER", 2, text
        elif ch == 9 and can_exit("TAB"):
            return "TAB", 3, text
        elif ch == curses.KEY_BTAB and can_exit("SHIFT-TAB"):
            return "SHIFT-TAB", 4, text
        elif ch == curses.KEY_F10 and can_exit("F10"):
            return "F10", 5, text
        elif ch == curses.KEY_UP and can_exit("UP_TOP"):
            return "UP_TOP", 6, text
        elif ch == curses.KEY_DOWN and can_exit("DOWN_BOTTOM"):
            return "DOWN_BOTTOM", 7, text
        elif ch == curses.KEY_PPAGE and can_exit("PGUP"):
            return "PGUP", 8, text
        elif ch == curses.KEY_NPAGE and can_exit("PGDN"):
            return "PGDN", 9, text

        # === NAVIGATION ===
        elif ch == curses.KEY_LEFT:
            pos = max(0, pos - 1)
        elif ch == curses.KEY_RIGHT:
            pos = min(len(text), pos + 1)
        elif ch == curses.KEY_HOME:
            pos = 0
        elif ch == curses.KEY_END:
            pos = len(text)

        # === EDITING ===
        elif ch in (8, 127, curses.KEY_BACKSPACE):
            if pos > 0:
                push_undo()
                text = text[:pos - 1] + text[pos:]
                pos -= 1
        elif ch == curses.KEY_DC:
            if pos < len(text):
                push_undo()
                text = text[:pos] + text[pos + 1:]

        # Insert key: toggle insert/overwrite mode
        elif ch == curses.KEY_IC:
            insert_mode = not insert_mode
            curses.curs_set(1 if insert_mode else 2)

        # Ctrl+E: Delete to end
        elif ch == 5:
            if pos < len(text):
                push_undo()
                text = text[:pos]

        # Ctrl+T: Delete to start
        elif ch == 20:
            if pos > 0:
                push_undo()
                text = text[pos:]
                pos = 0

        # Ctrl+Y: Clear all
        elif ch == 25:
            if text:
                push_undo()
                text = ""
                pos = 0

        # Ctrl+Z: Undo
        elif ch == 26:
            if undo_stack:
                text, pos = undo_stack.pop()

        # Character input
        elif 32 <= ch <= 126:
            char = chr(ch)
            if insert_mode:
                result = fedmask_filter_accept(char, pos, text, config)
                if result is not None and len(text) < max_len:
                    push_undo()
                    text = text[:pos] + result + text[pos:]
                    pos += 1
            else:
                if pos < len(text):
                    base = text[:pos] + text[pos + 1:]
                else:
                    base = text
                result = fedmask_filter_accept(char, pos, base, config)
                if result is not None and (pos < len(text) or len(text) < max_len):
                    push_undo()
                    text = base[:pos] + result + base[pos:]
                    pos += 1


def fedmask_main(stdscr, row, col, mask_str, exit_keys, fill_char, val, insert_mode, reverse, noop, cursor_pos, color=None):
    mode, config = fedmask_parse_mask(mask_str)
    if mode == 'pattern':
        return fedmask_main_pattern(stdscr, row, col, config, exit_keys, fill_char, val, reverse, noop, cursor_pos, color)
    else:
        return fedmask_main_filter(stdscr, row, col, config, exit_keys, fill_char, val, insert_mode, reverse, noop, cursor_pos, color)


def fedmask_parse_args(argv):
    positional = []
    flags = {}
    i = 0
    while i < len(argv):
        a = argv[i]
        if a in ('-v', '--val') and i + 1 < len(argv):
            flags['val'] = argv[i + 1]; i += 2
        elif a in ('-f', '--fill') and i + 1 < len(argv):
            flags['fill'] = argv[i + 1][:1]; i += 2
        elif a in ('-e', '--exit') and i + 1 < len(argv):
            flags['exit'] = set(argv[i + 1].split(',')); i += 2
        elif a in ('-q', '--quiet'):
            flags['quiet'] = True; i += 1
        elif a in ('-c', '--cursor') and i + 1 < len(argv):
            flags['cursor'] = argv[i + 1]; i += 2
        elif a in ('-m', '--mode') and i + 1 < len(argv):
            flags['mode'] = argv[i + 1]; i += 2
        elif a in ('-r', '--reverse'):
            flags['reverse'] = True; i += 1
        elif a == '--color' and i + 1 < len(argv):
            flags['color'] = argv[i + 1]; i += 2
        elif a in ('-n', '--noop'):
            flags['noop'] = True; i += 1
        else:
            positional.append(a); i += 1
    return positional, flags


def fedmask_run():
    args = sys.argv[1:]
    fedshared.early_dispatch(args, 'fedmask', '10.1ETwS6', fedmask_USAGE, fedmask_HELP)

    positional, flags = fedmask_parse_args(args)
    if len(positional) != 3:
        print(fedmask_USAGE, file=sys.stderr)
        sys.exit(1)

    start_row, start_col = fedshared.resolve_pos(positional[0], positional[1])
    mask_str = positional[2]
    val = flags.get('val', '')
    fill_char = flags.get('fill', '')
    exit_keys = flags.get('exit')
    quiet = flags.get('quiet', False)
    cursor_pos = flags.get('cursor', 'end')
    insert_mode = flags.get('mode', 'ins') != 'ovr'
    reverse = flags.get('reverse', False)
    noop = flags.get('noop', False)
    color = flags.get('color')

    stdscr, saved_stdout, saved_stderr = fedshared.curses_open(restore=False)
    try:
        exit_reason, exit_code, final_text = fedmask_main(
            stdscr, start_row, start_col, mask_str, exit_keys, fill_char, val,
            insert_mode, reverse, noop, cursor_pos, color
        )
    finally:
        fedshared.curses_close(stdscr, restore=False)

    fedshared.emit_and_exit(saved_stdout, saved_stderr, final_text, exit_reason, exit_code, quiet)

# ================================================================
# fedlist  ->  fed list   (top-level names prefixed fedlist_)
# ================================================================


fedlist_USAGE = """\
Usage: fed list <row> <col> <width> <rows> [options]
Try --help for full documentation."""

fedlist_HELP = """\
fedlist - Scrollable list selection for shell scripts

Usage: fed list <row> <col> <width> <rows> [options]

Arguments:
  row         Screen row (@ for current cursor row)
  col         Screen column (@ for current cursor column)
  width       Visible width (columns)
  rows        Visible height (rows / items shown)

Options:
  -i, --items TEXT       Pipe-delimited list items (e.g. "US|UK|CA")
  --items-file FILE      Load items from file (one per line, - for stdin)
  -v, --val TEXT         Initially selected value (first match)
  -s, --selected INDEX   Initially selected index (0-based, default: 0)
  -e, --exit KEYS        Comma-separated exit key mnemonics (default: all)
  -q, --quiet            Suppress exit reason on stderr
  -r, --reverse          Display in reverse video
  --color FG,BG          Field colors (ANSI names, optional 'bright-' prefix)
  --select-color FG,BG   Highlight color for the selected item
  -n, --noop             Display and exit immediately (code 0)
  -h                     Print brief usage and exit
  --help                 Print full help text and exit
  --version              Print version and copyright information and exit

Exit Keys (mnemonic -> code):
  ESC (1)          Escape key pressed
  ENTER (2)        Enter key pressed (select current item)
  TAB (3)          Tab key pressed
  SHIFT-TAB (4)    Shift+Tab pressed
  F10 (5)          F10 key pressed
  UP_TOP (6)       Up arrow at first item
  DOWN_BOTTOM (7)  Down arrow at last item
  PGUP (8)         PgUp at first item
  PGDN (9)         PgDn at last item

Navigation Keys:
  Up/Down       Move selection by one item
  PgUp/PgDn     Move selection by one page (rows items)
  Home/End      Jump to first/last item
  a-z, 0-9      Jump to next item starting with that character

Terminal:
  This field draws INLINE: it renders only within its own area, over
  whatever is already on the screen. It does not clear the screen and does
  not restore it on exit — the calling script manages the surrounding
  display. (fedform is different: it owns and clears the screen and restores
  it on exit by default.) While active, the field runs in raw mode, so
  Ctrl+C does NOT interrupt it (and Ctrl+S/Ctrl+Q do not pause output);
  leave the field with a configured exit key.

Output:
  stdout      Selected item text
  stderr      Exit reason mnemonic (unless --quiet)
  exit code   Numeric exit code (0 for --noop, 1-9 for exit keys)

Examples:
  fed list 0 0 20 5 -i "Apple|Banana|Cherry|Date|Fig|Grape"
  fed list 2 4 30 10 --items-file options.txt -v "Cherry"
  fed list @ @ 25 8 -i "Yes|No|Maybe" -e ENTER,ESC"""


def fedlist_parse_args(argv):
    positional = []
    flags = {}
    i = 0
    while i < len(argv):
        a = argv[i]
        if a in ('-i', '--items') and i + 1 < len(argv):
            flags['items'] = argv[i + 1]; i += 2
        elif a == '--items-file' and i + 1 < len(argv):
            flags['items_file'] = argv[i + 1]; i += 2
        elif a in ('-v', '--val') and i + 1 < len(argv):
            flags['val'] = argv[i + 1]; i += 2
        elif a in ('-s', '--selected') and i + 1 < len(argv):
            flags['selected'] = int(argv[i + 1]); i += 2
        elif a in ('-e', '--exit') and i + 1 < len(argv):
            flags['exit'] = set(argv[i + 1].split(',')); i += 2
        elif a in ('-q', '--quiet'):
            flags['quiet'] = True; i += 1
        elif a in ('-r', '--reverse'):
            flags['reverse'] = True; i += 1
        elif a == '--color' and i + 1 < len(argv):
            flags['color'] = argv[i + 1]; i += 2
        elif a == '--select-color' and i + 1 < len(argv):
            flags['select_color'] = argv[i + 1]; i += 2
        elif a in ('-n', '--noop'):
            flags['noop'] = True; i += 1
        else:
            positional.append(a); i += 1
    return positional, flags


def fedlist_main(stdscr, row, col, width, rows, items, exit_keys, selected_idx, reverse, noop, color=None, select_color=None):
    fedshared.init_colors()
    curses.curs_set(0)

    if not items:
        return "ESC", 1, ""

    n = len(items)
    sel = max(0, min(selected_idx, n - 1))
    vy = 0

    if sel >= rows:
        vy = sel - rows + 1

    win = curses.newwin(rows, width, row, col)
    win.keypad(True)

    color_attr = fedshared.attr_for(color)
    base_attr = color_attr | (curses.A_REVERSE if reverse else 0)
    if select_color:
        sel_attr = fedshared.attr_for(select_color)
    elif color_attr and not reverse:
        sel_attr = color_attr | curses.A_REVERSE
    else:
        sel_attr = curses.A_NORMAL if reverse else curses.A_REVERSE
    if base_attr:
        win.bkgd(' ', base_attr)

    def can_exit(name):
        return exit_keys is None or name in exit_keys

    def render():
        for r in range(rows):
            idx = vy + r
            if idx < n:
                text = items[idx][:width]
                attr = sel_attr if idx == sel else base_attr
                try:
                    win.addstr(r, 0, text.ljust(width), attr)
                except curses.error:
                    pass
            else:
                try:
                    win.addstr(r, 0, ' ' * width, base_attr)
                except curses.error:
                    pass
        fedshared.draw_scrollbar(win, rows, width, vy, n, base_attr)
        win.refresh()

    if noop:
        render()
        return "NOOP", 0, items[sel]

    while True:
        render()
        ch = win.getch()

        if ch == 27 and can_exit("ESC"):
            return "ESC", 1, items[sel]
        elif ch in (10, 13, curses.KEY_ENTER) and can_exit("ENTER"):
            return "ENTER", 2, items[sel]
        elif ch == 9 and can_exit("TAB"):
            return "TAB", 3, items[sel]
        elif ch == curses.KEY_BTAB and can_exit("SHIFT-TAB"):
            return "SHIFT-TAB", 4, items[sel]
        elif ch == curses.KEY_F10 and can_exit("F10"):
            return "F10", 5, items[sel]

        elif ch == curses.KEY_UP:
            if sel > 0:
                sel -= 1
                if sel < vy:
                    vy = sel
            elif can_exit("UP_TOP"):
                return "UP_TOP", 6, items[sel]

        elif ch == curses.KEY_DOWN:
            if sel < n - 1:
                sel += 1
                if sel >= vy + rows:
                    vy = sel - rows + 1
            elif can_exit("DOWN_BOTTOM"):
                return "DOWN_BOTTOM", 7, items[sel]

        elif ch == curses.KEY_PPAGE:
            if sel == 0:
                if can_exit("PGUP"):
                    return "PGUP", 8, items[sel]
            else:
                sel = max(0, sel - rows)
                if sel < vy:
                    vy = sel

        elif ch == curses.KEY_NPAGE:
            if sel == n - 1:
                if can_exit("PGDN"):
                    return "PGDN", 9, items[sel]
            else:
                sel = min(n - 1, sel + rows)
                if sel >= vy + rows:
                    vy = sel - rows + 1

        elif ch == curses.KEY_HOME:
            sel = 0
            vy = 0

        elif ch == curses.KEY_END:
            sel = n - 1
            vy = max(0, n - rows)

        elif 32 <= ch <= 126:
            char = chr(ch).lower()
            for offset in range(1, n):
                idx = (sel + offset) % n
                if items[idx].lower().startswith(char):
                    sel = idx
                    if sel < vy:
                        vy = sel
                    elif sel >= vy + rows:
                        vy = sel - rows + 1
                    break


def fedlist_run():
    args = sys.argv[1:]
    fedshared.early_dispatch(args, 'fedlist', '7.1ETwS6', fedlist_USAGE, fedlist_HELP)

    positional, flags = fedlist_parse_args(args)
    if len(positional) != 4:
        print(fedlist_USAGE, file=sys.stderr); sys.exit(1)

    start_row, start_col = fedshared.resolve_pos(positional[0], positional[1])
    width = int(positional[2])
    rows = int(positional[3])

    items = []
    if 'items' in flags:
        items = flags['items'].split('|')
    elif 'items_file' in flags:
        if flags['items_file'] == '-':
            items = [line.rstrip('\n') for line in sys.stdin if line.strip()]
        else:
            with open(flags['items_file']) as f:
                items = [line.rstrip('\n') for line in f if line.strip()]

    if not items:
        print("fedlist: no items provided", file=sys.stderr)
        sys.exit(1)

    selected_idx = flags.get('selected', 0)
    if 'val' in flags:
        for i, item in enumerate(items):
            if item == flags['val']:
                selected_idx = i
                break

    exit_keys = flags.get('exit')
    quiet = flags.get('quiet', False)
    reverse = flags.get('reverse', False)
    noop = flags.get('noop', False)
    color = flags.get('color')
    select_color = flags.get('select_color')

    stdscr, saved_stdout, saved_stderr = fedshared.curses_open(restore=False)
    try:
        exit_reason, exit_code, final_text = fedlist_main(
            stdscr, start_row, start_col, width, rows, items, exit_keys,
            selected_idx, reverse, noop, color, select_color
        )
    finally:
        fedshared.curses_close(stdscr, restore=False)

    fedshared.emit_and_exit(saved_stdout, saved_stderr, final_text, exit_reason, exit_code, quiet)

# ================================================================
# fedcheck  ->  fed check   (top-level names prefixed fedcheck_)
# ================================================================


fedcheck_USAGE = """\
Usage: fed check <row> <col> <width> [rows] [options]
Try --help for full documentation."""

fedcheck_HELP = """\
fedcheck - Checkbox (multi-select) field for shell scripts

Usage: fed check <row> <col> <width> [rows] [options]

Arguments:
  row         Screen row (@ for current cursor row)
  col         Screen column (@ for current cursor column)
  width       Visible width (columns)
  rows        Visible height; scrolls when items exceed it
              (optional; default shows all items)

Options:
  -i, --items TEXT       Pipe-delimited items (e.g. "Red|Green|Blue")
  --items-file FILE      Load items from file (one per line, - for stdin)
  -v, --val TEXT         Initially checked values (pipe-delimited)
  -e, --exit KEYS        Comma-separated exit key mnemonics (default: all)
  -q, --quiet            Suppress exit reason on stderr
  -r, --reverse          Display in reverse video
  --color FG,BG          Field colors (ANSI names, optional 'bright-' prefix)
  --select-color FG,BG   Highlight color for the focused item
  --orient ORIENT        Layout: vertical (default) or horizontal (one row,
                         Left/Right to move between options)
  -n, --noop             Display and exit immediately (code 0)
  -h                     Print brief usage and exit
  --help                 Print full help text and exit
  --version              Print version and copyright information and exit

Display:
  ■ Checked    □ Unchecked

Exit Keys (mnemonic -> code):
  ESC (1)          Escape key pressed
  ENTER (2)        Enter key pressed
  TAB (3)          Tab key pressed
  SHIFT-TAB (4)    Shift+Tab pressed
  F10 (5)          F10 key pressed
  UP_TOP (6)       Up arrow at first item
  DOWN_BOTTOM (7)  Down arrow at last item
  PGUP (8)         PgUp at first item
  PGDN (9)         PgDn at last item

Navigation Keys:
  Up/Down       Move focus by one item
  Space         Toggle checkbox on the focused item
  Home/End      Jump to first/last item
  a-z, 0-9      Jump to next item starting with that character

Terminal:
  This field draws INLINE: it renders only within its own area, over
  whatever is already on the screen. It does not clear the screen and does
  not restore it on exit — the calling script manages the surrounding
  display. (fedform is different: it owns and clears the screen and restores
  it on exit by default.) While active, the field runs in raw mode, so
  Ctrl+C does NOT interrupt it (and Ctrl+S/Ctrl+Q do not pause output);
  leave the field with a configured exit key.

Output:
  stdout      Pipe-delimited list of checked values
  stderr      Exit reason mnemonic (unless --quiet)
  exit code   Numeric exit code (0 for --noop, 1-9 for exit keys)

Examples:
  fed check 0 0 30 -i "Red|Green|Blue" -v "Red|Blue"
  fed check 2 4 25 -i "Apple|Banana|Cherry" -e ENTER,ESC
  fed check @ @ 30 -i "A|B|C" --color cyan,default"""


def fedcheck_parse_args(argv):
    positional = []
    flags = {}
    i = 0
    while i < len(argv):
        a = argv[i]
        if a in ('-i', '--items') and i + 1 < len(argv):
            flags['items'] = argv[i + 1]; i += 2
        elif a == '--items-file' and i + 1 < len(argv):
            flags['items_file'] = argv[i + 1]; i += 2
        elif a in ('-v', '--val') and i + 1 < len(argv):
            flags['val'] = argv[i + 1]; i += 2
        elif a in ('-e', '--exit') and i + 1 < len(argv):
            flags['exit'] = set(argv[i + 1].split(',')); i += 2
        elif a in ('-q', '--quiet'):
            flags['quiet'] = True; i += 1
        elif a == '--color' and i + 1 < len(argv):
            flags['color'] = argv[i + 1]; i += 2
        elif a == '--select-color' and i + 1 < len(argv):
            flags['select_color'] = argv[i + 1]; i += 2
        elif a == '--orient' and i + 1 < len(argv):
            flags['orient'] = argv[i + 1]; i += 2
        elif a in ('-r', '--reverse'):
            flags['reverse'] = True; i += 1
        elif a in ('-n', '--noop'):
            flags['noop'] = True; i += 1
        else:
            positional.append(a); i += 1
    return positional, flags


def fedcheck_main(stdscr, row, col, width, rows, items, exit_keys, selected, reverse, noop, color=None, select_color=None, orient='vertical'):
    fedshared.init_colors()
    curses.curs_set(0)

    if not items:
        return "ESC", 1, ""

    n = len(items)
    horizontal = (orient == 'horizontal')
    # rows is the viewport height; None/<=0 means "show all items" (no scroll).
    view = rows if (rows and rows > 0) else n
    view = max(1, min(view, n))
    win_rows = 1 if horizontal else view
    focus = 0
    vy = 0
    checked = set(selected) if selected else set()

    win = curses.newwin(win_rows, width, row, col)
    win.keypad(True)

    color_attr = fedshared.attr_for(color)
    base_attr = color_attr | (curses.A_REVERSE if reverse else 0)
    if select_color:
        sel_attr = fedshared.attr_for(select_color)
    elif color_attr and not reverse:
        sel_attr = color_attr | curses.A_REVERSE
    else:
        sel_attr = curses.A_NORMAL if reverse else curses.A_REVERSE
    if base_attr:
        win.bkgd(' ', base_attr)

    def can_exit(name):
        return exit_keys is None or name in exit_keys

    def output():
        return '|'.join(items[i] for i in sorted(checked))

    def render():
        nonlocal vy
        if horizontal:
            segments = [f"{'■' if i in checked else '□'} {items[i]}"
                        for i in range(n)]
            fedshared.render_options_horizontal(
                win, width, segments, focus, base_attr, sel_attr)
            win.refresh()
            return
        if focus < vy:
            vy = focus
        elif focus >= vy + view:
            vy = focus - view + 1
        for r in range(view):
            idx = vy + r
            if idx < n:
                marker = "■" if idx in checked else "□"
                text = f"{marker} {items[idx]}"[:width]
                attr = sel_attr if idx == focus else base_attr
                try:
                    win.addstr(r, 0, text.ljust(width), attr)
                except curses.error:
                    pass
            else:
                try:
                    win.addstr(r, 0, ' ' * width, base_attr)
                except curses.error:
                    pass
        fedshared.draw_scrollbar(win, view, width, vy, n, base_attr)
        win.refresh()

    if noop:
        render()
        return "NOOP", 0, output()

    while True:
        render()
        ch = win.getch()

        if ch == 27 and can_exit("ESC"):
            return "ESC", 1, output()
        elif ch in (10, 13, curses.KEY_ENTER) and can_exit("ENTER"):
            return "ENTER", 2, output()
        elif ch == 9 and can_exit("TAB"):
            return "TAB", 3, output()
        elif ch == curses.KEY_BTAB and can_exit("SHIFT-TAB"):
            return "SHIFT-TAB", 4, output()
        elif ch == curses.KEY_F10 and can_exit("F10"):
            return "F10", 5, output()

        elif ch == curses.KEY_UP:
            if horizontal:
                if can_exit("UP_TOP"):
                    return "UP_TOP", 6, output()
            elif focus > 0:
                focus -= 1
            elif can_exit("UP_TOP"):
                return "UP_TOP", 6, output()

        elif ch == curses.KEY_DOWN:
            if horizontal:
                if can_exit("DOWN_BOTTOM"):
                    return "DOWN_BOTTOM", 7, output()
            elif focus < n - 1:
                focus += 1
            elif can_exit("DOWN_BOTTOM"):
                return "DOWN_BOTTOM", 7, output()

        elif ch == curses.KEY_LEFT and horizontal:
            if focus > 0:
                focus -= 1

        elif ch == curses.KEY_RIGHT and horizontal:
            if focus < n - 1:
                focus += 1

        elif ch == curses.KEY_PPAGE:
            if focus == 0 and can_exit("PGUP"):
                return "PGUP", 8, output()
            focus = 0

        elif ch == curses.KEY_NPAGE:
            if focus == n - 1 and can_exit("PGDN"):
                return "PGDN", 9, output()
            focus = n - 1

        elif ch == curses.KEY_HOME:
            focus = 0

        elif ch == curses.KEY_END:
            focus = n - 1

        elif ch == 32:  # Space toggles
            if focus in checked:
                checked.discard(focus)
            else:
                checked.add(focus)

        elif 33 <= ch <= 126:
            char = chr(ch).lower()
            for offset in range(1, n):
                idx = (focus + offset) % n
                if items[idx].lower().startswith(char):
                    focus = idx
                    break


def fedcheck_run():
    args = sys.argv[1:]
    fedshared.early_dispatch(args, 'fedcheck', '9.1ETwS6', fedcheck_USAGE, fedcheck_HELP)

    positional, flags = fedcheck_parse_args(args)
    if len(positional) not in (3, 4):
        print(fedcheck_USAGE, file=sys.stderr); sys.exit(1)

    start_row, start_col = fedshared.resolve_pos(positional[0], positional[1])
    width = int(positional[2])
    rows = int(positional[3]) if len(positional) == 4 else None

    items = []
    if 'items' in flags:
        items = flags['items'].split('|')
    elif 'items_file' in flags:
        if flags['items_file'] == '-':
            items = [line.rstrip('\n') for line in sys.stdin if line.strip()]
        else:
            with open(flags['items_file']) as f:
                items = [line.rstrip('\n') for line in f if line.strip()]

    if not items:
        print("fedcheck: no items provided", file=sys.stderr)
        sys.exit(1)

    selected = set()
    if 'val' in flags:
        val_items = flags['val'].split('|')
        for v in val_items:
            for i, item in enumerate(items):
                if item == v:
                    selected.add(i)
                    break

    exit_keys = flags.get('exit')
    quiet = flags.get('quiet', False)
    reverse = flags.get('reverse', False)
    noop = flags.get('noop', False)
    color = flags.get('color')
    select_color = flags.get('select_color')

    stdscr, saved_stdout, saved_stderr = fedshared.curses_open(restore=False)
    try:
        exit_reason, exit_code, final_text = fedcheck_main(
            stdscr, start_row, start_col, width, rows, items, exit_keys,
            selected, reverse, noop, color, select_color,
            flags.get('orient', 'vertical')
        )
    finally:
        fedshared.curses_close(stdscr, restore=False)

    fedshared.emit_and_exit(saved_stdout, saved_stderr, final_text, exit_reason, exit_code, quiet)

# ================================================================
# fedradio  ->  fed radio   (top-level names prefixed fedradio_)
# ================================================================


fedradio_USAGE = """\
Usage: fed radio <row> <col> <width> [options]
Try --help for full documentation."""

fedradio_HELP = """\
fedradio - Radio button field for shell scripts

Usage: fed radio <row> <col> <width> [options]

Arguments:
  row         Screen row (@ for current cursor row)
  col         Screen column (@ for current cursor column)
  width       Visible width (columns)

Options:
  -i, --items TEXT       Pipe-delimited items (e.g. "Small|Medium|Large")
  --items-file FILE      Load items from file (one per line, - for stdin)
  -v, --val TEXT         Initially selected value
  -e, --exit KEYS        Comma-separated exit key mnemonics (default: all)
  -q, --quiet            Suppress exit reason on stderr
  -r, --reverse          Display in reverse video
  --color FG,BG          Field colors (ANSI names, optional 'bright-' prefix)
  --select-color FG,BG   Highlight color for the focused item
  --orient ORIENT        Layout: vertical (default) or horizontal (one row,
                         Left/Right to move between options)
  -n, --noop             Display and exit immediately (code 0)
  -h                     Print brief usage and exit
  --help                 Print full help text and exit
  --version              Print version and copyright information and exit

Display:
  ◉ Selected    ○ Unselected

Exit Keys (mnemonic -> code):
  ESC (1)          Escape key pressed
  ENTER (2)        Enter key pressed (selects current item, then exits)
  TAB (3)          Tab key pressed
  SHIFT-TAB (4)    Shift+Tab pressed
  F10 (5)          F10 key pressed
  UP_TOP (6)       Up arrow at first item
  DOWN_BOTTOM (7)  Down arrow at last item
  PGUP (8)         PgUp at first item
  PGDN (9)         PgDn at last item

Navigation Keys:
  Up/Down       Move focus by one item
  Space         Select the focused item
  Home/End      Jump to first/last item
  a-z, 0-9      Jump to next item starting with that character

Terminal:
  This field draws INLINE: it renders only within its own area, over
  whatever is already on the screen. It does not clear the screen and does
  not restore it on exit — the calling script manages the surrounding
  display. (fedform is different: it owns and clears the screen and restores
  it on exit by default.) While active, the field runs in raw mode, so
  Ctrl+C does NOT interrupt it (and Ctrl+S/Ctrl+Q do not pause output);
  leave the field with a configured exit key.

Output:
  stdout      Selected value
  stderr      Exit reason mnemonic (unless --quiet)
  exit code   Numeric exit code (0 for --noop, 1-9 for exit keys)

Examples:
  fed radio 0 0 25 -i "Small|Medium|Large" -v "Medium"
  fed radio 2 4 20 -i "Yes|No" -e ENTER,ESC
  fed radio @ @ 25 -i "Red|Green|Blue" --color cyan,default"""


def fedradio_parse_args(argv):
    positional = []
    flags = {}
    i = 0
    while i < len(argv):
        a = argv[i]
        if a in ('-i', '--items') and i + 1 < len(argv):
            flags['items'] = argv[i + 1]; i += 2
        elif a == '--items-file' and i + 1 < len(argv):
            flags['items_file'] = argv[i + 1]; i += 2
        elif a in ('-v', '--val') and i + 1 < len(argv):
            flags['val'] = argv[i + 1]; i += 2
        elif a in ('-e', '--exit') and i + 1 < len(argv):
            flags['exit'] = set(argv[i + 1].split(',')); i += 2
        elif a in ('-q', '--quiet'):
            flags['quiet'] = True; i += 1
        elif a == '--color' and i + 1 < len(argv):
            flags['color'] = argv[i + 1]; i += 2
        elif a == '--select-color' and i + 1 < len(argv):
            flags['select_color'] = argv[i + 1]; i += 2
        elif a == '--orient' and i + 1 < len(argv):
            flags['orient'] = argv[i + 1]; i += 2
        elif a in ('-r', '--reverse'):
            flags['reverse'] = True; i += 1
        elif a in ('-n', '--noop'):
            flags['noop'] = True; i += 1
        else:
            positional.append(a); i += 1
    return positional, flags


def fedradio_main(stdscr, row, col, width, items, exit_keys, selected_idx, reverse, noop, color=None, select_color=None, orient='vertical'):
    fedshared.init_colors()
    curses.curs_set(0)

    if not items:
        return "ESC", 1, ""

    n = len(items)
    horizontal = (orient == 'horizontal')
    chosen = selected_idx if 0 <= selected_idx < n else None
    # Focus starts on the selected item so the highlight lands on the active
    # option at render time (matches fedlist); falls back to the first item
    # when nothing is selected.
    focus = chosen if chosen is not None else 0

    win = curses.newwin(1 if horizontal else n, width, row, col)
    win.keypad(True)

    color_attr = fedshared.attr_for(color)
    base_attr = color_attr | (curses.A_REVERSE if reverse else 0)
    if select_color:
        sel_attr = fedshared.attr_for(select_color)
    elif color_attr and not reverse:
        sel_attr = color_attr | curses.A_REVERSE
    else:
        sel_attr = curses.A_NORMAL if reverse else curses.A_REVERSE
    if base_attr:
        win.bkgd(' ', base_attr)

    def can_exit(name):
        return exit_keys is None or name in exit_keys

    def output():
        return items[chosen] if chosen is not None else ""

    def render():
        if horizontal:
            segments = [f"{'◉' if i == chosen else '○'} {items[i]}"
                        for i in range(n)]
            fedshared.render_options_horizontal(
                win, width, segments, focus, base_attr, sel_attr)
            win.refresh()
            return
        for r in range(n):
            marker = "◉" if r == chosen else "○"
            text = f"{marker} {items[r]}"[:width]
            attr = sel_attr if r == focus else base_attr
            try:
                win.addstr(r, 0, text.ljust(width), attr)
            except curses.error:
                pass
        win.refresh()

    if noop:
        render()
        return "NOOP", 0, output()

    while True:
        render()
        ch = win.getch()

        if ch == 27 and can_exit("ESC"):
            return "ESC", 1, output()
        elif ch in (10, 13, curses.KEY_ENTER) and can_exit("ENTER"):
            chosen = focus
            return "ENTER", 2, output()
        elif ch == 9 and can_exit("TAB"):
            return "TAB", 3, output()
        elif ch == curses.KEY_BTAB and can_exit("SHIFT-TAB"):
            return "SHIFT-TAB", 4, output()
        elif ch == curses.KEY_F10 and can_exit("F10"):
            return "F10", 5, output()

        elif ch == curses.KEY_UP:
            if horizontal:
                if can_exit("UP_TOP"):
                    return "UP_TOP", 6, output()
            elif focus > 0:
                focus -= 1
            elif can_exit("UP_TOP"):
                return "UP_TOP", 6, output()

        elif ch == curses.KEY_DOWN:
            if horizontal:
                if can_exit("DOWN_BOTTOM"):
                    return "DOWN_BOTTOM", 7, output()
            elif focus < n - 1:
                focus += 1
            elif can_exit("DOWN_BOTTOM"):
                return "DOWN_BOTTOM", 7, output()

        elif ch == curses.KEY_LEFT and horizontal:
            if focus > 0:
                focus -= 1

        elif ch == curses.KEY_RIGHT and horizontal:
            if focus < n - 1:
                focus += 1

        elif ch == curses.KEY_PPAGE:
            if focus == 0 and can_exit("PGUP"):
                return "PGUP", 8, output()
            focus = 0

        elif ch == curses.KEY_NPAGE:
            if focus == n - 1 and can_exit("PGDN"):
                return "PGDN", 9, output()
            focus = n - 1

        elif ch == curses.KEY_HOME:
            focus = 0

        elif ch == curses.KEY_END:
            focus = n - 1

        elif ch == 32:  # Space selects
            chosen = focus

        elif 33 <= ch <= 126:
            char = chr(ch).lower()
            for offset in range(1, n):
                idx = (focus + offset) % n
                if items[idx].lower().startswith(char):
                    focus = idx
                    break


def fedradio_run():
    args = sys.argv[1:]
    fedshared.early_dispatch(args, 'fedradio', '7.1F6r6P', fedradio_USAGE, fedradio_HELP)

    positional, flags = fedradio_parse_args(args)
    if len(positional) != 3:
        print(fedradio_USAGE, file=sys.stderr); sys.exit(1)

    start_row, start_col = fedshared.resolve_pos(positional[0], positional[1])
    width = int(positional[2])

    items = []
    if 'items' in flags:
        items = flags['items'].split('|')
    elif 'items_file' in flags:
        if flags['items_file'] == '-':
            items = [line.rstrip('\n') for line in sys.stdin if line.strip()]
        else:
            with open(flags['items_file']) as f:
                items = [line.rstrip('\n') for line in f if line.strip()]

    if not items:
        print("fedradio: no items provided", file=sys.stderr)
        sys.exit(1)

    selected_idx = -1
    if 'val' in flags:
        for i, item in enumerate(items):
            if item == flags['val']:
                selected_idx = i
                break

    exit_keys = flags.get('exit')
    quiet = flags.get('quiet', False)
    reverse = flags.get('reverse', False)
    noop = flags.get('noop', False)
    color = flags.get('color')
    select_color = flags.get('select_color')

    stdscr, saved_stdout, saved_stderr = fedshared.curses_open(restore=False)
    try:
        exit_reason, exit_code, final_text = fedradio_main(
            stdscr, start_row, start_col, width, items, exit_keys,
            selected_idx, reverse, noop, color, select_color,
            flags.get('orient', 'vertical')
        )
    finally:
        fedshared.curses_close(stdscr, restore=False)

    fedshared.emit_and_exit(saved_stdout, saved_stderr, final_text, exit_reason, exit_code, quiet)

# ================================================================
# fedform  ->  fed form   (top-level names prefixed fedform_)
# ================================================================


fedform_USAGE = """\
Usage: fed form <definition> [options]
Try --help for full documentation."""

fedform_HELP = """\
fedform - Terminal form manager

Usage: fed form <definition> [options]

Arguments:
  definition    Path to form definition file, or - for stdin.

Options:
  -j, --json           Parse definition as JSON (default: line-oriented).
  -v, --values FILE    Load initial values from JSON file (keyed by field name).
  -o, --output FORMAT  Output format: kv (default), json, or shell.
  -q, --quiet          Suppress exit reason on stderr.
  -f, --focus FIELD    Field name for initial focus (default: first in tab order).
  -e, --exit KEYS      Form-level exit keys (default: ESC,F10).
  --no-restore         Keep the form on the main screen on exit; the final
                       layout persists (default: alt-screen; caller's screen
                       restored on exit).
  -h                   Print brief usage and exit.
  --help               Print full help text and exit.
  --version            Print version and copyright information and exit.

Line-Oriented Definition Format:
  @<row>,<col> SAY "<text>" [COLOR fg,bg]
  @<row>,<col> GET <name> LINE  <width> <maxlen> [field-options]
  @<row>,<col> GET <name> AREA  <width> <rows> <maxlen> <v_scroll> <word_wrap> [field-options]
  @<row>,<col> GET <name> MASK  <mask_spec> [field-options]
  @<row>,<col> GET <name> LIST  <width> <rows> [field-options]
  @<row>,<col> GET <name> CHECK <width> [field-options]
  @<row>,<col> GET <name> RADIO <width> [field-options]

  Field options: -v <text>, -f <char>, -c start|end, -m ins|ovr, -r/REVERSE,
                 -i <a|b|c>, --items-file <path>, TAB <n>,
                 COLOR fg,bg, FOCUS fg,bg, SELECT fg,bg, LOCKED [fg,bg]
  Validation:    --required, --range MIN:MAX, --in val1|val2, --regex PATTERN
  (-i/--items and --items-file supply choices for LIST, CHECK, and RADIO.)
  Lines starting with # are comments. Blank lines are ignored.

Form Directives & Commands (their own lines, no @ prefix):
  DEF COLOR|FOCUS|SELECT|LOCKED fg,bg   Set form-wide default colors.
  DEF SAY fg,bg | DEF REVERSE | DEF CLS Defaults for labels / reverse / clear.
  CLS|CLEAR [fg,bg]                     Clear the screen (optionally to a color).
  PUSH | POP                            Save / restore terminal screen state.
  CURSOR <row>,<col>                    Position the hardware cursor.
  Commands before the first @-line run pre-form; after the last, post-form.

JSON Definition Format:
  Use -j flag. Top-level keys: elements (required), exit_keys, focus.
  Element types: say, line, area, mask, list, check, radio.

Terminal:
  fedform owns the whole screen: it clears the screen on start and lays out all
  fields. By default it uses the terminal's alternate screen and restores the
  caller's screen on exit; with --no-restore it draws on the main screen and
  leaves the final layout in place. This differs from the standalone field tools
  (fedline, fedarea, fedmask, fedlist, fedcheck, fedradio), which draw inline
  over existing content and never clear or restore the screen. While active, the
  form runs in raw mode: Ctrl+C does NOT interrupt it (and Ctrl+S/Ctrl+Q do not
  pause output) - leave the form with a configured exit key.

Output (stdout):
  kv:    name=value (one per line, \\n-escaped newlines)
  json:  JSON object
  shell: eval-safe single-quoted assignments

Exit Codes:
  0   Form submitted (F10 or configured submit key)
  1   Form cancelled (ESC)
  2   CTRL-X exit
  10  Definition parse error

Examples:
  fed form customer.def
  fed form customer.def -o shell -v saved.json
  fed form form.json -j -o json
  echo '{"name":"Jane"}' | fed form customer.def --values -"""

fedform_LINE_EXIT_KEYS = {"TAB", "SHIFT-TAB", "ENTER", "ESC", "F10", "UP_TOP", "DOWN_BOTTOM", "PGUP", "PGDN"}
fedform_AREA_EXIT_KEYS = {"TAB", "SHIFT-TAB", "ESC", "F10", "CTRL-X", "UP_TOP", "DOWN_BOTTOM", "PGUP", "PGDN"}
fedform_MASK_EXIT_KEYS = {"TAB", "SHIFT-TAB", "ENTER", "ESC", "F10", "UP_TOP", "DOWN_BOTTOM", "PGUP", "PGDN"}
fedform_LIST_EXIT_KEYS = {"TAB", "SHIFT-TAB", "ENTER", "ESC", "F10", "UP_TOP", "DOWN_BOTTOM", "PGUP", "PGDN"}
fedform_CHECK_EXIT_KEYS = {"TAB", "SHIFT-TAB", "ESC", "F10", "UP_TOP", "DOWN_BOTTOM", "PGUP", "PGDN"}
fedform_RADIO_EXIT_KEYS = {"TAB", "SHIFT-TAB", "ENTER", "ESC", "F10", "UP_TOP", "DOWN_BOTTOM", "PGUP", "PGDN"}

fedform_FORM_EXIT_CODES = {"ESC": 1, "F10": 0, "CTRL-X": 2}


def fedform_tokenize_line(line):
    tokens = []
    i = 0
    while i < len(line):
        if line[i].isspace():
            i += 1
            continue
        if line[i] == '"':
            i += 1
            token = []
            while i < len(line):
                if line[i] == '\\' and i + 1 < len(line) and line[i + 1] in ('"', '\\'):
                    token.append(line[i + 1])
                    i += 2
                elif line[i] == '"':
                    i += 1
                    break
                else:
                    token.append(line[i])
                    i += 1
            tokens.append(''.join(token))
        else:
            start = i
            while i < len(line) and not line[i].isspace():
                i += 1
            tokens.append(line[start:i])
    return tokens


def fedform_parse_field_options(tokens):
    flags = {}
    i = 0
    while i < len(tokens):
        a = tokens[i]
        if a in ('-v', '--val') and i + 1 < len(tokens):
            flags['val'] = tokens[i + 1]; i += 2
        elif a in ('-f', '--fill') and i + 1 < len(tokens):
            flags['fill'] = tokens[i + 1][:1]; i += 2
        elif a in ('-c', '--cursor') and i + 1 < len(tokens):
            flags['cursor'] = tokens[i + 1]; i += 2
        elif a in ('-m', '--mode') and i + 1 < len(tokens):
            flags['mode'] = tokens[i + 1]; i += 2
        elif a in ('-r', '--reverse') or a == 'REVERSE':
            flags['reverse'] = True; i += 1
        elif a == 'COLOR' and i + 1 < len(tokens):
            flags['color'] = tokens[i + 1]; i += 2
        elif a == 'FOCUS' and i + 1 < len(tokens):
            flags['focus_color'] = tokens[i + 1]; i += 2
        elif a == 'SELECT' and i + 1 < len(tokens):
            flags['select_color'] = tokens[i + 1]; i += 2
        elif a == 'LOCKED':
            # `LOCKED` alone marks the field locked; `LOCKED fg,bg` overrides locked color
            if i + 1 < len(tokens) and ',' in tokens[i + 1]:
                flags['locked_color'] = tokens[i + 1]; i += 2
            else:
                flags['locked'] = True; i += 1
        elif a == '--required':
            flags['required'] = True; i += 1
        elif a == '--range' and i + 1 < len(tokens):
            flags['range'] = tokens[i + 1]; i += 2
        elif a == '--in' and i + 1 < len(tokens):
            flags['in'] = tokens[i + 1]; i += 2
        elif a == '--regex' and i + 1 < len(tokens):
            flags['regex'] = tokens[i + 1]; i += 2
        elif a in ('-i', '--items') and i + 1 < len(tokens):
            flags['items'] = tokens[i + 1]; i += 2
        elif a == '--items-file' and i + 1 < len(tokens):
            flags['items_file'] = tokens[i + 1]; i += 2
        elif a == '--orient' and i + 1 < len(tokens):
            flags['orient'] = tokens[i + 1]; i += 2
        elif a == 'TAB' and i + 1 < len(tokens):
            flags['tab'] = int(tokens[i + 1]); i += 2
        else:
            i += 1
    return flags


def fedform_resolve_items(field):
    if 'items' in field:
        return field['items'].split('|')
    elif 'items_file' in field:
        path = field['items_file']
        with open(path) as f:
            return [line.rstrip('\n') for line in f if line.strip()]
    return []


def fedform_parse_line_definition(text):
    # A fedmake mockup (not a form definition) is identified by its section
    # separator: a line of only '%' (>= 2), e.g. '%%'. Catch the common mistake
    # of feeding a .mockup straight to fedform.
    for raw_line in text.splitlines():
        s = raw_line.strip()
        if len(s) >= 2 and set(s) == {'%'}:
            print("fedform: this looks like a fedmake mockup, not a form "
                  "definition.", file=sys.stderr)
            print("         convert it first, e.g.:  fedmake FILE > form.def "
                  "&& fedform form.def", file=sys.stderr)
            sys.exit(10)

    says = []
    fields = []
    form_defs = {}
    pre_cmds = []
    post_cmds = []
    seen_at = False
    for lineno, raw_line in enumerate(text.splitlines(), 1):
        line = raw_line.strip()
        if not line or line.startswith('#'):
            continue

        # DEF directive: no '@' prefix
        if line.startswith('DEF'):
            tokens = fedform_tokenize_line(line)
            if len(tokens) < 3:
                print(f"fedform: DEF needs <kind> <value> at line {lineno}", file=sys.stderr)
                sys.exit(10)
            kind = tokens[1].upper()
            if kind == 'MESSAGE':
                # DEF MESSAGE accepts several params in any order, recognized by
                # shape: row,col | width(int)|FULL | AUTO|STATUSBAR | fg,bg color.
                cfg = {}
                for tok in tokens[2:]:
                    if re.fullmatch(r'\d+,\d+', tok):
                        rr, cc = tok.split(',')
                        cfg['row'] = int(rr)
                        cfg['col'] = int(cc)
                    elif tok.upper() == 'FULL':
                        cfg['width'] = 'FULL'
                    elif re.fullmatch(r'\d+', tok):
                        cfg['width'] = int(tok)
                    elif tok.upper() in ('AUTO', 'STATUSBAR'):
                        cfg['mode'] = tok.upper()
                    else:
                        cfg['color'] = tok  # fg,bg
                form_defs['message'] = cfg
                continue
            valid_kinds = ('SAY', 'COLOR', 'FOCUS', 'SELECT', 'LOCKED', 'REVERSE', 'CLS')
            if kind not in valid_kinds:
                print(f"fedform: unknown DEF kind {kind!r} at line {lineno}", file=sys.stderr)
                sys.exit(10)
            form_defs[kind.lower()] = tokens[2]
            continue

        # Form-level commands without an '@' prefix: PUSH, POP, CLS, CLEAR, CURSOR
        if not line.startswith('@'):
            tokens = fedform_tokenize_line(line)
            head = tokens[0].upper() if tokens else ''
            if head in ('CLS', 'CLEAR'):
                color = tokens[1] if len(tokens) > 1 else None
                cmd = ('CLS', color)
            elif head == 'PUSH':
                cmd = ('PUSH',)
            elif head == 'POP':
                cmd = ('POP',)
            elif head == 'CURSOR':
                if len(tokens) < 2:
                    print(f"fedform: CURSOR needs row,col at line {lineno}", file=sys.stderr)
                    sys.exit(10)
                m = re.match(r'^(\d+)\s*,\s*(\d+)$', tokens[1])
                if not m:
                    print(f"fedform: CURSOR row,col format at line {lineno}", file=sys.stderr)
                    sys.exit(10)
                cmd = ('CURSOR', int(m.group(1)), int(m.group(2)))
            else:
                print(f"fedform: unknown directive {head!r} at line {lineno}", file=sys.stderr)
                sys.exit(10)
            (post_cmds if seen_at else pre_cmds).append(cmd)
            continue

        m = re.match(r'@(\d+)\s*,\s*(\d+)\s+(.*)', line)
        if not m:
            print(f"fedform: parse error at line {lineno}: {raw_line}", file=sys.stderr)
            sys.exit(10)

        seen_at = True
        row, col = int(m.group(1)), int(m.group(2))
        rest = m.group(3)
        tokens = fedform_tokenize_line(rest)

        if not tokens:
            print(f"fedform: parse error at line {lineno}: {raw_line}", file=sys.stderr)
            sys.exit(10)

        if tokens[0] == 'SAY':
            if len(tokens) < 2:
                print(f"fedform: SAY missing text at line {lineno}", file=sys.stderr)
                sys.exit(10)
            say = {'row': row, 'col': col, 'text': tokens[1]}
            # Optional COLOR fg,bg after the text
            j = 2
            while j < len(tokens):
                if tokens[j] == 'COLOR' and j + 1 < len(tokens):
                    say['color'] = tokens[j + 1]
                    j += 2
                else:
                    j += 1
            says.append(say)

        elif tokens[0] == 'GET':
            if len(tokens) < 3:
                print(f"fedform: GET missing name/type at line {lineno}", file=sys.stderr)
                sys.exit(10)
            name = tokens[1]
            if name.startswith('__'):
                print(f"fedform: field name {name!r} uses reserved '__' prefix "
                      f"at line {lineno}", file=sys.stderr)
                sys.exit(10)
            ftype = tokens[2].upper()

            if ftype == 'LINE':
                if len(tokens) < 5:
                    print(f"fedform: LINE requires L I at line {lineno}", file=sys.stderr)
                    sys.exit(10)
                opts = fedform_parse_field_options(tokens[5:])
                fields.append({
                    'type': 'line', 'name': name, 'row': row, 'col': col,
                    'width': int(tokens[3]), 'maxlen': int(tokens[4]), **opts
                })

            elif ftype == 'AREA':
                if len(tokens) < 8:
                    print(f"fedform: AREA requires WIDTH ROWS MAXLEN v_scroll word_wrap at line {lineno}", file=sys.stderr)
                    sys.exit(10)
                opts = fedform_parse_field_options(tokens[8:])
                fields.append({
                    'type': 'area', 'name': name, 'row': row, 'col': col,
                    'width': int(tokens[3]), 'rows': int(tokens[4]), 'maxlen': int(tokens[5]),
                    'v_scroll': int(tokens[6]), 'word_wrap': int(tokens[7]), **opts
                })

            elif ftype == 'MASK':
                if len(tokens) < 4:
                    print(f"fedform: MASK requires mask_spec at line {lineno}", file=sys.stderr)
                    sys.exit(10)
                opts = fedform_parse_field_options(tokens[4:])
                fields.append({
                    'type': 'mask', 'name': name, 'row': row, 'col': col,
                    'mask': tokens[3], **opts
                })

            elif ftype == 'LIST':
                if len(tokens) < 5:
                    print(f"fedform: LIST requires WIDTH ROWS at line {lineno}", file=sys.stderr)
                    sys.exit(10)
                opts = fedform_parse_field_options(tokens[5:])
                field = {
                    'type': 'list', 'name': name, 'row': row, 'col': col,
                    'width': int(tokens[3]), 'rows': int(tokens[4]), **opts
                }
                field['_items'] = fedform_resolve_items(field)
                fields.append(field)

            elif ftype in ('CHECK', 'RADIO'):
                if len(tokens) < 4:
                    print(f"fedform: {ftype} requires WIDTH at line {lineno}", file=sys.stderr)
                    sys.exit(10)
                rest = tokens[4:]
                extra = {}
                # CHECK accepts an optional viewport height (bare integer) after
                # WIDTH; scrolls when items exceed it. RADIO has no viewport.
                if ftype == 'CHECK' and rest and re.fullmatch(r'\d+', rest[0]):
                    extra['rows'] = int(rest[0])
                    rest = rest[1:]
                opts = fedform_parse_field_options(rest)
                field = {
                    'type': ftype.lower(), 'name': name, 'row': row, 'col': col,
                    'width': int(tokens[3]), **extra, **opts
                }
                field['_items'] = fedform_resolve_items(field)
                fields.append(field)

            else:
                print(f"fedform: unknown field type '{ftype}' at line {lineno}", file=sys.stderr)
                sys.exit(10)
        else:
            print(f"fedform: parse error at line {lineno}: {raw_line}", file=sys.stderr)
            sys.exit(10)

    return says, fields, form_defs, pre_cmds, post_cmds


def fedform_parse_json_definition(text):
    data = json.loads(text)
    says = []
    fields = []
    json_exit_keys = data.get('exit_keys')
    json_focus = data.get('focus')
    form_defs = {}
    if 'def' in data and isinstance(data['def'], dict):
        for k in ('say', 'color', 'focus', 'select', 'locked', 'reverse', 'cls'):
            if k in data['def']:
                form_defs[k] = data['def'][k]

    def _cmds_from(lst):
        out = []
        for c in lst or []:
            t = c.get('type', '').upper()
            if t in ('CLS', 'CLEAR'):
                out.append(('CLS', c.get('color')))
            elif t == 'PUSH':
                out.append(('PUSH',))
            elif t == 'POP':
                out.append(('POP',))
            elif t == 'CURSOR':
                out.append(('CURSOR', int(c['row']), int(c['col'])))
        return out

    pre_cmds = _cmds_from(data.get('pre_commands', []))
    post_cmds = _cmds_from(data.get('post_commands', []))

    for elem in data.get('elements', []):
        etype = elem['type']

        if etype == 'say':
            say = {'row': elem['row'], 'col': elem['col'], 'text': elem['text']}
            says.append(say)
        else:
            if str(elem.get('name', '')).startswith('__'):
                print(f"fedform: field name {elem['name']!r} uses reserved "
                      f"'__' prefix", file=sys.stderr)
                sys.exit(10)
            field = {
                'type': etype,
                'name': elem['name'],
                'row': elem['row'],
                'col': elem['col'],
            }
            for key in ('val', 'fill', 'cursor', 'mode', 'reverse', 'tab', 'required', 'range', 'in', 'regex',
                        'color', 'focus_color', 'select_color', 'locked_color', 'locked'):
                if key in elem:
                    field[key] = elem[key]

            if etype == 'line':
                field['width'] = elem['width']
                field['maxlen'] = elem['maxlen']
            elif etype == 'area':
                field['width'] = elem['width']
                field['rows'] = elem['rows']
                field['maxlen'] = elem['maxlen']
                field['v_scroll'] = elem.get('v_scroll', 1)
                field['word_wrap'] = elem.get('word_wrap', 1)
            elif etype == 'mask':
                field['mask'] = elem['mask']
            elif etype == 'list':
                field['width'] = elem['width']
                field['rows'] = elem['rows']
                if 'items' in elem:
                    field['items'] = '|'.join(elem['items']) if isinstance(elem['items'], list) else elem['items']
                elif 'items_file' in elem:
                    field['items_file'] = elem['items_file']
                field['_items'] = fedform_resolve_items(field)
            elif etype in ('check', 'radio'):
                field['width'] = elem['width']
                if etype == 'check' and 'rows' in elem:
                    field['rows'] = elem['rows']
                if 'orient' in elem:
                    field['orient'] = elem['orient']
                if 'items' in elem:
                    field['items'] = '|'.join(elem['items']) if isinstance(elem['items'], list) else elem['items']
                elif 'items_file' in elem:
                    field['items_file'] = elem['items_file']
                field['_items'] = fedform_resolve_items(field)

            if 'label' in elem:
                lab = elem['label']
                say = {'row': lab['row'], 'col': lab['col'], 'text': lab['text']}
                if 'color' in lab:
                    say['color'] = lab['color']
                says.append(say)

            fields.append(field)

    return says, fields, json_exit_keys, json_focus, form_defs, pre_cmds, post_cmds


def fedform_first_active(fields, start_idx=0):
    """Return the index of the first non-locked field at or after start_idx, wrapping."""
    n = len(fields)
    if n == 0:
        return 0
    for off in range(n):
        idx = (start_idx + off) % n
        if not fields[idx].get('locked'):
            return idx
    return start_idx  # all locked


def fedform_last_active(fields):
    n = len(fields)
    for off in range(n):
        idx = n - 1 - off
        if not fields[idx].get('locked'):
            return idx
    return 0


def fedform_next_active(fields, start_idx, direction):
    """Return next/prev non-locked field index (wrapping). If all are locked, returns start_idx."""
    n = len(fields)
    if n == 0:
        return start_idx
    idx = start_idx
    for _ in range(n):
        idx = (idx + direction) % n
        if not fields[idx].get('locked'):
            return idx
    return start_idx


def fedform_order_fields(fields):
    explicit = [(f['tab'], i, f) for i, f in enumerate(fields) if 'tab' in f]
    implicit = [(i, f) for i, f in enumerate(fields) if 'tab' not in f]
    explicit.sort(key=lambda x: x[0])
    ordered = [f for _, _, f in explicit] + [f for _, f in implicit]
    return ordered


def fedform_find_spatial(fields, current_idx, direction):
    current = fields[current_idx]
    cur_row = current['row']
    cur_col = current['col']
    best = None
    best_row_dist = 999999
    best_col_dist = 999999

    for i, f in enumerate(fields):
        if i == current_idx:
            continue
        if f.get('locked'):
            continue
        if direction < 0 and f['row'] >= cur_row:
            continue
        if direction > 0 and f['row'] <= cur_row:
            continue
        row_dist = abs(f['row'] - cur_row)
        col_dist = abs(f['col'] - cur_col)
        if row_dist < best_row_dist or (row_dist == best_row_dist and col_dist < best_col_dist):
            best = i
            best_row_dist = row_dist
            best_col_dist = col_dist

    return best


def fedform_resolve_normal_color(field, form_defs):
    """Color used to render the field when inactive."""
    if field.get('reverse'):
        rev = field.get('color')
        if not rev:
            rev = form_defs.get('reverse')
        if not rev:
            base = field.get('color') or form_defs.get('color')
            if base:
                return fedshared.swap_color(base)
        return rev or field.get('color') or form_defs.get('color')
    return field.get('color') or form_defs.get('color')


def fedform_resolve_focus_color(field, form_defs):
    """Color used to render the field when active/focused."""
    return (field.get('focus_color')
            or form_defs.get('focus')
            or fedform_resolve_normal_color(field, form_defs))


def fedform_resolve_select_color(field, form_defs):
    """Color used for the selected/focused item within list/check fields."""
    return field.get('select_color') or form_defs.get('select')


def fedform_resolve_locked_color(field, form_defs):
    """Color used when a field is locked (read-only)."""
    return (field.get('locked_color')
            or form_defs.get('locked')
            or fedform_resolve_normal_color(field, form_defs))


def fedform_resolve_say_color(say, form_defs):
    return say.get('color') or form_defs.get('say')


def fedform_call_field(stdscr, field, values, form_defs=None):
    if form_defs is None:
        form_defs = {}
    ftype = field['type']
    name = field['name']
    text = values.get(name, '')
    cursor_pos = field.get('cursor', 'end')
    insert_mode = field.get('mode', 'ins') != 'ovr'
    reverse = field.get('reverse', False)
    fill_char = field.get('fill', '')
    focus_color = fedform_resolve_focus_color(field, form_defs)
    select_color = fedform_resolve_select_color(field, form_defs)

    if ftype == 'line':
        return fedline_main(
            stdscr, field['row'], field['col'], field['width'], field['maxlen'],
            fedform_LINE_EXIT_KEYS, fill_char, text, insert_mode, reverse, False, cursor_pos,
            focus_color
        )
    elif ftype == 'area':
        return fedarea_main(
            stdscr, field['row'], field['col'], field['width'], field['rows'], field['maxlen'],
            bool(field.get('v_scroll', 1)), bool(field.get('word_wrap', 1)),
            fedform_AREA_EXIT_KEYS, text, insert_mode, reverse, False, cursor_pos,
            focus_color
        )
    elif ftype == 'mask':
        return fedmask_main(
            stdscr, field['row'], field['col'], field['mask'],
            fedform_MASK_EXIT_KEYS, fill_char, text, insert_mode, reverse, False, cursor_pos,
            focus_color
        )
    elif ftype == 'list':
        items = field.get('_items', [])
        sel_idx = 0
        if text:
            for i, item in enumerate(items):
                if item == text:
                    sel_idx = i
                    break
        return fedlist_main(
            stdscr, field['row'], field['col'], field['width'], field['rows'],
            items, fedform_LIST_EXIT_KEYS, sel_idx, reverse, False,
            focus_color, select_color
        )
    elif ftype == 'check':
        items = field.get('_items', [])
        selected = set()
        if text:
            for v in text.split('|'):
                for i, item in enumerate(items):
                    if item == v:
                        selected.add(i)
                        break
        return fedcheck_main(
            stdscr, field['row'], field['col'], field['width'], field.get('rows'),
            items, fedform_CHECK_EXIT_KEYS, selected, reverse, False,
            focus_color, select_color, field.get('orient', 'vertical')
        )
    elif ftype == 'radio':
        items = field.get('_items', [])
        selected_idx = -1
        if text:
            for i, item in enumerate(items):
                if item == text:
                    selected_idx = i
                    break
        return fedradio_main(
            stdscr, field['row'], field['col'], field['width'],
            items, fedform_RADIO_EXIT_KEYS, selected_idx, reverse, False,
            focus_color, select_color, field.get('orient', 'vertical')
        )


def fedform_render_noop(stdscr, field, values, form_defs=None):
    if form_defs is None:
        form_defs = {}
    ftype = field['type']
    name = field['name']
    text = values.get(name, '')
    cursor_pos = field.get('cursor', 'end')
    insert_mode = field.get('mode', 'ins') != 'ovr'
    reverse = field.get('reverse', False)
    fill_char = field.get('fill', '')
    if field.get('locked'):
        normal_color = fedform_resolve_locked_color(field, form_defs)
    else:
        normal_color = fedform_resolve_normal_color(field, form_defs)
    select_color = fedform_resolve_select_color(field, form_defs)

    if ftype == 'line':
        fedline_main(
            stdscr, field['row'], field['col'], field['width'], field['maxlen'],
            None, fill_char, text, insert_mode, reverse, True, cursor_pos,
            normal_color
        )
    elif ftype == 'area':
        # AREA fields, when inactive, scroll back to line 1 (cursor at start)
        fedarea_main(
            stdscr, field['row'], field['col'], field['width'], field['rows'], field['maxlen'],
            bool(field.get('v_scroll', 1)), bool(field.get('word_wrap', 1)),
            None, text, insert_mode, reverse, True, 'start',
            normal_color
        )
    elif ftype == 'mask':
        fedmask_main(
            stdscr, field['row'], field['col'], field['mask'],
            None, fill_char, text, insert_mode, reverse, True, cursor_pos,
            normal_color
        )
    elif ftype == 'list':
        items = field.get('_items', [])
        sel_idx = 0
        if text:
            for i, item in enumerate(items):
                if item == text:
                    sel_idx = i
                    break
        fedlist_main(
            stdscr, field['row'], field['col'], field['width'], field['rows'],
            items, None, sel_idx, reverse, True,
            normal_color, select_color
        )
    elif ftype == 'check':
        items = field.get('_items', [])
        selected = set()
        if text:
            for v in text.split('|'):
                for i, item in enumerate(items):
                    if item == v:
                        selected.add(i)
                        break
        fedcheck_main(
            stdscr, field['row'], field['col'], field['width'], field.get('rows'),
            items, None, selected, reverse, True,
            normal_color, select_color, field.get('orient', 'vertical')
        )
    elif ftype == 'radio':
        items = field.get('_items', [])
        selected_idx = -1
        if text:
            for i, item in enumerate(items):
                if item == text:
                    selected_idx = i
                    break
        fedradio_main(
            stdscr, field['row'], field['col'], field['width'],
            items, None, selected_idx, reverse, True,
            normal_color, select_color, field.get('orient', 'vertical')
        )


# Meta field carrying the exit-key mnemonic in stdout. The '__' prefix is a
# reserved namespace (user field names starting with '__' are rejected at parse
# time), so callers can read the exit key without capturing stderr.
fedform_EXIT_KEY_FIELD = '__EXIT_KEY'


def fedform_format_kv(fields, values, exit_key=None):
    lines = []
    for f in fields:
        name = f['name']
        val = values.get(name, '')
        escaped = val.replace('\\', '\\\\').replace('\n', '\\n')
        lines.append(f"{name}={escaped}")
    if exit_key is not None:
        lines.append(f"{fedform_EXIT_KEY_FIELD}={exit_key}")
    return '\n'.join(lines)


def fedform_format_json(fields, values, exit_key=None):
    obj = {}
    for f in fields:
        obj[f['name']] = values.get(f['name'], '')
    if exit_key is not None:
        obj[fedform_EXIT_KEY_FIELD] = exit_key
    return json.dumps(obj)


def fedform_format_shell(fields, values, exit_key=None):
    lines = []
    for f in fields:
        name = f['name']
        val = values.get(name, '')
        escaped = val.replace("'", "'\\''")
        lines.append(f"{name}='{escaped}'")
    if exit_key is not None:
        lines.append(f"{fedform_EXIT_KEY_FIELD}='{exit_key}'")
    return '\n'.join(lines)


def fedform_validate_field(field, value):
    name = field['name']
    if field.get('required') and not value.strip():
        return f"{name}: required"
    if 'range' in field and value.strip():
        try:
            parts = field['range'].split(':')
            lo, hi = float(parts[0]), float(parts[1])
            num = float(value)
            if num < lo or num > hi:
                return f"{name}: must be {parts[0]}-{parts[1]}"
        except (ValueError, IndexError):
            return f"{name}: not a number"
    if 'in' in field and value.strip():
        allowed = field['in'].split('|')
        if value not in allowed:
            return f"{name}: must be one of {', '.join(allowed)}"
    if 'regex' in field and value.strip():
        if not re.fullmatch(field['regex'], value):
            return f"{name}: invalid format"
    return None


def fedform_validate_all(fields, values):
    for i, f in enumerate(fields):
        val = values.get(f['name'], '')
        err = fedform_validate_field(f, val)
        if err:
            return i, err
    return None, None


def fedform_resolve_message_cfg(form_defs, fields):
    """Resolve the message area from DEF MESSAGE, applying defaults. When no
    DEF MESSAGE is given the default is a reverse-video AUTO bar placed just
    below the last field, full width."""
    raw = form_defs.get('message')
    cfg = dict(raw) if isinstance(raw, dict) else {}
    return {
        'row': cfg.get('row', fedform_form_status_row(fields)),
        'col': cfg.get('col', 0),
        'width': cfg.get('width', 'FULL'),
        'mode': (cfg.get('mode') or 'AUTO').upper(),
        'color': cfg.get('color'),
    }


def fedform_render_message(stdscr, message, cfg):
    """Render or clear the message area per cfg. AUTO shows nothing when there
    is no message; STATUSBAR always paints the bar."""
    col = cfg['col']
    if cfg['width'] == 'FULL':
        width = max(0, curses.COLS - col - 1)
    else:
        width = cfg['width']
    attr = fedshared.attr_for(cfg['color']) if cfg['color'] else curses.A_REVERSE
    try:
        if message:
            stdscr.addstr(cfg['row'], col, message[:width].ljust(width), attr)
        elif cfg['mode'] == 'STATUSBAR':
            stdscr.addstr(cfg['row'], col, ' ' * width, attr)
        else:  # AUTO with no message: clear the area
            stdscr.addstr(cfg['row'], col, ' ' * width)
    except curses.error:
        pass
    stdscr.refresh()


def fedform_field_bottom_row(field):
    """Last screen row a field occupies (multi-row fields span several rows)."""
    ftype = field['type']
    if ftype in ('area', 'list'):
        return field['row'] + field.get('rows', 1) - 1
    if ftype in ('check', 'radio'):
        # Horizontal check/radio is a single row; vertical spans one row per
        # visible item (CHECK may cap the visible count with its viewport).
        if field.get('orient') == 'horizontal':
            return field['row']
        n = max(1, len(field.get('_items', [])))
        view = field.get('rows') or n if ftype == 'check' else n
        return field['row'] + min(view, n) - 1
    return field['row']


def fedform_form_status_row(fields):
    """Row for status messages: clear of every field, including multi-row ones."""
    if not fields:
        return 2
    return max(fedform_field_bottom_row(f) for f in fields) + 2


def fedform_run_form_commands(stdscr, cmds, cursor_stack, form_defs):
    for cmd in cmds:
        op = cmd[0]
        if op == 'PUSH':
            try:
                cursor_stack.append(stdscr.getyx())
            except curses.error:
                pass
        elif op == 'POP':
            if cursor_stack:
                r, c = cursor_stack.pop()
                try:
                    stdscr.move(r, c)
                except curses.error:
                    pass
        elif op == 'CLS':
            color = cmd[1] or form_defs.get('cls')
            attr = fedshared.attr_for(color)
            if attr:
                stdscr.bkgd(' ', attr)
            stdscr.clear()
            stdscr.refresh()
        elif op == 'CURSOR':
            try:
                stdscr.move(cmd[1], cmd[2])
            except curses.error:
                pass


def fedform_main_form(stdscr, says, fields, values, form_exit_keys, focus_idx=0, form_defs=None,
              pre_cmds=None, post_cmds=None):
    if form_defs is None:
        form_defs = {}
    if pre_cmds is None:
        pre_cmds = []
    if post_cmds is None:
        post_cmds = []
    fedshared.init_colors()
    curses.curs_set(0)

    # Unlike the single-field editors (which draw inline and preserve the
    # surrounding screen), fedform owns the screen — clear it before laying out
    # the form. (curses_open disables the automatic full-screen clear/alt-screen
    # for inline use, so fedform clears explicitly here.)
    stdscr.clear()

    cursor_stack = []
    fedform_run_form_commands(stdscr, pre_cmds, cursor_stack, form_defs)

    # Ensure starting focus lands on an active (non-locked) field
    if fields and fields[focus_idx].get('locked'):
        focus_idx = fedform_first_active(fields, focus_idx)

    for say in says:
        say_attr = fedshared.attr_for(fedform_resolve_say_color(say, form_defs))
        try:
            stdscr.addstr(say['row'], say['col'], say['text'], say_attr)
        except curses.error:
            pass
    stdscr.refresh()

    for f in fields:
        fedform_render_noop(stdscr, f, values, form_defs)

    form_reason = "ESC"

    msg_cfg = fedform_resolve_message_cfg(form_defs, fields)
    message = None  # persists until the user acts on the flagged field

    while True:
        fedform_render_message(stdscr, message, msg_cfg)
        curses.curs_set(1)
        reason, _, text = fedform_call_field(stdscr, fields[focus_idx], values, form_defs)
        values[fields[focus_idx]['name']] = text

        if reason in form_exit_keys:
            if reason != 'ESC':
                fail_idx, err_msg = fedform_validate_all(fields, values)
                if fail_idx is not None:
                    message = err_msg
                    curses.curs_set(0)
                    fedform_render_message(stdscr, message, msg_cfg)
                    fedform_render_noop(stdscr, fields[focus_idx], values, form_defs)
                    focus_idx = fail_idx
                    continue
            form_reason = reason
            break

        # Successful field action: the message has served its purpose; drop it
        # so the next render clears the area.
        message = None
        curses.curs_set(0)
        fedform_render_noop(stdscr, fields[focus_idx], values, form_defs)

        next_idx = focus_idx
        if reason in ('TAB', 'ENTER'):
            next_idx = fedform_next_active(fields, focus_idx, 1)
        elif reason == 'SHIFT-TAB':
            next_idx = fedform_next_active(fields, focus_idx, -1)
        elif reason in ('UP_TOP', 'PGUP'):
            spatial = fedform_find_spatial(fields, focus_idx, -1)
            if spatial is not None:
                next_idx = spatial
            elif reason == 'PGUP':
                next_idx = fedform_first_active(fields, 0)
        elif reason in ('DOWN_BOTTOM', 'PGDN'):
            spatial = fedform_find_spatial(fields, focus_idx, 1)
            if spatial is not None:
                next_idx = spatial
            elif reason == 'PGDN':
                next_idx = fedform_last_active(fields)

        focus_idx = next_idx

    fedform_run_form_commands(stdscr, post_cmds, cursor_stack, form_defs)
    stdscr.refresh()
    return form_reason, values


def fedform_parse_cli(argv):
    opts = {
        'json_mode': False,
        'values_file': None,
        'output': 'kv',
        'quiet': False,
        'focus': None,
        'exit_keys': None,
        'definition': None,
        'restore': True,
    }
    i = 0
    while i < len(argv):
        a = argv[i]
        if a in ('-j', '--json'):
            opts['json_mode'] = True; i += 1
        elif a in ('-v', '--values') and i + 1 < len(argv):
            opts['values_file'] = argv[i + 1]; i += 2
        elif a in ('-o', '--output') and i + 1 < len(argv):
            opts['output'] = argv[i + 1]; i += 2
        elif a in ('-q', '--quiet'):
            opts['quiet'] = True; i += 1
        elif a in ('-f', '--focus') and i + 1 < len(argv):
            opts['focus'] = argv[i + 1]; i += 2
        elif a in ('-e', '--exit') and i + 1 < len(argv):
            opts['exit_keys'] = set(argv[i + 1].split(',')); i += 2
        elif a == '--no-restore':
            opts['restore'] = False; i += 1
        elif opts['definition'] is None:
            opts['definition'] = a; i += 1
        else:
            i += 1
    return opts


def fedform_run():
    args = sys.argv[1:]
    fedshared.early_dispatch(args, 'fedform', '21.1ETwS6', fedform_USAGE, fedform_HELP)

    opts = fedform_parse_cli(args)
    if not opts['definition']:
        print(fedform_USAGE, file=sys.stderr); sys.exit(1)

    if opts['definition'] == '-':
        def_text = sys.stdin.read()
    else:
        with open(opts['definition']) as f:
            def_text = f.read()

    json_exit_keys = None
    json_focus = None
    if opts['json_mode']:
        says, fields, json_exit_keys, json_focus, form_defs, pre_cmds, post_cmds = fedform_parse_json_definition(def_text)
    else:
        says, fields, form_defs, pre_cmds, post_cmds = fedform_parse_line_definition(def_text)

    if not fields:
        print("fedform: no fields defined", file=sys.stderr)
        sys.exit(10)

    fields = fedform_order_fields(fields)

    if opts['exit_keys']:
        form_exit_keys = opts['exit_keys']
    elif json_exit_keys:
        form_exit_keys = set(json_exit_keys)
    else:
        form_exit_keys = {"ESC", "F10"}

    values = {}
    for f in fields:
        values[f['name']] = f.get('val', '')

    if opts['values_file']:
        if opts['values_file'] == '-':
            val_data = json.load(sys.stdin)
        else:
            with open(opts['values_file']) as vf:
                val_data = json.load(vf)
        for name, val in val_data.items():
            if any(f['name'] == name for f in fields):
                values[name] = val

    focus_name = opts['focus'] or json_focus
    focus_idx = fedform_first_active(fields)
    if focus_name:
        for i, f in enumerate(fields):
            if f['name'] == focus_name:
                focus_idx = i
                break

    stdscr, saved_stdout, saved_stderr = fedshared.curses_open(opts['restore'])
    try:
        form_reason, final_values = fedform_main_form(
            stdscr, says, fields, values, form_exit_keys, focus_idx,
            form_defs, pre_cmds, post_cmds
        )
    finally:
        fedshared.curses_close(stdscr, opts['restore'])

    if opts['output'] == 'json':
        output = fedform_format_json(fields, final_values, form_reason)
    elif opts['output'] == 'shell':
        output = fedform_format_shell(fields, final_values, form_reason)
    else:
        output = fedform_format_kv(fields, final_values, form_reason)

    fedshared.emit_and_exit(
        saved_stdout, saved_stderr, output, form_reason,
        fedform_FORM_EXIT_CODES.get(form_reason, 1), opts.get('quiet', False)
    )

# ================================================================
# fedmake  ->  fed make   (top-level names prefixed fedmake_)
# ================================================================


fedmake_USAGE = """\
Usage: fed make <mockup> [options]
Try --help for full documentation."""

fedmake_HELP = """\
fedmake - Convert visual text mockups to fedform definitions

Usage: fed make <mockup> [options]

Arguments:
  mockup      Path to the mockup file, or - for stdin.

Options:
  -j, --json    Emit JSON form definition instead of line-oriented.
  -h            Print brief usage and exit.
  --help        Print full help text and exit.
  --version     Print version and copyright information and exit.

A mockup file has two sections, separated by a line of only '%' (>= 2),
i.e. '%%' (lex/yacc style). The layout section above the separator carries
only visual geometry: where fields and labels sit. The attributes section
below declares field types, validation, colors, items, and form directives.

All fields use the [...] bracket shape. Type is declared in attributes
(--type area|list|check|radio, or --mask <spec>). Multi-row continuation
via aligned |...| lines below an [...] opener sizes AREA/LIST, and for
CHECK/RADIO selects vertical layout (a single-row bracket = horizontal).

Every field receives a name from its adjacent label (slugified), and
also an ordinal name field_N. Either works in attribute references."""


# ============================================================
# Slugification
# ============================================================

def fedmake_slugify(text):
    """Replace non-alnum with space, trim, condense whitespace to '_', lowercase."""
    chars = [c if c.isalnum() else ' ' for c in text]
    s = ''.join(chars).strip()
    s = re.sub(r'\s+', '_', s)
    return s.lower()


# ============================================================
# Tokenization (matches fedform's tokenize_line)
# ============================================================

def fedmake_tokenize_line(line):
    tokens = []
    i = 0
    while i < len(line):
        if line[i].isspace():
            i += 1
            continue
        if line[i] == '"':
            i += 1
            token = []
            while i < len(line):
                if line[i] == '\\' and i + 1 < len(line) and line[i + 1] in ('"', '\\'):
                    token.append(line[i + 1])
                    i += 2
                elif line[i] == '"':
                    i += 1
                    break
                else:
                    token.append(line[i])
                    i += 1
            tokens.append(''.join(token))
        else:
            start = i
            while i < len(line) and not line[i].isspace():
                i += 1
            tokens.append(line[start:i])
    return tokens


# ============================================================
# File split: layout vs attributes
# ============================================================

def fedmake_is_section_separator(line):
    """The layout/attributes separator is a line of only '%' (>= 2), e.g. '%%'
    (lex/yacc style). Chosen over a run of '=' so decorative '=== HEADER ==='
    lines in the layout never collide with the separator."""
    s = line.strip()
    return len(s) >= 2 and set(s) == {'%'}


def fedmake_split_sections(text):
    lines = text.splitlines()
    sep_idx = None
    for i, line in enumerate(lines):
        if fedmake_is_section_separator(line):
            sep_idx = i
            break
    if sep_idx is None:
        return lines, []
    return lines[:sep_idx], lines[sep_idx + 1:]


# ============================================================
# Layout parsing
# ============================================================

def fedmake_parse_layout(layout_lines):
    """Parse the layout section. Returns (fields, says).

    fields: list of dicts with keys row, col, col_close, width, field_n,
            cont_rows (list of row indices), label_slug (or None), name.
    says:   list of dicts with keys row, col, text, slug.
    """
    # 1. Locate all [...] openers
    fields = []
    field_n = 0
    for row_idx, line in enumerate(layout_lines):
        col = 0
        while col < len(line):
            if line[col] == '[':
                close = line.find(']', col + 1)
                if close == -1:
                    sys.stderr.write(
                        f"fedmake: unclosed bracket at line {row_idx + 1}, col {col + 1}\n"
                    )
                    sys.exit(10)
                field_n += 1
                fields.append({
                    'row': row_idx,
                    'col': col,
                    'col_close': close,
                    'width': close - col + 1,
                    'field_n': field_n,
                    'cont_rows': [],
                })
                col = close + 1
            else:
                col += 1

    # 2. Walk continuation rows for each field
    for f in fields:
        r = f['row'] + 1
        while r < len(layout_lines):
            line = layout_lines[r]
            if (len(line) > f['col_close']
                    and line[f['col']] == '|'
                    and line[f['col_close']] == '|'):
                f['cont_rows'].append(r)
                r += 1
            else:
                break

    # 3. Build occupancy: cells consumed by field openers (only on opener row)
    occupancy = {}
    for f in fields:
        for c in range(f['col'], f['col_close'] + 1):
            occupancy[(f['row'], c)] = f

    # Rows fully consumed by being a continuation
    cont_row_set = set()
    for f in fields:
        cont_row_set.update(f['cont_rows'])

    # 4. Find SAY labels: maximal runs separated by 2+ spaces / field bracket / EOL
    says = []
    for row_idx, line in enumerate(layout_lines):
        if row_idx in cont_row_set:
            continue
        col = 0
        while col < len(line):
            ch = line[col]
            if ch.isspace():
                col += 1
                continue
            if (row_idx, col) in occupancy:
                # Skip over the field
                col = occupancy[(row_idx, col)]['col_close'] + 1
                continue
            # Start of a label
            start = col
            text_chars = []
            while col < len(line):
                ch = line[col]
                if (row_idx, col) in occupancy:
                    break
                if ch.isspace():
                    space_start = col
                    while col < len(line) and line[col].isspace():
                        col += 1
                    if col >= len(line):
                        break
                    if (row_idx, col) in occupancy:
                        break
                    if col - space_start >= 2:
                        break
                    text_chars.append(' ')
                else:
                    text_chars.append(ch)
                    col += 1
            text = ''.join(text_chars).rstrip()
            if text:
                says.append({
                    'row': row_idx,
                    'col': start,
                    'text': text,
                    'slug': fedmake_slugify(text),
                })

    # 5. Slug collision check. Empty slugs come from all-punctuation decorative
    #    text (e.g. a '====|  Title  |====' header); they never name a field
    #    (fields fall back to field_N), so they don't collide.
    seen = {}
    for s in says:
        if not s['slug']:
            continue
        if s['slug'] in seen:
            prev = seen[s['slug']]
            sys.stderr.write(
                f"fedmake: duplicate field name '{s['slug']}' from labels at "
                f"lines {prev['row'] + 1} and {s['row'] + 1}\n"
            )
            sys.exit(10)
        seen[s['slug']] = s

    # 6. Attach labels to fields by adjacency
    for f in fields:
        label = None
        # Rule 1: same row, to the left (rightmost SAY ending before the field)
        candidates = [
            s for s in says
            if s['row'] == f['row'] and s['col'] + len(s['text']) <= f['col']
        ]
        if candidates:
            label = max(candidates, key=lambda s: s['col'])
        else:
            # Rule 2: row above, same start column
            above = [
                s for s in says
                if s['row'] == f['row'] - 1 and s['col'] == f['col']
            ]
            if above:
                label = above[0]
        f['label_slug'] = label['slug'] if label else None
        f['name'] = f['label_slug'] or f'field_{f["field_n"]}'

    return fields, says


# ============================================================
# Attribute parsing
# ============================================================

fedmake_DIRECTIVE_HEADS = {'DEF', 'CLS', 'CLEAR', 'PUSH', 'POP', 'CURSOR'}


def fedmake_parse_attributes(attr_lines, fields, says):
    """Parse attribute section. Returns (field_attrs, say_attrs, pre_cmds, post_cmds).

    field_attrs: dict mapping id(field) -> list of remaining tokens
    say_attrs:   dict mapping slug -> list of remaining tokens
    pre_cmds, post_cmds: list of raw directive lines
    """
    field_lookup = {}
    for f in fields:
        field_lookup[f'field_{f["field_n"]}'] = f
        if f['label_slug']:
            field_lookup[f['label_slug']] = f
    say_lookup = {s['slug']: s for s in says if s['slug']}

    field_attrs = {}
    say_attrs = {}
    pre_cmds = []
    post_cmds = []
    interleave_warned = False
    saw_field = False

    for raw_lineno, raw in enumerate(attr_lines, 1):
        line = raw.strip()
        if not line or line.startswith('#'):
            continue
        tokens = fedmake_tokenize_line(line)
        if not tokens:
            continue
        head = tokens[0]

        if head.upper() in fedmake_DIRECTIVE_HEADS:
            target = post_cmds if saw_field else pre_cmds
            target.append(line)
            if saw_field and not interleave_warned:
                # Check if any further field-attribute lines remain; if so warn
                rest = attr_lines[raw_lineno:]
                for r in rest:
                    rt = r.strip()
                    if not rt or rt.startswith('#'):
                        continue
                    rtoks = fedmake_tokenize_line(rt)
                    if not rtoks:
                        continue
                    if rtoks[0].upper() not in fedmake_DIRECTIVE_HEADS and not rtoks[0].startswith('@'):
                        # A later field-attribute line exists; directive is interleaved
                        sys.stderr.write(
                            f"fedmake: form-level directive at line {raw_lineno} placed in "
                            f"post-form group; for explicit ordering, edit the generated .def\n"
                        )
                        interleave_warned = True
                        break
            continue

        if head.startswith('@'):
            slug = head[1:]
            if slug not in say_lookup:
                sys.stderr.write(
                    f"fedmake: unknown label '@{slug}' at attribute line {raw_lineno}\n"
                )
                sys.exit(10)
            say_attrs.setdefault(slug, []).extend(tokens[1:])
            continue

        # Field attribute
        if head not in field_lookup:
            sys.stderr.write(
                f"fedmake: unknown field '{head}' at attribute line {raw_lineno}\n"
            )
            sys.exit(10)
        f = field_lookup[head]
        field_attrs.setdefault(id(f), []).extend(tokens[1:])
        saw_field = True

    return field_attrs, say_attrs, pre_cmds, post_cmds


# ============================================================
# Token extraction helpers
# ============================================================

def fedmake_take_value(tokens, flag):
    """Return (value, remaining_tokens). value is None if flag absent."""
    out = []
    value = None
    i = 0
    while i < len(tokens):
        if tokens[i] == flag and i + 1 < len(tokens) and value is None:
            value = tokens[i + 1]
            i += 2
        else:
            out.append(tokens[i])
            i += 1
    return value, out


def fedmake_take_flag(tokens, flag):
    """Return (present, remaining_tokens)."""
    out = [t for t in tokens if t != flag]
    return (len(out) != len(tokens)), out


# ============================================================
# Type discovery and emission
# ============================================================

def fedmake_determine_type(field, tokens):
    """Inspect tokens (without consuming) to decide type. Returns one of
    'line', 'area', 'mask', 'list', 'check', 'radio'."""
    has_mask = any(t == '--mask' for t in tokens)
    type_value = None
    for i, t in enumerate(tokens):
        if t == '--type' and i + 1 < len(tokens):
            type_value = tokens[i + 1].lower()
            break

    if has_mask:
        if type_value and type_value != 'mask':
            sys.stderr.write(
                f"fedmake: --mask incompatible with --type {type_value} on field '{field['name']}'\n"
            )
            sys.exit(10)
        return 'mask'
    if type_value:
        if type_value not in ('line', 'area', 'list', 'check', 'radio', 'mask'):
            sys.stderr.write(
                f"fedmake: invalid --type '{type_value}' on field '{field['name']}'\n"
            )
            sys.exit(10)
        return type_value
    if field['cont_rows']:
        return 'area'
    return 'line'


def fedmake_emit_field(field, attr_tokens):
    """Generate the '@row,col GET ...' line for one field."""
    tokens = list(attr_tokens)
    ftype = fedmake_determine_type(field, tokens)

    # Strip fedmake-specific tokens
    _, tokens = fedmake_take_value(tokens, '--type')
    mask_spec, tokens = fedmake_take_value(tokens, '--mask')
    maxlen_str, tokens = fedmake_take_value(tokens, '--maxlen')
    rows_str, tokens = fedmake_take_value(tokens, '--rows')
    items_str, tokens = fedmake_take_value(tokens, '--items')
    items_file, tokens = fedmake_take_value(tokens, '--items-file')
    v_scroll_str, tokens = fedmake_take_value(tokens, '--v-scroll')
    word_wrap_str, tokens = fedmake_take_value(tokens, '--word-wrap')

    width = field['width']
    pos = f'@{field["row"]},{field["col"]}'
    name = field['name']

    # Incompatibility checks
    if ftype in ('line', 'mask') and field['cont_rows']:
        sys.stderr.write(
            f"fedmake: continuation rows not allowed for type '{ftype}' on field '{name}'\n"
        )
        sys.exit(10)
    if ftype not in ('list', 'check', 'radio') and (items_str or items_file):
        sys.stderr.write(
            f"fedmake: --items/--items-file not applicable to type '{ftype}' on field '{name}'\n"
        )
        sys.exit(10)
    if ftype not in ('area', 'list', 'check') and rows_str:
        sys.stderr.write(
            f"fedmake: --rows not applicable to type '{ftype}' on field '{name}'\n"
        )
        sys.exit(10)
    if ftype != 'area' and (v_scroll_str or word_wrap_str):
        sys.stderr.write(
            f"fedmake: --v-scroll/--word-wrap only apply to type 'area' on field '{name}'\n"
        )
        sys.exit(10)

    # Build positional header per type
    if ftype == 'line':
        maxlen = int(maxlen_str) if maxlen_str else width
        header = f'{pos} GET {name} LINE {width} {maxlen}'
    elif ftype == 'area':
        rows = int(rows_str) if rows_str else (
            1 + len(field['cont_rows']) if field['cont_rows'] else 3
        )
        maxlen = int(maxlen_str) if maxlen_str else max(500, width * rows * 4)
        v_scroll = int(v_scroll_str) if v_scroll_str else 1
        word_wrap = int(word_wrap_str) if word_wrap_str else 1
        header = f'{pos} GET {name} AREA {width} {rows} {maxlen} {v_scroll} {word_wrap}'
    elif ftype == 'mask':
        if not mask_spec:
            sys.stderr.write(
                f"fedmake: --mask spec required for type 'mask' on field '{name}'\n"
            )
            sys.exit(10)
        mask_token = f'"{mask_spec}"' if ' ' in mask_spec else mask_spec
        header = f'{pos} GET {name} MASK {mask_token}'
    elif ftype == 'list':
        if not items_str and not items_file:
            sys.stderr.write(
                f"fedmake: --items or --items-file required for type 'list' on field '{name}'\n"
            )
            sys.exit(10)
        rows = int(rows_str) if rows_str else (
            1 + len(field['cont_rows']) if field['cont_rows'] else 5
        )
        header = f'{pos} GET {name} LIST {width} {rows}'
        if items_str:
            header += f' -i "{items_str}"'
        elif items_file:
            header += f' --items-file {items_file}'
    elif ftype in ('check', 'radio'):
        if not items_str and not items_file:
            sys.stderr.write(
                f"fedmake: --items or --items-file required for type '{ftype}' on field '{name}'\n"
            )
            sys.exit(10)
        # Orientation: explicit --orient wins; otherwise the drawn shape decides
        # (continuation rows -> vertical; a single row -> horizontal).
        orient_str, tokens = fedmake_take_value(tokens, '--orient')
        orient = orient_str or ('vertical' if field['cont_rows'] else 'horizontal')
        header = f'{pos} GET {name} {ftype.upper()} {width}'
        if ftype == 'check' and orient == 'vertical':
            # CHECK viewport height = the drawn rows (bracket + continuations).
            rows = int(rows_str) if rows_str else (1 + len(field['cont_rows']))
            header += f' {rows}'
        if items_str:
            header += f' -i "{items_str}"'
        elif items_file:
            header += f' --items-file {items_file}'
        if orient == 'horizontal':
            header += ' --orient horizontal'

    if tokens:
        header += ' ' + ' '.join(fedmake__requote(t) for t in tokens)
    return header


def fedmake_emit_say(say, attr_tokens):
    """Return SAY line, or None if HIDDEN."""
    tokens = list(attr_tokens)
    hidden, tokens = fedmake_take_flag(tokens, 'HIDDEN')
    if hidden:
        return None
    color, tokens = fedmake_take_value(tokens, 'COLOR')
    escaped = say['text'].replace('\\', '\\\\').replace('"', '\\"')
    line = f'@{say["row"]},{say["col"]} SAY "{escaped}"'
    if color:
        line += f' COLOR {color}'
    return line


def fedmake__requote(tok):
    """Re-quote a token if it contains spaces or shell-y chars."""
    if any(c.isspace() for c in tok):
        escaped = tok.replace('\\', '\\\\').replace('"', '\\"')
        return f'"{escaped}"'
    return tok


# ============================================================
# Top-level emission
# ============================================================

def fedmake_emit_def(layout_lines, attr_lines):
    fields, says = fedmake_parse_layout(layout_lines)
    field_attrs, say_attrs, pre_cmds, post_cmds = fedmake_parse_attributes(attr_lines, fields, says)

    out = []
    for cmd in pre_cmds:
        out.append(cmd)
    if pre_cmds:
        out.append('')

    say_lines = []
    for s in sorted(says, key=lambda s: (s['row'], s['col'])):
        line = fedmake_emit_say(s, say_attrs.get(s['slug'], []))
        if line:
            say_lines.append(line)
    out.extend(say_lines)
    if say_lines:
        out.append('')

    for f in sorted(fields, key=lambda f: (f['row'], f['col'])):
        out.append(fedmake_emit_field(f, field_attrs.get(id(f), [])))

    if post_cmds:
        out.append('')
        for cmd in post_cmds:
            out.append(cmd)

    return '\n'.join(out) + '\n'


def fedmake_emit_json(layout_lines, attr_lines):
    fields, says = fedmake_parse_layout(layout_lines)
    field_attrs, say_attrs, pre_cmds, post_cmds = fedmake_parse_attributes(attr_lines, fields, says)

    elements = []
    for s in sorted(says, key=lambda s: (s['row'], s['col'])):
        tokens = list(say_attrs.get(s['slug'], []))
        hidden, tokens = fedmake_take_flag(tokens, 'HIDDEN')
        if hidden:
            continue
        color, tokens = fedmake_take_value(tokens, 'COLOR')
        elem = {'type': 'say', 'row': s['row'], 'col': s['col'], 'text': s['text']}
        if color:
            elem['color'] = color
        elements.append(elem)

    for f in sorted(fields, key=lambda f: (f['row'], f['col'])):
        elem = fedmake__json_field(f, field_attrs.get(id(f), []))
        elements.append(elem)

    out = {'elements': elements}
    if pre_cmds:
        out['pre_commands'] = [fedmake__json_cmd(c) for c in pre_cmds]
    if post_cmds:
        out['post_commands'] = [fedmake__json_cmd(c) for c in post_cmds]
    return json.dumps(out, indent=2) + '\n'


def fedmake__json_field(field, attr_tokens):
    tokens = list(attr_tokens)
    ftype = fedmake_determine_type(field, tokens)
    _, tokens = fedmake_take_value(tokens, '--type')
    mask_spec, tokens = fedmake_take_value(tokens, '--mask')
    maxlen_str, tokens = fedmake_take_value(tokens, '--maxlen')
    rows_str, tokens = fedmake_take_value(tokens, '--rows')
    items_str, tokens = fedmake_take_value(tokens, '--items')
    items_file, tokens = fedmake_take_value(tokens, '--items-file')
    v_scroll_str, tokens = fedmake_take_value(tokens, '--v-scroll')
    word_wrap_str, tokens = fedmake_take_value(tokens, '--word-wrap')

    width = field['width']
    name = field['name']
    elem = {'type': ftype, 'row': field['row'], 'col': field['col'], 'name': name}

    if ftype == 'line':
        elem['width'] = width
        elem['maxlen'] = int(maxlen_str) if maxlen_str else width
    elif ftype == 'area':
        rows = int(rows_str) if rows_str else (
            1 + len(field['cont_rows']) if field['cont_rows'] else 3
        )
        elem['width'] = width
        elem['rows'] = rows
        elem['maxlen'] = int(maxlen_str) if maxlen_str else max(500, width * rows * 4)
        elem['v_scroll'] = int(v_scroll_str) if v_scroll_str else 1
        elem['word_wrap'] = int(word_wrap_str) if word_wrap_str else 1
    elif ftype == 'mask':
        elem['mask'] = mask_spec
    elif ftype == 'list':
        rows = int(rows_str) if rows_str else (
            1 + len(field['cont_rows']) if field['cont_rows'] else 5
        )
        elem['width'] = width
        elem['rows'] = rows
        if items_str:
            elem['items'] = items_str.split('|')
        elif items_file:
            elem['items_file'] = items_file
    elif ftype in ('check', 'radio'):
        elem['width'] = width
        orient_str, tokens = fedmake_take_value(tokens, '--orient')
        orient = orient_str or ('vertical' if field['cont_rows'] else 'horizontal')
        if ftype == 'check' and orient == 'vertical':
            elem['rows'] = int(rows_str) if rows_str else (1 + len(field['cont_rows']))
        if orient == 'horizontal':
            elem['orient'] = 'horizontal'
        if items_str:
            elem['items'] = items_str.split('|')
        elif items_file:
            elem['items_file'] = items_file

    # Stash remaining tokens as a free-form 'extra' key — JSON consumers may ignore
    if tokens:
        elem['extra'] = tokens
    return elem


def fedmake__json_cmd(line):
    tokens = fedmake_tokenize_line(line)
    head = tokens[0].upper()
    cmd = {'type': head}
    if head in ('CLS', 'CLEAR'):
        if len(tokens) > 1:
            cmd['color'] = tokens[1]
    elif head == 'CURSOR':
        m = re.match(r'^(\d+)\s*,\s*(\d+)$', tokens[1]) if len(tokens) > 1 else None
        if m:
            cmd['row'] = int(m.group(1))
            cmd['col'] = int(m.group(2))
    return cmd


# ============================================================
# CLI entry
# ============================================================

def fedmake_run():
    args = sys.argv[1:]
    fedshared.early_dispatch(args, 'fedmake', '10.1F5x7q', fedmake_USAGE, fedmake_HELP)

    json_mode = False
    positional = []
    i = 0
    while i < len(args):
        a = args[i]
        if a in ('-j', '--json'):
            json_mode = True
            i += 1
        else:
            positional.append(a)
            i += 1

    if not positional:
        print(fedmake_USAGE, file=sys.stderr)
        sys.exit(1)

    mockup = positional[0]
    if mockup == '-':
        text = sys.stdin.read()
    else:
        with open(mockup) as f:
            text = f.read()

    layout_lines, attr_lines = fedmake_split_sections(text)
    if json_mode:
        sys.stdout.write(fedmake_emit_json(layout_lines, attr_lines))
    else:
        sys.stdout.write(fedmake_emit_def(layout_lines, attr_lines))

# ================================================================
# fedsay  ->  fed say   (top-level names prefixed fedsay_)
# ================================================================
# fedsay.py - positioned, colored text output for fed-field-editor scripts
#
# Unlike the field editors, fedsay runs NO curses session. It emits ANSI
# escape sequences to stdout and exits — the non-interactive companion that
# draws the labels and decoration around inline fields. Think "tput cup +
# setaf/setab, but speaking fed color names" so a script never mixes fed
# color vocabulary with raw terminfo/ANSI codes.



fedsay_USAGE = """\
Usage: fed say <row> <col> [text] [options]
Try --help for full documentation."""

fedsay_HELP = """\
fedsay - Positioned, colored text output (the SAY companion to the editors)

Usage: fed say <row> <col> [text] [options]

Arguments:
  row, col    Cursor position for the text (0-indexed). Either may be '@'
              to mean the terminal's current cursor position.
  text        The text to write. Optional (omit it for a bare --cls).

Options:
  --color fg,bg   Text color. ANSI names (black red green yellow blue
                  magenta cyan white), optional 'bright-' prefix, or
                  'default'. Same vocabulary as the editors' --color.
  -r, --reverse   Reverse video for the text.
  --cls [fg,bg]   Clear the whole screen first. With fg,bg, paint the screen
                  in those colors and leave them as the active screen color
                  (which the system 'clear' cannot do); subsequent output
                  inherits them. Without a color, a plain clear.
  --keep          Save the cursor before writing and restore it afterward, so
                  drawing a label does not disturb the cursor's position.
  -h              Print brief usage and exit.
  --help          Print full help text and exit.
  --version       Print version and copyright information and exit.

fedsay writes ANSI escapes to stdout and is used inline, like tput: run it
for its effect on the screen. It is NOT a field — it captures no input and
follows no exit-reason protocol. Exit 0 on success, 1 on a usage error.

Examples:
  fed say 0 0 "Name:" --color bright-white,blue
  fed say 2 4 "Saved." --color green,default --keep
  fed say 0 0 --cls white,blue        # paint the screen, then clear"""


# ============================================================
# Output assembly
# ============================================================

def fedsay_build_output(row, col, text, color, reverse, cls_present, cls_color, keep):
    """Assemble the ANSI byte stream fedsay writes to stdout.

    Order matters: save cursor (if --keep) before any movement so the saved
    state is the caller's; set the screen color then clear (so the cleared
    cells take the screen background); move; draw the text in its own color;
    restore the SGR to the screen color (if --cls set one) or to default;
    finally restore the cursor (if --keep), which also restores the caller's
    original SGR attributes."""
    parts = []

    if keep:
        parts.append('\0337')              # DECSC: save cursor + attrs

    if cls_present:
        if cls_color:
            parts.append(fedshared.sgr_for(cls_color))
        parts.append('\033[2J')            # clear (fills with current bg)

    parts.append(f'\033[{row + 1};{col + 1}H')  # CUP (1-indexed)

    text_sgr = fedshared.sgr_for(color, reverse)
    if text_sgr:
        parts.append(text_sgr)
    parts.append(text)

    # Return the SGR to a sensible resting state for subsequent caller output:
    # the screen color if --cls set one, otherwise plain default.
    if cls_present and cls_color:
        parts.append(fedshared.sgr_for(cls_color))
    else:
        parts.append('\033[0m')

    if keep:
        parts.append('\0338')              # DECRC: restore cursor + attrs

    return ''.join(parts)


# ============================================================
# Argument parsing
# ============================================================

def fedsay_parse_args(args):
    """Return (row_raw, col_raw, text, color, reverse, cls_present, cls_color,
    keep) or raise ValueError on a usage error."""
    positional = []
    color = None
    reverse = False
    cls_present = False
    cls_color = None
    keep = False

    i = 0
    while i < len(args):
        a = args[i]
        if a == '--color':
            if i + 1 >= len(args):
                raise ValueError("--color requires a fg,bg argument")
            color = args[i + 1]
            i += 2
        elif a in ('-r', '--reverse'):
            reverse = True
            i += 1
        elif a in ('--cls', '--clear'):
            cls_present = True
            # Optional fg,bg value: consume the next token only if it looks
            # like a color spec (contains a comma, not another flag).
            if i + 1 < len(args) and ',' in args[i + 1] and not args[i + 1].startswith('-'):
                cls_color = args[i + 1]
                i += 2
            else:
                i += 1
        elif a == '--keep':
            keep = True
            i += 1
        else:
            positional.append(a)
            i += 1

    if len(positional) < 2:
        raise ValueError("row and col are required")
    if len(positional) > 3:
        raise ValueError(f"too many arguments: {positional[3:]}")

    row_raw = positional[0]
    col_raw = positional[1]
    text = positional[2] if len(positional) == 3 else ''

    # Validate colors eagerly so a bad name fails before any output.
    if color is not None:
        fedshared.parse_color(color)
    if cls_color is not None:
        fedshared.parse_color(cls_color)

    return row_raw, col_raw, text, color, reverse, cls_present, cls_color, keep


def fedsay_run():
    args = sys.argv[1:]
    fedshared.early_dispatch(args, 'fedsay', '4.1F5x7q', fedsay_USAGE, fedsay_HELP)

    try:
        (row_raw, col_raw, text, color, reverse,
         cls_present, cls_color, keep) = fedsay_parse_args(args)
        row, col = fedshared.resolve_pos(row_raw, col_raw)
    except ValueError as e:
        print(f"fedsay: {e}", file=sys.stderr)
        print(fedsay_USAGE, file=sys.stderr)
        sys.exit(1)

    out = fedsay_build_output(row, col, text, color, reverse,
                        cls_present, cls_color, keep)
    sys.stdout.write(out)
    sys.stdout.flush()
    sys.exit(0)

# ================================================================
# fedbox  ->  fed box   (top-level names prefixed fedbox_)
# ================================================================
# fedbox.py - box / frame / panel drawing for fed-field-editor scripts
#
# Like fedsay, fedbox runs NO curses session: it emits ANSI escape sequences to
# stdout and exits. It is the non-interactive FRAME companion to fedsay's TEXT —
# a script draws panels and dividers with fedbox, labels them with fedsay, and
# fills them with the inline editors, all landing on the same terminal. It is
# NOT a field: no input, no exit-reason protocol; exit 0 on success, 1 on a
# usage error. Buttons are deliberately out of scope (that is feddialog
# territory) — compose with `fedradio --orient horizontal` for a button bar.



fedbox_USAGE = """\
Usage: fed box <row> <col> <rows> <cols> [options]
Try --help for full documentation."""

fedbox_HELP = """\
fedbox - Box / frame / panel drawing (the FRAME companion to fedsay)

Usage: fed box <row> <col> <rows> <cols> [options]

Arguments:
  row, col    Top-left corner (0-indexed). Either may be '@' to mean the
              terminal's current cursor position.
  rows        Box height in terminal rows, including both borders (min 2).
  cols        Box width in terminal columns, including both borders (min 2).

Options:
  --style SPEC      A named style: single (default), double, ascii, solid,
                    half-in, half-out -- OR a 3-character custom string
                    horz+vert+corner (e.g. '=:o'). The half styles draw a
                    half-block frame hugging the interior (half-in) or the
                    exterior (half-out).
  --fill [CHAR]     Fill the interior with CHAR (default space). Omit --fill
                    for a frame only. First char is used; a single '-' is ok.
                    A space fill shows only with a background color.
  --color SPEC      Base color for ALL elements. SPEC is 'fg,bg' (fed color
                    names, optional 'bright-' prefix, or 'default') or 'reverse'.
  --border-color SPEC   Override the border (corners + edges) color.
  --title-color SPEC    Override the top-title text color.
  --status-color SPEC   Override the bottom-status text color.
  --fill-color SPEC     Override the interior fill color.
  -r, --reverse     Alias for --color reverse.
  --title TEXT      Centered title on the top border (= --center-title).
  --left-title TEXT, --center-title TEXT, --right-title TEXT
                    Place titles on the top border. Text is verbatim (no
                    padding) -- add your own brackets/spacing if you want them.
  --status TEXT     Centered status on the bottom border (= --center-status).
  --left-status TEXT, --center-status TEXT, --right-status TEXT
                    Place status text on the bottom border (verbatim).
  --keep            Save the cursor before drawing and restore it afterward.
  -h                Print brief usage and exit.
  --help            Print full help text and exit.
  --version         Print version and copyright information and exit.

fedbox writes ANSI escapes to stdout and is used inline, like tput: run it for
its effect on the screen. It runs no curses session and does not clear or
restore the screen. Without --keep, the cursor is left at the box's interior
origin (row+1,col+1), ready to write the first line of content.

Examples:
  fed box 2 4 5 30
  fed box 0 0 10 40 --style double --fill --color white,blue --title "[ Settings ]"
  fed box 0 0 8 40 --color white,blue --fill --title "Account" --title-color bright-yellow,blue
  fed box 0 0 5 24 --style '=:o'
  fed box 3 3 6 24 --style half-in --fill ."""


# ============================================================
# Border styles
# ============================================================

# Each named style supplies four corners plus the per-edge glyphs (top/bottom
# horizontals, left/right verticals). For the non-half styles top==bottom and
# left==right; the half styles differ per edge so the ink lands on the correct
# half of each cell. Half-block corners are quadrant glyphs chosen so the joins
# are continuous (see local/facts/specs/fedbox-spec.md SS IV).
fedbox_STYLES = {
    'single':   dict(TL='┌', TR='┐', BL='└', BR='┘', top='─', bottom='─', left='│', right='│'),
    'double':   dict(TL='╔', TR='╗', BL='╚', BR='╝', top='═', bottom='═', left='║', right='║'),
    'ascii':    dict(TL='+', TR='+', BL='+', BR='+', top='-', bottom='-', left='|', right='|'),
    'solid':    dict(TL='█', TR='█', BL='█', BR='█', top='█', bottom='█', left='█', right='█'),
    'half-in':  dict(TL='▗', TR='▖', BL='▝', BR='▘', top='▄', bottom='▀', left='▐', right='▌'),
    'half-out': dict(TL='▛', TR='▜', BL='▙', BR='▟', top='▀', bottom='▄', left='▌', right='▐'),
}


def fedbox_style_glyphs(style):
    """Resolve a --style value to a glyph dict. A named style, or a 3-character
    custom string horz+vert+corner. Raises ValueError otherwise."""
    if style in fedbox_STYLES:
        return fedbox_STYLES[style]
    if len(style) == 3:
        h, v, c = style[0], style[1], style[2]
        return dict(TL=c, TR=c, BL=c, BR=c, top=h, bottom=h, left=v, right=v)
    raise ValueError(f"unknown style: {style!r} (a name or 3-char 'horz+vert+corner')")


# ============================================================
# Title / status overlay
# ============================================================

def fedbox__fit(text, avail):
    """Truncate `text` to `avail` cells, ending with an ellipsis when clipped."""
    if avail <= 0:
        return ''
    if len(text) <= avail:
        return text
    if avail == 1:
        return '…'
    return text[:avail - 1] + '…'


def fedbox__overlay_segments(run_width, hch, slots):
    """Build the `run_width`-cell horizontal run (border glyph `hch`) with
    left/center/right title text overlaid verbatim, and return it as a list of
    (text, kind) segments where kind is 'b' (border) or 't' (title). On
    collision, precedence is left > right > center."""
    chars = [hch] * run_width
    kinds = ['b'] * run_width

    def place(text, start):
        for k, ch in enumerate(text):
            if 0 <= start + k < run_width:
                chars[start + k] = ch
                kinds[start + k] = 't'

    left, center, right = slots.get('left'), slots.get('center'), slots.get('right')
    used_left = used_right = 0
    if left:
        t = fedbox__fit(left, run_width)
        place(t, 0)
        used_left = len(t)
    if right:
        t = fedbox__fit(right, run_width - used_left)
        place(t, run_width - len(t))
        used_right = len(t)
    if center:
        avail = run_width - used_left - used_right
        t = fedbox__fit(center, avail)
        place(t, used_left + (avail - len(t)) // 2)

    # Coalesce consecutive same-kind cells into segments.
    segs = []
    for ch, kind in zip(chars, kinds):
        if segs and segs[-1][1] == kind:
            segs[-1][0] += ch
        else:
            segs.append([ch, kind])
    return [(text, kind) for text, kind in segs]


# ============================================================
# Output assembly
# ============================================================

def fedbox__sgr(spec):
    """SGR for an element color spec: None -> '', 'reverse' -> reverse video,
    else a 'fg,bg' pair via fedshared."""
    if not spec:
        return ''
    if spec == 'reverse':
        return '\033[7m'
    return fedshared.sgr_for(spec, False)


def fedbox_build_box(row, col, rows, cols, glyphs, fill_char, colors, keep, titles, statuses):
    """Assemble the ANSI byte stream fedbox writes to stdout. Each element group
    (border, title, status, fill) is colored independently: an element uses its
    own override if set, else the base --color. SGR is (re)emitted only at color
    boundaries, so same-colored runs stay contiguous and colors never bleed."""
    g = glyphs
    run_width = cols - 2
    base = colors.get('base')
    border_spec = colors['border'] if colors.get('border') is not None else base
    title_spec = colors['title'] if colors.get('title') is not None else base
    status_spec = colors['status'] if colors.get('status') is not None else base
    fill_spec = colors['fill'] if colors.get('fill') is not None else base

    parts = []
    state = {'cur': '\x00'}   # sentinel != any real spec (None or str)

    def emit(text, spec):
        if spec != state['cur']:
            parts.append('\033[0m')
            s = fedbox__sgr(spec)
            if s:
                parts.append(s)
            state['cur'] = spec
        parts.append(text)

    if keep:
        parts.append('\0337')                       # DECSC: save cursor + attrs

    # Top border, with any titles spliced in.
    parts.append(f'\033[{row + 1};{col + 1}H')
    emit(g['TL'], border_spec)
    for text, kind in fedbox__overlay_segments(run_width, g['top'], titles):
        emit(text, title_spec if kind == 't' else border_spec)
    emit(g['TR'], border_spec)

    # Interior rows.
    for r in range(1, rows - 1):
        rr = row + r
        if fill_char is not None and run_width > 0:
            parts.append(f'\033[{rr + 1};{col + 1}H')
            emit(g['left'], border_spec)
            emit(fill_char * run_width, fill_spec)
            emit(g['right'], border_spec)
        else:
            parts.append(f'\033[{rr + 1};{col + 1}H')
            emit(g['left'], border_spec)
            parts.append(f'\033[{rr + 1};{col + cols}H')   # right border column
            emit(g['right'], border_spec)

    # Bottom border, with any status spliced in.
    parts.append(f'\033[{row + rows};{col + 1}H')
    emit(g['BL'], border_spec)
    for text, kind in fedbox__overlay_segments(run_width, g['bottom'], statuses):
        emit(text, status_spec if kind == 't' else border_spec)
    emit(g['BR'], border_spec)

    parts.append('\033[0m')                         # reset SGR for caller output

    if keep:
        parts.append('\0338')                       # DECRC: restore cursor + attrs
    elif rows > 2 and cols > 2:
        parts.append(f'\033[{row + 2};{col + 2}H')  # interior origin
    else:
        parts.append(f'\033[{row + 1};{col + 1}H')  # no interior: box origin

    return ''.join(parts)


# ============================================================
# Argument parsing
# ============================================================

fedbox__TITLE_FLAGS = {
    '--title': ('titles', 'center'),
    '--left-title': ('titles', 'left'),
    '--center-title': ('titles', 'center'),
    '--right-title': ('titles', 'right'),
    '--status': ('statuses', 'center'),
    '--left-status': ('statuses', 'left'),
    '--center-status': ('statuses', 'center'),
    '--right-status': ('statuses', 'right'),
}

fedbox__COLOR_FLAGS = {
    '--color': 'base',
    '--border-color': 'border',
    '--title-color': 'title',
    '--status-color': 'status',
    '--fill-color': 'fill',
}


def fedbox_parse_args(args):
    """Return a dict of parsed options, or raise ValueError on a usage error."""
    positional = []
    style = 'single'
    fill_char = None
    colors = {'base': None, 'border': None, 'title': None, 'status': None, 'fill': None}
    keep = False
    titles = {'left': None, 'center': None, 'right': None}
    statuses = {'left': None, 'center': None, 'right': None}

    i = 0
    while i < len(args):
        a = args[i]
        if a == '--style':
            if i + 1 >= len(args):
                raise ValueError("--style requires a name or 3-char custom string")
            style = args[i + 1]
            i += 2
        elif a == '--fill':
            # Optional fill char: take the next token unless it is a flag.
            # A lone '-' is allowed as a fill char; default is a space.
            nxt = args[i + 1] if i + 1 < len(args) else None
            if nxt is not None and (nxt == '-' or not nxt.startswith('-')):
                fill_char = nxt[:1] or ' '
                i += 2
            else:
                fill_char = ' '
                i += 1
        elif a in fedbox__COLOR_FLAGS:
            if i + 1 >= len(args):
                raise ValueError(f"{a} requires a fg,bg or 'reverse' argument")
            colors[fedbox__COLOR_FLAGS[a]] = args[i + 1]
            i += 2
        elif a in ('-r', '--reverse'):
            colors['base'] = 'reverse'
            i += 1
        elif a == '--keep':
            keep = True
            i += 1
        elif a in fedbox__TITLE_FLAGS:
            if i + 1 >= len(args):
                raise ValueError(f"{a} requires text")
            group, slot = fedbox__TITLE_FLAGS[a]
            (titles if group == 'titles' else statuses)[slot] = args[i + 1]
            i += 2
        else:
            positional.append(a)
            i += 1

    if len(positional) < 4:
        raise ValueError("row, col, rows and cols are required")
    if len(positional) > 4:
        raise ValueError(f"too many arguments: {positional[4:]}")

    glyphs = fedbox_style_glyphs(style)   # validates the style (named or 3-char custom)

    row_raw, col_raw = positional[0], positional[1]
    try:
        rows = int(positional[2])
        cols = int(positional[3])
    except ValueError:
        raise ValueError("rows and cols must be integers")
    if rows < 2:
        raise ValueError("rows must be >= 2")
    if cols < 2:
        raise ValueError("cols must be >= 2")

    # Validate colors eagerly so a bad name fails before any output.
    for spec in colors.values():
        if spec is not None and spec != 'reverse':
            fedshared.parse_color(spec)

    return dict(row_raw=row_raw, col_raw=col_raw, rows=rows, cols=cols,
                glyphs=glyphs, fill_char=fill_char, colors=colors,
                keep=keep, titles=titles, statuses=statuses)


def fedbox_run():
    args = sys.argv[1:]
    fedshared.early_dispatch(args, 'fedbox', '2.1F6u1h', fedbox_USAGE, fedbox_HELP)

    try:
        p = fedbox_parse_args(args)
        row, col = fedshared.resolve_pos(p['row_raw'], p['col_raw'])
    except ValueError as e:
        print(f"fedbox: {e}", file=sys.stderr)
        print(fedbox_USAGE, file=sys.stderr)
        sys.exit(1)

    out = fedbox_build_box(row, col, p['rows'], p['cols'], p['glyphs'], p['fill_char'],
                    p['colors'], p['keep'], p['titles'], p['statuses'])
    sys.stdout.write(out)
    sys.stdout.flush()
    sys.exit(0)

# ================================================================
# Dispatch:  fed <tool> [options]
# ================================================================
BUNDLE_VERSION = '2.1F7kY0'
TOOL_VERSIONS = {
    'line': '15.1ETwS6',
    'area': '17.1ETwS6',
    'mask': '10.1ETwS6',
    'list': '7.1ETwS6',
    'check': '9.1ETwS6',
    'radio': '7.1F6r6P',
    'form': '21.1ETwS6',
    'make': '10.1F5x7q',
    'say': '4.1F5x7q',
    'box': '2.1F6u1h',
}
_RUN = {
    'line': fedline_run,
    'area': fedarea_run,
    'mask': fedmask_run,
    'list': fedlist_run,
    'check': fedcheck_run,
    'radio': fedradio_run,
    'form': fedform_run,
    'make': fedmake_run,
    'say': fedsay_run,
    'box': fedbox_run,
}


def _tool_list():
    return " ".join(TOOL_VERSIONS)


def _top_usage(stream=sys.stderr):
    stream.write("usage: fed <tool> [options]\n")
    stream.write("tools: " + _tool_list() + "\n")
    stream.write("       fed --version | --versions | -h | --help\n")


def _print_versions():
    print("fed bundle  b" + BUNDLE_VERSION)
    for _k in TOOL_VERSIONS:
        print("  %-6s  b%s" % (_k, TOOL_VERSIONS[_k]))


def main():
    argv = sys.argv[1:]
    if not argv:
        _top_usage()
        sys.exit(1)
    first = argv[0]
    if first == "--version":
        print("fed (C) 2026 smisco\nfed@smisco.biz\nb" + BUNDLE_VERSION)
        sys.exit(0)
    if first == "--versions":
        _print_versions()
        sys.exit(0)
    if first in ("-h", "--help"):
        _top_usage(sys.stdout)
        sys.exit(0)
    if first.startswith("-"):
        sys.stderr.write("fed: unknown option %r\n" % first)
        _top_usage()
        sys.exit(1)
    run = _RUN.get(first)
    if run is None:
        sys.stderr.write("fed: unknown tool %r\n" % first)
        _top_usage()
        sys.exit(2)
    sys.argv = ["fed-" + first] + argv[1:]   # tool reads args = sys.argv[1:]
    run()


if __name__ == "__main__":
    main()

