Add keyboard shortcuts (#409)

This commit is contained in:
Chidi Williams 2023-04-23 08:38:30 +01:00 committed by GitHub
commit b4a095ef3a
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
14 changed files with 318 additions and 44 deletions

8
buzz/assets.py Normal file
View file

@ -0,0 +1,8 @@
import os
import sys
def get_asset_path(path: str):
if getattr(sys, 'frozen', False):
return os.path.join(os.path.dirname(sys.executable), path)
return os.path.join(os.path.dirname(__file__), '..', path)

View file

@ -1,5 +1,4 @@
import enum
import gettext
import json
import logging
import os
@ -14,7 +13,7 @@ import sounddevice
from PyQt6 import QtGui
from PyQt6.QtCore import (QObject, Qt, QThread,
QTimer, QUrl, pyqtSignal, QModelIndex, QSize, QPoint,
QUrlQuery, QMetaObject, QEvent, QLocale)
QUrlQuery, QMetaObject, QEvent)
from PyQt6.QtGui import (QAction, QCloseEvent, QDesktopServices, QIcon,
QKeySequence, QPixmap, QTextCursor, QValidator, QKeyEvent, QPainter, QColor)
from PyQt6.QtNetwork import QNetworkAccessManager, QNetworkReply, QNetworkRequest
@ -28,32 +27,19 @@ from whisper import tokenizer
from buzz.cache import TasksCache
from .__version__ import VERSION
from .assets import get_asset_path
from .locale import _
from .model_loader import ModelLoader, WhisperModelSize, ModelType, TranscriptionModel
from .recording import RecordingAmplitudeListener
from .settings import Settings, APP_NAME
from .settings.settings import Settings, APP_NAME
from .settings.shortcut import Shortcut
from .settings.shortcut_settings import ShortcutSettings
from .transcriber import (SUPPORTED_OUTPUT_FORMATS, FileTranscriptionOptions, OutputFormat,
Task,
get_default_output_file_path, segments_to_text, write_output, TranscriptionOptions,
FileTranscriberQueueWorker, FileTranscriptionTask, RecordingTranscriber, LOADED_WHISPER_DLL,
DEFAULT_WHISPER_TEMPERATURE)
def get_asset_path(path: str):
if getattr(sys, 'frozen', False):
return os.path.join(os.path.dirname(sys.executable), path)
return os.path.join(os.path.dirname(__file__), '..', path)
if 'LANG' not in os.environ:
language = str(QLocale().uiLanguages()[0]).replace("-", "_")
os.environ['LANG'] = language
locale_dir = get_asset_path('locale')
gettext.bindtextdomain('buzz', locale_dir)
translate = gettext.translation(APP_NAME, locale_dir, fallback=True)
_ = translate.gettext
from .widgets.preferences_dialog import PreferencesDialog
def get_platform_styles(all_platform_styles: Dict[str, str]):
@ -762,8 +748,6 @@ class AboutDialog(QDialog):
parent: Optional[QWidget] = None) -> None:
super().__init__(parent)
self.setFixedSize(200, 250)
self.setWindowIcon(QIcon(BUZZ_ICON_PATH))
self.setWindowTitle(f'{_("About")} {APP_NAME}')
@ -835,6 +819,8 @@ class TranscriptionTasksTableWidget(QTableWidget):
FILE_NAME_COLUMN_INDEX = 1
STATUS_COLUMN_INDEX = 2
return_clicked = pyqtSignal()
def __init__(self, parent: Optional[QWidget] = None):
super().__init__(parent)
@ -906,6 +892,11 @@ class TranscriptionTasksTableWidget(QTableWidget):
sibling_index = index.siblingAtColumn(TranscriptionTasksTableWidget.TASK_ID_COLUMN_INDEX).data()
return int(sibling_index) if sibling_index is not None else None
def keyPressEvent(self, event: QtGui.QKeyEvent) -> None:
if event.key() == Qt.Key.Key_Return:
self.return_clicked.emit()
super().keyPressEvent(event)
class MainWindowToolbar(QToolBar):
new_transcription_action_triggered: pyqtSignal
@ -914,15 +905,14 @@ class MainWindowToolbar(QToolBar):
ICON_LIGHT_THEME_BACKGROUND = '#555'
ICON_DARK_THEME_BACKGROUND = '#AAA'
def __init__(self, parent: Optional[QWidget]):
def __init__(self, shortcuts: Dict[str, str], parent: Optional[QWidget]):
super().__init__(parent)
record_action = QAction(self.load_icon(RECORD_ICON_PATH), _('Record'), self)
record_action.triggered.connect(self.on_record_action_triggered)
self.record_action = QAction(self.load_icon(RECORD_ICON_PATH), _('Record'), self)
self.record_action.triggered.connect(self.on_record_action_triggered)
new_transcription_action = QAction(
self.load_icon(ADD_ICON_PATH), _('New Transcription'), self)
self.new_transcription_action_triggered = new_transcription_action.triggered
self.new_transcription_action = QAction(self.load_icon(ADD_ICON_PATH), _('New Transcription'), self)
self.new_transcription_action_triggered = self.new_transcription_action.triggered
self.open_transcript_action = QAction(self.load_icon(EXPAND_ICON_PATH),
_('Open Transcript'), self)
@ -937,9 +927,11 @@ class MainWindowToolbar(QToolBar):
self.clear_history_action_triggered = self.clear_history_action.triggered
self.clear_history_action.setDisabled(True)
self.addAction(record_action)
self.set_shortcuts(shortcuts)
self.addAction(self.record_action)
self.addSeparator()
self.addAction(new_transcription_action)
self.addAction(self.new_transcription_action)
self.addAction(self.open_transcript_action)
self.addAction(self.stop_transcription_action)
self.addAction(self.clear_history_action)
@ -957,6 +949,14 @@ class MainWindowToolbar(QToolBar):
self.widgetForAction(self.actions()[0]).setStyleSheet(
'QToolButton { margin-left: 9px; margin-right: 1px; }')
def set_shortcuts(self, shortcuts: Dict[str, str]):
self.record_action.setShortcut(QKeySequence.fromString(shortcuts[Shortcut.OPEN_RECORD_WINDOW.name]))
self.new_transcription_action.setShortcut(QKeySequence.fromString(shortcuts[Shortcut.OPEN_IMPORT_WINDOW.name]))
self.open_transcript_action.setShortcut(
QKeySequence.fromString(shortcuts[Shortcut.OPEN_TRANSCRIPT_EDITOR.name]))
self.stop_transcription_action.setShortcut(QKeySequence.fromString(shortcuts[Shortcut.STOP_TRANSCRIPTION.name]))
self.clear_history_action.setShortcut(QKeySequence.fromString(shortcuts[Shortcut.CLEAR_HISTORY.name]))
def load_icon(self, file_path: str):
is_dark_theme = self.palette().window().color().black() > 127
return self.load_icon_with_color(file_path,
@ -1003,24 +1003,31 @@ class MainWindow(QMainWindow):
self.tasks_cache = tasks_cache
self.settings = Settings()
self.shortcut_settings = ShortcutSettings(settings=self.settings)
self.shortcuts = self.shortcut_settings.load()
self.tasks = {}
self.tasks_changed.connect(self.on_tasks_changed)
self.toolbar = MainWindowToolbar(self)
self.toolbar = MainWindowToolbar(shortcuts=self.shortcuts, parent=self)
self.toolbar.new_transcription_action_triggered.connect(self.on_new_transcription_action_triggered)
self.toolbar.open_transcript_action_triggered.connect(self.on_open_transcript_action_triggered)
self.toolbar.open_transcript_action_triggered.connect(self.open_transcript_viewer)
self.toolbar.clear_history_action_triggered.connect(self.on_clear_history_action_triggered)
self.toolbar.stop_transcription_action_triggered.connect(self.on_stop_transcription_action_triggered)
self.addToolBar(self.toolbar)
self.setUnifiedTitleAndToolBarOnMac(True)
menu_bar = MenuBar(self)
menu_bar.import_action_triggered.connect(
self.menu_bar = MenuBar(shortcuts=self.shortcuts, parent=self)
self.menu_bar.import_action_triggered.connect(
self.on_new_transcription_action_triggered)
self.setMenuBar(menu_bar)
self.menu_bar.shortcuts_changed.connect(self.on_shortcuts_changed)
self.setMenuBar(self.menu_bar)
self.table_widget = TranscriptionTasksTableWidget(self)
self.table_widget.doubleClicked.connect(self.on_table_double_clicked)
self.table_widget.return_clicked.connect(self.open_transcript_viewer)
self.table_widget.itemSelectionChanged.connect(
self.on_table_selection_changed)
@ -1119,7 +1126,7 @@ class MainWindow(QMainWindow):
def on_openai_access_token_changed(self, access_token: str):
self.openai_access_token = access_token
def on_open_transcript_action_triggered(self):
def open_transcript_viewer(self):
selected_rows = self.table_widget.selectionModel().selectedRows()
for selected_row in selected_rows:
task_id = TranscriptionTasksTableWidget.find_task_id(selected_row)
@ -1182,11 +1189,18 @@ class MainWindow(QMainWindow):
self.toolbar.set_clear_history_action_enabled(self.should_enable_clear_history_action())
self.save_tasks_to_cache()
def on_shortcuts_changed(self, shortcuts: dict):
self.shortcuts = shortcuts
self.menu_bar.set_shortcuts(shortcuts=self.shortcuts)
self.toolbar.set_shortcuts(shortcuts=self.shortcuts)
self.shortcut_settings.save(shortcuts=self.shortcuts)
def closeEvent(self, event: QtGui.QCloseEvent) -> None:
self.transcriber_worker.stop()
self.transcriber_thread.quit()
self.transcriber_thread.wait()
self.save_tasks_to_cache()
self.shortcut_settings.save(shortcuts=self.shortcuts)
super().closeEvent(event)
@ -1444,23 +1458,31 @@ class TranscriptionOptionsGroupBox(QGroupBox):
class MenuBar(QMenuBar):
import_action_triggered = pyqtSignal()
shortcuts_changed = pyqtSignal(dict)
def __init__(self, parent: QWidget):
def __init__(self, shortcuts: Dict[str, str], parent: QWidget):
super().__init__(parent)
import_action = QAction(_("Import Media File..."), self)
import_action.triggered.connect(
self.on_import_action_triggered)
import_action.setShortcut(QKeySequence.fromString('Ctrl+O'))
self.shortcuts = shortcuts
about_action = QAction(f'{_("About")} {APP_NAME}', self)
self.import_action = QAction(_("Import Media File..."), self)
self.import_action.triggered.connect(
self.on_import_action_triggered)
about_action = QAction(f'{_("About...")} {APP_NAME}', self)
about_action.triggered.connect(self.on_about_action_triggered)
self.preferences_action = QAction(_("Preferences..."), self)
self.preferences_action.triggered.connect(self.on_preferences_action_triggered)
self.set_shortcuts(shortcuts)
file_menu = self.addMenu(_("File"))
file_menu.addAction(import_action)
file_menu.addAction(self.import_action)
help_menu = self.addMenu(_("Help"))
help_menu.addAction(about_action)
help_menu.addAction(self.preferences_action)
def on_import_action_triggered(self):
self.import_action_triggered.emit()
@ -1469,6 +1491,17 @@ class MenuBar(QMenuBar):
about_dialog = AboutDialog(parent=self)
about_dialog.open()
def on_preferences_action_triggered(self):
preferences_dialog = PreferencesDialog(shortcuts=self.shortcuts, parent=self)
preferences_dialog.shortcuts_changed.connect(self.shortcuts_changed)
preferences_dialog.open()
def set_shortcuts(self, shortcuts: Dict[str, str]):
self.shortcuts = shortcuts
self.import_action.setShortcut(QKeySequence.fromString(shortcuts[Shortcut.OPEN_IMPORT_WINDOW.name]))
self.preferences_action.setShortcut(QKeySequence.fromString(shortcuts[Shortcut.OPEN_PREFERENCES_WINDOW.name]))
class Application(QApplication):
window: MainWindow

18
buzz/locale.py Normal file
View file

@ -0,0 +1,18 @@
import gettext
import os
from PyQt6.QtCore import QLocale
from buzz.assets import get_asset_path
from buzz.settings.settings import APP_NAME
if 'LANG' not in os.environ:
language = str(QLocale().uiLanguages()[0]).replace("-", "_")
os.environ['LANG'] = language
locale_dir = get_asset_path('locale')
gettext.bindtextdomain('buzz', locale_dir)
translate = gettext.translation(APP_NAME, locale_dir, fallback=True)
_ = translate.gettext

View file

View file

@ -24,6 +24,8 @@ class Settings:
FILE_TRANSCRIBER_WORD_LEVEL_TIMINGS = 'file-transcriber/word-level-timings'
FILE_TRANSCRIBER_EXPORT_FORMATS = 'file-transcriber/export-formats'
SHORTCUTS = 'shortcuts'
def set_value(self, key: Key, value: typing.Any) -> None:
self.settings.setValue(key.value, value)

26
buzz/settings/shortcut.py Normal file
View file

@ -0,0 +1,26 @@
import enum
import typing
class Shortcut(str, enum.Enum):
sequence: str
description: str
def __new__(cls, sequence: str, description: str):
obj = str.__new__(cls, sequence)
obj._value_ = sequence
obj.sequence = sequence
obj.description = description
return obj
OPEN_RECORD_WINDOW = ('Ctrl+R', "Open Record Window")
OPEN_IMPORT_WINDOW = ('Ctrl+O', "Import File")
OPEN_PREFERENCES_WINDOW = ('Ctrl+,', 'Open Preferences Window')
OPEN_TRANSCRIPT_EDITOR = ('Ctrl+E', "Open Transcript Viewer")
CLEAR_HISTORY = ('Ctrl+S', "Clear History")
STOP_TRANSCRIPTION = ('Ctrl+X', "Cancel Transcription")
@staticmethod
def get_default_shortcuts() -> typing.Dict[str, str]:
return {shortcut.name: shortcut.sequence for shortcut in Shortcut}

View file

@ -0,0 +1,19 @@
import typing
from buzz.settings.settings import Settings
from buzz.settings.shortcut import Shortcut
class ShortcutSettings:
def __init__(self, settings: Settings):
self.settings = settings
def load(self) -> typing.Dict[str, str]:
shortcuts = Shortcut.get_default_shortcuts()
custom_shortcuts: typing.Dict[str, str] = self.settings.value(Settings.Key.SHORTCUTS, {})
for shortcut_name in custom_shortcuts:
shortcuts[shortcut_name] = custom_shortcuts[shortcut_name]
return shortcuts
def save(self, shortcuts: typing.Dict[str, str]) -> None:
self.settings.set_value(Settings.Key.SHORTCUTS, shortcuts)

0
buzz/widgets/__init__.py Normal file
View file

View file

@ -0,0 +1,35 @@
from typing import Dict, Optional
from PyQt6.QtCore import pyqtSignal
from PyQt6.QtWidgets import QDialog, QWidget, QVBoxLayout, QTabWidget, QDialogButtonBox
from buzz.locale import _
from buzz.widgets.shortcuts_editor_widget import ShortcutsEditorWidget
class PreferencesDialog(QDialog):
shortcuts_changed = pyqtSignal(dict)
def __init__(self, shortcuts: Dict[str, str], parent: Optional[QWidget] = None) -> None:
super().__init__(parent)
self.setWindowTitle('Preferences')
layout = QVBoxLayout(self)
tab_widget = QTabWidget(self)
shortcuts_table_widget = ShortcutsEditorWidget(shortcuts, self)
shortcuts_table_widget.shortcuts_changed.connect(self.shortcuts_changed)
tab_widget.addTab(shortcuts_table_widget, _('Shortcuts'))
button_box = QDialogButtonBox(QDialogButtonBox.StandardButton(
QDialogButtonBox.StandardButton.Ok), self)
button_box.accepted.connect(self.accept)
button_box.rejected.connect(self.reject)
layout.addWidget(tab_widget)
layout.addWidget(button_box)
self.setLayout(layout)
self.setFixedSize(self.sizeHint())

View file

@ -0,0 +1,71 @@
import platform
from typing import Optional, Dict
from PyQt6 import QtGui
from PyQt6.QtCore import Qt, pyqtSignal
from PyQt6.QtGui import QKeySequence
from PyQt6.QtWidgets import QKeySequenceEdit, QWidget, QFormLayout, QPushButton
from buzz.settings.shortcut import Shortcut
class ShortcutsEditorWidget(QWidget):
shortcuts_changed = pyqtSignal(dict)
def __init__(self, shortcuts: Dict[str, str], parent: Optional[QWidget] = None):
super().__init__(parent)
self.shortcuts = shortcuts
self.layout = QFormLayout(self)
for shortcut in Shortcut:
sequence_edit = SequenceEdit(shortcuts.get(shortcut.name, ''), self)
sequence_edit.keySequenceChanged.connect(self.get_key_sequence_changed(shortcut.name))
self.layout.addRow(shortcut.description, sequence_edit)
reset_to_defaults_button = QPushButton('Reset to Defaults', self)
reset_to_defaults_button.setDefault(False)
reset_to_defaults_button.setAutoDefault(False)
reset_to_defaults_button.clicked.connect(self.reset_to_defaults)
self.layout.addWidget(reset_to_defaults_button)
def get_key_sequence_changed(self, shortcut_name: str):
def key_sequence_changed(sequence: QKeySequence):
self.shortcuts[shortcut_name] = sequence.toString()
self.shortcuts_changed.emit(self.shortcuts)
return key_sequence_changed
def reset_to_defaults(self):
self.shortcuts = Shortcut.get_default_shortcuts()
for i, shortcut in enumerate(Shortcut):
sequence_edit = self.layout.itemAt(i, QFormLayout.ItemRole.FieldRole).widget()
assert isinstance(sequence_edit, SequenceEdit)
sequence_edit.setKeySequence(QKeySequence(self.shortcuts[shortcut.name]))
self.shortcuts_changed.emit(self.shortcuts)
class SequenceEdit(QKeySequenceEdit):
def __init__(self, sequence: str, parent: Optional[QWidget] = None):
super().__init__(sequence, parent)
self.setClearButtonEnabled(True)
if platform.system() == 'Darwin':
self.setStyleSheet('QLineEdit:focus { border: 2px solid #4d90fe; }')
def keyPressEvent(self, event: QtGui.QKeyEvent) -> None:
key = event.key()
# The shortcut editor always focuses on the sequence edit widgets, so we need to
# manually capture Esc key presses to close the dialog. The downside being that
# the user can't set a shortcut that contains the Esc key.
if key == Qt.Key.Key_Escape:
self.parent().keyPressEvent(event)
return
# Ignore pressing *only* modifier keys
if key == Qt.Key.Key_Control or key == Qt.Key.Key_Shift or key == Qt.Key.Key_Alt or key == Qt.Key.Key_Meta:
return
super().keyPressEvent(event)

View file

@ -235,7 +235,7 @@ class TestMainWindow:
window.close()
@pytest.mark.parametrize('tasks_cache', [mock_tasks], indirect=True)
def test_should_open_transcription_viewer(self, qtbot, tasks_cache):
def test_should_open_transcription_viewer_when_menu_action_is_clicked(self, qtbot, tasks_cache):
window = MainWindow(tasks_cache=tasks_cache)
qtbot.add_widget(window)
@ -250,6 +250,21 @@ class TestMainWindow:
window.close()
@pytest.mark.parametrize('tasks_cache', [mock_tasks], indirect=True)
def test_should_open_transcription_viewer_when_return_clicked(self, qtbot, tasks_cache):
window = MainWindow(tasks_cache=tasks_cache)
qtbot.add_widget(window)
table_widget: QTableWidget = window.findChild(QTableWidget)
table_widget.selectRow(0)
table_widget.keyPressEvent(
QKeyEvent(QKeyEvent.Type.KeyPress, Qt.Key.Key_Return, Qt.KeyboardModifier.NoModifier, '\r'))
transcription_viewer = window.findChild(TranscriptionViewerWidget)
assert transcription_viewer is not None
window.close()
@pytest.mark.parametrize('tasks_cache', [mock_tasks], indirect=True)
def test_should_have_open_transcript_action_disabled_with_no_rows_selected(self, qtbot, tasks_cache):
window = MainWindow(tasks_cache=tasks_cache)

View file

View file

@ -0,0 +1,16 @@
from PyQt6.QtWidgets import QPushButton, QTabWidget
from pytestqt.qtbot import QtBot
from buzz.widgets.preferences_dialog import PreferencesDialog
class TestPreferencesDialog:
def test_create(self, qtbot: QtBot):
dialog = PreferencesDialog(shortcuts={})
qtbot.add_widget(dialog)
assert dialog.windowTitle() == 'Preferences'
tab_widget = dialog.findChild(QTabWidget)
assert isinstance(tab_widget, QTabWidget)
assert tab_widget.tabText(0) == 'Shortcuts'

View file

@ -0,0 +1,31 @@
from PyQt6.QtWidgets import QPushButton, QLabel
from buzz.settings.shortcut import Shortcut
from buzz.widgets.shortcuts_editor_widget import ShortcutsEditorWidget, SequenceEdit
class TestShortcutsEditorWidget:
def test_should_reset_to_defaults(self, qtbot):
widget = ShortcutsEditorWidget(shortcuts=Shortcut.get_default_shortcuts())
qtbot.add_widget(widget)
reset_button = widget.findChild(QPushButton)
assert isinstance(reset_button, QPushButton)
reset_button.click()
labels = widget.findChildren(QLabel)
sequence_edits = widget.findChildren(SequenceEdit)
expected = (
('Open Record Window', 'Ctrl+R'),
('Import File', 'Ctrl+O'),
('Open Preferences Window', 'Ctrl+,'),
('Open Transcript Viewer', 'Ctrl+E'),
('Clear History', 'Ctrl+S'),
('Cancel Transcription', 'Ctrl+X'))
for i, (label, sequence_edit) in enumerate(zip(labels, sequence_edits)):
assert isinstance(label, QLabel)
assert isinstance(sequence_edit, SequenceEdit)
assert label.text() == expected[i][0]
assert sequence_edit.keySequence().toString() == expected[i][1]