diff options
| author | historia <historiavg@proton.me> | 2026-08-24 14:49:17 -0400 |
|---|---|---|
| committer | historia <historiavg@proton.me> | 2026-08-24 14:49:17 -0400 |
| commit | 7ee1d4bb63c12982ec4900ec870ad96baba4b22b (patch) | |
| tree | 3f4261201b1bf05546c7c9241f7f3bcb4ea42b5e /app/ui | |
| parent | e7a3d65f68659d17f37b79e8bfefea19d7ac0648 (diff) | |
| download | tts-audiobook-generator-7ee1d4bb63c12982ec4900ec870ad96baba4b22b.tar.gz | |
feat: combine wizard menus into a single generate options menu
Diffstat (limited to 'app/ui')
| -rw-r--r-- | app/ui/hub.py | 388 | ||||
| -rw-r--r-- | app/ui/tui.py | 143 |
2 files changed, 319 insertions, 212 deletions
diff --git a/app/ui/hub.py b/app/ui/hub.py index 0b599d0..b233d1b 100644 --- a/app/ui/hub.py +++ b/app/ui/hub.py @@ -31,6 +31,7 @@ from backends import ( ) from backends import audiocpp as audiocpp_backend from backends import faster as faster_backend +from backends import qwen as qwen_backend from converter import config from converter.converter import AUDIO_FORMATS from converter.tts import ( @@ -218,7 +219,7 @@ def _convert_menu(stdscr, statuses) -> Optional[tuple]: if key is _GO_BACK or key is None: return None if key == BACKEND_AUDIOCPP: - cmd = _convert_audiocpp(stdscr, statuses) + cmd = _convert_audiocpp(stdscr) elif key == BACKEND_QWEN: cmd = _convert_qwen(stdscr) elif key == BACKEND_FASTER: @@ -227,17 +228,75 @@ def _convert_menu(stdscr, statuses) -> Optional[tuple]: return None if cmd is None: return None - _add_autostart(stdscr, cmd, statuses) + _add_autostart(cmd, statuses) return cmd -def _convert_audiocpp(stdscr, statuses) -> Optional[tuple]: - """Collect audio.cpp run settings for a managed or remote server. +# Sentinel value the audio.cpp Voice field uses for "no --voice" (the +# built-in CustomVoice speaker); mapped to None when the form returns. +_AUDIOCPP_BUILTIN_SPEAKER = "(built-in speaker)" - With a local checkout configured (its server.json), the menus are fed - from that file — the config of the server this tool manages. Without - one, the running server is external and nothing is known about it - locally, so its model and voice lists are queried live instead (the + +def _field_value(fields, key: str, default=None): + """Current value of the field named KEY, or DEFAULT when absent.""" + for field in fields: + if field.get("key") == key: + return field["value"] + return default + + +def _common_fields() -> list: + """Field dicts for output format, speed, single-file, and debug. + + The single-file field is hidden for m4b (always a single file with + embedded chapter markers), so its "visible" callable reads the live + output-format value from the field list. + """ + fmt_default = config.AUDIO_FORMAT \ + if config.AUDIO_FORMAT in AUDIO_FORMATS else AUDIO_FORMATS[0] + return [ + {"key": "output_format", "label": "Output format", "kind": "choice", + "value": fmt_default, "choices": list(AUDIO_FORMATS)}, + {"key": "speed", "label": "Speed", "kind": "text", "value": "1.0", + "validate": lambda s: None if (_is_float(s) and float(s) > 0) + else "Enter a positive number, e.g. 1.0"}, + {"key": "single_file", "label": "Combine all chapters", + "kind": "bool", "value": False, + "visible": lambda fs: _field_value(fs, "output_format") != "m4b"}, + {"key": "debug", "label": "Debug", "kind": "bool", "value": False}, + ] + + +def _common_kwargs(values: dict) -> dict: + """Map the common form fields to converter keyword arguments.""" + output_format = values["output_format"] + return { + "output_format": output_format, + "speed": float(values["speed"]), + "single_file": bool(values["single_file"]) + and output_format != "m4b", + "debug": bool(values["debug"]), + } + + +def _show_convert_form(stdscr, title: str, fields: list) -> Optional[dict]: + """Show the single conversion form (Generate!/Cancel, focus on + Generate!) and return its values, or None/_GO_BACK to go back.""" + result = tui.form(stdscr, title, fields, + buttons=("Generate!", "Cancel"), + start_on_buttons=True, back_value=_GO_BACK) + if result is None or result is _GO_BACK: + return None + return result + + +def _convert_audiocpp(stdscr) -> Optional[tuple]: + """Collect audio.cpp run settings on one form. + + With a local checkout configured (its server.json), the model list is + fed from that file — the config of the server this tool manages. + Without one, the running server is external and nothing is known about + it locally, so its models and voices are queried live instead (the same GET /v1/models and GET /v1/audio/voices endpoints the converter resolves at run time). """ @@ -273,128 +332,146 @@ def _convert_audiocpp(stdscr, statuses) -> Optional[tuple]: return None data = {} - model_options = [(f"{m.get('id')} ({m.get('family') or '?'}, " - f"{m.get('task') or 'tts'})", m.get("id")) - for m in models] - model_id = tui.menu(stdscr, "Select the audio.cpp model to use", - model_options, back_value=_GO_BACK) - if model_id is _GO_BACK or model_id is None: - return None - entry = next((m for m in models if m.get("id") == model_id), {}) - family = entry.get("family") - task = entry.get("task", "tts") - if not local: - # Servers predating the family/task fields omit them; mirror the - # converter's defaults (_resolve_family/_resolve_task): unknown - # family means qwen3_tts, a missing task plain tts. - family = family or AUDIOCPP_FAMILY_QWEN3_TTS - task = task or "tts" - - def _voices() -> Optional[list]: - """Voice names for MODEL_ID, or None when a remote server's voice - list cannot be queried. Locally: the voice_dir's .wav stems; - remotely: GET /v1/audio/voices.""" + # Normalize each entry so the form logic sees a family/task always. + models = [dict(m) for m in models] + for entry in models: + entry["family"] = entry.get("family") or "" + entry["task"] = entry.get("task") or "tts" + if not local and not entry["family"]: + # Servers predating the family field omit it; mirror the + # converter's default: unknown family means qwen3_tts. + entry["family"] = AUDIOCPP_FAMILY_QWEN3_TTS + + local_voices = _list_voices(data.get("voice_dir")) \ + if data.get("voice_dir") else [] + voice_cache: dict = {} # model id -> voices (local: shared list) + + def voices_for(model_id: str) -> list: if local: - voice_dir = data.get("voice_dir") - return _list_voices(voice_dir) if voice_dir else [] - return audiocpp_backend.fetch_server_voices(url, model_id) - - # Voice: optional for qwen3_tts (built-in speaker), required otherwise. - voice = None - if task == "vdes": - # Voice design: no voice, instructions required. - pass - elif family == AUDIOCPP_FAMILY_QWEN3_TTS: - # Speaker mode available; voice optional. - voices = _voices() or [] - if voices: - opts = [("(built-in speaker)", None)] + [(v, v) for v in voices] - voice = tui.menu(stdscr, "Voice", opts, back_value=_GO_BACK) - if voice is _GO_BACK: - return None + return local_voices + if model_id not in voice_cache: + fetched = audiocpp_backend.fetch_server_voices(url, model_id) + voice_cache[model_id] = fetched or [] + return voice_cache[model_id] + + def model_entry(fields): + model_id = _field_value(fields, "model_id") + return next((m for m in models if m.get("id") == model_id), + models[0]) + + def model_task(fields) -> str: + return model_entry(fields).get("task", "tts") + + def model_family(fields) -> str: + return model_entry(fields).get("family") or "" + + def reset_voice(fields) -> None: + """Re-point the Voice field at the newly selected model's voice.""" + voice_field = next(f for f in fields if f.get("key") == "voice") + if model_task(fields) == "vdes": + voice_field["value"] = None + elif model_family(fields) == AUDIOCPP_FAMILY_QWEN3_TTS: + voice_field["value"] = _AUDIOCPP_BUILTIN_SPEAKER else: - voice = None - else: - voices = _voices() - if voices is None: - tui.flash(stdscr, f"Could not list voices from the audio.cpp " - f"server at {url}.") - return None - if not voices: - if local: - tui.flash(stdscr, f"This model needs a --voice but voice_dir " - f"{data.get('voice_dir')} has no .wav voices. " - "Reconfigure audio.cpp or add voices.") - else: - tui.flash(stdscr, f"This model needs a --voice but the " - f"server at {url} lists none for '{model_id}'. " - "Configure voice presets or a voice_dir on the " - "server.") - return None - voice = tui.menu(stdscr, "Select the voice to clone", [(v, v) for v in voices], - back_value=_GO_BACK) - if voice is _GO_BACK or voice is None: - return None + voices = voices_for(_field_value(fields, "model_id")) + voice_field["value"] = voices[0] if voices else "" + + def voice_choices(fields) -> list: + if model_family(fields) == AUDIOCPP_FAMILY_QWEN3_TTS: + return [(_AUDIOCPP_BUILTIN_SPEAKER, _AUDIOCPP_BUILTIN_SPEAKER)] \ + + [(v, v) for v in voices_for(_field_value(fields, + "model_id"))] + return [(v, v) for v in voices_for(_field_value(fields, "model_id"))] + + model_ids = [m.get("id") for m in models] + default_model = config.AUDIOCPP_MODEL_ID \ + if config.AUDIOCPP_MODEL_ID in model_ids else model_ids[0] + default_entry = next((m for m in models if m.get("id") == default_model), + models[0]) + initial_voice = _AUDIOCPP_BUILTIN_SPEAKER + if default_entry.get("task") == "vdes": + initial_voice = None + elif default_entry.get("family") != AUDIOCPP_FAMILY_QWEN3_TTS: + initial = voices_for(default_model) + initial_voice = initial[0] if initial else "" - # Instructions: required for vdes, optional otherwise. - instructions = None - if task == "vdes": - instructions = tui.line_edit( - stdscr, "Voice design instructions (required for this model)", - config.AUDIOCPP_INSTRUCTIONS, - validate=lambda s: None if s.strip() - else "Describe the voice, e.g. 'A warm female narrator'", - back_value=_GO_BACK) - if instructions is _GO_BACK: - return None - else: - instructions = tui.line_edit( - stdscr, "Style instructions (optional, blank for none)", - config.AUDIOCPP_INSTRUCTIONS, back_value=_GO_BACK) - if instructions is _GO_BACK: - return None - if not instructions.strip(): - instructions = None + fields = [ + {"key": "model_id", "label": "Model", "kind": "choice", + "value": default_model, + "choices": [(f"{m.get('id')} ({m.get('family') or '?'}, " + f"{m.get('task') or 'tts'})", m.get("id")) + for m in models], + "on_change": reset_voice}, + {"key": "voice", "label": "Voice", "kind": "choice", + "value": initial_voice, + "choices": lambda fs: voice_choices(fs), + "visible": lambda fs: model_task(fs) != "vdes", + "validate": lambda value: None + if (model_family(fields) == AUDIOCPP_FAMILY_QWEN3_TTS or value) + else "This model needs a voice — pick one or switch models"}, + {"key": "instructions", "label": "Instructions", "kind": "text", + "value": config.AUDIOCPP_INSTRUCTIONS, + "validate": lambda value: None + if (model_task(fields) != "vdes" or str(value).strip()) + else "Describe the voice, e.g. 'A warm female narrator'"}, + ] + fields += _common_fields() - common_kw = _common_options(stdscr) - if common_kw is None: + result = _show_convert_form(stdscr, "Convert with audio.cpp", fields) + if result is None: return None + + model_id = result["model_id"] + voice = result["voice"] + if voice == _AUDIOCPP_BUILTIN_SPEAKER or not voice: + voice = None + entry = next((m for m in models if m.get("id") == model_id), {}) + if entry.get("task") == "vdes": + voice = None + instructions = (result["instructions"] or "").strip() or None return ("convert", BACKEND_AUDIOCPP, { "model_id": model_id, "voice": voice, "instructions": instructions, - **common_kw, + **_common_kwargs(result), }) def _convert_qwen(stdscr) -> Optional[tuple]: - """Collect qwen run settings: built-in speaker or clone a .wav.""" - mode = tui.menu( - stdscr, "qwen-tts mode", - [("Custom voice (built-in speaker)", "custom"), - ("Voice clone from a .wav file", "clone")], - back_value=_GO_BACK, - help_lines=[f"Speaker: {config.SPEAKER} (change it via Configure " - "qwen-tts)"]) - if mode is _GO_BACK or mode is None: - return None - clone = None - if mode == "clone": - clone = tui.line_edit( - stdscr, "Path to a reference .wav (10-15s is ideal)", - "", - validate=lambda s: None if (s and Path(s).is_file() + """Collect qwen run settings on one form: speaker or clone a .wav.""" + speakers = list(qwen_backend.QWEN_SPEAKERS) + default_speaker = config.SPEAKER if config.SPEAKER in speakers \ + else speakers[0] + fields = [ + {"key": "mode", "label": "Voice mode", "kind": "choice", + "value": "custom", + "choices": [("Built-in speaker", "custom"), + ("Clone from a .wav file", "clone")]}, + {"key": "speaker", "label": "Speaker", "kind": "choice", + "value": default_speaker, "choices": speakers, + "visible": lambda fs: _field_value(fs, "mode") == "custom"}, + {"key": "clone", "label": "Clone .wav path", "kind": "text", + "value": "", + "validate": lambda s: None if (s and Path(s).is_file() and s.lower().endswith(".wav")) - else "Enter the path to an existing .wav file", - back_value=_GO_BACK) - if clone is _GO_BACK: - return None - common_kw = _common_options(stdscr) - if common_kw is None: + else "Enter the path to an existing .wav file", + "visible": lambda fs: _field_value(fs, "mode") == "clone"}, + ] + fields += _common_fields() + result = _show_convert_form(stdscr, "Convert with qwen-tts", fields) + if result is None: return None - return ("convert", BACKEND_QWEN, {"clone": clone, **common_kw}) + clone = result["clone"].strip() if result["mode"] == "clone" else None + speaker = result["speaker"] + if result["mode"] == "custom" and speaker != config.SPEAKER: + # Persist the speaker choice for this and future runs (mirrors the + # qwen setup wizard), so the converter picks it up at request time. + common.update_config_value("SPEAKER", speaker) + config.SPEAKER = speaker + return ("convert", BACKEND_QWEN, {"clone": clone, + **_common_kwargs(result)}) def _convert_faster(stdscr) -> Optional[tuple]: - """Collect faster run settings: pick or type a voice name. + """Collect faster run settings on one form: pick or type a voice name. With a local checkout's voices.json the picker lists it (the config of the server this tool manages). Without one, the running server was @@ -416,60 +493,29 @@ def _convert_faster(stdscr) -> Optional[tuple]: return None if voices is None: # No local voices.json: prompt for a server-side voice name. - voice_text = tui.line_edit( - stdscr, "Server-side voice to clone with " - "(blank uses the server's first voice)", - config.FASTER_VOICE, - validate=lambda s: None if s.strip() else "Enter a voice name", - back_value=_GO_BACK) - if voice_text is _GO_BACK: - return None - voice = voice_text.strip() + fields = [ + {"key": "voice", "label": "Voice", "kind": "text", + "value": config.FASTER_VOICE, + "validate": lambda s: None if s.strip() else "Enter a voice name"}, + ] else: default = config.FASTER_VOICE if config.FASTER_VOICE in voices else \ next(iter(voices)) - voice = tui.menu( - stdscr, "Select the voice to clone", - [(k, k) for k in voices], - default_index=list(voices).index(default), back_value=_GO_BACK) - if voice is _GO_BACK or voice is None: - return None - common_kw = _common_options(stdscr) - if common_kw is None: - return None - return ("convert", BACKEND_FASTER, {"voice": voice, **common_kw}) - - -def _common_options(stdscr) -> Optional[dict]: - """Collect output format, speed, single-file, debug.""" - fmt_options = [(f, f) for f in AUDIO_FORMATS] - fmt_default = AUDIO_FORMATS.index(config.AUDIO_FORMAT) \ - if config.AUDIO_FORMAT in AUDIO_FORMATS else 0 - output_format = tui.menu(stdscr, "Output format", fmt_options, - default_index=fmt_default, back_value=_GO_BACK) - if output_format is _GO_BACK or output_format is None: - return None - speed_text = tui.line_edit( - stdscr, "Playback speed (1.0 = normal)", "1.0", - validate=lambda s: None if (_is_float(s) and float(s) > 0) - else "Enter a positive number, e.g. 1.0", - back_value=_GO_BACK) - if speed_text is _GO_BACK: + fields = [ + {"key": "voice", "label": "Voice", "kind": "choice", + "value": default, "choices": [(k, k) for k in voices]}, + ] + fields += _common_fields() + result = _show_convert_form(stdscr, "Convert with faster-qwen3-tts", + fields) + if result is None: return None - single_file = tui.confirm(stdscr, "Combine all chapters into one file?", - default=False, cancel_value=_GO_BACK) - if single_file is _GO_BACK: - return None - debug = tui.confirm(stdscr, "Debug mode (dump per-chunk audio/text)?", - default=False, cancel_value=_GO_BACK) - if debug is _GO_BACK: - return None - return { - "output_format": output_format, - "speed": float(speed_text), - "single_file": single_file, - "debug": debug, - } + voice = result["voice"].strip() if isinstance(result["voice"], str) \ + else result["voice"] + return ("convert", BACKEND_FASTER, { + "voice": voice or None, + **_common_kwargs(result), + }) # --------------------------------------------------------------------------- @@ -696,11 +742,13 @@ def _maybe_stop_server(name: str) -> None: servers.stop(name) -def _add_autostart(stdscr, cmd: tuple, statuses) -> None: - """Offer to auto-start the conversion's target server when it isn't running. +def _add_autostart(cmd: tuple, statuses) -> None: + """Auto-start the conversion's target server when it isn't running. Records the chosen server spec name as ``kwargs['autostart']`` for - ``_run_conversion`` to act on. Mode-aware for qwen (custom vs clone). + ``_run_conversion`` to act on. The user already accepted the run on the + Generate! screen, so no start-server prompt is asked here — the server + is simply started. Mode-aware for qwen (custom vs clone). """ _, key, kwargs = cmd status = next((s for s in statuses if s.key == key), None) @@ -711,11 +759,7 @@ def _add_autostart(stdscr, cmd: tuple, statuses) -> None: return if common.server_running(spec.url): return - choice = tui.confirm(stdscr, f"The {status.label} server is not running. " - "Start it automatically?", default=True, - cancel_value=False) - if choice is True: - kwargs["autostart"] = spec.name + kwargs["autostart"] = spec.name def _select_spec(status, kwargs) -> Optional[ServerSpec]: 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) # --------------------------------------------------------------------------- |
