aboutsummaryrefslogtreecommitdiff
path: root/ui
diff options
context:
space:
mode:
Diffstat (limited to 'ui')
-rw-r--r--ui/hub.py103
-rw-r--r--ui/tui.py106
2 files changed, 209 insertions, 0 deletions
diff --git a/ui/hub.py b/ui/hub.py
index f3e2b7e..b2a2b08 100644
--- a/ui/hub.py
+++ b/ui/hub.py
@@ -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).
diff --git a/ui/tui.py b/ui/tui.py
index c73fb09..9047f36 100644
--- a/ui/tui.py
+++ b/ui/tui.py
@@ -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
# ---------------------------------------------------------------------------