feat: add configurable keyboard shortcut system

- src/hotkeys.py: HotkeyManager with 17 default shortcuts, config
  load/save via config.ini, KeyCaptureDialog for reassignment UI
- src/config_loader.py: [Hotkeys] section defaults, _set_defaults
  called on every load to backfill missing sections
- src/main.py: integrate HotkeyManager, seek_forward/backward (+/-5s),
  context-aware select_all/delete_selected, Settings tab UI with
  shortcut table and Reset to Defaults button
This commit is contained in:
Maksim Totmin 2026-05-31 23:26:41 +07:00
parent 8cc4ef5279
commit 1ace4ad98c
3 changed files with 484 additions and 3 deletions

View File

@ -60,11 +60,10 @@ class ConfigLoader:
if os.path.exists(self.config_file):
logger.info(f"Loading configuration from {self.config_file}")
self.config.read(self.config_file)
return True
else:
logger.warning(f"Configuration file {self.config_file} not found, using defaults")
self._set_defaults()
return False
self._set_defaults()
return True
except Exception as e:
logger.error(f"Failed to load configuration: {e}")
self._set_defaults()
@ -129,6 +128,32 @@ class ConfigLoader:
self.config.set('Advanced', 'concurrent_downloads', '2')
self.config.set('Advanced', 'debug', 'false')
# Hotkeys section
if not self.config.has_section('Hotkeys'):
self.config.add_section('Hotkeys')
hotkey_defaults = {
'play_pause': 'Space',
'prev_track': 'Ctrl+Left',
'next_track': 'Ctrl+Right',
'volume_up': 'Ctrl+Up',
'volume_down': 'Ctrl+Down',
'seek_forward': 'Right',
'seek_backward': 'Left',
'tab_download': 'Ctrl+1',
'tab_library': 'Ctrl+2',
'tab_ipod': 'Ctrl+3',
'tab_settings': 'Ctrl+4',
'search_focus': 'Ctrl+F',
'select_all': 'Ctrl+A',
'library_refresh': 'F5',
'ipod_refresh': 'Ctrl+R',
'download': 'Ctrl+Enter',
'delete_selected': 'Delete',
}
for key, value in hotkey_defaults.items():
self.config.set('Hotkeys', key, value)
def get(self, section: str, option: str, fallback: Any = None) -> Any:
"""
Get configuration value

308
src/hotkeys.py Normal file
View File

@ -0,0 +1,308 @@
#!/usr/bin/env python3
"""
Hotkey Manager for neo-pod-desktop
Provides configurable keyboard shortcuts with scope awareness.
"""
import logging
from typing import Callable, Dict, Optional
from PyQt6.QtCore import Qt, QObject
from PyQt6.QtGui import QKeySequence, QShortcut, QAction
from PyQt6.QtWidgets import QApplication, QWidget, QDialog, QVBoxLayout, QLabel, QPushButton
logger = logging.getLogger(__name__)
QSeq = QKeySequence
# Key names that QKeySequence doesn't expose as enum values
_KEY_MAP = {
"Space": QSeq(Qt.Key.Key_Space),
"Delete": QSeq(Qt.Key.Key_Delete),
"Up": QSeq(Qt.Key.Key_Up),
"Down": QSeq(Qt.Key.Key_Down),
"Left": QSeq(Qt.Key.Key_Left),
"Right": QSeq(Qt.Key.Key_Right),
"Enter": QSeq(Qt.Key.Key_Enter),
"Return": QSeq(Qt.Key.Key_Return),
"Esc": QSeq(Qt.Key.Key_Escape),
"Escape": QSeq(Qt.Key.Key_Escape),
"Tab": QSeq(Qt.Key.Key_Tab),
"Backspace": QSeq(Qt.Key.Key_Backspace),
"Plus": QSeq(Qt.Key.Key_Plus),
"Minus": QSeq(Qt.Key.Key_Minus),
"F1": QSeq(Qt.Key.Key_F1),
"F2": QSeq(Qt.Key.Key_F2),
"F3": QSeq(Qt.Key.Key_F3),
"F4": QSeq(Qt.Key.Key_F4),
"F5": QSeq(Qt.Key.Key_F5),
"F6": QSeq(Qt.Key.Key_F6),
"F7": QSeq(Qt.Key.Key_F7),
"F8": QSeq(Qt.Key.Key_F8),
"F9": QSeq(Qt.Key.Key_F9),
"F10": QSeq(Qt.Key.Key_F10),
"F11": QSeq(Qt.Key.Key_F11),
"F12": QSeq(Qt.Key.Key_F12),
}
def _build_seq(text: str) -> QKeySequence:
"""Build a QKeySequence from a human-readable shortcut string."""
text = text.strip()
if not text:
return QSeq()
known = _KEY_MAP.get(text)
if known is not None:
return known
return QSeq(text)
def _seq_to_string(seq: QKeySequence) -> str:
"""Convert a QKeySequence to a human-readable string."""
return seq.toString()
def _is_editable_focused() -> bool:
"""Return True if an editable widget currently has keyboard focus."""
from PyQt6.QtWidgets import QLineEdit, QTextEdit, QPlainTextEdit, QSpinBox, QDoubleSpinBox
w = QApplication.focusWidget()
if w is None:
return False
return isinstance(w, (QLineEdit, QTextEdit, QPlainTextEdit, QSpinBox, QDoubleSpinBox))
# ---------------------------------------------------------------------------
# Default shortcut definitions
# (action_name, default_key_string, display_label, scope)
# scope: "global" — always active; "tab" — handler decides based on current tab
# ---------------------------------------------------------------------------
DEFAULTS = [
("play_pause", "Space", "Play / Pause", "global"),
("prev_track", "Ctrl+Left", "Previous Track", "global"),
("next_track", "Ctrl+Right", "Next Track", "global"),
("volume_up", "Ctrl+Up", "Volume Up", "global"),
("volume_down", "Ctrl+Down", "Volume Down", "global"),
("seek_forward", "Right", "Seek Forward 5s", "global"),
("seek_backward", "Left", "Seek Backward 5s", "global"),
("tab_download", "Ctrl+1", "Switch to Download", "global"),
("tab_library", "Ctrl+2", "Switch to Library", "global"),
("tab_ipod", "Ctrl+3", "Switch to iPod", "global"),
("tab_settings", "Ctrl+4", "Switch to Settings", "global"),
("search_focus", "Ctrl+F", "Focus Search", "global"),
("select_all", "Ctrl+A", "Select All", "global"),
("library_refresh", "F5", "Refresh Library", "global"),
("ipod_refresh", "Ctrl+R", "Refresh Devices", "global"),
("download", "Ctrl+Enter", "Start Download", "global"),
("delete_selected", "Delete", "Delete Selected", "global"),
]
class KeyCaptureDialog(QDialog):
"""Modal dialog that captures a single key combination."""
def __init__(self, parent=None, current: str = ""):
super().__init__(parent)
self.setWindowTitle("Press New Shortcut")
self.setMinimumWidth(320)
self.setModal(True)
self._captured_seq: QKeySequence = QSeq()
self._modifiers = Qt.KeyboardModifier.NoModifier
self._key = Qt.Key.Key_unknown
layout = QVBoxLayout(self)
self._label = QLabel(f"Press new shortcut for this action...\n(current: {current or 'none'})")
self._label.setAlignment(Qt.AlignmentFlag.AlignCenter)
self._label.setWordWrap(True)
layout.addWidget(self._label)
clear_btn = QPushButton("Clear (Disable)")
clear_btn.clicked.connect(self._on_clear)
layout.addWidget(clear_btn)
layout.addWidget(QPushButton("Cancel", clicked=self.reject))
self.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
self.setFocus()
def keyPressEvent(self, event):
key = event.key()
modifiers = event.modifiers()
mod_mask = modifiers & (
Qt.KeyboardModifier.ControlModifier
| Qt.KeyboardModifier.ShiftModifier
| Qt.KeyboardModifier.AltModifier
| Qt.KeyboardModifier.MetaModifier
)
# Ignore pure modifier presses
ignore = {
Qt.Key.Key_Control, Qt.Key.Key_Shift, Qt.Key.Key_Alt, Qt.Key.Key_Meta,
Qt.Key.Key_Super_L, Qt.Key.Key_Super_R,
}
if key in ignore:
return
# Build the sequence
keys = int(mod_mask) | int(key)
self._captured_seq = QSeq(keys)
self._label.setText(f"Captured: {self._captured_seq.toString()}\nPress Close to accept")
self.accept()
def _on_clear(self):
self._captured_seq = QSeq()
self._label.setText("Shortcut cleared (disabled)")
self.accept()
def captured_sequence(self) -> QKeySequence:
return self._captured_seq
class HotkeyManager:
"""Manages all keyboard shortcuts for the application.
Loads key bindings from config, creates QShortcut instances,
and supports live reassignment at runtime.
"""
def __init__(self, parent: QWidget, config_loader=None):
self._parent = parent
self._config_loader = config_loader
# action_name → (QShortcut, callback, default_seq, scope)
self._shortcuts: Dict[str, QShortcut] = {}
self._callbacks: Dict[str, Callable[[], None]] = {}
self._defaults: Dict[str, QKeySequence] = {}
self._labels: Dict[str, str] = {}
self._scopes: Dict[str, str] = {}
for name, key_str, label, scope in DEFAULTS:
self._defaults[name] = _build_seq(key_str)
self._labels[name] = label
self._scopes[name] = scope
# ------------------------------------------------------------------
# Registration
# ------------------------------------------------------------------
def register(self, action_name: str, callback: Callable[[], None]) -> None:
"""Register a shortcut with its callback.
If a saved key exists in config it is used; otherwise the default.
"""
saved_key = None
if self._config_loader:
saved_key = self._config_loader.get("Hotkeys", action_name, fallback=None)
seq = _build_seq(saved_key) if saved_key else self._defaults.get(action_name, QSeq())
self._callbacks[action_name] = callback
scope = self._scopes.get(action_name, "global")
context = Qt.ShortcutContext.WindowShortcut
sc = QShortcut(seq, self._parent, context=context)
def _make_handler(name):
def handler():
self._dispatch(name)
return handler
sc.activated.connect(_make_handler(action_name))
self._shortcuts[action_name] = sc
def register_all(self, action_map: Dict[str, Callable[[], None]]) -> None:
"""Register all known shortcuts from an action_name→callback mapping."""
for name, callback in action_map.items():
self.register(name, callback)
# ------------------------------------------------------------------
# Dispatch
# ------------------------------------------------------------------
def _dispatch(self, action_name: str):
"""Check if the shortcut should fire (not blocked by editable focus),
then invoke the callback.
"""
# For seek arrows and space, don't fire when text editing
editable_blocked = {"seek_forward", "seek_backward", "play_pause"}
if action_name in editable_blocked and _is_editable_focused():
return
cb = self._callbacks.get(action_name)
if cb:
cb()
# ------------------------------------------------------------------
# Configuration persistence
# ------------------------------------------------------------------
def save_to_config(self) -> None:
"""Write all current key bindings to the config loader."""
if self._config_loader is None:
return
for name, sc in self._shortcuts.items():
key_str = sc.key().toString()
self._config_loader.set("Hotkeys", name, key_str if key_str else "")
self._config_loader.save()
def load_from_config(self) -> None:
"""Re-read key bindings from config and update all shortcuts."""
if self._config_loader is None:
return
for name, sc in self._shortcuts.items():
saved = self._config_loader.get("Hotkeys", name, fallback=None)
if saved is not None:
seq = _build_seq(saved) if saved != "" else QSeq()
else:
seq = self._defaults.get(name, QSeq())
sc.setKey(seq)
def reset_to_defaults(self) -> None:
"""Reset all shortcuts to their default keys and save."""
for name, sc in self._shortcuts.items():
default_seq = self._defaults.get(name, QSeq())
sc.setKey(default_seq)
self.save_to_config()
# ------------------------------------------------------------------
# Query API (for Settings UI)
# ------------------------------------------------------------------
def get_label(self, action_name: str) -> str:
return self._labels.get(action_name, action_name)
def get_current_key_string(self, action_name: str) -> str:
sc = self._shortcuts.get(action_name)
if sc is None:
return ""
return sc.key().toString()
def get_default_key_string(self, action_name: str) -> str:
return self._defaults.get(action_name, QSeq()).toString()
def all_action_names(self):
return list(self._shortcuts.keys())
# ------------------------------------------------------------------
# Live reassignment (called from Settings UI)
# ------------------------------------------------------------------
def set_shortcut(self, action_name: str, key_string: str) -> None:
"""Change the key binding for an action at runtime and persist."""
sc = self._shortcuts.get(action_name)
if sc is None:
return
seq = _build_seq(key_string)
sc.setKey(seq)
if self._config_loader:
self._config_loader.set("Hotkeys", action_name, key_string)
self._config_loader.save()
def set_shortcut_seq(self, action_name: str, seq: QKeySequence) -> None:
"""Set a shortcut directly from a QKeySequence (from key capture dialog)."""
sc = self._shortcuts.get(action_name)
if sc is not None:
sc.setKey(seq)
if self._config_loader:
self._config_loader.set("Hotkeys", action_name, seq.toString())
self._config_loader.save()

View File

@ -26,6 +26,8 @@ from audio_converter import AudioConverter
from metadata_handler import MetadataHandler
from ipod_device import IPodDevice
from artwork.cache import cover_cache
from config_loader import ConfigLoader
from hotkeys import HotkeyManager, KeyCaptureDialog
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s')
logger = logging.getLogger(__name__)
@ -282,6 +284,9 @@ class MainWindow(QMainWindow):
self.current_mount_point: Optional[str] = None
self.show_download_tab = False
self.config_loader = ConfigLoader()
self.hotkey_manager: Optional[HotkeyManager] = None
self.player: Optional[QMediaPlayer] = None
self.audio_output: Optional[QAudioOutput] = None
self.current_playback_index: int = -1
@ -289,6 +294,8 @@ class MainWindow(QMainWindow):
self._setup_ui()
self._setup_player()
self._load_settings()
self._setup_hotkeys()
self._scan_library()
self._on_refresh_devices_clicked()
@ -304,6 +311,54 @@ class MainWindow(QMainWindow):
self.worker_thread.deleteLater()
self.worker_thread = None
def _load_settings(self):
config = self.config_loader
self.output_dir_input.setText(
config.get("General", "output_dir",
fallback=os.path.join(os.path.expanduser("~"), "Music", "iPod"))
)
self.format_combo.setCurrentText(config.get("General", "format", fallback="m4a"))
self.quality_spin.setValue(config.get_int("General", "quality", fallback=256))
self.enable_video_check.setChecked(
config.get_boolean("Video", "enable_video", fallback=False)
)
self.embed_artwork_check.setChecked(
config.get_boolean("Metadata", "embed_artwork", fallback=True)
)
self.clean_temp_check.setChecked(
config.get_boolean("Advanced", "clean_temp", fallback=True)
)
self.auto_detect_check.setChecked(
config.get_boolean("Device", "auto_detect", fallback=True)
)
def _setup_hotkeys(self):
self.hotkey_manager = HotkeyManager(self, self.config_loader)
self.hotkey_manager.register_all({
"play_pause": self._on_play_pause_clicked,
"prev_track": self._on_prev_clicked,
"next_track": self._on_next_clicked,
"volume_up": lambda: self._adjust_volume(5),
"volume_down": lambda: self._adjust_volume(-5),
"seek_forward": self._seek_forward,
"seek_backward": self._seek_backward,
"tab_download": lambda: self.tab_widget.setCurrentIndex(self.download_tab_index),
"tab_library": lambda: self.tab_widget.setCurrentIndex(1),
"tab_ipod": lambda: self.tab_widget.setCurrentIndex(2),
"tab_settings": lambda: self.tab_widget.setCurrentIndex(3),
"search_focus": self._focus_search,
"select_all": self._select_all_current,
"library_refresh": self._on_refresh_library_clicked,
"ipod_refresh": self._on_refresh_devices_clicked,
"download": self._on_download_clicked,
"delete_selected": self._delete_selected_current,
})
self._refresh_shortcuts_table()
def _adjust_volume(self, delta: int):
new_val = max(0, min(100, self.volume_slider.value() + delta))
self.volume_slider.setValue(new_val)
def _setup_ui(self):
main_widget = QWidget()
main_layout = QVBoxLayout(main_widget)
@ -668,6 +723,28 @@ class MainWindow(QMainWindow):
layout.addWidget(advanced_group)
shortcuts_group = QGroupBox("Keyboard Shortcuts")
shortcuts_layout = QVBoxLayout(shortcuts_group)
self.shortcuts_table = QTableWidget()
self.shortcuts_table.setColumnCount(2)
self.shortcuts_table.setHorizontalHeaderLabels(["Action", "Shortcut"])
self.shortcuts_table.setSelectionBehavior(QTableWidget.SelectionBehavior.SelectRows)
self.shortcuts_table.setSelectionMode(QTableWidget.SelectionMode.SingleSelection)
self.shortcuts_table.setEditTriggers(QTableWidget.EditTrigger.NoEditTriggers)
self.shortcuts_table.setAlternatingRowColors(True)
self.shortcuts_table.cellDoubleClicked.connect(self._on_shortcut_double_clicked)
shortcuts_header = self.shortcuts_table.horizontalHeader()
shortcuts_header.setSectionResizeMode(0, QHeaderView.ResizeMode.Stretch)
shortcuts_header.setSectionResizeMode(1, QHeaderView.ResizeMode.ResizeToContents)
shortcuts_layout.addWidget(self.shortcuts_table)
reset_shortcuts_btn = QPushButton("Reset to Defaults")
reset_shortcuts_btn.clicked.connect(self._on_reset_shortcuts_clicked)
shortcuts_layout.addWidget(reset_shortcuts_btn)
layout.addWidget(shortcuts_group)
layout.addStretch()
about_label = QLabel(
@ -770,6 +847,37 @@ class MainWindow(QMainWindow):
idx = min(self.library_table.rowCount() - 1, self.current_playback_index + 1)
self._play_track_from_index(idx)
def _seek_forward(self):
if self.player is None or self.player.duration() <= 0:
return
new_pos = min(self.player.duration(), self.player.position() + 5000)
self.player.setPosition(new_pos)
def _seek_backward(self):
if self.player is None or self.player.duration() <= 0:
return
new_pos = max(0, self.player.position() - 5000)
self.player.setPosition(new_pos)
def _focus_search(self):
self.tab_widget.setCurrentIndex(1)
self.library_search_input.setFocus()
self.library_search_input.selectAll()
def _select_all_current(self):
idx = self.tab_widget.currentIndex()
if idx == 1:
self.library_table.selectAll()
elif idx == 2:
self.transferred_list.selectAll()
def _delete_selected_current(self):
idx = self.tab_widget.currentIndex()
if idx == 1:
self._on_library_remove_selected()
elif idx == 2:
self._on_remove_tracks_clicked()
def _on_slider_pressed(self):
if self.player:
self.player.setPosition(
@ -1745,6 +1853,46 @@ class MainWindow(QMainWindow):
QMessageBox.information(self, "Duplicates" if success else "Error", message)
self._cleanup_worker()
def _refresh_shortcuts_table(self):
if self.hotkey_manager is None:
return
names = self.hotkey_manager.all_action_names()
self.shortcuts_table.setRowCount(len(names))
for row, name in enumerate(names):
label = self.hotkey_manager.get_label(name)
key = self.hotkey_manager.get_current_key_string(name)
self.shortcuts_table.setItem(row, 0, QTableWidgetItem(label))
item = QTableWidgetItem(key if key else "\u2014")
self.shortcuts_table.setItem(row, 1, item)
def _on_shortcut_double_clicked(self, row: int, _col: int):
if self.hotkey_manager is None:
return
names = self.hotkey_manager.all_action_names()
if row < 0 or row >= len(names):
return
action_name = names[row]
current = self.hotkey_manager.get_current_key_string(action_name)
dialog = KeyCaptureDialog(self, current=current)
if dialog.exec() == QDialog.DialogCode.Accepted:
seq = dialog.captured_sequence()
self.hotkey_manager.set_shortcut_seq(action_name, seq)
self._refresh_shortcuts_table()
def _on_reset_shortcuts_clicked(self):
if self.hotkey_manager is None:
return
reply = QMessageBox.question(
self, "Reset Shortcuts",
"Reset all keyboard shortcuts to their default values?",
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No
)
if reply == QMessageBox.StandardButton.Yes:
self.hotkey_manager.reset_to_defaults()
self._refresh_shortcuts_table()
QMessageBox.information(self, "Done", "Shortcuts reset to defaults.")
def closeEvent(self, event):
if self.worker_thread and self.worker_thread.isRunning():
self.worker_thread.stop()