Source code for jdxi_editor.ui.editors.preset.widget

"""
preset widget
"""

from typing import Any, Optional

from decologr import Decologr as log
from PySide6.QtCore import Qt, QTimer
from PySide6.QtWidgets import QComboBox, QHBoxLayout, QLabel, QPushButton

from jdxi_editor.log.midi_info import log_midi_info
from jdxi_editor.midi.channel.channel import MidiChannel
from jdxi_editor.ui.common import JDXi, QVBoxLayout, QWidget
from jdxi_editor.ui.editors.helpers.preset import (
    get_preset_parameter_value,
    preset_to_jdxi_bank_pc,
)
from jdxi_editor.ui.editors.helpers.widgets import create_jdxi_button, create_jdxi_row
from jdxi_editor.ui.editors.preset.type import PresetTitle
from jdxi_editor.ui.preset.tone.lists import JDXiUIPreset
from jdxi_editor.ui.style import JDXiUIDimensions, JDXiUIStyle
from jdxi_editor.ui.widgets.combo_box.searchable_filterable import (
    SearchableFilterableComboBox,
)
from jdxi_editor.ui.widgets.editor.helper import transfer_layout_items


[docs] class PresetWidget(QWidget): """Preset Widget""" def __init__(self, parent): super().__init__(parent)
[docs] self.preset_list = None
[docs] self.midi_channel = None
[docs] self.parent = parent
[docs] self._actual_preset_list = [] # Will be set by _update_preset_combo_box()
preset_vlayout = QVBoxLayout() preset_vlayout.setContentsMargins( JDXi.UI.Style.PADDING, JDXi.UI.Style.PADDING, JDXi.UI.Style.PADDING, JDXi.UI.Style.PADDING, ) preset_vlayout.setSpacing(JDXi.UI.Style.SPACING) self.setLayout(preset_vlayout) # Add icon row at the top (centered with stretch on both sides) icon_row_container = QHBoxLayout() icon_row_container.addStretch() icon_row = JDXi.UI.Icon.create_generic_musical_icon_row() transfer_layout_items(icon_row, icon_row_container) icon_row_container.addStretch() preset_vlayout.addLayout(icon_row_container) preset_vlayout.addSpacing(10) # Add spacing after icon row
[docs] self.image_label = QLabel()
self.image_label.setAlignment( Qt.AlignmentFlag.AlignVCenter ) # Center align the image preset_vlayout.addWidget(self.image_label) # Synth type selection combo box
[docs] self.digital_preset_type_combo = QComboBox()
self.digital_preset_type_combo.addItems( [ PresetTitle.DIGITAL_SYNTH1, PresetTitle.DIGITAL_SYNTH2, PresetTitle.DRUMS, PresetTitle.ANALOG_SYNTH, ] ) self.digital_preset_type_combo.currentIndexChanged.connect( self.on_preset_type_changed ) preset_vlayout.addWidget(self.digital_preset_type_combo) # Create SearchableFilterableComboBox for preset selection # Initialize with empty data - will be populated when preset type is selected
[docs] self.preset_combo_box = SearchableFilterableComboBox( label="Preset", options=[], values=[], categories=[], show_label=True, show_search=True, show_category=True, search_placeholder="Search presets...", )
preset_vlayout.addWidget(self.preset_combo_box) # Initialize the combo box with default preset type (Digital Synth 1) # This will be called again when preset type changes, but we need initial population QTimer.singleShot(0, self._update_preset_combo_box) # Load Preset (round button + icon + label, centered) load_preset_row = QHBoxLayout() load_preset_row.addStretch()
[docs] self.load_button = self._add_round_action_button( JDXi.UI.Icon.FOLDER_NOTCH_OPEN, "Load Preset", lambda: self.load_preset_by_program_change(), load_preset_row, name="load", )
load_preset_row.addStretch() preset_vlayout.addLayout(load_preset_row) preset_vlayout.addStretch() # Squish content upwards # Connect combo box valueChanged to load preset directly (optional) # self.preset_combo_box.valueChanged.connect(self.load_preset_by_program_change)
[docs] def _add_round_action_button( self, icon_enum: Any, text: str, slot: Any, layout: QHBoxLayout, *, name: Optional[str] = None, checkable: bool = False, ) -> QPushButton: """Create a round button with icon + text label (same style as Transport).""" btn = create_jdxi_button("") btn.setCheckable(checkable) if slot is not None: btn.clicked.connect(slot) if name: setattr(self, f"{name}_button", btn) layout.addWidget(btn) pixmap = JDXi.UI.Icon.get_icon_pixmap( icon_enum, color=JDXi.UI.Style.FOREGROUND, size=20 ) label_row, _ = create_jdxi_row(text, icon_pixmap=pixmap) layout.addWidget(label_row) return btn
[docs] def load_preset_by_program_change(self, preset_id: str = None) -> None: """ Load a preset by program change. :param preset_id: str Optional preset ID (if None, gets from combo box) """ # Get preset ID from combo box value if not provided if preset_id is None: # Get the current value from SearchableFilterableComboBox # The value is the preset ID as integer (e.g., 1 for "001") preset_id_int = self.preset_combo_box.value() preset_id = str(preset_id_int).zfill(3) # Convert back to 3-digit format program_number = str(preset_id).zfill(3) # Ensure 3-digit format log.message("[PresetWidget] =======load_preset_by_program_change=======") log.parameter("[PresetWidget] preset_id", preset_id) log.parameter("[PresetWidget] program_number", program_number) # Get MSB, LSB, PC values from the preset using get_preset_parameter_value # Use _actual_preset_list (list of dicts) instead of preset_list (enum) preset_list_to_use = getattr(self, "_actual_preset_list", None) if preset_list_to_use is None: # Fallback: determine preset list from preset type preset_type = self.digital_preset_type_combo.currentText() if preset_type in [PresetTitle.DIGITAL_SYNTH1, PresetTitle.DIGITAL_SYNTH2]: preset_list_to_use = JDXi.UI.Preset.Digital.PROGRAM_CHANGE elif preset_type == PresetTitle.DRUMS: preset_list_to_use = JDXi.UI.Preset.Drum.PROGRAM_CHANGE elif preset_type == PresetTitle.ANALOG_SYNTH: preset_list_to_use = JDXi.UI.Preset.Analog.PROGRAM_CHANGE else: preset_list_to_use = JDXi.UI.Preset.Digital.PROGRAM_CHANGE msb = get_preset_parameter_value("msb", program_number, preset_list_to_use) lsb = get_preset_parameter_value("lsb", program_number, preset_list_to_use) pc = get_preset_parameter_value("pc", program_number, preset_list_to_use) if None in [msb, lsb, pc]: log.message( f"[PresetWidget] Could not retrieve preset parameters for program {program_number}" ) return log.message("[PresetWidget] retrieved msb, lsb, pc :") log.parameter("[PresetWidget] combo box msb", msb) log.parameter("[PresetWidget] combo box lsb", lsb) log.parameter("[PresetWidget] combo box pc", pc) log_midi_info(msb, lsb, pc) # Convert to JD-Xi bank format (LSB 65 for presets 129-256) bank_msb, bank_lsb, midi_pc = preset_to_jdxi_bank_pc(msb, lsb, pc) # Use PresetWidget's midi_channel if set, otherwise fall back to parent's # Access ProgramEditor through ProgramGroupWidget.parent program_editor = getattr(self.parent, "parent", None) if self.parent else None if not program_editor or not hasattr(program_editor, "midi_helper"): log.error( "Cannot access midi_helper: ProgramEditor not found in parent chain" ) return midi_channel = ( self.midi_channel if self.midi_channel is not None else getattr(self.parent, "midi_channel", None) ) program_editor.midi_helper.send_bank_select_and_program_change( midi_channel, bank_msb, bank_lsb, midi_pc, # Already 0-127 ) if hasattr(program_editor, "data_request"): program_editor.data_request()
[docs] def on_preset_type_changed(self, index: int) -> None: """ on_preset_type_changed :param index: int Handle preset type selection change """ preset_type = self.digital_preset_type_combo.currentText() log.message(f"preset_type: {preset_type}") # Update PresetWidget's own state self.set_channel_and_preset_lists(preset_type) self._update_preset_combo_box() # Also notify ProgramEditor to keep it in sync (access through ProgramGroupWidget.parent) program_editor = getattr(self.parent, "parent", None) if self.parent else None if program_editor and hasattr(program_editor, "set_channel_and_preset_lists"): program_editor.set_channel_and_preset_lists(preset_type)
[docs] def set_channel_and_preset_lists(self, preset_type: str) -> None: """ set_channel_and_preset_lists :param preset_type: :return: None """ if preset_type == PresetTitle.DIGITAL_SYNTH1: self.midi_channel = MidiChannel.DIGITAL_SYNTH_1 self.preset_list = JDXiUIPreset.Digital.PROGRAM_CHANGE elif preset_type == PresetTitle.DIGITAL_SYNTH2: self.midi_channel = MidiChannel.DIGITAL_SYNTH_2 self.preset_list = JDXiUIPreset.Digital.PROGRAM_CHANGE elif preset_type == PresetTitle.DRUMS: self.midi_channel = MidiChannel.DRUM_KIT self.preset_list = JDXiUIPreset.Drum.PROGRAM_CHANGE elif preset_type == PresetTitle.ANALOG_SYNTH: self.midi_channel = MidiChannel.ANALOG_SYNTH self.preset_list = JDXiUIPreset.Analog.PROGRAM_CHANGE
[docs] def _update_preset_combo_box(self) -> None: """ Update the SearchableFilterableComboBox with current preset list. Called when preset type changes. """ preset_type = self.digital_preset_type_combo.currentText() if preset_type in [PresetTitle.DIGITAL_SYNTH1, PresetTitle.DIGITAL_SYNTH2]: preset_list = JDXi.UI.Preset.Digital.PROGRAM_CHANGE elif preset_type == PresetTitle.DRUMS: preset_list = JDXi.UI.Preset.Drum.PROGRAM_CHANGE elif preset_type == PresetTitle.ANALOG_SYNTH: preset_list = JDXi.UI.Preset.Analog.PROGRAM_CHANGE else: preset_list = ( JDXi.UI.Preset.Digital.PROGRAM_CHANGE ) # Default to digital synth 1 # Store the actual preset list for use in load_preset_by_program_change # Note: self.preset_list is still set to JDXiPresetToneList enum in set_channel_and_preset_lists # for use with get_preset_parameter_value, but we also need the actual list for the combo box # Convert dictionary format (Digital/Analog) to list format if needed if isinstance(preset_list, dict): # Convert dictionary {1: {"Name": "...", "Category": "...", ...}, ...} to list format self._actual_preset_list = [ { "id": f"{preset_id:03d}", # Format as "001", "002", etc. "name": preset_data.get("Name", ""), "category": preset_data.get("Category", ""), "msb": str(preset_data.get("MSB", 0)), "lsb": str(preset_data.get("LSB", 0)), "pc": str(preset_data.get("PC", preset_id)), } for preset_id, preset_data in sorted(preset_list.items()) ] else: # Already a list (Drum format) self._actual_preset_list = preset_list # Build options, values, and categories preset_options = [ f"{preset['id']} - {preset['name']}" for preset in self._actual_preset_list ] # Convert preset IDs to integers for SearchableFilterableComboBox (e.g., "001" -> 1) preset_values = [int(preset["id"]) for preset in self._actual_preset_list] preset_categories = sorted( set(preset["category"] for preset in self._actual_preset_list) ) # Category filter function for presets def preset_category_filter(preset_display: str, category: str) -> bool: """Check if a preset matches a category.""" if not category: return True # Extract preset ID from digital string (format: "001 - Preset Name") preset_id_str = ( preset_display.split(" - ")[0] if " - " in preset_display else None ) if preset_id_str: # Find the preset in the list and check its category for preset in self._actual_preset_list: if preset["id"] == preset_id_str: return preset["category"] == category return False # Update the combo box by recreating it (since SearchableFilterableComboBox doesn't have update methods) # Get parent widget and layout preset_widget = self.digital_preset_type_combo.parent() preset_vlayout = preset_widget.layout() if preset_widget else None if preset_vlayout: # Remove old combo box from layout preset_vlayout.removeWidget(self.preset_combo_box) self.preset_combo_box.deleteLater() # Create new combo box with updated data self.preset_combo_box = SearchableFilterableComboBox( label="Preset", options=preset_options, values=preset_values, categories=preset_categories, category_filter_func=preset_category_filter, show_label=True, show_search=True, show_category=True, search_placeholder="Search presets...", ) # Insert after digital_preset_type_combo index = preset_vlayout.indexOf(self.digital_preset_type_combo) preset_vlayout.insertWidget(index + 1, self.preset_combo_box)