Add interactive edit mode with floating popup
This commit is contained in:
parent
b42298b9b5
commit
99f07aef82
10 changed files with 1045 additions and 46 deletions
|
|
@ -1,6 +1,7 @@
|
|||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
|
@ -18,6 +19,15 @@ class FakeDesktop:
|
|||
def __init__(self):
|
||||
self.inject_calls = []
|
||||
self.quit_calls = 0
|
||||
self.clipboard_text = ""
|
||||
self.popup_open = False
|
||||
self.popup_text = ""
|
||||
self.popup_statuses = []
|
||||
self.popup_callbacks = {}
|
||||
self.popup_close_calls = 0
|
||||
self.focus_restore_calls = 0
|
||||
self.cancel_listener_active = False
|
||||
self.cancel_listener_callback = None
|
||||
|
||||
def inject_text(
|
||||
self,
|
||||
|
|
@ -28,6 +38,53 @@ class FakeDesktop:
|
|||
) -> None:
|
||||
self.inject_calls.append((text, backend, remove_transcription_from_clipboard))
|
||||
|
||||
def read_clipboard_text(self) -> str | None:
|
||||
return self.clipboard_text
|
||||
|
||||
def write_clipboard_text(self, text: str) -> None:
|
||||
self.clipboard_text = text
|
||||
|
||||
def open_edit_popup(
|
||||
self,
|
||||
initial_text: str,
|
||||
*,
|
||||
on_submit,
|
||||
on_copy,
|
||||
on_cancel,
|
||||
) -> None:
|
||||
self.popup_open = True
|
||||
self.popup_text = initial_text
|
||||
self.popup_callbacks = {
|
||||
"submit": on_submit,
|
||||
"copy": on_copy,
|
||||
"cancel": on_cancel,
|
||||
}
|
||||
|
||||
def close_edit_popup(self) -> None:
|
||||
self.popup_open = False
|
||||
self.popup_close_calls += 1
|
||||
|
||||
def get_edit_popup_text(self) -> str:
|
||||
return self.popup_text
|
||||
|
||||
def set_edit_popup_text(self, text: str) -> None:
|
||||
self.popup_text = text
|
||||
|
||||
def set_edit_popup_status(self, status: str) -> None:
|
||||
self.popup_statuses.append(status)
|
||||
|
||||
def restore_previous_focus(self) -> bool:
|
||||
self.focus_restore_calls += 1
|
||||
return True
|
||||
|
||||
def start_cancel_listener(self, callback) -> None:
|
||||
self.cancel_listener_active = True
|
||||
self.cancel_listener_callback = callback
|
||||
|
||||
def stop_cancel_listener(self) -> None:
|
||||
self.cancel_listener_active = False
|
||||
self.cancel_listener_callback = None
|
||||
|
||||
def request_quit(self) -> None:
|
||||
self.quit_calls += 1
|
||||
|
||||
|
|
@ -73,9 +130,30 @@ class FakeHintModel:
|
|||
|
||||
|
||||
class FakeAIProcessor:
|
||||
def __init__(self):
|
||||
self.edit_calls = []
|
||||
|
||||
def process(self, text, lang="en", **_kwargs):
|
||||
return text
|
||||
|
||||
def process_edit(
|
||||
self,
|
||||
current_text,
|
||||
latest_instruction,
|
||||
instruction_history,
|
||||
lang="en",
|
||||
**_kwargs,
|
||||
):
|
||||
self.edit_calls.append(
|
||||
{
|
||||
"current_text": current_text,
|
||||
"latest_instruction": latest_instruction,
|
||||
"instruction_history": list(instruction_history),
|
||||
"lang": lang,
|
||||
}
|
||||
)
|
||||
return f"{current_text} [{latest_instruction}]"
|
||||
|
||||
|
||||
class FakeAudio:
|
||||
def __init__(self, size: int):
|
||||
|
|
@ -101,6 +179,14 @@ class DaemonTests(unittest.TestCase):
|
|||
):
|
||||
return aman.Daemon(active_cfg, desktop, verbose=verbose)
|
||||
|
||||
def _wait_until(self, predicate, timeout: float = 1.0):
|
||||
end = time.time() + timeout
|
||||
while time.time() < end:
|
||||
if predicate():
|
||||
return True
|
||||
time.sleep(0.01)
|
||||
return predicate()
|
||||
|
||||
@patch("aman.stop_audio_recording", return_value=FakeAudio(8))
|
||||
@patch("aman.start_audio_recording", return_value=(object(), object()))
|
||||
def test_toggle_start_stop_injects_text(self, _start_mock, _stop_mock):
|
||||
|
|
@ -239,6 +325,120 @@ class DaemonTests(unittest.TestCase):
|
|||
any("DEBUG:root:state: idle -> recording" in line for line in logs.output)
|
||||
)
|
||||
|
||||
@patch("aman.stop_audio_recording", return_value=FakeAudio(8))
|
||||
@patch("aman.start_audio_recording", return_value=(object(), object()))
|
||||
def test_escape_listener_is_only_armed_while_recording(self, _start_mock, _stop_mock):
|
||||
desktop = FakeDesktop()
|
||||
daemon = self._build_daemon(desktop, FakeModel(), verbose=False)
|
||||
daemon._start_stop_worker = (
|
||||
lambda stream, record, trigger, process_audio: daemon._stop_and_process(
|
||||
stream, record, trigger, process_audio
|
||||
)
|
||||
)
|
||||
|
||||
self.assertFalse(desktop.cancel_listener_active)
|
||||
daemon.toggle()
|
||||
self.assertTrue(desktop.cancel_listener_active)
|
||||
daemon.toggle()
|
||||
self.assertFalse(desktop.cancel_listener_active)
|
||||
|
||||
@patch("aman.start_audio_recording", return_value=(object(), object()))
|
||||
def test_edit_mode_opens_popup_and_starts_recording(self, _start_mock):
|
||||
desktop = FakeDesktop()
|
||||
desktop.clipboard_text = "Hello team"
|
||||
daemon = self._build_daemon(desktop, FakeModel(text="make it funnier"), verbose=False)
|
||||
|
||||
daemon.toggle_edit()
|
||||
|
||||
self.assertTrue(desktop.popup_open)
|
||||
self.assertEqual(desktop.popup_text, "Hello team")
|
||||
self.assertEqual(daemon.get_state(), aman.State.EDIT_RECORDING)
|
||||
|
||||
@patch("aman.stop_audio_recording", return_value=FakeAudio(8))
|
||||
@patch("aman.start_audio_recording", return_value=(object(), object()))
|
||||
def test_edit_mode_instruction_updates_popup_text(self, _start_mock, _stop_mock):
|
||||
desktop = FakeDesktop()
|
||||
desktop.clipboard_text = "Hello team"
|
||||
daemon = self._build_daemon(desktop, FakeModel(text="make it funnier"), verbose=False)
|
||||
|
||||
daemon.toggle_edit()
|
||||
daemon.toggle_edit()
|
||||
|
||||
self.assertTrue(
|
||||
self._wait_until(lambda: daemon.get_state() == aman.State.EDIT_IDLE),
|
||||
"edit mode did not return to EDIT_IDLE",
|
||||
)
|
||||
self.assertEqual(desktop.popup_text, "Hello team [make it funnier]")
|
||||
self.assertEqual(len(daemon.ai_processor.edit_calls), 1)
|
||||
self.assertEqual(
|
||||
daemon.ai_processor.edit_calls[0]["instruction_history"],
|
||||
["make it funnier"],
|
||||
)
|
||||
|
||||
@patch("aman.stop_audio_recording", return_value=FakeAudio(8))
|
||||
@patch("aman.start_audio_recording", return_value=(object(), object()))
|
||||
def test_enter_finalizes_and_injects(self, _start_mock, _stop_mock):
|
||||
desktop = FakeDesktop()
|
||||
desktop.clipboard_text = "Initial"
|
||||
daemon = self._build_daemon(desktop, FakeModel(text="instruction"), verbose=False)
|
||||
|
||||
daemon.toggle_edit()
|
||||
desktop.popup_text = "Final text"
|
||||
daemon.finalize_edit_session_inject()
|
||||
|
||||
self.assertTrue(
|
||||
self._wait_until(lambda: len(desktop.inject_calls) == 1),
|
||||
"edit finalize did not inject text",
|
||||
)
|
||||
self.assertFalse(desktop.popup_open)
|
||||
self.assertEqual(desktop.inject_calls[0], ("Final text", "clipboard", False))
|
||||
self.assertEqual(desktop.focus_restore_calls, 1)
|
||||
|
||||
@patch("aman.stop_audio_recording", return_value=FakeAudio(8))
|
||||
@patch("aman.start_audio_recording", return_value=(object(), object()))
|
||||
def test_ctrl_c_copies_and_closes_without_inject(self, _start_mock, _stop_mock):
|
||||
desktop = FakeDesktop()
|
||||
desktop.clipboard_text = "Initial"
|
||||
daemon = self._build_daemon(desktop, FakeModel(text="instruction"), verbose=False)
|
||||
|
||||
daemon.toggle_edit()
|
||||
desktop.popup_text = "Copied text"
|
||||
daemon.finalize_edit_session_copy()
|
||||
|
||||
self.assertTrue(
|
||||
self._wait_until(lambda: not desktop.popup_open),
|
||||
"edit popup did not close after copy",
|
||||
)
|
||||
self.assertEqual(desktop.clipboard_text, "Copied text")
|
||||
self.assertEqual(desktop.inject_calls, [])
|
||||
|
||||
@patch("aman.start_audio_recording", return_value=(object(), object()))
|
||||
def test_normal_hotkey_ignored_while_edit_session_active(self, _start_mock):
|
||||
desktop = FakeDesktop()
|
||||
desktop.clipboard_text = "Initial"
|
||||
daemon = self._build_daemon(desktop, FakeModel(text="instruction"), verbose=False)
|
||||
|
||||
daemon.toggle_edit()
|
||||
daemon.toggle()
|
||||
|
||||
self.assertEqual(daemon.get_state(), aman.State.EDIT_RECORDING)
|
||||
|
||||
@patch("aman.stop_audio_recording", return_value=FakeAudio(8))
|
||||
@patch("aman.start_audio_recording", return_value=(object(), object()))
|
||||
def test_handle_cancel_closes_edit_session(self, _start_mock, _stop_mock):
|
||||
desktop = FakeDesktop()
|
||||
desktop.clipboard_text = "Initial"
|
||||
daemon = self._build_daemon(desktop, FakeModel(text="instruction"), verbose=False)
|
||||
|
||||
daemon.toggle_edit()
|
||||
daemon.handle_cancel()
|
||||
|
||||
self.assertTrue(
|
||||
self._wait_until(lambda: daemon.get_state() == aman.State.IDLE),
|
||||
"edit cancel did not reach idle state",
|
||||
)
|
||||
self.assertFalse(desktop.popup_open)
|
||||
|
||||
|
||||
class LockTests(unittest.TestCase):
|
||||
def test_lock_rejects_second_instance(self):
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ class ConfigTests(unittest.TestCase):
|
|||
cfg = load(str(missing))
|
||||
|
||||
self.assertEqual(cfg.daemon.hotkey, "Cmd+m")
|
||||
self.assertEqual(cfg.daemon.edit_hotkey, "Cmd+Shift+m")
|
||||
self.assertEqual(cfg.recording.input, "")
|
||||
self.assertEqual(cfg.stt.model, "base")
|
||||
self.assertEqual(cfg.stt.device, "cpu")
|
||||
|
|
@ -33,7 +34,7 @@ class ConfigTests(unittest.TestCase):
|
|||
|
||||
def test_loads_nested_config(self):
|
||||
payload = {
|
||||
"daemon": {"hotkey": "Ctrl+space"},
|
||||
"daemon": {"hotkey": "Ctrl+space", "edit_hotkey": "Ctrl+Shift+space"},
|
||||
"recording": {"input": 3},
|
||||
"stt": {"model": "small", "device": "cuda"},
|
||||
"injection": {
|
||||
|
|
@ -55,6 +56,7 @@ class ConfigTests(unittest.TestCase):
|
|||
cfg = load(str(path))
|
||||
|
||||
self.assertEqual(cfg.daemon.hotkey, "Ctrl+space")
|
||||
self.assertEqual(cfg.daemon.edit_hotkey, "Ctrl+Shift+space")
|
||||
self.assertEqual(cfg.recording.input, 3)
|
||||
self.assertEqual(cfg.stt.model, "small")
|
||||
self.assertEqual(cfg.stt.device, "cuda")
|
||||
|
|
@ -66,7 +68,7 @@ class ConfigTests(unittest.TestCase):
|
|||
self.assertEqual(cfg.vocabulary.terms, ["Systemd", "Kubernetes"])
|
||||
|
||||
def test_super_modifier_hotkey_is_valid(self):
|
||||
payload = {"daemon": {"hotkey": "Super+m"}}
|
||||
payload = {"daemon": {"hotkey": "Super+m", "edit_hotkey": "Super+Shift+m"}}
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
path = Path(td) / "config.json"
|
||||
path.write_text(json.dumps(payload), encoding="utf-8")
|
||||
|
|
@ -74,6 +76,7 @@ class ConfigTests(unittest.TestCase):
|
|||
cfg = load(str(path))
|
||||
|
||||
self.assertEqual(cfg.daemon.hotkey, "Super+m")
|
||||
self.assertEqual(cfg.daemon.edit_hotkey, "Super+Shift+m")
|
||||
|
||||
def test_invalid_hotkey_missing_key_raises(self):
|
||||
payload = {"daemon": {"hotkey": "Ctrl+Alt"}}
|
||||
|
|
@ -95,6 +98,24 @@ class ConfigTests(unittest.TestCase):
|
|||
):
|
||||
load(str(path))
|
||||
|
||||
def test_invalid_edit_hotkey_raises(self):
|
||||
payload = {"daemon": {"edit_hotkey": "Ctrl+Alt"}}
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
path = Path(td) / "config.json"
|
||||
path.write_text(json.dumps(payload), encoding="utf-8")
|
||||
|
||||
with self.assertRaisesRegex(ValueError, "daemon.edit_hotkey is invalid: missing key"):
|
||||
load(str(path))
|
||||
|
||||
def test_equal_hotkeys_raise(self):
|
||||
payload = {"daemon": {"hotkey": "Cmd+m", "edit_hotkey": "Cmd+m"}}
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
path = Path(td) / "config.json"
|
||||
path.write_text(json.dumps(payload), encoding="utf-8")
|
||||
|
||||
with self.assertRaisesRegex(ValueError, "must be different"):
|
||||
load(str(path))
|
||||
|
||||
def test_invalid_injection_backend_raises(self):
|
||||
payload = {"injection": {"backend": "invalid"}}
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
|
|
@ -126,6 +147,7 @@ class ConfigTests(unittest.TestCase):
|
|||
cfg = load(str(path))
|
||||
|
||||
self.assertEqual(cfg.daemon.hotkey, "Cmd+m")
|
||||
self.assertEqual(cfg.daemon.edit_hotkey, "Cmd+Shift+m")
|
||||
self.assertEqual(cfg.injection.backend, "clipboard")
|
||||
|
||||
def test_conflicting_replacements_raise(self):
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue