diff --git a/README.md b/README.md index 4c972b7..ca3471d 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,8 @@ It sits quietly in your system tray and is always ready with a single click or g - **Clipboard Integration:** Automatically copies the file (or file path) to your clipboard. - **Clean Workflow:** Option to move the file to a temp folder and copy it, keeping your desktop clean. - **Control:** - - **Global Hotkeys:** Start/Stop recording from anywhere (e.g., `Ctrl+Alt+R`). + - **Global Hotkeys:** Start/stop recording from anywhere, including `Alt+Shift+` combinations. + - **Notification Toggle:** Turn tray balloon notifications on or off from Settings. - **Tray Icon:** Left-click to toggle recording immediately. - **Visual Feedback:** Tray icon changes color when recording. @@ -37,6 +38,8 @@ It sits quietly in your system tray and is always ready with a single click or g 1. **Right-click** the tray icon to open **Settings**. 2. Select your **Microphone** and **Output Folder**. 3. Set your **Hotkeys** (optional). + - Enable **Use record hotkeys to stop recording** if you want any record hotkey to stop the active recording. + - Disable **Show tray notifications** if you prefer silent tray operation. 4. **Left-click** the tray icon or use a hotkey to start recording. 5. Click again to stop. The file is saved and ready to use! diff --git a/docs/superpowers/plans/2026-05-18-hotkey-notification-settings.md b/docs/superpowers/plans/2026-05-18-hotkey-notification-settings.md new file mode 100644 index 0000000..e0d210f --- /dev/null +++ b/docs/superpowers/plans/2026-05-18-hotkey-notification-settings.md @@ -0,0 +1,831 @@ +# Hotkey Notification Settings Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 修复快捷键捕捉对 `alt+shift+字母` 的识别,增加捕捉态提示、录音快捷键复用为停止快捷键、以及托盘通知开关。 + +**Architecture:** 保持当前单文件 GUI 架构,不拆大模块;把易测逻辑限制在 `HotkeyEdit`、`TrayApplication.toggle_recording()`、`TrayApplication.show_tray_notification()` 三个小边界。设置继续写入 `settings.json`,新增布尔字段带默认值,旧配置自动兼容。 + +**Tech Stack:** Python 3.13, PyQt6, keyboard, unittest, PyInstaller. + +--- + +## File Structure + +- Modify: `D:\Git\quickaudiorecorder\gui.py` + - `HotkeyEdit`: 增加捕捉态、提示文字、`alt+shift+letter` 格式化。 + - `SettingsWindow`: 增加 `stop_with_record_hotkeys` 和 `show_notifications` 两个设置项,保存/加载到 JSON。 + - `TrayApplication`: 热键注册从 `start_recording()` 改为 `toggle_recording()`,并用 `show_tray_notification()` 统一控制托盘弹窗。 +- Create: `D:\Git\quickaudiorecorder\tests\test_gui_hotkeys.py` + - 用 `unittest` 验证快捷键捕捉、录音快捷键复用停止、通知开关。 +- Modify: `D:\Git\quickaudiorecorder\README.md` + - 更新 Usage/Features 中的快捷键和通知行为说明。 + +--- + +### Task 1: Add Failing Tests For Hotkey Capture And Toggle Behavior + +**Files:** +- Create: `D:\Git\quickaudiorecorder\tests\test_gui_hotkeys.py` +- Test: `D:\Git\quickaudiorecorder\tests\test_gui_hotkeys.py` + +- [ ] **Step 1: Create the failing test file** + +Create `tests/test_gui_hotkeys.py` with this exact content: + +```python +import os +import unittest +from types import SimpleNamespace + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + +from PyQt6.QtCore import QEvent, Qt +from PyQt6.QtGui import QKeyEvent +from PyQt6.QtWidgets import QApplication + +from gui import HotkeyEdit, TrayApplication + + +class FakeRecorder: + def __init__(self, alive): + self.alive = alive + + def is_alive(self): + return self.alive + + +class FakeSettingsWindow: + def __init__(self, settings): + self.settings = settings + + def get_settings(self): + return dict(self.settings) + + +class FakeTrayIcon: + def __init__(self): + self.messages = [] + + def showMessage(self, title, message, icon, duration): + self.messages.append((title, message, icon, duration)) + + +class HotkeyEditTests(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.app = QApplication.instance() or QApplication([]) + + def test_alt_shift_letter_hotkey_is_captured(self): + edit = HotkeyEdit() + edit.begin_capture() + + event = QKeyEvent( + QEvent.Type.KeyPress, + Qt.Key.Key_R.value, + Qt.KeyboardModifier.AltModifier | Qt.KeyboardModifier.ShiftModifier, + ) + edit.keyPressEvent(event) + + self.assertEqual(edit.text(), "alt+shift+r") + + def test_capture_prompt_is_visible_and_restores_on_escape(self): + edit = HotkeyEdit() + edit.setText("ctrl+alt+r") + + edit.begin_capture() + self.assertEqual(edit.text(), "Press shortcut...") + + event = QKeyEvent( + QEvent.Type.KeyPress, + Qt.Key.Key_Escape.value, + Qt.KeyboardModifier.NoModifier, + ) + edit.keyPressEvent(event) + + self.assertEqual(edit.text(), "ctrl+alt+r") + + def test_delete_clears_hotkey(self): + edit = HotkeyEdit() + edit.setText("ctrl+alt+r") + edit.begin_capture() + + event = QKeyEvent( + QEvent.Type.KeyPress, + Qt.Key.Key_Delete.value, + Qt.KeyboardModifier.NoModifier, + ) + edit.keyPressEvent(event) + + self.assertEqual(edit.text(), "") + + +class TrayApplicationHotkeyTests(unittest.TestCase): + def test_record_hotkey_stops_active_recording_when_option_enabled(self): + subject = SimpleNamespace( + recorder=FakeRecorder(alive=True), + settings_window=FakeSettingsWindow({"stop_with_record_hotkeys": True}), + stopped=False, + started=None, + ) + subject.stop_recording = lambda: setattr(subject, "stopped", True) + subject.start_recording = lambda mode: setattr(subject, "started", mode) + + TrayApplication.toggle_recording(subject, "mic") + + self.assertTrue(subject.stopped) + self.assertIsNone(subject.started) + + def test_record_hotkey_does_not_switch_mode_when_option_disabled(self): + subject = SimpleNamespace( + recorder=FakeRecorder(alive=True), + settings_window=FakeSettingsWindow({"stop_with_record_hotkeys": False}), + stopped=False, + started=None, + ) + subject.stop_recording = lambda: setattr(subject, "stopped", True) + subject.start_recording = lambda mode: setattr(subject, "started", mode) + + TrayApplication.toggle_recording(subject, "loopback") + + self.assertFalse(subject.stopped) + self.assertIsNone(subject.started) + + def test_record_hotkey_starts_recording_when_idle(self): + subject = SimpleNamespace( + recorder=None, + settings_window=FakeSettingsWindow({"stop_with_record_hotkeys": True}), + stopped=False, + started=None, + ) + subject.stop_recording = lambda: setattr(subject, "stopped", True) + subject.start_recording = lambda mode: setattr(subject, "started", mode) + + TrayApplication.toggle_recording(subject, "both") + + self.assertFalse(subject.stopped) + self.assertEqual(subject.started, "both") + + +class TrayApplicationNotificationTests(unittest.TestCase): + def test_notification_is_skipped_when_disabled(self): + subject = SimpleNamespace( + tray_icon=FakeTrayIcon(), + settings_window=FakeSettingsWindow({"show_notifications": False}), + ) + + TrayApplication.show_tray_notification(subject, "Started", "Recording mic") + + self.assertEqual(subject.tray_icon.messages, []) + + def test_notification_is_sent_when_enabled(self): + subject = SimpleNamespace( + tray_icon=FakeTrayIcon(), + settings_window=FakeSettingsWindow({"show_notifications": True}), + ) + + TrayApplication.show_tray_notification(subject, "Started", "Recording mic", duration=1234) + + self.assertEqual(len(subject.tray_icon.messages), 1) + self.assertEqual(subject.tray_icon.messages[0][0], "Started") + self.assertEqual(subject.tray_icon.messages[0][1], "Recording mic") + self.assertEqual(subject.tray_icon.messages[0][3], 1234) + + +if __name__ == "__main__": + unittest.main() +``` + +- [ ] **Step 2: Run tests and verify they fail for the expected reasons** + +Run: + +```powershell +$env:QT_QPA_PLATFORM = 'offscreen' +.\.venv\Scripts\python.exe -m unittest tests.test_gui_hotkeys -v +``` + +Expected: FAIL/ERROR because `HotkeyEdit.begin_capture`, `TrayApplication.toggle_recording`, and `TrayApplication.show_tray_notification` do not exist yet, and `alt+shift+r` behavior is not explicitly protected. + +- [ ] **Step 3: Commit the failing tests** + +```powershell +git add tests/test_gui_hotkeys.py +git commit -m "test: cover hotkey capture and tray notification behavior" +``` + +--- + +### Task 2: Fix HotkeyEdit Capture State And Alt+Shift Letter Formatting + +**Files:** +- Modify: `D:\Git\quickaudiorecorder\gui.py:29-90` +- Test: `D:\Git\quickaudiorecorder\tests\test_gui_hotkeys.py` + +- [ ] **Step 1: Replace the current `HotkeyEdit` class** + +In `gui.py`, replace the entire `HotkeyEdit` class with: + +```python +class HotkeyEdit(QLineEdit): + """ + Custom widget to capture hotkeys by pressing them. + Maps Qt events to 'keyboard' library compatible strings. + """ + CAPTURE_PROMPT = "Press shortcut..." + + def __init__(self, parent=None): + super().__init__(parent) + self.setPlaceholderText("Click to set hotkey...") + self.setReadOnly(True) + self.current_sequence = None + self.is_capturing = False + self._previous_text = "" + + def begin_capture(self): + if self.is_capturing: + return + self.is_capturing = True + self._previous_text = self.text() + self.setText(self.CAPTURE_PROMPT) + self.selectAll() + self.setStyleSheet("color: #666;") + + def finish_capture(self, sequence): + self.is_capturing = False + self.current_sequence = sequence or None + self.setStyleSheet("") + self.setText(sequence) + self.clearFocus() + + def cancel_capture(self): + self.is_capturing = False + self.setStyleSheet("") + self.setText(self._previous_text) + self.clearFocus() + + def mousePressEvent(self, event): + self.setFocus() + self.begin_capture() + super().mousePressEvent(event) + + def focusInEvent(self, event): + super().focusInEvent(event) + self.begin_capture() + + def focusOutEvent(self, event): + if self.is_capturing: + self.is_capturing = False + self.setStyleSheet("") + self.setText(self._previous_text) + super().focusOutEvent(event) + + def keyPressEvent(self, event): + key = event.key() + modifiers = event.modifiers() + + if key in (Qt.Key.Key_Backspace.value, Qt.Key.Key_Delete.value): + self.finish_capture("") + return + + if key == Qt.Key.Key_Escape.value: + self.cancel_capture() + return + + if key in ( + Qt.Key.Key_Control.value, + Qt.Key.Key_Shift.value, + Qt.Key.Key_Alt.value, + Qt.Key.Key_Meta.value, + ): + return + + final_hotkey = self.format_hotkey(key, modifiers) + if final_hotkey: + self.finish_capture(final_hotkey) + + def format_hotkey(self, key, modifiers): + parts = [] + if modifiers & Qt.KeyboardModifier.ControlModifier: + parts.append("ctrl") + if modifiers & Qt.KeyboardModifier.AltModifier: + parts.append("alt") + if modifiers & Qt.KeyboardModifier.ShiftModifier: + parts.append("shift") + if modifiers & Qt.KeyboardModifier.MetaModifier: + parts.append("windows") + + key_text = self.key_to_text(key) + if key_text: + parts.append(key_text) + + return "+".join(parts) + + def key_to_text(self, key): + if Qt.Key.Key_A.value <= key <= Qt.Key.Key_Z.value: + return chr(key).lower() + + if Qt.Key.Key_0.value <= key <= Qt.Key.Key_9.value: + return chr(key) + + key_map = { + Qt.Key.Key_F1.value: "f1", + Qt.Key.Key_F2.value: "f2", + Qt.Key.Key_F3.value: "f3", + Qt.Key.Key_F4.value: "f4", + Qt.Key.Key_F5.value: "f5", + Qt.Key.Key_F6.value: "f6", + Qt.Key.Key_F7.value: "f7", + Qt.Key.Key_F8.value: "f8", + Qt.Key.Key_F9.value: "f9", + Qt.Key.Key_F10.value: "f10", + Qt.Key.Key_F11.value: "f11", + Qt.Key.Key_F12.value: "f12", + Qt.Key.Key_Left.value: "left", + Qt.Key.Key_Right.value: "right", + Qt.Key.Key_Up.value: "up", + Qt.Key.Key_Down.value: "down", + Qt.Key.Key_Space.value: "space", + Qt.Key.Key_Tab.value: "tab", + Qt.Key.Key_Return.value: "enter", + Qt.Key.Key_Enter.value: "enter", + Qt.Key.Key_Insert.value: "insert", + Qt.Key.Key_Home.value: "home", + Qt.Key.Key_End.value: "end", + Qt.Key.Key_PageUp.value: "pageup", + Qt.Key.Key_PageDown.value: "pagedown", + Qt.Key.Key_CapsLock.value: "capslock", + Qt.Key.Key_NumLock.value: "numlock", + Qt.Key.Key_ScrollLock.value: "scrolllock", + Qt.Key.Key_Print.value: "print_screen", + Qt.Key.Key_Pause.value: "pause", + } + return key_map.get(key, "") +``` + +- [ ] **Step 2: Run the hotkey capture tests** + +Run: + +```powershell +$env:QT_QPA_PLATFORM = 'offscreen' +.\.venv\Scripts\python.exe -m unittest tests.test_gui_hotkeys.HotkeyEditTests -v +``` + +Expected: PASS for `alt+shift+r`, capture prompt, Escape restore, and Delete clear. + +- [ ] **Step 3: Run import and syntax checks** + +Run: + +```powershell +.\.venv\Scripts\python.exe -m compileall -q main.py gui.py audio_recorder.py clipboard_utils.py tests +.\.venv\Scripts\python.exe -c "import main" +``` + +Expected: both commands exit with code 0 and no output. + +- [ ] **Step 4: Commit the hotkey capture fix** + +```powershell +git add gui.py tests/test_gui_hotkeys.py +git commit -m "fix: improve hotkey capture feedback" +``` + +--- + +### Task 3: Add Stop-With-Record-Hotkeys And Notification Settings + +**Files:** +- Modify: `D:\Git\quickaudiorecorder\gui.py:146-258` +- Test: `D:\Git\quickaudiorecorder\tests\test_gui_hotkeys.py` + +- [ ] **Step 1: Add the notification checkbox in `SettingsWindow.init_ui()`** + +In `gui.py`, after the tray left-click mode group is added, insert: + +```python + # Notifications + group_notifications = QGroupBox("Notifications") + layout_notifications = QVBoxLayout() + self.chk_notifications = QCheckBox("Show tray notifications") + self.chk_notifications.setChecked(True) + layout_notifications.addWidget(self.chk_notifications) + group_notifications.setLayout(layout_notifications) + layout.addWidget(group_notifications) +``` + +- [ ] **Step 2: Add the stop-with-record-hotkeys checkbox in the hotkey group** + +In `SettingsWindow.init_ui()`, after `self.hk_stop = HotkeyEdit()`, insert: + +```python + self.chk_stop_with_record_hotkeys = QCheckBox("Use record hotkeys to stop recording") + self.chk_stop_with_record_hotkeys.setToolTip("When enabled, pressing any record hotkey while recording stops the active recording instead of starting another mode.") + self.chk_stop_with_record_hotkeys.setChecked(True) + self.chk_stop_with_record_hotkeys.toggled.connect(self.update_stop_hotkey_state) +``` + +Then change the hotkey layout rows to: + +```python + layout_hotkeys.addRow("Record Mic:", self.hk_mic) + layout_hotkeys.addRow("Record Loopback:", self.hk_loop) + layout_hotkeys.addRow("Record Both:", self.hk_both) + layout_hotkeys.addRow("", self.chk_stop_with_record_hotkeys) + layout_hotkeys.addRow("Stop Recording:", self.hk_stop) +``` + +- [ ] **Step 3: Add the stop hotkey enabled-state helper** + +In `SettingsWindow`, after `refresh_devices()`, add: + +```python + def update_stop_hotkey_state(self): + use_record_hotkeys = self.chk_stop_with_record_hotkeys.isChecked() + self.hk_stop.setEnabled(not use_record_hotkeys) + if use_record_hotkeys: + self.hk_stop.setPlaceholderText("Using record hotkeys") + else: + self.hk_stop.setPlaceholderText("Click to set hotkey...") +``` + +- [ ] **Step 4: Load the new settings with backward-compatible defaults** + +In `SettingsWindow.load_settings()`, after the existing clipboard/delete settings load, insert: + +```python + self.chk_notifications.setChecked(data.get("show_notifications", True)) + self.chk_stop_with_record_hotkeys.setChecked(data.get("stop_with_record_hotkeys", True)) + self.update_stop_hotkey_state() +``` + +Also change the JSON read line from: + +```python + with open(CONFIG_FILE, 'r') as f: +``` + +to: + +```python + with open(CONFIG_FILE, 'r', encoding='utf-8') as f: +``` + +- [ ] **Step 5: Save the new settings** + +In `SettingsWindow.save_settings()`, change: + +```python + with open(CONFIG_FILE, 'w') as f: + json.dump(data, f) +``` + +to: + +```python + with open(CONFIG_FILE, 'w', encoding='utf-8') as f: + json.dump(data, f, indent=2) +``` + +In `SettingsWindow.get_settings()`, return these two additional fields: + +```python + "show_notifications": self.chk_notifications.isChecked(), + "stop_with_record_hotkeys": self.chk_stop_with_record_hotkeys.isChecked(), +``` + +The final return object should keep the existing fields and include: + +```python + return { + "device_id": self.combo_mic.currentData(), + "output_folder": self.lbl_folder.text(), + "format": self.combo_fmt.currentText(), + "tray_click_mode": self.combo_left_click.currentText(), + "normalize": self.chk_normalize.isChecked(), + "clipboard": self.chk_clipboard.isChecked(), + "delete_after": self.chk_delete.isChecked(), + "show_notifications": self.chk_notifications.isChecked(), + "stop_with_record_hotkeys": self.chk_stop_with_record_hotkeys.isChecked(), + "hk_mic": self.hk_mic.text(), + "hk_loop": self.hk_loop.text(), + "hk_both": self.hk_both.text(), + "hk_stop": self.hk_stop.text() + } +``` + +- [ ] **Step 6: Run focused tests** + +Run: + +```powershell +$env:QT_QPA_PLATFORM = 'offscreen' +.\.venv\Scripts\python.exe -m unittest tests.test_gui_hotkeys -v +``` + +Expected: `HotkeyEditTests` pass; `TrayApplicationHotkeyTests` and `TrayApplicationNotificationTests` still fail until Task 4. + +- [ ] **Step 7: Commit the settings UI change** + +```powershell +git add gui.py +git commit -m "feat: add hotkey and notification settings" +``` + +--- + +### Task 4: Wire Hotkey Toggle Behavior And Notification Filtering + +**Files:** +- Modify: `D:\Git\quickaudiorecorder\gui.py:282-443` +- Test: `D:\Git\quickaudiorecorder\tests\test_gui_hotkeys.py` + +- [ ] **Step 1: Replace the ready notification call** + +In `TrayApplication.__init__()`, change: + +```python + self.tray_icon.showMessage("Ready", "Left-click to toggle recording.", QSystemTrayIcon.MessageIcon.Information, 2000) +``` + +to: + +```python + self.show_tray_notification("Ready", "Left-click to toggle recording.", QSystemTrayIcon.MessageIcon.Information, 2000) +``` + +- [ ] **Step 2: Add notification helper methods** + +In `TrayApplication`, after `build_menu()`, add: + +```python + def notifications_enabled(self): + try: + return self.settings_window.get_settings().get("show_notifications", True) + except Exception: + return True + + def show_tray_notification(self, title, message, icon=QSystemTrayIcon.MessageIcon.Information, duration=2000): + if self.notifications_enabled(): + self.tray_icon.showMessage(title, message, icon, duration) +``` + +- [ ] **Step 3: Add the toggle recording method** + +In `TrayApplication`, before `start_recording()`, add: + +```python + def toggle_recording(self, mode="mic"): + if self.recorder and self.recorder.is_alive(): + settings = self.settings_window.get_settings() + if settings.get("stop_with_record_hotkeys", True): + self.stop_recording() + return + + self.start_recording(mode) +``` + +- [ ] **Step 4: Register record hotkeys against `toggle_recording()`** + +In `TrayApplication.register_hotkeys()`, replace the hotkey registration block with: + +```python + try: + if hk_mic: + keyboard.add_hotkey(hk_mic, lambda: self.toggle_recording("mic")) + if hk_loop: + keyboard.add_hotkey(hk_loop, lambda: self.toggle_recording("loopback")) + if hk_both: + keyboard.add_hotkey(hk_both, lambda: self.toggle_recording("both")) + if hk_stop and not settings.get("stop_with_record_hotkeys", True): + keyboard.add_hotkey(hk_stop, self.stop_recording) + except Exception as e: + print(f"Failed to register hotkeys: {e}") +``` + +- [ ] **Step 5: Replace remaining tray `showMessage()` calls** + +In `start_recording()`, change: + +```python + self.tray_icon.showMessage("Started", f"Recording {mode}", QSystemTrayIcon.MessageIcon.NoIcon, 1000) +``` + +to: + +```python + self.show_tray_notification("Started", f"Recording {mode}", QSystemTrayIcon.MessageIcon.NoIcon, 1000) +``` + +In `on_recording_finished()`, change: + +```python + self.tray_icon.showMessage("Error", f"Recording failed: {error}", QSystemTrayIcon.MessageIcon.Critical, 4000) +``` + +to: + +```python + self.show_tray_notification("Error", f"Recording failed: {error}", QSystemTrayIcon.MessageIcon.Critical, 4000) +``` + +At the end of `on_recording_finished()`, change: + +```python + self.tray_icon.showMessage("Finished", msg, QSystemTrayIcon.MessageIcon.Information, 2000) +``` + +to: + +```python + self.show_tray_notification("Finished", msg, QSystemTrayIcon.MessageIcon.Information, 2000) +``` + +- [ ] **Step 6: Run all unit tests** + +Run: + +```powershell +$env:QT_QPA_PLATFORM = 'offscreen' +.\.venv\Scripts\python.exe -m unittest discover -s tests -v +``` + +Expected: all tests pass. + +- [ ] **Step 7: Run syntax and import checks** + +Run: + +```powershell +.\.venv\Scripts\python.exe -m compileall -q main.py gui.py audio_recorder.py clipboard_utils.py tests +.\.venv\Scripts\python.exe -c "import main" +``` + +Expected: both commands exit with code 0 and no output. + +- [ ] **Step 8: Commit the behavior wiring** + +```powershell +git add gui.py tests/test_gui_hotkeys.py +git commit -m "feat: allow record hotkeys to stop recording" +``` + +--- + +### Task 5: Update User Documentation + +**Files:** +- Modify: `D:\Git\quickaudiorecorder\README.md` + +- [ ] **Step 1: Update the feature list** + +In `README.md`, under `**Control:**`, replace the hotkey bullet: + +```markdown + - **Global Hotkeys:** Start/Stop recording from anywhere (e.g., `Ctrl+Alt+R`). +``` + +with: + +```markdown + - **Global Hotkeys:** Start/stop recording from anywhere, including `Alt+Shift+` combinations. + - **Notification Toggle:** Turn tray balloon notifications on or off from Settings. +``` + +- [ ] **Step 2: Update Usage** + +In `README.md`, after: + +```markdown +3. Set your **Hotkeys** (optional). +``` + +insert: + +```markdown + - Enable **Use record hotkeys to stop recording** if you want the same hotkey to stop the active recording. + - Disable **Show tray notifications** if you prefer silent tray operation. +``` + +- [ ] **Step 3: Commit documentation** + +```powershell +git add README.md +git commit -m "docs: document hotkey and notification settings" +``` + +--- + +### Task 6: Package And Verify The EXE + +**Files:** +- Read: `D:\Git\quickaudiorecorder\README.md` +- Output: `D:\Git\quickaudiorecorder\dist\QuickAudioRecorder.exe` + +- [ ] **Step 1: Confirm no running executable will lock the output** + +Run: + +```powershell +Get-Process QuickAudioRecorder -ErrorAction SilentlyContinue | Select-Object Id,Path,StartTime +``` + +Expected: no output. If a process is listed, stop only that process before packaging: + +```powershell +Stop-Process -Name QuickAudioRecorder +``` + +- [ ] **Step 2: Run the full verification set** + +Run: + +```powershell +$env:QT_QPA_PLATFORM = 'offscreen' +.\.venv\Scripts\python.exe -m unittest discover -s tests -v +.\.venv\Scripts\python.exe -m compileall -q main.py gui.py audio_recorder.py clipboard_utils.py tests +.\.venv\Scripts\python.exe -c "import main" +``` + +Expected: tests pass, compile/import checks exit with code 0. + +- [ ] **Step 3: Build the executable** + +Run: + +```powershell +.\.venv\Scripts\python.exe -m PyInstaller --noconsole --onefile --name QuickAudioRecorder main.py +``` + +Expected: PyInstaller exits with code 0 and reports: + +```text +Build complete! The results are available in: D:\Git\quickaudiorecorder\dist +``` + +- [ ] **Step 4: Verify the artifact** + +Run: + +```powershell +Get-Item -LiteralPath 'dist\QuickAudioRecorder.exe' | Select-Object FullName,Length,LastWriteTime +Get-FileHash -LiteralPath 'dist\QuickAudioRecorder.exe' -Algorithm SHA256 +``` + +Expected: `dist\QuickAudioRecorder.exe` exists, has a current timestamp, and has a non-empty SHA256 hash. + +- [ ] **Step 5: Review PyInstaller warnings** + +Run: + +```powershell +Get-Content -LiteralPath 'build\QuickAudioRecorder\warn-QuickAudioRecorder.txt' -Encoding UTF8 +``` + +Expected: warnings may include optional POSIX/macOS modules, cffi parser tables, and numpy optional imports. Treat compile errors, missing PyQt6 DLLs, or missing `soundcard`, `soundfile`, `lameenc`, `keyboard` as blockers. + +- [ ] **Step 6: Commit final state if there are source/doc changes not yet committed** + +Run: + +```powershell +git status --short --branch +git diff --stat +``` + +Expected: only ignored build artifacts are present. If source/doc/test changes remain unstaged, commit them: + +```powershell +git add gui.py README.md tests/test_gui_hotkeys.py +git commit -m "feat: improve hotkey and notification settings" +``` + +--- + +## Manual Acceptance Checklist + +- [ ] Click a hotkey field: the field immediately displays `Press shortcut...`. +- [ ] Press `Alt+Shift+R`: the field stores `alt+shift+r`. +- [ ] Press Escape while capturing: the previous hotkey text is restored. +- [ ] Press Delete while capturing: the hotkey is cleared. +- [ ] Enable `Use record hotkeys to stop recording`, start Mic recording with its hotkey, press the same hotkey again, and recording stops. +- [ ] While recording Mic, press another configured record hotkey; the active recording stops and does not switch mode mid-recording. +- [ ] Disable `Use record hotkeys to stop recording`, start recording, press a record hotkey again, and recording continues. +- [ ] Configure a separate Stop Recording hotkey while `Use record hotkeys to stop recording` is disabled; pressing it stops the active recording. +- [ ] Disable `Show tray notifications`; Ready/Started/Error/Finished tray balloons do not appear. +- [ ] Re-enable `Show tray notifications`; Started and Finished tray balloons appear again. + +## Self-Review + +- Spec coverage: + - `alt+shift+字母键` 识别: Task 1 and Task 2. + - 捕捉态文字提示: Task 1 and Task 2. + - 结束快捷键增加“使用与开始录音相同的快捷键”: Task 3. + - 录音中再次按快捷键结束录音: Task 4. + - 是否开启通知弹出提示: Task 3 and Task 4. + - 打包 exe 验证: Task 6. +- Placeholder scan: no placeholder markers, undefined function names, or unspecified test commands remain. +- Type consistency: + - Setting keys are `show_notifications` and `stop_with_record_hotkeys` in load, save, tests, and runtime. + - Runtime methods are `toggle_recording()`, `notifications_enabled()`, and `show_tray_notification()` in tests and implementation steps. diff --git a/gui.py b/gui.py index 5c0b485..fad5f9c 100644 --- a/gui.py +++ b/gui.py @@ -3,12 +3,14 @@ import json import shutil import tempfile +import ctypes +from ctypes import wintypes from PyQt6.QtWidgets import (QApplication, QSystemTrayIcon, QMenu, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout, QLabel, QComboBox, QPushButton, QFileDialog, QMessageBox, QGroupBox, QLineEdit, QFormLayout, QCheckBox) from PyQt6.QtGui import QIcon, QAction, QColor, QPixmap, QPainter, QBrush, QKeySequence -from PyQt6.QtCore import pyqtSignal, QObject, Qt, QUrl, QMimeData, QDir +from PyQt6.QtCore import pyqtSignal, QObject, Qt, QUrl, QMimeData, QDir, QEvent import soundcard as sc import keyboard from audio_recorder import AudioRecorder, get_devices @@ -23,6 +25,231 @@ def resource_path(relative_path): base_path = os.path.abspath(".") return os.path.join(base_path, relative_path) +WINDOWS_MODIFIER_KEYS = { + "alt": 0x0001, + "ctrl": 0x0002, + "control": 0x0002, + "shift": 0x0004, + "windows": 0x0008, + "win": 0x0008, +} + +WINDOWS_SPECIAL_KEYS = { + "backspace": 0x08, + "tab": 0x09, + "enter": 0x0D, + "return": 0x0D, + "esc": 0x1B, + "escape": 0x1B, + "space": 0x20, + "left": 0x25, + "up": 0x26, + "right": 0x27, + "down": 0x28, + "delete": 0x2E, + "plus": 0xBB, + "comma": 0xBC, + "-": 0xBD, + "minus": 0xBD, + ".": 0xBE, + "period": 0xBE, + "/": 0xBF, + "slash": 0xBF, +} + +for number in range(1, 13): + WINDOWS_SPECIAL_KEYS[f"f{number}"] = 0x70 + number - 1 + +def parse_windows_hotkey(hotkey): + parts = [part.strip().lower() for part in (hotkey or "").split("+") if part.strip()] + if not parts: + return None + + modifiers = 0 + keys = [] + for part in parts: + modifier = WINDOWS_MODIFIER_KEYS.get(part) + if modifier: + modifiers |= modifier + else: + keys.append(part) + + if len(keys) != 1: + return None + + key = keys[0] + if len(key) == 1 and "a" <= key <= "z": + virtual_key = ord(key.upper()) + elif len(key) == 1 and "0" <= key <= "9": + virtual_key = ord(key) + else: + virtual_key = WINDOWS_SPECIAL_KEYS.get(key) + + if not virtual_key: + return None + + return modifiers, virtual_key + +class KBDLLHOOKSTRUCT(ctypes.Structure): + _fields_ = [ + ("vkCode", wintypes.DWORD), + ("scanCode", wintypes.DWORD), + ("flags", wintypes.DWORD), + ("time", wintypes.DWORD), + ("dwExtraInfo", ctypes.c_void_p), + ] + +LowLevelKeyboardProc = ctypes.WINFUNCTYPE( + wintypes.LPARAM, ctypes.c_int, wintypes.WPARAM, wintypes.LPARAM +) + +class KeyboardHotkeyManager: + def clear(self): + try: + keyboard.unhook_all_hotkeys() + except Exception: + pass + + def register(self, hotkey, callback): + keyboard.add_hotkey(hotkey, callback) + return True + +class WindowsLowLevelHotkeyManager: + WH_KEYBOARD_LL = 13 + WM_KEYDOWN = 0x0100 + WM_KEYUP = 0x0101 + WM_SYSKEYDOWN = 0x0104 + WM_SYSKEYUP = 0x0105 + KEY_DOWN_MESSAGES = {WM_KEYDOWN, WM_SYSKEYDOWN} + KEY_UP_MESSAGES = {WM_KEYUP, WM_SYSKEYUP} + VK_TO_MODIFIER = { + 0x10: WINDOWS_MODIFIER_KEYS["shift"], + 0xA0: WINDOWS_MODIFIER_KEYS["shift"], + 0xA1: WINDOWS_MODIFIER_KEYS["shift"], + 0x11: WINDOWS_MODIFIER_KEYS["ctrl"], + 0xA2: WINDOWS_MODIFIER_KEYS["ctrl"], + 0xA3: WINDOWS_MODIFIER_KEYS["ctrl"], + 0x12: WINDOWS_MODIFIER_KEYS["alt"], + 0xA4: WINDOWS_MODIFIER_KEYS["alt"], + 0xA5: WINDOWS_MODIFIER_KEYS["alt"], + 0x5B: WINDOWS_MODIFIER_KEYS["windows"], + 0x5C: WINDOWS_MODIFIER_KEYS["windows"], + } + + def __init__(self, install_hook=True, fallback=None): + self.fallback = fallback or KeyboardHotkeyManager() + self.callbacks = {} + self.active_modifiers = 0 + self.active_hotkeys = set() + self.hook = None + self.user32 = None + self.kernel32 = None + self.hook_callback = None + if sys.platform == "win32": + self.user32 = ctypes.WinDLL("user32", use_last_error=True) + self.kernel32 = ctypes.WinDLL("kernel32", use_last_error=True) + self.configure_api() + self.hook_callback = LowLevelKeyboardProc(self.low_level_keyboard_proc) + if install_hook: + self.install_hook() + + def configure_api(self): + self.user32.SetWindowsHookExW.argtypes = [ + ctypes.c_int, + LowLevelKeyboardProc, + wintypes.HINSTANCE, + wintypes.DWORD, + ] + self.user32.SetWindowsHookExW.restype = wintypes.HHOOK + self.user32.UnhookWindowsHookEx.argtypes = [wintypes.HHOOK] + self.user32.UnhookWindowsHookEx.restype = wintypes.BOOL + self.user32.CallNextHookEx.argtypes = [ + wintypes.HHOOK, + ctypes.c_int, + wintypes.WPARAM, + wintypes.LPARAM, + ] + self.user32.CallNextHookEx.restype = wintypes.LPARAM + self.kernel32.GetModuleHandleW.argtypes = [wintypes.LPCWSTR] + self.kernel32.GetModuleHandleW.restype = wintypes.HMODULE + + def install_hook(self): + if self.user32 is None or self.hook: + return bool(self.hook) + + self.hook = self.user32.SetWindowsHookExW( + self.WH_KEYBOARD_LL, + self.hook_callback, + self.kernel32.GetModuleHandleW(None), + 0, + ) + if not self.hook: + print(f"Failed to install low-level hotkey hook: {ctypes.get_last_error()}") + return bool(self.hook) + + def clear(self): + self.callbacks.clear() + self.active_modifiers = 0 + self.active_hotkeys.clear() + try: + self.fallback.clear() + except Exception as e: + print(f"Failed to clear fallback hotkeys: {e}") + + def register(self, hotkey, callback): + parsed = parse_windows_hotkey(hotkey) + if parsed is None: + return self.fallback.register(hotkey, callback) + + if self.user32 is not None and not self.install_hook(): + return self.fallback.register(hotkey, callback) + + self.callbacks.setdefault(parsed, []).append(callback) + return True + + def low_level_keyboard_proc(self, n_code, w_param, l_param): + try: + if n_code >= 0: + event = ctypes.cast(l_param, ctypes.POINTER(KBDLLHOOKSTRUCT)).contents + self.process_key_event(int(w_param), int(event.vkCode)) + except Exception as e: + print(f"Failed to handle low-level hotkey event: {e}") + return self.user32.CallNextHookEx(None, n_code, w_param, l_param) + + def process_key_event(self, message, virtual_key): + if message in self.KEY_DOWN_MESSAGES: + self.handle_key_down(virtual_key) + elif message in self.KEY_UP_MESSAGES: + self.handle_key_up(virtual_key) + + def handle_key_down(self, virtual_key): + modifier = self.VK_TO_MODIFIER.get(virtual_key) + if modifier: + self.active_modifiers |= modifier + return + + hotkey = (self.active_modifiers, virtual_key) + if hotkey in self.callbacks and hotkey not in self.active_hotkeys: + self.active_hotkeys.add(hotkey) + for callback in list(self.callbacks[hotkey]): + callback() + + def handle_key_up(self, virtual_key): + modifier = self.VK_TO_MODIFIER.get(virtual_key) + if modifier: + self.active_modifiers &= ~modifier + self.active_hotkeys.clear() + return + + for hotkey in list(self.active_hotkeys): + if hotkey[1] == virtual_key: + self.active_hotkeys.discard(hotkey) + +def create_hotkey_manager(app): + if sys.platform == "win32": + return WindowsLowLevelHotkeyManager() + return KeyboardHotkeyManager() + class SignalManager(QObject): recording_finished = pyqtSignal(str, str) @@ -31,65 +258,299 @@ class HotkeyEdit(QLineEdit): Custom widget to capture hotkeys by pressing them. Maps Qt events to 'keyboard' library compatible strings. """ + CAPTURE_PROMPT = "Press shortcut..." + sequence_captured = pyqtSignal(str) + capture_cancelled = pyqtSignal() + def __init__(self, parent=None): super().__init__(parent) self.setPlaceholderText("Click to set hotkey...") - self.setReadOnly(True) + self.setReadOnly(True) self.current_sequence = None + self.is_capturing = False + self._previous_text = "" + self._keyboard_hook = None + self._modifier_scan_codes = { + "ctrl": set(), + "alt": set(), + "shift": set(), + "windows": set(), + } + self._modifier_names_by_scan_code = self.build_modifier_scan_code_lookup() + self.sequence_captured.connect(self.finish_capture) + self.capture_cancelled.connect(self.cancel_capture) + + def begin_capture(self): + if self.is_capturing: + return + self.is_capturing = True + self._previous_text = self.text() + self.setText(self.CAPTURE_PROMPT) + self.selectAll() + self.setStyleSheet("color: #666;") + self.start_keyboard_capture() + + def finish_capture(self, sequence): + self.stop_keyboard_capture() + self.is_capturing = False + self.current_sequence = sequence or None + self.setStyleSheet("") + self.setText(sequence) + self.clearFocus() + + def cancel_capture(self): + self.stop_keyboard_capture() + self.is_capturing = False + self.setStyleSheet("") + self.setText(self._previous_text) + self.clearFocus() def mousePressEvent(self, event): self.setFocus() + self.begin_capture() super().mousePressEvent(event) + def focusInEvent(self, event): + super().focusInEvent(event) + self.begin_capture() + + def focusOutEvent(self, event): + if self.is_capturing: + self.is_capturing = False + self.stop_keyboard_capture() + self.setStyleSheet("") + self.setText(self._previous_text) + super().focusOutEvent(event) + + def start_keyboard_capture(self): + if self._keyboard_hook is not None: + return + for scan_codes in self._modifier_scan_codes.values(): + scan_codes.clear() + try: + self._keyboard_hook = keyboard.hook(self.handle_keyboard_hook) + except Exception as e: + print(f"Failed to start hotkey capture hook: {e}") + + def stop_keyboard_capture(self): + if self._keyboard_hook is None: + return + try: + keyboard.unhook(self._keyboard_hook) + except Exception as e: + print(f"Failed to stop hotkey capture hook: {e}") + finally: + self._keyboard_hook = None + for scan_codes in self._modifier_scan_codes.values(): + scan_codes.clear() + + def handle_keyboard_hook(self, event): + if not self.is_capturing: + return + + key_name = self.normalize_hook_key_name(event.name) + modifier = self.modifier_name_for_hook_event(key_name, event.scan_code) + scan_code = event.scan_code + + if modifier: + if event.event_type == "down": + self._modifier_scan_codes[modifier].add(scan_code) + elif event.event_type == "up": + self._modifier_scan_codes[modifier].discard(scan_code) + return + + if event.event_type != "down": + return + + if key_name in ("esc", "escape"): + self.capture_cancelled.emit() + return + + if key_name in ("backspace", "delete"): + self.sequence_captured.emit("") + return + + sequence = self.format_hook_hotkey(key_name) + if sequence: + self.sequence_captured.emit(sequence) + + def normalize_hook_key_name(self, key_name): + key_name = (key_name or "").lower() + aliases = { + "left windows": "windows", + "right windows": "windows", + "win": "windows", + "cmd": "windows", + "+": "plus", + ",": "comma", + " ": "space", + "return": "enter", + } + return aliases.get(key_name, key_name) + + def modifier_name_for_hook_key(self, key_name): + aliases = { + "ctrl": "ctrl", + "control": "ctrl", + "left ctrl": "ctrl", + "right ctrl": "ctrl", + "alt": "alt", + "left alt": "alt", + "right alt": "alt", + "shift": "shift", + "left shift": "shift", + "right shift": "shift", + "windows": "windows", + "left windows": "windows", + "right windows": "windows", + } + return aliases.get(key_name) + + def modifier_name_for_hook_event(self, key_name, scan_code): + if scan_code in self._modifier_names_by_scan_code: + return self._modifier_names_by_scan_code[scan_code] + return self.modifier_name_for_hook_key(key_name) + + def build_modifier_scan_code_lookup(self): + lookup = {} + modifier_names = { + "ctrl": ("ctrl", "control", "left ctrl", "right ctrl"), + "alt": ("alt", "left alt", "right alt"), + "shift": ("shift", "left shift", "right shift"), + "windows": ("windows", "left windows", "right windows"), + } + + for modifier, names in modifier_names.items(): + for name in names: + try: + scan_codes = keyboard.key_to_scan_codes(name, False) + except Exception: + scan_codes = () + for scan_code in scan_codes: + lookup[scan_code] = modifier + + return lookup + + def format_hook_hotkey(self, key_name): + parts = [] + for modifier in ("ctrl", "alt", "shift", "windows"): + if self._modifier_scan_codes[modifier]: + parts.append(modifier) + + key_text = self.normalize_hook_key_name(key_name) + if not key_text or self.modifier_name_for_hook_key(key_text): + return "" + + parts.append(key_text) + return "+".join(parts) + + def event(self, event): + if event.type() == QEvent.Type.ShortcutOverride and self.is_capturing: + self.handle_hotkey_event(event) + event.accept() + return True + return super().event(event) + def keyPressEvent(self, event): - key = event.key() + self.handle_hotkey_event(event) + + def handle_hotkey_event(self, event): + key = self.key_from_event(event) modifiers = event.modifiers() - - if key == Qt.Key.Key_Backspace or key == Qt.Key.Key_Delete: - self.clear() - self.current_sequence = None + + if key in (Qt.Key.Key_Backspace.value, Qt.Key.Key_Delete.value): + self.finish_capture("") return - - if key == Qt.Key.Key_Escape: - self.clearFocus() + + if key == Qt.Key.Key_Escape.value: + self.cancel_capture() return - if key in (Qt.Key.Key_Control, Qt.Key.Key_Shift, Qt.Key.Key_Alt, Qt.Key.Key_Meta): + if key in ( + Qt.Key.Key_Control.value, + Qt.Key.Key_Shift.value, + Qt.Key.Key_Alt.value, + Qt.Key.Key_Meta.value, + ): return + final_hotkey = self.format_hotkey(key, modifiers) + if final_hotkey: + self.finish_capture(final_hotkey) + + def key_from_event(self, event): + key = event.key() + if key == Qt.Key.Key_unknown.value and event.nativeVirtualKey(): + return event.nativeVirtualKey() + return key + + def format_hotkey(self, key, modifiers): parts = [] - if modifiers & Qt.KeyboardModifier.ControlModifier: parts.append("ctrl") - if modifiers & Qt.KeyboardModifier.ShiftModifier: parts.append("shift") - if modifiers & Qt.KeyboardModifier.AltModifier: parts.append("alt") - if modifiers & Qt.KeyboardModifier.MetaModifier: parts.append("windows") - - key_text = "" - if key >= 0x20 and key <= 0x7E: - key_text = chr(key).lower() - else: - key_map = { - Qt.Key.Key_F1: "f1", Qt.Key.Key_F2: "f2", Qt.Key.Key_F3: "f3", Qt.Key.Key_F4: "f4", - Qt.Key.Key_F5: "f5", Qt.Key.Key_F6: "f6", Qt.Key.Key_F7: "f7", Qt.Key.Key_F8: "f8", - Qt.Key.Key_F9: "f9", Qt.Key.Key_F10: "f10", Qt.Key.Key_F11: "f11", Qt.Key.Key_F12: "f12", - Qt.Key.Key_Left: "left", Qt.Key.Key_Right: "right", Qt.Key.Key_Up: "up", Qt.Key.Key_Down: "down", - Qt.Key.Key_Space: "space", Qt.Key.Key_Tab: "tab", Qt.Key.Key_Return: "enter", Qt.Key.Key_Enter: "enter", - Qt.Key.Key_Backspace: "backspace", Qt.Key.Key_Delete: "delete", Qt.Key.Key_Insert: "insert", - Qt.Key.Key_Home: "home", Qt.Key.Key_End: "end", Qt.Key.Key_PageUp: "pageup", Qt.Key.Key_PageDown: "pagedown", - Qt.Key.Key_CapsLock: "capslock", Qt.Key.Key_NumLock: "numlock", Qt.Key.Key_ScrollLock: "scrolllock", - Qt.Key.Key_Print: "print_screen", Qt.Key.Key_Pause: "pause" - } - key_text = key_map.get(key) - if not key_text: - try: key_text = QKeySequence(key).toString().lower() - except: pass - - if key_text: - parts.append(key_text) - - final_hotkey = "+".join(parts) - self.setText(final_hotkey) - self.current_sequence = final_hotkey - self.clearFocus() + if modifiers & Qt.KeyboardModifier.ControlModifier: + parts.append("ctrl") + if modifiers & Qt.KeyboardModifier.AltModifier: + parts.append("alt") + if modifiers & Qt.KeyboardModifier.ShiftModifier: + parts.append("shift") + if modifiers & Qt.KeyboardModifier.MetaModifier: + parts.append("windows") + + key_text = self.key_to_text(key) + if not key_text: + return "" + + parts.append(key_text) + + return "+".join(parts) + + def key_to_text(self, key): + if Qt.Key.Key_A.value <= key <= Qt.Key.Key_Z.value: + return chr(key).lower() + + if Qt.Key.Key_0.value <= key <= Qt.Key.Key_9.value: + return chr(key) + + key_map = { + Qt.Key.Key_F1.value: "f1", + Qt.Key.Key_F2.value: "f2", + Qt.Key.Key_F3.value: "f3", + Qt.Key.Key_F4.value: "f4", + Qt.Key.Key_F5.value: "f5", + Qt.Key.Key_F6.value: "f6", + Qt.Key.Key_F7.value: "f7", + Qt.Key.Key_F8.value: "f8", + Qt.Key.Key_F9.value: "f9", + Qt.Key.Key_F10.value: "f10", + Qt.Key.Key_F11.value: "f11", + Qt.Key.Key_F12.value: "f12", + Qt.Key.Key_Left.value: "left", + Qt.Key.Key_Right.value: "right", + Qt.Key.Key_Up.value: "up", + Qt.Key.Key_Down.value: "down", + Qt.Key.Key_Space.value: "space", + Qt.Key.Key_Plus.value: "plus", + Qt.Key.Key_Comma.value: "comma", + Qt.Key.Key_Tab.value: "tab", + Qt.Key.Key_Return.value: "enter", + Qt.Key.Key_Enter.value: "enter", + Qt.Key.Key_Insert.value: "insert", + Qt.Key.Key_Home.value: "home", + Qt.Key.Key_End.value: "end", + Qt.Key.Key_PageUp.value: "pageup", + Qt.Key.Key_PageDown.value: "pagedown", + Qt.Key.Key_CapsLock.value: "capslock", + Qt.Key.Key_NumLock.value: "numlock", + Qt.Key.Key_ScrollLock.value: "scrolllock", + Qt.Key.Key_Print.value: "print_screen", + Qt.Key.Key_Pause.value: "pause", + } + if key in key_map: + return key_map[key] + + if 0x20 <= key <= 0x7E: + return chr(key).lower() + + return "" class SettingsWindow(QMainWindow): settings_saved = pyqtSignal() @@ -147,6 +608,15 @@ def init_ui(self): group_tray.setLayout(layout_tray) layout.addWidget(group_tray) + # Notifications + group_notifications = QGroupBox("Notifications") + layout_notifications = QVBoxLayout() + self.chk_notifications = QCheckBox("Show tray notifications") + self.chk_notifications.setChecked(True) + layout_notifications.addWidget(self.chk_notifications) + group_notifications.setLayout(layout_notifications) + layout.addWidget(group_notifications) + # Post-Processing group_post = QGroupBox("Post-Processing & Clipboard") layout_post = QVBoxLayout() @@ -170,9 +640,14 @@ def init_ui(self): self.hk_loop = HotkeyEdit() self.hk_both = HotkeyEdit() self.hk_stop = HotkeyEdit() + self.chk_stop_with_record_hotkeys = QCheckBox("Use record hotkeys to stop recording") + self.chk_stop_with_record_hotkeys.setToolTip("When enabled, pressing any record hotkey while recording stops the active recording instead of starting another mode.") + self.chk_stop_with_record_hotkeys.setChecked(True) + self.chk_stop_with_record_hotkeys.toggled.connect(self.update_stop_hotkey_state) layout_hotkeys.addRow("Record Mic:", self.hk_mic) layout_hotkeys.addRow("Record Loopback:", self.hk_loop) layout_hotkeys.addRow("Record Both:", self.hk_both) + layout_hotkeys.addRow("", self.chk_stop_with_record_hotkeys) layout_hotkeys.addRow("Stop Recording:", self.hk_stop) group_hotkeys.setLayout(layout_hotkeys) layout.addWidget(group_hotkeys) @@ -182,6 +657,15 @@ def init_ui(self): layout.addWidget(btn_save) self.refresh_devices() + self.update_stop_hotkey_state() + + def update_stop_hotkey_state(self): + use_record_hotkeys = self.chk_stop_with_record_hotkeys.isChecked() + self.hk_stop.setEnabled(not use_record_hotkeys) + if use_record_hotkeys: + self.hk_stop.setPlaceholderText("Using record hotkeys") + else: + self.hk_stop.setPlaceholderText("Click to set hotkey...") def refresh_devices(self): self.combo_mic.clear() @@ -205,7 +689,7 @@ def browse_folder(self): def load_settings(self): if os.path.exists(CONFIG_FILE): try: - with open(CONFIG_FILE, 'r') as f: + with open(CONFIG_FILE, 'r', encoding='utf-8') as f: data = json.load(f) self.lbl_folder.setText(data.get("output_folder", os.getcwd())) @@ -225,6 +709,11 @@ def load_settings(self): self.chk_clipboard.setChecked(data.get("clipboard", False)) self.chk_delete.setChecked(data.get("delete_after", False)) self.chk_delete.setEnabled(self.chk_clipboard.isChecked()) + self.chk_notifications.setChecked(data.get("show_notifications", True)) + stop_with_record_hotkeys = data.get("stop_with_record_hotkeys") + if stop_with_record_hotkeys is None: + stop_with_record_hotkeys = not bool(data.get("hk_stop", "")) + self.chk_stop_with_record_hotkeys.setChecked(stop_with_record_hotkeys) self.hk_mic.setText(data.get("hk_mic", "")) self.hk_loop.setText(data.get("hk_loop", "")) @@ -232,12 +721,13 @@ def load_settings(self): self.hk_stop.setText(data.get("hk_stop", "")) except Exception as e: print(f"Error loading settings: {e}") + self.update_stop_hotkey_state() def save_settings(self): data = self.get_settings() try: - with open(CONFIG_FILE, 'w') as f: - json.dump(data, f) + with open(CONFIG_FILE, 'w', encoding='utf-8') as f: + json.dump(data, f, indent=2) QMessageBox.information(self, "Settings", "Settings saved successfully.") self.settings_saved.emit() except Exception as e: @@ -249,9 +739,11 @@ def get_settings(self): "output_folder": self.lbl_folder.text(), "format": self.combo_fmt.currentText(), "tray_click_mode": self.combo_left_click.currentText(), + "show_notifications": self.chk_notifications.isChecked(), "normalize": self.chk_normalize.isChecked(), "clipboard": self.chk_clipboard.isChecked(), "delete_after": self.chk_delete.isChecked(), + "stop_with_record_hotkeys": self.chk_stop_with_record_hotkeys.isChecked(), "hk_mic": self.hk_mic.text(), "hk_loop": self.hk_loop.text(), "hk_both": self.hk_both.text(), @@ -281,8 +773,9 @@ def __init__(self, app): self.settings_window = SettingsWindow() self.settings_window.settings_saved.connect(self.register_hotkeys) + self.hotkey_manager = create_hotkey_manager(self.app) - self.tray_icon.showMessage("Ready", "Left-click to toggle recording.", QSystemTrayIcon.MessageIcon.Information, 2000) + self.show_tray_notification("Ready", "Left-click to toggle recording.", QSystemTrayIcon.MessageIcon.Information, 2000) self.register_hotkeys() def generate_icons(self): @@ -336,20 +829,47 @@ def build_menu(self): self.tray_icon.setContextMenu(self.menu) def register_hotkeys(self): - try: keyboard.unhook_all_hotkeys() # Ensure no old hotkeys are active - except: pass + hotkey_manager = getattr(self, "hotkey_manager", None) + if hotkey_manager is None: + hotkey_manager = KeyboardHotkeyManager() + self.hotkey_manager = hotkey_manager + try: + hotkey_manager.clear() + except Exception as e: + print(f"Failed to clear hotkeys: {e}") settings = self.settings_window.get_settings() hk_mic = settings.get("hk_mic") hk_loop = settings.get("hk_loop") hk_both = settings.get("hk_both") hk_stop = settings.get("hk_stop") try: - if hk_mic: keyboard.add_hotkey(hk_mic, lambda: self.start_recording("mic")) - if hk_loop: keyboard.add_hotkey(hk_loop, lambda: self.start_recording("loopback")) - if hk_both: keyboard.add_hotkey(hk_both, lambda: self.start_recording("both")) - if hk_stop: keyboard.add_hotkey(hk_stop, self.stop_recording) + if hk_mic: hotkey_manager.register(hk_mic, lambda: self.toggle_recording("mic")) + if hk_loop: hotkey_manager.register(hk_loop, lambda: self.toggle_recording("loopback")) + if hk_both: hotkey_manager.register(hk_both, lambda: self.toggle_recording("both")) + if hk_stop and not settings.get("stop_with_record_hotkeys", True): + hotkey_manager.register(hk_stop, self.stop_recording) except Exception as e: print(f"Failed to register hotkeys: {e}") + def notifications_enabled(self): + try: + return self.settings_window.get_settings().get("show_notifications", True) + except Exception: + return True + + def show_tray_notification(self, title, message, icon=QSystemTrayIcon.MessageIcon.Information, duration=2000): + notifications_enabled = getattr(self, "notifications_enabled", lambda: TrayApplication.notifications_enabled(self)) + if notifications_enabled(): + self.tray_icon.showMessage(title, message, icon, duration) + + def toggle_recording(self, mode="mic"): + if self.recorder and self.recorder.is_alive(): + settings = self.settings_window.get_settings() + if settings.get("stop_with_record_hotkeys", True): + self.stop_recording() + return + + self.start_recording(mode) + def on_tray_activated(self, reason): if reason == QSystemTrayIcon.ActivationReason.Trigger: if self.recorder and self.recorder.is_alive(): @@ -392,7 +912,7 @@ def finish_callback(path, error): self.action_stop.setEnabled(True) self.tray_icon.setIcon(QIcon(self.icon_rec_path)) self.tray_icon.setToolTip(f"Recording ({mode})...") - self.tray_icon.showMessage("Started", f"Recording {mode}", QSystemTrayIcon.MessageIcon.NoIcon, 1000) + self.show_tray_notification("Started", f"Recording {mode}", QSystemTrayIcon.MessageIcon.NoIcon, 1000) def stop_recording(self): if self.recorder: self.recorder.stop() @@ -407,7 +927,7 @@ def on_recording_finished(self, path, error): self.recorder = None if error: - self.tray_icon.showMessage("Error", f"Recording failed: {error}", QSystemTrayIcon.MessageIcon.Critical, 4000) + self.show_tray_notification("Error", f"Recording failed: {error}", QSystemTrayIcon.MessageIcon.Critical, 4000) return settings = self.settings_window.get_settings() @@ -440,8 +960,12 @@ def on_recording_finished(self, path, error): except Exception as e: msg += f"\nClipboard/Move error: {e}" - self.tray_icon.showMessage("Finished", msg, QSystemTrayIcon.MessageIcon.Information, 2000) + self.show_tray_notification("Finished", msg, QSystemTrayIcon.MessageIcon.Information, 2000) def exit_app(self): if self.recorder: self.recorder.stop() + try: + self.hotkey_manager.clear() + except Exception: + pass self.app.quit() diff --git a/tests/test_gui_hotkeys.py b/tests/test_gui_hotkeys.py new file mode 100644 index 0000000..3cde74a --- /dev/null +++ b/tests/test_gui_hotkeys.py @@ -0,0 +1,463 @@ +import os +import json +import tempfile +import unittest +from types import SimpleNamespace +from unittest.mock import patch + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + +from PyQt6.QtCore import QEvent, Qt +from PyQt6.QtGui import QKeyEvent +from PyQt6.QtWidgets import QApplication + +from gui import ( + HotkeyEdit, + SettingsWindow, + TrayApplication, + WindowsLowLevelHotkeyManager, + parse_windows_hotkey, +) + + +class FakeRecorder: + def __init__(self, alive): + self.alive = alive + + def is_alive(self): + return self.alive + + +class FakeSettingsWindow: + def __init__(self, settings): + self.settings = settings + + def get_settings(self): + return dict(self.settings) + + +class FakeTrayIcon: + def __init__(self): + self.messages = [] + + def showMessage(self, title, message, icon, duration): + self.messages.append((title, message, icon, duration)) + + +class FakeHotkeyManager: + def __init__(self): + self.cleared = False + self.registrations = [] + + def clear(self): + self.cleared = True + + def register(self, hotkey, callback): + self.registrations.append((hotkey, callback)) + + +class HotkeyEditTests(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.app = QApplication.instance() or QApplication([]) + + def test_alt_shift_letter_hotkey_is_captured(self): + edit = HotkeyEdit() + edit.begin_capture() + + event = QKeyEvent( + QEvent.Type.KeyPress, + Qt.Key.Key_R.value, + Qt.KeyboardModifier.AltModifier | Qt.KeyboardModifier.ShiftModifier, + ) + edit.keyPressEvent(event) + + self.assertEqual(edit.text(), "alt+shift+r") + + def test_shortcut_override_alt_shift_letter_is_captured(self): + edit = HotkeyEdit() + edit.begin_capture() + + event = QKeyEvent( + QEvent.Type.ShortcutOverride, + Qt.Key.Key_R.value, + Qt.KeyboardModifier.AltModifier | Qt.KeyboardModifier.ShiftModifier, + ) + QApplication.sendEvent(edit, event) + + self.assertEqual(edit.text(), "alt+shift+r") + self.assertTrue(event.isAccepted()) + + def test_native_virtual_key_fallback_captures_letter(self): + edit = HotkeyEdit() + edit.begin_capture() + + event = QKeyEvent( + QEvent.Type.KeyPress, + Qt.Key.Key_unknown.value, + Qt.KeyboardModifier.AltModifier | Qt.KeyboardModifier.ShiftModifier, + 0, + ord("R"), + 0, + ) + edit.keyPressEvent(event) + + self.assertEqual(edit.text(), "alt+shift+r") + + def test_keyboard_hook_captures_alt_shift_letter(self): + for alt_name in ("alt", "left alt", "right alt"): + with self.subTest(alt_name=alt_name): + edit = HotkeyEdit() + edit.is_capturing = True + + edit.handle_keyboard_hook( + SimpleNamespace(event_type="down", name=alt_name, scan_code=56) + ) + edit.handle_keyboard_hook( + SimpleNamespace(event_type="down", name="shift", scan_code=42) + ) + edit.handle_keyboard_hook( + SimpleNamespace(event_type="down", name="r", scan_code=19) + ) + + self.assertEqual(edit.text(), "alt+shift+r") + + def test_keyboard_hook_captures_physical_alt_when_ctrl_alt_are_swapped(self): + cases = ( + ("ctrl", 56, "alt+shift+r"), + ("alt", 29, "ctrl+shift+r"), + ) + + for mapped_name, scan_code, expected in cases: + with self.subTest(mapped_name=mapped_name, scan_code=scan_code): + edit = HotkeyEdit() + edit.is_capturing = True + + edit.handle_keyboard_hook( + SimpleNamespace( + event_type="down", name=mapped_name, scan_code=scan_code + ) + ) + edit.handle_keyboard_hook( + SimpleNamespace(event_type="down", name="shift", scan_code=42) + ) + edit.handle_keyboard_hook( + SimpleNamespace(event_type="down", name="r", scan_code=19) + ) + + self.assertEqual(edit.text(), expected) + + def test_capture_prompt_is_visible_and_restores_on_escape(self): + edit = HotkeyEdit() + edit.setText("ctrl+alt+r") + + edit.begin_capture() + self.assertEqual(edit.text(), "Press shortcut...") + + event = QKeyEvent( + QEvent.Type.KeyPress, + Qt.Key.Key_Escape.value, + Qt.KeyboardModifier.NoModifier, + ) + edit.keyPressEvent(event) + + self.assertEqual(edit.text(), "ctrl+alt+r") + + def test_delete_clears_hotkey(self): + edit = HotkeyEdit() + edit.setText("ctrl+alt+r") + edit.begin_capture() + + event = QKeyEvent( + QEvent.Type.KeyPress, + Qt.Key.Key_Delete.value, + Qt.KeyboardModifier.NoModifier, + ) + edit.keyPressEvent(event) + + self.assertEqual(edit.text(), "") + + def test_punctuation_hotkeys_are_captured(self): + cases = ( + (Qt.Key.Key_Minus.value, "ctrl+alt+-"), + (Qt.Key.Key_Slash.value, "ctrl+alt+/"), + ) + + for key, expected in cases: + with self.subTest(expected=expected): + edit = HotkeyEdit() + edit.begin_capture() + + event = QKeyEvent( + QEvent.Type.KeyPress, + key, + Qt.KeyboardModifier.ControlModifier | Qt.KeyboardModifier.AltModifier, + ) + edit.keyPressEvent(event) + + self.assertEqual(edit.text(), expected) + + def test_hotkey_separator_punctuation_uses_keyboard_names(self): + cases = ( + (Qt.Key.Key_Plus.value, "ctrl+alt+plus"), + (Qt.Key.Key_Comma.value, "ctrl+alt+comma"), + ) + + for key, expected in cases: + with self.subTest(expected=expected): + edit = HotkeyEdit() + edit.begin_capture() + + event = QKeyEvent( + QEvent.Type.KeyPress, + key, + Qt.KeyboardModifier.ControlModifier | Qt.KeyboardModifier.AltModifier, + ) + edit.keyPressEvent(event) + + self.assertEqual(edit.text(), expected) + + def test_modifier_only_unknown_key_is_ignored(self): + edit = HotkeyEdit() + + self.assertEqual( + edit.format_hotkey( + 0, + Qt.KeyboardModifier.ControlModifier | Qt.KeyboardModifier.AltModifier, + ), + "", + ) + + +class WindowsHotkeyParserTests(unittest.TestCase): + def test_parse_alt_shift_letter_for_register_hotkey(self): + self.assertEqual(parse_windows_hotkey("alt+shift+r"), (0x0001 | 0x0004, 0x52)) + + def test_parse_common_keys_for_register_hotkey(self): + cases = { + "ctrl+alt+plus": (0x0002 | 0x0001, 0xBB), + "ctrl+alt+comma": (0x0002 | 0x0001, 0xBC), + "ctrl+alt+-": (0x0002 | 0x0001, 0xBD), + "ctrl+alt+/": (0x0002 | 0x0001, 0xBF), + "windows+shift+f12": (0x0008 | 0x0004, 0x7B), + } + + for hotkey, expected in cases.items(): + with self.subTest(hotkey=hotkey): + self.assertEqual(parse_windows_hotkey(hotkey), expected) + + def test_parse_unknown_key_returns_none(self): + self.assertIsNone(parse_windows_hotkey("alt+shift+unknown-key")) + + +class WindowsLowLevelHotkeyManagerTests(unittest.TestCase): + def test_alt_shift_letter_triggers_from_low_level_events(self): + manager = WindowsLowLevelHotkeyManager(install_hook=False) + calls = [] + manager.register("alt+shift+r", lambda: calls.append("mic")) + + manager.process_key_event(0x0104, 0xA4) + manager.process_key_event(0x0100, 0xA0) + manager.process_key_event(0x0100, 0x52) + + self.assertEqual(calls, ["mic"]) + + def test_repeated_keydown_does_not_repeat_until_keyup(self): + manager = WindowsLowLevelHotkeyManager(install_hook=False) + calls = [] + manager.register("alt+shift+r", lambda: calls.append("mic")) + + manager.process_key_event(0x0104, 0xA4) + manager.process_key_event(0x0100, 0xA0) + manager.process_key_event(0x0100, 0x52) + manager.process_key_event(0x0100, 0x52) + manager.process_key_event(0x0101, 0x52) + manager.process_key_event(0x0100, 0x52) + + self.assertEqual(calls, ["mic", "mic"]) + + def test_clear_removes_low_level_registrations(self): + manager = WindowsLowLevelHotkeyManager(install_hook=False) + calls = [] + manager.register("alt+shift+r", lambda: calls.append("mic")) + + manager.clear() + manager.process_key_event(0x0104, 0xA4) + manager.process_key_event(0x0100, 0xA0) + manager.process_key_event(0x0100, 0x52) + + self.assertEqual(calls, []) + + +class SettingsWindowLegacyHotkeyTests(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.app = QApplication.instance() or QApplication([]) + + def make_settings_window(self, settings): + temp_dir = tempfile.TemporaryDirectory() + self.addCleanup(temp_dir.cleanup) + previous_cwd = os.getcwd() + os.chdir(temp_dir.name) + self.addCleanup(os.chdir, previous_cwd) + + with open("settings.json", "w", encoding="utf-8") as f: + json.dump(settings, f) + + patches = [ + patch("gui.get_devices", return_value=[{"name": "Default Mic", "id": "mic1"}]), + patch("gui.sc.default_microphone", return_value=SimpleNamespace(id="mic1")), + ] + for patcher in patches: + patcher.start() + self.addCleanup(patcher.stop) + + window = SettingsWindow() + self.addCleanup(window.close) + return window + + def test_legacy_stop_hotkey_keeps_dedicated_stop_enabled(self): + window = self.make_settings_window({"hk_stop": "ctrl+alt+s"}) + + self.assertFalse(window.chk_stop_with_record_hotkeys.isChecked()) + self.assertTrue(window.hk_stop.isEnabled()) + self.assertEqual(window.hk_stop.text(), "ctrl+alt+s") + + def test_legacy_settings_without_stop_hotkey_use_record_hotkeys_to_stop(self): + window = self.make_settings_window({}) + + self.assertTrue(window.chk_stop_with_record_hotkeys.isChecked()) + self.assertFalse(window.hk_stop.isEnabled()) + + def test_explicit_stop_with_record_hotkeys_true_overrides_legacy_stop_hotkey(self): + window = self.make_settings_window( + {"hk_stop": "ctrl+alt+s", "stop_with_record_hotkeys": True} + ) + + self.assertTrue(window.chk_stop_with_record_hotkeys.isChecked()) + self.assertFalse(window.hk_stop.isEnabled()) + self.assertEqual(window.hk_stop.text(), "ctrl+alt+s") + + def test_explicit_stop_with_record_hotkeys_false_keeps_dedicated_stop_enabled(self): + window = self.make_settings_window( + {"hk_stop": "ctrl+alt+s", "stop_with_record_hotkeys": False} + ) + + self.assertFalse(window.chk_stop_with_record_hotkeys.isChecked()) + self.assertTrue(window.hk_stop.isEnabled()) + self.assertEqual(window.hk_stop.text(), "ctrl+alt+s") + + +class TrayApplicationHotkeyTests(unittest.TestCase): + def test_register_hotkeys_uses_app_hotkey_manager(self): + hotkey_manager = FakeHotkeyManager() + subject = SimpleNamespace( + hotkey_manager=hotkey_manager, + settings_window=FakeSettingsWindow( + { + "hk_mic": "alt+shift+r", + "hk_loop": "ctrl+shift+l", + "hk_both": "", + "hk_stop": "ctrl+shift+s", + "stop_with_record_hotkeys": False, + } + ), + toggled=[], + stopped=False, + ) + subject.toggle_recording = lambda mode: subject.toggled.append(mode) + subject.stop_recording = lambda: setattr(subject, "stopped", True) + + with patch("gui.keyboard.add_hotkey") as add_hotkey, patch( + "gui.keyboard.unhook_all_hotkeys" + ) as unhook_all_hotkeys: + TrayApplication.register_hotkeys(subject) + + self.assertTrue(hotkey_manager.cleared) + self.assertEqual([item[0] for item in hotkey_manager.registrations], [ + "alt+shift+r", + "ctrl+shift+l", + "ctrl+shift+s", + ]) + self.assertFalse(add_hotkey.called) + self.assertFalse(unhook_all_hotkeys.called) + + hotkey_manager.registrations[0][1]() + hotkey_manager.registrations[2][1]() + + self.assertEqual(subject.toggled, ["mic"]) + self.assertTrue(subject.stopped) + + def test_record_hotkey_stops_active_recording_when_option_enabled(self): + subject = SimpleNamespace( + recorder=FakeRecorder(alive=True), + settings_window=FakeSettingsWindow({"stop_with_record_hotkeys": True}), + stopped=False, + started=None, + ) + subject.stop_recording = lambda: setattr(subject, "stopped", True) + subject.start_recording = lambda mode: setattr(subject, "started", mode) + + TrayApplication.toggle_recording(subject, "mic") + + self.assertTrue(subject.stopped) + self.assertIsNone(subject.started) + + def test_record_hotkey_does_not_switch_mode_when_option_disabled(self): + subject = SimpleNamespace( + recorder=FakeRecorder(alive=True), + settings_window=FakeSettingsWindow({"stop_with_record_hotkeys": False}), + stopped=False, + started=None, + ) + subject.stop_recording = lambda: setattr(subject, "stopped", True) + subject.start_recording = lambda mode: setattr(subject, "started", mode) + + TrayApplication.toggle_recording(subject, "loopback") + + self.assertFalse(subject.stopped) + self.assertIsNone(subject.started) + + def test_record_hotkey_starts_recording_when_idle(self): + subject = SimpleNamespace( + recorder=None, + settings_window=FakeSettingsWindow({"stop_with_record_hotkeys": True}), + stopped=False, + started=None, + ) + subject.stop_recording = lambda: setattr(subject, "stopped", True) + subject.start_recording = lambda mode: setattr(subject, "started", mode) + + TrayApplication.toggle_recording(subject, "both") + + self.assertFalse(subject.stopped) + self.assertEqual(subject.started, "both") + + +class TrayApplicationNotificationTests(unittest.TestCase): + def test_notification_is_skipped_when_disabled(self): + subject = SimpleNamespace( + tray_icon=FakeTrayIcon(), + settings_window=FakeSettingsWindow({"show_notifications": False}), + ) + + TrayApplication.show_tray_notification(subject, "Started", "Recording mic") + + self.assertEqual(subject.tray_icon.messages, []) + + def test_notification_is_sent_when_enabled(self): + subject = SimpleNamespace( + tray_icon=FakeTrayIcon(), + settings_window=FakeSettingsWindow({"show_notifications": True}), + ) + + TrayApplication.show_tray_notification(subject, "Started", "Recording mic", duration=1234) + + self.assertEqual(len(subject.tray_icon.messages), 1) + self.assertEqual(subject.tray_icon.messages[0][0], "Started") + self.assertEqual(subject.tray_icon.messages[0][1], "Recording mic") + self.assertEqual(subject.tray_icon.messages[0][3], 1234) + + +if __name__ == "__main__": + unittest.main()