aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--tests/test_hub.py204
-rw-r--r--tests/test_tui.py76
-rw-r--r--ui/hub.py103
-rw-r--r--ui/tui.py106
4 files changed, 478 insertions, 11 deletions
diff --git a/tests/test_hub.py b/tests/test_hub.py
index 21f795e..b3a17e8 100644
--- a/tests/test_hub.py
+++ b/tests/test_hub.py
@@ -71,14 +71,14 @@ class HubMenuTests(unittest.TestCase):
return BackendStatus(key, label, installed=False, configured=False)
def test_quit_returns_none_when_no_backend(self):
- # No backends installed/running: menu is [Set up, Quit]. Quit is the
- # 2nd option (Down once) then Enter.
- screen = FakeScreen(keys=[FakeCurses.KEY_DOWN, 10])
+ # No backends installed/running: menu is [Set up, Settings, Quit].
+ # Quit is the 3rd option (Down twice) then Enter.
+ screen = FakeScreen(keys=[FakeCurses.KEY_DOWN, FakeCurses.KEY_DOWN, 10])
with patch.object(hub, "detect_all", return_value=[]):
result = hub._hub_menu(screen)
self.assertIsNone(result)
- def test_menu_has_only_setup_and_quit_without_backends(self):
+ def test_menu_has_only_setup_settings_and_quit_without_backends(self):
# Capture the options handed to tui.menu: with nothing installed or
# running, Convert/Configure must be absent.
captured = {}
@@ -92,9 +92,10 @@ class HubMenuTests(unittest.TestCase):
patch.object(hub, "detect_all", return_value=[]):
hub._hub_menu(screen)
labels = [label for label, _ in captured["options"]]
- self.assertEqual(labels, ["Set up a backend...", "Quit"])
+ self.assertEqual(labels, ["Set up a backend...", "Settings...",
+ "Quit"])
- def test_menu_has_all_five_when_one_installed(self):
+ def test_menu_has_all_six_when_one_installed(self):
captured = {}
def fake_menu(stdscr, title, options, **kwargs):
@@ -112,7 +113,7 @@ class HubMenuTests(unittest.TestCase):
self.assertEqual(
labels,
["Convert books...", "Set up a backend...",
- "Configure a backend...", "Server...", "Quit"])
+ "Configure a backend...", "Server...", "Settings...", "Quit"])
# The status table is passed through, one row per backend.
self.assertEqual(captured["rows"],
[("qwen-tts", "installed", "warn", "body")])
@@ -138,7 +139,7 @@ class HubMenuTests(unittest.TestCase):
[("audio.cpp", "unavailable", "err", "dim"),
("qwen-tts", "running", "ok", "body")])
- def test_menu_has_all_five_when_one_running_only(self):
+ def test_menu_has_all_six_when_one_running_only(self):
# Running but not installed (an external server) still unlocks the
# Convert/Configure/Server entries.
captured = {}
@@ -157,7 +158,7 @@ class HubMenuTests(unittest.TestCase):
self.assertEqual(
labels,
["Convert books...", "Set up a backend...",
- "Configure a backend...", "Server...", "Quit"])
+ "Configure a backend...", "Server...", "Settings...", "Quit"])
def test_convert_with_no_available_backend_offers_setup(self):
# One installed-but-not-ready backend → Convert is offered. The
@@ -182,11 +183,12 @@ class HubMenuTests(unittest.TestCase):
with patch.object(hub, "detect_all", return_value=statuses), \
patch.object(hub, "REGISTRY", infos):
# Convert(Enter), setup-entry(Enter), Esc on setup menu,
- # back at main menu -> Down x4 -> Enter (Quit).
+ # back at main menu -> Down x5 -> Enter (Quit; Settings sits
+ # just before it).
screen = FakeScreen(keys=[10, 10, 27,
FakeCurses.KEY_DOWN, FakeCurses.KEY_DOWN,
FakeCurses.KEY_DOWN, FakeCurses.KEY_DOWN,
- 10])
+ FakeCurses.KEY_DOWN, 10])
result = hub._hub_menu(screen)
self.assertIsNone(result)
@@ -316,5 +318,185 @@ class AddAutostartTests(unittest.TestCase):
self.assertNotIn("autostart", cmd[2])
+class SettingsTests(unittest.TestCase):
+ """Settings menu: field collection, validation, config.py writing."""
+
+ def test_write_config_preserves_comments_and_other_lines(self):
+ import tempfile
+ with tempfile.TemporaryDirectory() as td:
+ path = Path(td) / "config.py"
+ path.write_text(
+ "# Default output options\n"
+ 'AUDIO_FORMAT = "m4b"\n'
+ 'AUDIO_BITRATE = "128k"\n'
+ 'LANGUAGE = "English"\n'
+ "\n"
+ "CHUNK_SIZE = 250 # words per request\n",
+ encoding="utf-8")
+ with patch.object(hub.config, "__file__", str(path)):
+ hub._write_config({"AUDIO_FORMAT": "mp3",
+ "AUDIO_BITRATE": "192k",
+ "LANGUAGE": "Japanese",
+ "CHUNK_SIZE": 300})
+ text = path.read_text(encoding="utf-8")
+ self.assertEqual(
+ text,
+ "# Default output options\n"
+ 'AUDIO_FORMAT = "mp3"\n'
+ 'AUDIO_BITRATE = "192k"\n'
+ 'LANGUAGE = "Japanese"\n'
+ "\n"
+ "CHUNK_SIZE = 300 # words per request\n")
+
+ def test_write_config_missing_key_raises(self):
+ import tempfile
+ with tempfile.TemporaryDirectory() as td:
+ path = Path(td) / "config.py"
+ path.write_text("X = 1\n", encoding="utf-8")
+ with patch.object(hub.config, "__file__", str(path)):
+ with self.assertRaises(ValueError):
+ hub._write_config({"AUDIO_FORMAT": "mp3"})
+
+ def test_apply_settings_writes_and_reloads_in_memory(self):
+ written = {}
+
+ def fake_write(updates):
+ written.update(updates)
+
+ original = {name: getattr(hub.config, name) for name in
+ ("AUDIO_FORMAT", "AUDIO_BITRATE", "LANGUAGE",
+ "CHUNK_SIZE")}
+ self.addCleanup(lambda: [setattr(hub.config, name, value)
+ for name, value in original.items()])
+ values = {"audio_format": "ogg", "audio_bitrate": " 192k ",
+ "language": "en", "chunk_size": "300"}
+ with patch.object(hub, "_write_config", fake_write):
+ hub._apply_settings(values)
+ # Values are trimmed and language normalized to a display name.
+ self.assertEqual(written, {"AUDIO_FORMAT": "ogg",
+ "AUDIO_BITRATE": "192k",
+ "LANGUAGE": "English",
+ "CHUNK_SIZE": 300})
+ # In-memory config is reloaded so this session sees the change.
+ self.assertEqual(hub.config.AUDIO_FORMAT, "ogg")
+ self.assertEqual(hub.config.AUDIO_BITRATE, "192k")
+ self.assertEqual(hub.config.LANGUAGE, "English")
+ self.assertEqual(hub.config.CHUNK_SIZE, 300)
+
+ def test_apply_settings_rejects_bad_values(self):
+ original = {name: getattr(hub.config, name) for name in
+ ("AUDIO_FORMAT", "AUDIO_BITRATE", "LANGUAGE",
+ "CHUNK_SIZE")}
+ self.addCleanup(lambda: [setattr(hub.config, name, value)
+ for name, value in original.items()])
+ with patch.object(hub, "_write_config") as mk_write:
+ with self.assertRaises(ValueError):
+ hub._apply_settings({"audio_format": "m4b",
+ "audio_bitrate": "128k",
+ "language": "Klingon",
+ "chunk_size": "250"})
+ with self.assertRaises(ValueError):
+ hub._apply_settings({"audio_format": "m4b",
+ "audio_bitrate": "128k",
+ "language": "English",
+ "chunk_size": "0"})
+ mk_write.assert_not_called()
+
+ def test_field_validators(self):
+ self.assertIsNone(hub._validate_bitrate("128k"))
+ self.assertIsNotNone(hub._validate_bitrate(" "))
+ self.assertIsNone(hub._validate_language("English"))
+ self.assertIsNone(hub._validate_language("en"))
+ self.assertIsNotNone(hub._validate_language("Klingon"))
+ self.assertIsNone(hub._validate_chunk_size("250"))
+ self.assertIsNotNone(hub._validate_chunk_size("abc"))
+ self.assertIsNotNone(hub._validate_chunk_size("0"))
+
+ def test_settings_menu_builds_form_and_saves(self):
+ captured = {}
+
+ def fake_form(stdscr, title, fields, back_value=None):
+ captured["fields"] = fields
+ return {"audio_format": "ogg", "audio_bitrate": "192k",
+ "language": "English", "chunk_size": "300"}
+
+ applied = []
+
+ def fake_apply(values):
+ applied.append(values)
+
+ def fake_flash(stdscr, text, kind="warn"):
+ captured["flash"] = (text, kind)
+
+ with patch.object(hub.tui, "form", fake_form), \
+ patch.object(hub, "_apply_settings", fake_apply), \
+ patch.object(hub.tui, "flash", fake_flash):
+ hub._settings_menu(None)
+ self.assertEqual([f["key"] for f in captured["fields"]],
+ ["audio_format", "audio_bitrate", "language",
+ "chunk_size"])
+ kinds = {f["key"]: f["kind"] for f in captured["fields"]}
+ self.assertEqual(kinds["audio_format"], "choice")
+ self.assertEqual(kinds["audio_bitrate"], "text")
+ self.assertEqual(applied, [{"audio_format": "ogg",
+ "audio_bitrate": "192k",
+ "language": "English",
+ "chunk_size": "300"}])
+ self.assertEqual(captured["flash"], ("Settings saved.", "ok"))
+
+ def test_settings_menu_cancel_does_not_apply(self):
+ def fake_form(stdscr, title, fields, back_value=None):
+ return back_value # user pressed Cancel
+
+ applied = []
+
+ def fake_apply(values):
+ applied.append(values)
+
+ with patch.object(hub.tui, "form", fake_form), \
+ patch.object(hub, "_apply_settings", fake_apply):
+ hub._settings_menu(None)
+ self.assertEqual(applied, [])
+
+ def test_settings_menu_writes_config_end_to_end(self):
+ import tempfile
+ tui._THEME.clear()
+ self.addCleanup(tui._THEME.clear)
+ curses = FakeCurses()
+ patcher = patch.dict("sys.modules", {"curses": curses})
+ patcher.start()
+ self.addCleanup(patcher.stop)
+
+ original = {name: getattr(hub.config, name) for name in
+ ("AUDIO_FORMAT", "AUDIO_BITRATE", "LANGUAGE",
+ "CHUNK_SIZE")}
+ self.addCleanup(lambda: [setattr(hub.config, name, value)
+ for name, value in original.items()])
+
+ with tempfile.TemporaryDirectory() as td:
+ path = Path(td) / "config.py"
+ path.write_text(
+ "# Default output options\n"
+ 'AUDIO_FORMAT = "m4b"\n'
+ 'AUDIO_BITRATE = "128k"\n'
+ 'LANGUAGE = "English"\n'
+ "\n"
+ "CHUNK_SIZE = 250\n",
+ encoding="utf-8")
+ with patch.object(hub.config, "__file__", str(path)):
+ # Down to Chunk size, Enter -> editor, Ctrl-U + '300',
+ # Enter; Tab -> Save, Enter; a key dismisses the flash.
+ screen = FakeScreen(keys=[
+ FakeCurses.KEY_DOWN, FakeCurses.KEY_DOWN,
+ FakeCurses.KEY_DOWN, 10, 21, ord("3"), ord("0"),
+ ord("0"), 10, 9, 10, 10])
+ hub._settings_menu(screen)
+ text = path.read_text(encoding="utf-8")
+ self.assertIn('AUDIO_FORMAT = "m4b"', text)
+ self.assertIn("CHUNK_SIZE = 300", text)
+ # The running session also picked up the change in-memory.
+ self.assertEqual(hub.config.CHUNK_SIZE, 300)
+
+
if __name__ == "__main__":
unittest.main()
diff --git a/tests/test_tui.py b/tests/test_tui.py
index 58d8273..c121d55 100644
--- a/tests/test_tui.py
+++ b/tests/test_tui.py
@@ -365,6 +365,82 @@ class LineEditTests(TuiTestCase):
self.assertEqual(value, "teq")
+class FormTests(TuiTestCase):
+ def _fields(self):
+ # Fresh dicts each call: form() edits field values in place, and a
+ # shared class attribute would leak edits between tests.
+ return [
+ {"key": "fmt", "label": "Format", "kind": "choice",
+ "value": "m4b", "choices": ["mp3", "m4b", "ogg"]},
+ {"key": "chunk", "label": "Chunk", "kind": "text",
+ "value": "250"},
+ ]
+
+ def test_save_returns_current_values(self):
+ screen = FakeScreen(keys=[9, 10]) # Tab -> Save, Enter
+ result = tui.form(screen, "Settings", self._fields())
+ self.assertEqual(result, {"fmt": "m4b", "chunk": "250"})
+
+ def test_cancel_returns_back_value(self):
+ marker = object()
+ # Tab -> buttons, Left -> Cancel, Enter.
+ screen = FakeScreen(keys=[9, FakeCurses.KEY_LEFT, 10])
+ result = tui.form(screen, "Settings", self._fields(),
+ back_value=marker)
+ self.assertIs(result, marker)
+
+ def test_esc_returns_back_value(self):
+ marker = object()
+ screen = FakeScreen(keys=[27])
+ self.assertIs(
+ tui.form(screen, "Settings", self._fields(), back_value=marker),
+ marker)
+
+ def test_q_still_aborts_with_back_value(self):
+ marker = object()
+ screen = FakeScreen(keys=[ord("q")])
+ with self.assertRaises(tui.WizardCancelled):
+ tui.form(screen, "Settings", self._fields(), back_value=marker)
+
+ def test_choice_field_picks_another_value(self):
+ # Enter opens the choice menu on 'm4b' (index 1); Down moves to
+ # 'ogg', Enter accepts; Tab -> Save, Enter.
+ screen = FakeScreen(keys=[10, FakeCurses.KEY_DOWN, 10, 9, 10])
+ result = tui.form(screen, "Settings", self._fields())
+ self.assertEqual(result, {"fmt": "ogg", "chunk": "250"})
+
+ def test_text_field_edits_then_saves(self):
+ # Down to the text row, Enter opens the editor, type 'x', Enter,
+ # then Tab -> Save, Enter.
+ screen = FakeScreen(
+ keys=[FakeCurses.KEY_DOWN, 10, ord("x"), 10, 9, 10])
+ result = tui.form(screen, "Settings", self._fields())
+ self.assertEqual(result, {"fmt": "m4b", "chunk": "250x"})
+
+ def test_validation_error_refuses_save_then_recovers(self):
+ fields = [
+ {"key": "chunk", "label": "Chunk", "kind": "text", "value": "bad",
+ "validate": lambda s: None if s.isdigit() else "digits only"},
+ {"key": "fmt", "label": "Format", "kind": "text", "value": "x"},
+ ]
+ # Tab->Save(Enter) fails, the flash consumes the next key; Enter
+ # reopens the editor, Ctrl-U clears 'bad', type '120', Enter; then
+ # Tab->Save(Enter).
+ keys = [9, 10, 10, 10, 21, ord("1"), ord("2"), ord("0"), 10, 9, 10]
+ screen = FakeScreen(keys=keys)
+ result = tui.form(screen, "Settings", fields)
+ self.assertEqual(result, {"chunk": "120", "fmt": "x"})
+
+ def test_rows_left_justified_inside_the_border(self):
+ screen = FakeScreen(keys=[9, 10])
+ tui.form(screen, "Settings", self._fields())
+ self.assert_inside_border(screen)
+
+ def test_empty_fields_rejected(self):
+ with self.assertRaises(ValueError):
+ tui.form(self.screen, "Settings", [])
+
+
def _accept_audio_cpp(entry: Path):
"""auto_select callback that accepts an 'audio.cpp' checkout root."""
if entry.name == "audio.cpp" and (entry / "model_specs").is_dir():
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
# ---------------------------------------------------------------------------