From 7ee1d4bb63c12982ec4900ec870ad96baba4b22b Mon Sep 17 00:00:00 2001 From: historia Date: Mon, 24 Aug 2026 14:49:17 -0400 Subject: feat: combine wizard menus into a single generate options menu --- app/ui/tui.py | 143 ++++++++++++++++++++++++++++++++++++++++++---------------- 1 file changed, 103 insertions(+), 40 deletions(-) (limited to 'app/ui/tui.py') 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) # --------------------------------------------------------------------------- -- cgit v1.2.3