aboutsummaryrefslogtreecommitdiff
path: root/app/ui/tui.py
diff options
context:
space:
mode:
Diffstat (limited to 'app/ui/tui.py')
-rw-r--r--app/ui/tui.py143
1 files changed, 103 insertions, 40 deletions
diff --git a/app/ui/tui.py b/app/ui/tui.py
index 6d768dd..83ec43d 100644
--- a/app/ui/tui.py
+++ b/app/ui/tui.py
@@ -744,13 +744,15 @@ def line_edit(scr, title: str, default: str,
# ---------------------------------------------------------------------------
-# Widget: multi-field settings form with Save/Cancel buttons
+# Widget: multi-field settings form with accept/cancel buttons
# ---------------------------------------------------------------------------
def form(scr, title: str, fields: Sequence[dict],
back_value: object = None,
- help_lines: Optional[Sequence[str]] = None) -> Optional[dict]:
- """Edit several labeled fields on one screen, then Save or Cancel.
+ help_lines: Optional[Sequence[str]] = None,
+ buttons: Sequence[str] = ("Save", "Cancel"),
+ start_on_buttons: bool = False) -> Optional[dict]:
+ """Edit several labeled fields on one screen, then accept or cancel.
FIELDS is a list of dicts, one per row, shaped like::
@@ -760,42 +762,82 @@ def form(scr, title: str, fields: Sequence[dict],
{"key": "chunk_size", "label": "Chunk size",
"kind": "text", "value": "250",
"validate": lambda s: None if s.isdigit() else "digits only"}
+ {"key": "combine", "label": "Combine chapters",
+ "kind": "bool", "value": False}
Fields render as a two-column table: each label is padded to the
- widest label so every value starts in the same column. An optional
- ``note`` string on a field renders as a dim, non-selectable line in
- a blank-line frame above that field's row — a section divider with a
- short explanation. Up/Down (or k/j) move the cursor; Enter on a
- ``choice`` row opens a single choice menu, Enter on a ``text`` row
- opens a line editor (reusing its VALIDATE for that one field). Tab,
- Left/Right, j or k at the ends of the list move focus to the
- Save/Cancel buttons — Down from the last field and Up from the first
- field both land on Save (the fields wrap onto the buttons); on the
- buttons, arrows/j/k/Tab return to the fields. Enter on Save validates
- every text field (the first failure flashes in red and re-focuses
- that row) and returns ``{key: value}``, Enter on Cancel returns
- BACK_VALUE. Esc (or 'q') returns BACK_VALUE / aborts as in menu().
- Values are edited in place in the FIELDS dicts, so Cancel simply
- discards them.
+ widest label so every value starts in the same column. KINDS:
+ ``choice`` opens a single choice menu (its ``choices`` may be a
+ callable of the field list, resolved when the menu opens); ``text``
+ opens a line editor (reusing its VALIDATE); ``bool`` shows Yes/No and
+ toggles in place on Enter or Space.
+
+ A field may set ``visible`` to a bool or a callable of the field
+ list; hidden fields are not drawn, are skipped by the cursor, and
+ keep their value across hide/show. A field may set ``on_change`` to
+ a callable of the field list, invoked whenever its value changes so
+ dependent fields (choices, visibility, defaults) can be recomputed.
+
+ An optional ``note`` string on a field renders as a dim,
+ non-selectable line in a blank-line frame above that field's row — a
+ section divider with a short explanation. Up/Down (or k/j) move the
+ cursor; Enter edits or toggles the highlighted field. Tab, Left/Right,
+ j or k at the ends of the list move focus to the BUTTONS — Down from
+ the last field and Up from the first field both land on the first
+ button (the fields wrap onto the buttons); from the buttons, Down/j/Tab
+ wrap back to the first field and Up/k/BTAB to the last, Left/Right
+ switch the buttons. Enter on the first button validates every visible
+ field that has a ``validate`` (the first failure flashes in red and
+ re-focuses that row) and returns ``{key: value}``, Enter on the second
+ button returns BACK_VALUE. Esc (or 'q') returns BACK_VALUE / aborts as
+ in menu(). Values are edited in place in the FIELDS dicts, so Cancel
+ simply discards them.
+
+ BUTTONS customizes the two button labels (default "Save"/"Cancel");
+ START_ON_BUTTONS puts the initial focus on the first button so Enter
+ accepts immediately.
"""
if not fields:
raise ValueError("form() needs at least one field")
frame = Frame(scr, title,
- "Up/Down = move Enter = edit Tab/arrows = Save/Cancel "
+ "Up/Down = move Enter = edit Tab/arrows = buttons "
"Esc = cancel")
cursor = 0
- on_buttons = False
+ on_buttons = start_on_buttons
btn_index = 0
edit_cancel = object() # sentinel: backed out of a field editor
label_w = max(len(field["label"]) for field in fields)
+
+ def shown_fields() -> List[dict]:
+ result: List[dict] = []
+ for field in fields:
+ visible = field.get("visible", True)
+ if callable(visible):
+ visible = visible(fields)
+ if visible:
+ result.append(field)
+ return result
+
+ def run_on_change(field: dict) -> None:
+ callback = field.get("on_change")
+ if callback is not None:
+ callback(fields)
+
+ def display_value(field: dict) -> str:
+ if field.get("kind") == "bool":
+ return "Yes" if field["value"] else "No"
+ return str(field["value"])
+
while True:
+ shown = shown_fields()
+ cursor = max(0, min(cursor, len(shown) - 1)) if shown else 0
frame.rows = []
for line in help_lines or []:
frame.mark(line, frame.theme["dim"])
if help_lines:
frame.mark("")
- field_rows: List[int] = [] # field index -> row index
- for field in fields:
+ field_rows: List[int] = [] # visible field index -> row index
+ for field in shown:
if field.get("note"):
frame.mark("")
frame.mark(field["note"], frame.theme["dim"], align="left")
@@ -804,10 +846,10 @@ def form(scr, title: str, fields: Sequence[dict],
name = f"{field['label']}:".ljust(label_w + 1)
frame.mark_segments(
[(name, frame.theme["body"]),
- (" " + field["value"], frame.theme["input"])],
+ (" " + display_value(field), frame.theme["input"])],
selectable=True, align="left")
frame.cursor = None if on_buttons else field_rows[cursor]
- frame.buttons = (["Save", "Cancel"], btn_index if on_buttons else None)
+ frame.buttons = (list(buttons), btn_index if on_buttons else None)
frame.draw()
curses = frame.curses
key = frame.get_key(cancel_keys=())
@@ -816,17 +858,22 @@ def form(scr, title: str, fields: Sequence[dict],
if key in _CANCEL_KEYS:
raise WizardCancelled()
if on_buttons:
- if key in (9, curses.KEY_BTAB, curses.KEY_UP, curses.KEY_DOWN,
- ord("j"), ord("k")):
+ if key in (curses.KEY_UP, ord("k"), curses.KEY_BTAB):
+ # Wrap up through the buttons onto the last field.
+ on_buttons = False
+ cursor = len(shown) - 1 if shown else 0
+ elif key in (curses.KEY_DOWN, ord("j"), 9):
+ # Wrap down through the buttons back to the first field.
on_buttons = False
+ cursor = 0
elif key in (curses.KEY_LEFT, curses.KEY_RIGHT,
ord("h"), ord("l")):
btn_index = 1 - btn_index
elif key in (10, 13):
- if btn_index == 0: # Save
- for index, field in enumerate(fields):
+ if btn_index == 0: # accept (Save / Generate!)
+ for index, field in enumerate(shown):
validate = field.get("validate")
- if field.get("kind") == "text" and validate:
+ if validate is not None:
error = validate(field["value"])
if error is not None:
on_buttons = False
@@ -840,32 +887,47 @@ def form(scr, title: str, fields: Sequence[dict],
return back_value
else:
if key in (curses.KEY_DOWN, ord("j")) \
- and cursor == len(fields) - 1:
+ and cursor == len(shown) - 1:
on_buttons = True
- btn_index = 0 # Save
+ btn_index = 0 # first button
elif key in (curses.KEY_UP, ord("k")) and cursor == 0:
on_buttons = True
- btn_index = 0 # Save (wraps around from the top)
+ btn_index = 0 # first button (wraps around from the top)
else:
- moved = frame.motion(key, cursor, len(fields), wrap=True)
+ moved = frame.motion(key, cursor, len(shown), wrap=True)
if moved is not None:
cursor = moved
elif key in (9, curses.KEY_BTAB, curses.KEY_LEFT,
curses.KEY_RIGHT, ord("h"), ord("l")):
on_buttons = True
btn_index = 0
+ elif key == ord(" ") and shown[cursor].get("kind") == "bool":
+ shown[cursor]["value"] = not bool(shown[cursor]["value"])
+ run_on_change(shown[cursor])
elif key in (10, 13):
- field = fields[cursor]
- if field.get("kind") == "choice":
- choices = list(field.get("choices") or [])
- default = choices.index(field["value"]) \
- if field["value"] in choices else 0
- chosen = menu(scr, field["label"],
- [(c, c) for c in choices],
+ field = shown[cursor]
+ if field.get("kind") == "bool":
+ field["value"] = not bool(field["value"])
+ run_on_change(field)
+ elif field.get("kind") == "choice":
+ choices = field.get("choices") or []
+ if callable(choices):
+ choices = choices(fields)
+ choices = list(choices)
+ if choices and isinstance(choices[0], (tuple, list)) \
+ and len(choices[0]) == 2:
+ pairs = [(label, value) for label, value in choices]
+ else:
+ pairs = [(c, c) for c in choices]
+ values = [value for _, value in pairs]
+ default = values.index(field["value"]) \
+ if field["value"] in values else 0
+ chosen = menu(scr, field["label"], pairs,
default_index=default,
back_value=edit_cancel)
if chosen is not edit_cancel:
field["value"] = chosen
+ run_on_change(field)
else:
edited = line_edit(scr, field["label"],
field["value"],
@@ -873,6 +935,7 @@ def form(scr, title: str, fields: Sequence[dict],
back_value=edit_cancel)
if edited is not edit_cancel:
field["value"] = edited
+ run_on_change(field)
# ---------------------------------------------------------------------------