diff --git a/midi_app_controller/gui/binds_editor.py b/midi_app_controller/gui/binds_editor.py index 40f040f..f6fa040 100644 --- a/midi_app_controller/gui/binds_editor.py +++ b/midi_app_controller/gui/binds_editor.py @@ -8,12 +8,12 @@ QPushButton, QLabel, QHBoxLayout, - QMenu, QRadioButton, QDialog, QScrollArea, ) +from midi_app_controller.gui.utils import SearchableQComboBox from midi_app_controller.models.binds import ButtonBind, KnobBind, Binds from midi_app_controller.models.controller import Controller, ControllerElement @@ -26,9 +26,8 @@ class ButtonBinds(QWidget): actions : List[str] List of all actions available to bind and an empty string (used when no action is bound). - button_menus : Tuple[int, QPushButton] - List of all pairs (button id, QPushButton used to set action). Each - QPushButton text is the currently selected action. + button_menus : Tuple[int, SearchableQComboBox] + List of all pairs (button id, SearchableQComboBox used to set action). binds_dict : dict[int, ControllerElement] Dictionary that allows to get a controller's button by its id. """ @@ -53,7 +52,7 @@ def __init__( super().__init__() self.actions = [""] + actions - self.button_menus = [] + self.button_combos = [] self.binds_dict = {b.button_id: b for b in button_binds} # Description row. @@ -64,8 +63,8 @@ def __init__( # All buttons available to bind. button_list = QWidget() button_layout = QVBoxLayout() - for elem in buttons: - button_layout.addLayout(self._create_button_layout(elem.id, elem.name)) + for button in buttons: + button_layout.addLayout(self._create_button_layout(button.id, button.name)) button_layout.addStretch() button_list.setLayout(button_layout) @@ -85,7 +84,7 @@ def _create_button_layout(self, button_id: int, button_name: str) -> QHBoxLayout """Creates layout for a button. The layout consists of button name and action selector. An entry is - added to the `self.button_menus`. + added to the `self.button_combos`. """ # Check if there is an action bound to the button. if (bind := self.binds_dict.get(button_id)) is not None: @@ -93,34 +92,21 @@ def _create_button_layout(self, button_id: int, button_name: str) -> QHBoxLayout else: action = None - # QPushButton with menu. - button_action = QPushButton(action) - button_action.setMenu(self._create_action_menu(button_action)) - - self.button_menus.append((button_id, button_action)) + # SearchableQComboBox for action selection. + action_combo = SearchableQComboBox(self.actions, action, self) + self.button_combos.append((button_id, action_combo)) layout = QHBoxLayout() layout.addWidget(QLabel(button_name)) - layout.addWidget(button_action) + layout.addWidget(action_combo) return layout - def _create_action_menu(self, button: QPushButton) -> QMenu: - """Creates a scrollable menu consisting of all `self.actions`. - - When an action is selected, the text of `button` is set its name. - """ - menu = QMenu(self) - menu.setStyleSheet("QMenu { menu-scrollable: 1; }") - for action in self.actions: - menu.addAction(action, lambda action=action: button.setText(action)) - return menu - def get_binds(self) -> List[ButtonBind]: """Returns list of all binds currently set in this widget.""" result = [] - for button_id, button in self.button_menus: - action = button.text() or None + for button_id, combo in self.button_combos: + action = combo.currentText() or None if action is not None: result.append(ButtonBind(button_id=button_id, action_id=action)) return result @@ -134,10 +120,9 @@ class KnobBinds(QWidget): actions : List[str] List of all actions available to bind and an empty string (used when no action is bound). - knob_menus : Tuple[int, QPushButton, QPushButton] - List of all triples (knob id, QPushButton used to set increase action, - QPushButton used to set decrease action). Each QPushButton text is - the currently selected action. + knob_combos : Tuple[int, SearchableQComboBox, SearchableQComboBox] + List of all triples (knob id, SearchableQComboBox used to set increase action, + SearchableQComboBox used to set decrease action). binds_dict : dict[int, ControllerElement] Dictionary that allows to get a controller's knob by its id. """ @@ -162,7 +147,7 @@ def __init__( super().__init__() self.actions = [""] + actions - self.knob_menus = [] + self.knob_combos = [] self.binds_dict = {b.knob_id: b for b in knob_binds} # Description row. @@ -191,53 +176,39 @@ def __init__( self.setLayout(layout) - def _create_action_menu(self, knob: QPushButton) -> QMenu: - """Creates a scrollable menu consisting of all `self.actions`. - - When an action is selected, the text of `knob` is set its name. - """ - menu = QMenu(self) - menu.setStyleSheet("QMenu { menu-scrollable: 1; }") - for action in self.actions: - menu.addAction(action, lambda action=action: knob.setText(action)) - return menu - def _create_knob_layout(self, knob_id: int, knob_name: str) -> QHBoxLayout: """Creates layout for a knob. The layout consists of knob name and increase/decrease action selector. - An entry is added to the `self.knob_menus`. + An entry is added to the `self.knob_combos`. """ # Check if there are any actions bound to the knob. if (bind := self.binds_dict.get(knob_id)) is not None: action_increase = bind.action_id_increase action_decrease = bind.action_id_decrease else: - action_increase = "" - action_decrease = "" - - # QPushButton with menus. - knob_increase = QPushButton(action_increase) - knob_increase.setMenu(self._create_action_menu(knob_increase)) - knob_decrease = QPushButton(action_decrease) - knob_decrease.setMenu(self._create_action_menu(knob_decrease)) + action_increase = None + action_decrease = None - self.knob_menus.append((knob_id, knob_increase, knob_decrease)) + # SearchableQComboBox for action selection. + increase_action_combo = SearchableQComboBox(self.actions, action_increase, self) + decrease_action_combo = SearchableQComboBox(self.actions, action_decrease, self) + self.knob_combos.append((knob_id, increase_action_combo, decrease_action_combo)) # Layout. layout = QHBoxLayout() layout.addWidget(QLabel(knob_name)) - layout.addWidget(knob_increase) - layout.addWidget(knob_decrease) + layout.addWidget(increase_action_combo) + layout.addWidget(decrease_action_combo) return layout def get_binds(self) -> List[KnobBind]: """Returns list of all binds currently set in this widget.""" result = [] - for knob_id, knob_increase, knob_decrease in self.knob_menus: - increase_action = knob_increase.text() or None - decrease_action = knob_decrease.text() or None + for knob_id, increase_action_combo, decrease_action_combo in self.knob_combos: + increase_action = increase_action_combo.currentText() or None + decrease_action = decrease_action_combo.currentText() or None if increase_action is not None or decrease_action is not None: result.append( KnobBind( @@ -333,7 +304,7 @@ def __init__( self.setLayout(layout) self.setStyleSheet(get_current_stylesheet()) self.knobs_radio.setChecked(True) - self.setMinimumSize(500, 550) + self.setMinimumSize(830, 650) def _switch_editors(self, checked): """Switches binds editor view for knobs/buttons based on checked radio.""" diff --git a/midi_app_controller/gui/midi_status.py b/midi_app_controller/gui/midi_status.py index 047e2c8..5da7c2a 100644 --- a/midi_app_controller/gui/midi_status.py +++ b/midi_app_controller/gui/midi_status.py @@ -1,5 +1,5 @@ import sys -from typing import Callable, List +from typing import List from napari._app_model import get_app from napari._app_model.actions._help_actions import HELP_ACTIONS @@ -10,7 +10,6 @@ QWidget, QVBoxLayout, QPushButton, - QMenu, QLabel, QHBoxLayout, ) @@ -18,6 +17,7 @@ from midi_app_controller.models.binds import ButtonBind, KnobBind, Binds from midi_app_controller.models.controller import Controller from midi_app_controller.gui.binds_editor import BindsEditor +from midi_app_controller.gui.utils import DynamicQComboBox from midi_app_controller.state.state_manager import StateManager # TODO I didn't find any better way to get all available actions. @@ -30,16 +30,16 @@ class MidiStatus(QWidget): Attributes ---------- - current_binds : QPushButton + current_binds : DynamicQComboBox Button that allows to select binds using its menu. Its text is set to currently selected binds. - current_controller : QPushButton + current_controller : DynamicQComboBox Button that allows to select controller using its menu. Its text is set to currently selected controller. - current_midi_in : QPushButton + current_midi_in : DynamicQComboBox Button that allows to select MIDI input port using its menu. Its text is set to currently selected port. - current_midi_in : QPushButton + current_midi_in : DynamicQComboBox Button that allows to select MIDI output port using its menu. Its text is set to currently selected port. status : QLabel @@ -56,53 +56,36 @@ def __init__(self): # Binds selection. selected_binds = state_manager.selected_binds - self.current_binds = QPushButton( - selected_binds.name if selected_binds is not None else None - ) - self.current_binds.setMenu( - self._create_dynamic_menu( - self.current_binds, - state_manager.get_available_binds, - state_manager.select_binds, - ) + self.current_binds = DynamicQComboBox( + selected_binds.name if selected_binds is not None else None, + state_manager.get_available_binds, + state_manager.select_binds, ) # Controller selection. - selected_controller = state_manager.selected_controller - self.current_controller = QPushButton( - selected_controller.name if selected_controller is not None else None - ) - def select_controller(name: str) -> None: state_manager.select_controller(name) state_manager.selected_binds = None - self.current_binds.setText(None) - - self.current_controller.setMenu( - self._create_dynamic_menu( - self.current_controller, - state_manager.get_available_controllers, - select_controller, - ) + self.current_binds.setCurrentText(None) + + selected_controller = state_manager.selected_controller + self.current_controller = DynamicQComboBox( + selected_controller.name if selected_controller is not None else None, + state_manager.get_available_controllers, + select_controller, ) # MIDI input and output selection. - self.current_midi_in = QPushButton(state_manager.selected_midi_in) - self.current_midi_in.setMenu( - self._create_dynamic_menu( - self.current_midi_in, - state_manager.get_available_midi_in, - state_manager.select_midi_in, - ) + self.current_midi_in = DynamicQComboBox( + state_manager.selected_midi_in, + state_manager.get_available_midi_in, + state_manager.select_midi_in, ) - self.current_midi_out = QPushButton(state_manager.selected_midi_out) - self.current_midi_out.setMenu( - self._create_dynamic_menu( - self.current_midi_out, - state_manager.get_available_midi_out, - state_manager.select_midi_out, - ) + self.current_midi_out = DynamicQComboBox( + state_manager.selected_midi_out, + state_manager.get_available_midi_out, + state_manager.select_midi_out, ) # Status. @@ -134,15 +117,11 @@ def update_status(): # Layout. layout = QVBoxLayout() layout.addLayout( - self._create_label_button_layout("Controller:", self.current_controller) - ) - layout.addLayout(self._create_label_button_layout("Binds:", self.current_binds)) - layout.addLayout( - self._create_label_button_layout("MIDI input:", self.current_midi_in) - ) - layout.addLayout( - self._create_label_button_layout("MIDI output:", self.current_midi_out) + self._horizontal_layout("Controller:", self.current_controller) ) + layout.addLayout(self._horizontal_layout("Binds:", self.current_binds)) + layout.addLayout(self._horizontal_layout("MIDI input:", self.current_midi_in)) + layout.addLayout(self._horizontal_layout("MIDI output:", self.current_midi_out)) layout.addLayout(status_layout) layout.addWidget(self.edit_binds_button) layout.addWidget(self.start_handling_button) @@ -151,45 +130,12 @@ def update_status(): self.setLayout(layout) - def _create_dynamic_menu( - self, - button: QPushButton, - get_entries: Callable[[], List[str]], - select_entry: Callable[[str], None], - ) -> QMenu: - """Creates a scrollable menu that will display entries from `get_entries()` - each time it's opened. - - When an entry is selected: - - the text of `button` is set to the entry, - - `select_entry` is invoked with the entry as argument. - """ - menu = QMenu(self) - menu.setStyleSheet("QMenu { menu-scrollable: 1; }") - - def add_actions(): - """Clears the menu and adds entries from `get_entries()`.""" - menu.clear() - for elem in get_entries(): - - def select(elem=elem): - """Update button's text and run `select_entry()`.""" - button.setText(elem) - select_entry(elem) - - menu.addAction(elem, select) - - menu.aboutToShow.connect(add_actions) - return menu - - def _create_label_button_layout( - self, label: str, button: QPushButton - ) -> QHBoxLayout: - """Creates horizontal layout consisting of label on the left half and - button on the right half.""" + def _horizontal_layout(self, label: str, widget: QWidget) -> QHBoxLayout: + """Creates horizontal layout consisting of the `label` on the left half\ + and the `widget` on the right half.""" layout = QHBoxLayout() layout.addWidget(QLabel(label)) - layout.addWidget(button) + layout.addWidget(widget) return layout def _edit_binds(self): diff --git a/midi_app_controller/gui/utils.py b/midi_app_controller/gui/utils.py new file mode 100644 index 0000000..4bf3436 --- /dev/null +++ b/midi_app_controller/gui/utils.py @@ -0,0 +1,75 @@ +from typing import Callable, List, Optional + +from qtpy.QtCore import Qt +from qtpy.QtWidgets import QComboBox, QWidget + + +class DynamicQComboBox(QComboBox): + """QComboBox that refreshes the list of items each time it is opened.""" + + def __init__( + self, + current_item: Optional[str], + get_items: Callable[[], List[str]], + select_item: Callable[[str], None], + parent: QWidget = None, + ): + """Creates DynamicQComboBox widget. + + Parameters + --------- + current_item : Optional[str] + Optional default item. + get_items : Callable[[], List[str]] + Functions that fetches list of current items. + select_item : Callable[[str], None] + Function that should be called when `textActivated` is emitted. + parent : QWidget + Parent widget. + """ + super().__init__(parent) + + self.get_items = get_items + self.textActivated.connect(select_item) + self.setCurrentText(current_item) + + def showPopup(self): + # Refresh items. + self.clear() + self.addItems(self.get_items()) + + super().showPopup() + + +class SearchableQComboBox(QComboBox): + """QComboBox that allows to search available items.""" + + def __init__( + self, + items: List[str], + default_item: Optional[str] = None, + parent: QWidget = None, + ): + """Creates SearchableQComboBox widget. + + Parameters + --------- + items : List[str] + List of available items. + default_item : Optional[str] + Optional default item used to initialize the widget. + parent : QWidget + Parent widget. + """ + super().__init__(parent) + + # Make searchable. + self.setEditable(True) + self.setInsertPolicy(QComboBox.NoInsert) + + # Add items. + self.addItems(items) + self.setCurrentText(default_item) + + # Set filter mode. + self.completer().setFilterMode(Qt.MatchContains)