From 4c3e78c39c81d2997e88b84e1b5a25b244e870e4 Mon Sep 17 00:00:00 2001 From: historia Date: Tue, 25 Aug 2026 21:02:21 -0400 Subject: feat: prompt to save settings when going back from settings menu --- app/tests/test_hub.py | 70 ++++++++++++++++++++++++++++++++++++++++++++++++--- app/tests/test_tui.py | 35 ++++++++++++++++++++++++++ app/ui/hub.py | 32 +++++++++++++++-------- app/ui/tui.py | 35 ++++++++++++++++++++++++++ 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 # --------------------------------------------------------------------------- -- cgit v1.2.3