aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorhistoria <historiavg@proton.me>2026-08-25 21:02:21 -0400
committerhistoria <historiavg@proton.me>2026-08-25 21:02:21 -0400
commit4c3e78c39c81d2997e88b84e1b5a25b244e870e4 (patch)
tree92c8df5cf5cf240da00d481df1d82105b69feb22
parentd453d88d1d40c5e54af07ceb89c8c18d09cfdb0e (diff)
downloadtts-audiobook-generator-4c3e78c39c81d2997e88b84e1b5a25b244e870e4.tar.gz
feat: prompt to save settings when going back from settings menu
-rw-r--r--app/tests/test_hub.py70
-rw-r--r--app/tests/test_tui.py35
-rw-r--r--app/ui/hub.py32
-rw-r--r--app/ui/tui.py35
4 files changed, 159 insertions, 13 deletions
diff --git a/app/tests/test_hub.py b/app/tests/test_hub.py
index 982df1d..f350072 100644
--- a/app/tests/test_hub.py
+++ b/app/tests/test_hub.py
@@ -723,7 +723,10 @@ class ConvertFlowTests(unittest.TestCase):
self._patch_remote(
[{"id": "higgs", "family": "higgs_audio_tts", "task": "tts"}],
voices=["narrator"])
- with patch.object(hub.config, "AUDIOCPP_INSTRUCTIONS", ""):
+ # Pin the seeded toggle so this test does not depend on the user's
+ # saved STOP_SERVER_AND_EXIT value in config.py.
+ with patch.object(hub.config, "STOP_SERVER_AND_EXIT", True), \
+ patch.object(hub.config, "AUDIOCPP_INSTRUCTIONS", ""):
self._answer_form(backend="audiocpp-remote", model_id="higgs",
audiocpp_voice="narrator", instructions="",
speed="1.5")
@@ -1711,11 +1714,12 @@ class SettingsTests(unittest.TestCase):
"qwen_clone_port": "7861",
"faster_port": "8000",
"audiocpp_port": "8080"}])
- self.assertEqual(captured["flash"], ("Settings saved.", "ok"))
+ # Saving is silent: no confirmation flash either way.
+ self.assertNotIn("flash", captured)
def test_settings_menu_cancel_does_not_apply(self):
def fake_form(stdscr, title, fields, back_value=None):
- return back_value # user pressed Cancel
+ return back_value # user pressed Cancel / q / Esc
applied = []
@@ -1723,10 +1727,70 @@ class SettingsTests(unittest.TestCase):
applied.append(values)
with patch.object(hub.tui, "form", fake_form), \
+ patch.object(hub.tui, "confirm_yn_cancel",
+ return_value="no") as mk_prompt, \
patch.object(hub, "_apply_settings", fake_apply):
hub._Hub(None).screen_settings()
+ mk_prompt.assert_called_once_with(None, "Save settings?")
self.assertEqual(applied, [])
+ def test_settings_menu_exit_yes_applies_the_edited_fields(self):
+ # Leaving via Esc and answering Yes applies a values dict built
+ # from the (edited) field list.
+ def fake_form(stdscr, title, fields, back_value=None):
+ next(f for f in fields if f["key"] == "chunk_size")["value"] = \
+ "300"
+ return back_value # leave without the Save button
+
+ applied = []
+
+ with patch.object(hub.tui, "form", fake_form), \
+ patch.object(hub.tui, "confirm_yn_cancel",
+ return_value="yes"), \
+ patch.object(hub, "_apply_settings",
+ lambda values: applied.append(values)):
+ hub._Hub(None).screen_settings()
+ self.assertEqual(len(applied), 1)
+ self.assertEqual(applied[0]["chunk_size"], "300")
+ # Every settings field's value travels on the dict.
+ self.assertIn("stop_and_exit", applied[0])
+ self.assertIn("audiocpp_port", applied[0])
+
+ def test_settings_menu_exit_cancel_reopens_the_form(self):
+ # "Cancel" on the save prompt returns to the form with edits kept;
+ # leaving through Save afterwards applies once.
+ seen = []
+ fields_seen = []
+
+ def fake_form(stdscr, title, fields, back_value=None):
+ seen.append(title)
+ fields_seen.append(fields)
+ if len(seen) == 1:
+ next(f for f in fields
+ if f["key"] == "chunk_size")["value"] = "300"
+ return back_value # first exit: Esc
+ return {"audio_format": "m4b", "audio_bitrate": "128k",
+ "language": "English", "chunk_size": "250",
+ "stop_and_exit": True, "unload_models": True,
+ "qwen_custom_port": "7860", "qwen_clone_port": "7861",
+ "faster_port": "8000", "audiocpp_port": "8080"}
+
+ applied = []
+
+ with patch.object(hub.tui, "form", fake_form), \
+ patch.object(hub.tui, "confirm_yn_cancel",
+ side_effect=["cancel"]), \
+ patch.object(hub, "_apply_settings",
+ lambda values: applied.append(values)):
+ hub._Hub(None).screen_settings()
+ # The form reopened with the same field objects (edits intact).
+ self.assertEqual(len(seen), 2)
+ self.assertIs(fields_seen[0], fields_seen[1])
+ self.assertEqual(
+ next(f for f in fields_seen[1]
+ if f["key"] == "chunk_size")["value"], "300")
+ self.assertEqual(len(applied), 1)
+
def test_settings_menu_writes_config_end_to_end(self):
import tempfile
tui._THEME.clear()
diff --git a/app/tests/test_tui.py b/app/tests/test_tui.py
index 1b7af86..c2cd267 100644
--- a/app/tests/test_tui.py
+++ b/app/tests/test_tui.py
@@ -440,6 +440,41 @@ class ConfirmTests(TuiTestCase):
marker)
+class ConfirmYnCancelTests(TuiTestCase):
+ def test_enter_takes_the_default_yes(self):
+ screen = FakeScreen(keys=[10])
+ self.assertEqual(tui.confirm_yn_cancel(screen, "Save settings?"),
+ "yes")
+
+ def test_tab_enter_selects_no_then_cancel(self):
+ screen = FakeScreen(keys=[9, 10])
+ self.assertEqual(tui.confirm_yn_cancel(screen, "Save settings?"),
+ "no")
+ screen = FakeScreen(keys=[9, 9, 10])
+ self.assertEqual(tui.confirm_yn_cancel(screen, "Save settings?"),
+ "cancel")
+
+ def test_buttons_wrap_around(self):
+ # Moving back from Yes lands on Cancel.
+ screen = FakeScreen(keys=[FakeCurses.KEY_LEFT, 10])
+ self.assertEqual(tui.confirm_yn_cancel(screen, "Save settings?"),
+ "cancel")
+
+ def test_y_and_n_answer_directly(self):
+ screen = FakeScreen(keys=[ord("y")])
+ self.assertEqual(tui.confirm_yn_cancel(screen, "Save settings?"),
+ "yes")
+ screen = FakeScreen(keys=[ord("n")])
+ self.assertEqual(tui.confirm_yn_cancel(screen, "Save settings?"),
+ "no")
+
+ def test_esc_and_q_count_as_cancel(self):
+ for key in (27, ord("q")):
+ screen = FakeScreen(keys=[key])
+ self.assertEqual(
+ tui.confirm_yn_cancel(screen, "Save settings?"), "cancel")
+
+
class LineEditTests(TuiTestCase):
def test_typing_backspace_and_enter(self):
keys = [ord("c"), ord("d"), FakeCurses.KEY_BACKSPACE, 10]
diff --git a/app/ui/hub.py b/app/ui/hub.py
index cad9a9d..de8b861 100644
--- a/app/ui/hub.py
+++ b/app/ui/hub.py
@@ -405,17 +405,29 @@ class _Hub:
# -- settings -------------------------------------------------------
def screen_settings(self):
- result = tui.form(self.stdscr, "Settings", _settings_fields(),
- back_value=tui.Wizard.BACK)
- if result is tui.Wizard.BACK or result is None:
- return tui.Wizard.BACK
- try:
- _apply_settings(result)
- except ValueError as exc:
- tui.flash(self.stdscr, str(exc), "err")
+ fields = _settings_fields()
+ while True:
+ result = tui.form(self.stdscr, "Settings", fields,
+ back_value=tui.Wizard.BACK)
+ if not (result is tui.Wizard.BACK or result is None):
+ # Save pressed: apply as before, no prompt.
+ try:
+ _apply_settings(result)
+ except ValueError as exc:
+ tui.flash(self.stdscr, str(exc), "err")
+ return tui.Wizard.BACK
+ # q/Esc (or the Cancel button) left the form without saving:
+ # ask whether the edits should be kept before discarding them.
+ answer = tui.confirm_yn_cancel(self.stdscr, "Save settings?")
+ if answer == "cancel":
+ continue # back into the form, edits intact
+ if answer == "yes":
+ values = {field["key"]: field["value"] for field in fields}
+ try:
+ _apply_settings(values)
+ except ValueError as exc:
+ tui.flash(self.stdscr, str(exc), "err")
return tui.Wizard.BACK
- tui.flash(self.stdscr, "Settings saved.", "ok")
- return tui.Wizard.BACK
# -- servers --------------------------------------------------------
diff --git a/app/ui/tui.py b/app/ui/tui.py
index b1b18f1..11f5653 100644
--- a/app/ui/tui.py
+++ b/app/ui/tui.py
@@ -669,6 +669,41 @@ def confirm(scr, question: str, default: bool = False,
return index == 0
+def confirm_yn_cancel(scr, question: str) -> str:
+ """Ask QUESTION with Yes / No / Cancel buttons; return the answer.
+
+ Like ``confirm``, but with a third Cancel button and a string result:
+ "yes", "no", or "cancel". Tab or the arrow keys cycle all three
+ buttons (wrapping around), Enter activates the highlighted one (Yes
+ starts highlighted), y/n answer directly, and Esc (or 'q') counts as
+ Cancel.
+ """
+ frame = Frame(scr, question,
+ "Tab/arrows = switch Enter = confirm y/n "
+ "Esc = cancel")
+ index = 0
+ while True:
+ frame.rows = []
+ frame.cursor = None
+ frame.buttons = (["Yes", "No", "Cancel"], index)
+ frame.draw()
+ curses = frame.curses
+ key = frame.get_key(cancel_keys=())
+ if key in _CANCEL_KEYS:
+ return "cancel"
+ if key in (9, curses.KEY_RIGHT, curses.KEY_DOWN, ord("l")):
+ index = (index + 1) % 3
+ elif key in (curses.KEY_BTAB, curses.KEY_LEFT, curses.KEY_UP,
+ ord("h")):
+ index = (index - 1) % 3
+ elif key in (ord("y"), ord("Y")):
+ return "yes"
+ elif key in (ord("n"), ord("N")):
+ return "no"
+ elif key in (10, 13):
+ return ("yes", "no", "cancel")[index]
+
+
# ---------------------------------------------------------------------------
# Widget: single-choice menu
# ---------------------------------------------------------------------------