diff options
Diffstat (limited to 'ui')
| -rw-r--r-- | ui/hub.py | 103 | ||||
| -rw-r--r-- | ui/tui.py | 106 |
2 files changed, 209 insertions, 0 deletions
@@ -13,6 +13,7 @@ main menu. """ import json +import re from pathlib import Path from typing import Optional, Tuple @@ -35,6 +36,7 @@ from converter.tts import ( BACKEND_AUDIOCPP, BACKEND_FASTER, BACKEND_QWEN, + normalize_language, ) from ui import tui @@ -79,6 +81,7 @@ def _hub_menu(stdscr) -> Optional[tuple]: options.insert(0, ("Convert books...", "convert")) options.append(("Configure a backend...", "configure")) options.append(("Server...", "server")) + options.append(("Settings...", "settings")) options.append(("Quit", "quit")) rows = [(st.label, *_status_mark(st)) for st in statuses] choice = tui.menu( @@ -102,6 +105,8 @@ def _hub_menu(stdscr) -> Optional[tuple]: cmd = _server_menu(stdscr, statuses) if cmd is not None: return cmd + elif choice == "settings": + _settings_menu(stdscr) def _setup_menu(stdscr, statuses) -> Optional[tuple]: @@ -373,6 +378,104 @@ def _common_options(stdscr) -> Optional[dict]: } +# --------------------------------------------------------------------------- +# Settings menu (global output options -> converter/config.py) +# --------------------------------------------------------------------------- + +def _settings_menu(stdscr) -> None: + """Edit the global output settings; Save writes them back to config.py.""" + fields = [ + {"key": "audio_format", "label": "Audio format", "kind": "choice", + "value": config.AUDIO_FORMAT, "choices": list(AUDIO_FORMATS)}, + {"key": "audio_bitrate", "label": "Audio bitrate", "kind": "text", + "value": config.AUDIO_BITRATE, + "validate": _validate_bitrate}, + {"key": "language", "label": "Language", "kind": "text", + "value": config.LANGUAGE, "validate": _validate_language}, + {"key": "chunk_size", "label": "Chunk size (words)", "kind": "text", + "value": str(config.CHUNK_SIZE), "validate": _validate_chunk_size}, + ] + result = tui.form(stdscr, "Settings", fields, back_value=_GO_BACK) + if result is None or result is _GO_BACK: + return + try: + _apply_settings(result) + except ValueError as exc: + tui.flash(stdscr, str(exc), "err") + return + tui.flash(stdscr, "Settings saved.", "ok") + + +def _validate_bitrate(value: str) -> Optional[str]: + """Error message for a blank audio bitrate, or None to accept it.""" + if value.strip(): + return None + return "Audio bitrate must not be empty" + + +def _validate_language(value: str) -> Optional[str]: + """Error message for an unrecognized LANGUAGE, or None to accept it.""" + try: + normalize_language(value) + return None + except ValueError as exc: + return str(exc) + + +def _validate_chunk_size(value: str) -> Optional[str]: + """Error message for an invalid CHUNK_SIZE, or None to accept it.""" + try: + number = int(value.strip()) + except ValueError: + return "Enter a whole number of words, e.g. 250" + if number < 1: + return "Chunk size must be at least 1" + return None + + +def _apply_settings(values: dict) -> None: + """Write VALUES to converter/config.py and reload them in-memory.""" + chunk_size = int(values["chunk_size"].strip()) + if chunk_size < 1: + raise ValueError("Chunk size must be at least 1") + bitrate = values["audio_bitrate"].strip() + if not bitrate: + raise ValueError("Audio bitrate must not be empty") + if values["audio_format"] not in AUDIO_FORMATS: + raise ValueError(f"Unsupported audio format: {values['audio_format']}") + updates = { + "AUDIO_FORMAT": values["audio_format"], + "AUDIO_BITRATE": bitrate, + "LANGUAGE": normalize_language(values["language"]), + "CHUNK_SIZE": chunk_size, + } + _write_config(updates) + for name, value in updates.items(): + setattr(config, name, value) + + +def _write_config(updates: dict) -> None: + """Rewrite the ``NAME = value`` lines for UPDATES in converter/config.py. + + Only the value of each named assignment changes: the indentation, the + quotes (double, matching the file's style) and any trailing comment on + the line are preserved. Every other line is left untouched. + """ + path = Path(config.__file__).resolve() + text = path.read_text(encoding="utf-8") + for name, value in updates.items(): + rendered = str(value) if isinstance(value, int) else f'"{value}"' + pattern = re.compile( + rf"^(\s*{re.escape(name)}\s*=\s*)(\S*)(\s*(#.*))?$", + re.MULTILINE) + text, count = pattern.subn( + lambda m, rendered=rendered: + f"{m.group(1)}{rendered}{m.group(3) or ''}", text) + if count != 1: + raise ValueError(f"Could not find {name} in {path}") + path.write_text(text, encoding="utf-8") + + def _run_conversion(backend: str, kwargs: dict) -> None: """Run a conversion in the plain console (after the TUI returns). @@ -726,6 +726,112 @@ def line_edit(scr, title: str, default: str, # --------------------------------------------------------------------------- +# Widget: multi-field settings form with Save/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. + + FIELDS is a list of dicts, one per row, shaped like:: + + {"key": "audio_format", "label": "Audio format", + "kind": "choice", "value": "m4b", + "choices": ["mp3", "m4b", "ogg", "flac"]} + {"key": "chunk_size", "label": "Chunk size", + "kind": "text", "value": "250", + "validate": lambda s: None if s.isdigit() else "digits only"} + + Each field renders as a left-justified ``Label: value`` row. 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 or the arrow keys move focus to the + Save/Cancel buttons; 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. + """ + if not fields: + raise ValueError("form() needs at least one field") + frame = Frame(scr, title, + "Up/Down = move Enter = edit Tab = Save/Cancel " + "Esc = cancel") + cursor = 0 + on_buttons = False + btn_index = 0 + edit_cancel = object() # sentinel: backed out of a field editor + while True: + frame.rows = [] + for line in help_lines or []: + frame.mark(line, frame.theme["dim"]) + if help_lines: + frame.mark("") + base = len(frame.rows) + for field in fields: + frame.mark(f"{field['label']}: {field['value']}", + selectable=True, align="left") + frame.cursor = None if on_buttons else base + cursor + frame.buttons = (["Save", "Cancel"], btn_index if on_buttons else None) + frame.draw() + curses = frame.curses + key = frame.get_key(cancel_keys=()) + if key == 27 and back_value is not None: + return back_value + if key in _CANCEL_KEYS: + raise WizardCancelled() + if on_buttons: + if key in (9, curses.KEY_BTAB, curses.KEY_UP, curses.KEY_DOWN): + on_buttons = False + 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): + validate = field.get("validate") + if field.get("kind") == "text" and validate: + error = validate(field["value"]) + if error is not None: + on_buttons = False + cursor = index + frame.flash(error, "err") + break + else: + return {field["key"]: field["value"] + for field in fields} + else: # Cancel + return back_value + else: + moved = frame.motion(key, cursor, len(fields), 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 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], + default_index=default, + back_value=edit_cancel) + if chosen is not edit_cancel: + field["value"] = chosen + else: + edited = line_edit(scr, field["label"], field["value"], + validate=field.get("validate"), + back_value=edit_cancel) + if edited is not edit_cancel: + field["value"] = edited + + +# --------------------------------------------------------------------------- # Widget: directory browser # --------------------------------------------------------------------------- |
