Source code for jdxi_editor.ui.preset.widget

"""Preset Widget to be used by All Editors"""

from typing import TYPE_CHECKING, Any, Optional

from decologr import Decologr as log
from PySide6.QtCore import Qt
from PySide6.QtWidgets import QGroupBox, QHBoxLayout, QLabel, QPushButton, QTabWidget

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
from jdxi_editor.ui.editors.helpers.widgets import create_jdxi_button, create_jdxi_row
from jdxi_editor.ui.editors.pattern.preset_list_provider import (
    get_preset_list_for_synth_type,
    get_preset_signals,
)
from jdxi_editor.ui.style import JDXiUIDimensions, JDXiUIStyle
from jdxi_editor.ui.widgets.combo_box.searchable_filterable import (
    SearchableFilterableComboBox,
)
from jdxi_editor.ui.widgets.digital.title import DigitalTitle
from jdxi_editor.ui.widgets.editor.helper import (
    create_layout_with_items,
    create_scroll_container,
    transfer_layout_items,
)

if TYPE_CHECKING:
    from jdxi_editor.ui.editors.synth.editor import SynthEditor


[docs] class InstrumentPresetWidget(QWidget): """InstrumentPresetWidget""" def __init__( self, parent: "SynthEditor", # parent is not optional ): """ InstrumentPresetWidget :param parent: QWidget """ super().__init__(parent)
[docs] self.group: QGroupBox | None = None
[docs] self.layout: QVBoxLayout | None = None
[docs] self.instrument_presets: QWidget | None = None
[docs] self.widget: QWidget | None = None
[docs] self.hlayout: QHBoxLayout | None = None
[docs] self.parent = parent
[docs] self._synth_type: str = ""
[docs] self._preset_list: list = []
[docs] self.instrument_selection_combo: Optional["SearchableFilterableComboBox"] = None
# Connect to preset list change signal for dynamic refresh get_preset_signals().soundfont_list_changed.connect(self._refresh_preset_list) log.info(scope="InstrumentPresetWidget", message="Connected to soundfont_list_changed signal")
[docs] def _refresh_preset_list(self) -> None: """Refresh the preset combo box when SoundFont list setting changes.""" log.info( scope="InstrumentPresetWidget", message=f"_refresh_preset_list called, synth_type={self._synth_type}" ) if not self._synth_type: log.info(scope="InstrumentPresetWidget", message="No synth_type set, skipping refresh") return if not hasattr(self, "instrument_selection_combo") or self.instrument_selection_combo is None: log.info(scope="InstrumentPresetWidget", message="No instrument_selection_combo, skipping refresh") return # Get updated preset list preset_list = get_preset_list_for_synth_type(self._synth_type) log.info( scope="InstrumentPresetWidget", message=f"Got {len(preset_list) if preset_list else 0} presets for {self._synth_type}" ) # Convert dictionary format to list format if needed if isinstance(preset_list, dict): converted_preset_list = [ { "id": f"{preset_id:03d}", "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: converted_preset_list = preset_list # Update parent's preset list self.parent.preset_preset_list = converted_preset_list self._preset_list = converted_preset_list # Build new options and values preset_options = [ f"{preset['id']} - {preset['name']}" for preset in converted_preset_list ] preset_values = [int(preset["id"]) for preset in converted_preset_list] preset_categories = sorted( set(preset["category"] for preset in converted_preset_list) ) log.message(f"preset_options: {preset_options}") log.message(f"preset_values: {preset_values}") log.message(f"preset_categories: {preset_categories}") # Create new category filter function with the updated preset list def preset_category_filter(preset_display: str, category: str) -> bool: """Check if a preset matches a category.""" if not category: return True preset_id_str = ( preset_display.split(" - ")[0] if " - " in preset_display else None ) if preset_id_str: for preset in converted_preset_list: if preset["id"] == preset_id_str: return preset["category"] == category return False # Update the combo box with new options and filter function current_value = self.instrument_selection_combo.value() self.instrument_selection_combo.set_options( preset_options, preset_values, preset_categories, preset_category_filter ) log.info( scope="InstrumentPresetWidget", message=f"Updated combo with {len(preset_options)} options" ) # Try to restore the previous selection if current_value in preset_values: self.instrument_selection_combo.set_value(current_value)
[docs] def add_image_group(self, group: QGroupBox): """add image group""" group.setMinimumWidth(JDXi.UI.Style.INSTRUMENT_IMAGE_WIDTH) self.hlayout.addWidget(group)
[docs] def add_preset_group(self, group: QGroupBox): """add groupbox for instruments""" self.hlayout.addWidget(group)
[docs] def setup(self): """set up the widget - creates the main vertical layout""" if self.layout is None: self.layout = QVBoxLayout() # Set proper margins and spacing to match PresetWidget self.layout.setContentsMargins( JDXi.UI.Style.PADDING, JDXi.UI.Style.PADDING, JDXi.UI.Style.PADDING, JDXi.UI.Style.PADDING, ) self.layout.setSpacing(JDXi.UI.Style.SPACING) self.setLayout(self.layout) # Add stretch at top for vertical centering self.layout.addStretch()
[docs] def create_instrument_image_group(self) -> tuple[QGroupBox, Any, Any]: """Image group""" instrument_image_group = QGroupBox() instrument_group_layout = QVBoxLayout() instrument_group_layout.setContentsMargins(5, 5, 5, 5) # Reduced margins instrument_group_layout.setSpacing(2) # Reduced spacing instrument_image_group.setLayout(instrument_group_layout) self.instrument_image_label = QLabel() self.instrument_image_label.setAlignment(Qt.AlignmentFlag.AlignCenter) instrument_group_layout.addWidget(self.instrument_image_label) instrument_image_group.setStyleSheet(JDXi.UI.Style.INSTRUMENT_IMAGE_LABEL) instrument_image_group.setMinimumWidth(JDXi.UI.Style.INSTRUMENT_IMAGE_WIDTH) instrument_image_group.setMaximumHeight(JDXi.UI.Style.INSTRUMENT_IMAGE_HEIGHT) return ( instrument_image_group, self.instrument_image_label, instrument_group_layout, )
[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 _add_centered_round_button( self, icon_enum: Any, text: str, slot: Any, parent_layout: Any, name: Optional[str] = None, ) -> QPushButton: """Add a round button + label row centered in a QHBoxLayout (stretch on both sides).""" row = QHBoxLayout() row.addStretch() btn = self._add_round_action_button(icon_enum, text, slot, row, name=name) row.addStretch() parent_layout.addLayout(row) return btn
[docs] def create_instrument_preset_group(self, synth_type: str = "Analog") -> QGroupBox: """ Create the instrument preset group box with tabs for normal and cheat presets (Analog only). :param synth_type: str :return: QGroupBox """ self._synth_type = synth_type # Store for refresh instrument_preset_group = QGroupBox(f"{synth_type} Synth") instrument_title_group_layout = QVBoxLayout(instrument_preset_group) instrument_title_group_layout.setSpacing(3) # Reduced spacing instrument_title_group_layout.setContentsMargins(5, 5, 5, 5) # Reduced margins # Apply Analog color styling for Analog Synth group box if synth_type == "Analog": instrument_preset_group.setStyleSheet(JDXi.UI.Style.GROUP_BOX_ANALOG) # For Analog, create tabs; for others, create simple layout if synth_type == "Analog": # Create tabbed widget inside the group box preset_tabs = QTabWidget() instrument_title_group_layout.addWidget(preset_tabs) # === Tab 1: Normal Analog Presets === normal_preset_widget, normal_preset_layout = create_scroll_container() self._add_normal_preset_content(normal_preset_layout, synth_type) try: analog_presets_icon = JDXi.UI.Icon.get_icon( JDXi.UI.Icon.MUSIC_NOTE_MULTIPLE, color=JDXi.UI.Style.GREY ) if analog_presets_icon is None or analog_presets_icon.isNull(): raise ValueError("Icon is null") except Exception: analog_presets_icon = JDXi.UI.Icon.get_icon( JDXi.UI.Icon.MUSIC, color=JDXi.UI.Style.GREY ) preset_tabs.addTab( normal_preset_widget, analog_presets_icon, "Analog Presets" ) # === Tab 2: Cheat Presets (Digital Synth presets on Analog channel) === cheat_preset_widget, cheat_preset_layout = create_scroll_container() self._add_cheat_preset_content(cheat_preset_layout) cheat_presets_icon = JDXi.UI.Icon.get_icon( JDXi.UI.Icon.CODE_BRACES, color=JDXi.UI.Style.GREY ) preset_tabs.addTab(cheat_preset_widget, cheat_presets_icon, "Cheat Presets") else: # For Digital/Drums, create simple layout without tabs self._add_normal_preset_content(instrument_title_group_layout, synth_type) return instrument_preset_group
[docs] def _add_normal_preset_content(self, layout: Any, synth_type: str): """Add normal preset selection content to the layout.""" # Add icon row at the top (centered with stretch on both sides, matching PresetWidget) 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() layout.addLayout(icon_row_container) layout.addSpacing(10) # Add spacing after icon row, matching PresetWidget self.instrument_title_label = DigitalTitle() layout.addWidget(self.instrument_title_label) # --- Edit Tone Name (round button + label, centered) self._add_centered_round_button( JDXi.UI.Icon.SETTINGS, "Edit Tone Name", self.parent.edit_tone_name, layout, name="edit_tone_name", ) # --- Send Read Request to Synth (round button + label, centered) self._add_centered_round_button( JDXi.UI.Icon.REFRESH, "Send Read Request to Synth", self.parent.data_request, layout, name="read_request", ) self.instrument_selection_label = QLabel(f"Select a {synth_type} synth:") layout.addWidget(self.instrument_selection_label) # Determine the correct preset list (SoundFont when enabled, else JD-Xi) preset_list = get_preset_list_for_synth_type(synth_type) # Convert dictionary format (Digital/Analog) to list format if needed if isinstance(preset_list, dict): # Convert dictionary {1: {"Name": "...", "Category": "...", ...}, ...} to list format converted_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 or SoundFont) converted_preset_list = preset_list # Ensure parent uses this list for load_preset lookups self.parent.preset_preset_list = converted_preset_list # Build preset options, values, and categories from converted_preset_list preset_options = [ f"{preset['id']} - {preset['name']}" for preset in converted_preset_list ] # Convert preset IDs to integers for SearchableFilterableComboBox (e.g., "001" -> 1) preset_values = [int(preset["id"]) for preset in converted_preset_list] preset_categories = sorted( set(preset["category"] for preset in converted_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 converted_preset_list: if preset["id"] == preset_id_str: return preset["category"] == category return False # Create SearchableFilterableComboBox for preset selection self.instrument_selection_combo = SearchableFilterableComboBox( label="", options=preset_options, values=preset_values, categories=preset_categories, category_filter_func=preset_category_filter, show_label=False, show_search=True, show_category=True, search_placeholder="Search presets...", use_analog_style=(synth_type == "Analog"), ) # Apply styling if synth_type == "Analog": # Apply Analog styling to the combo box and search line edit self.instrument_selection_combo.combo_box.setStyleSheet( JDXi.UI.Style.COMBO_BOX_ANALOG ) search_box = getattr(self.instrument_selection_combo, "search_box", None) if search_box is not None: search_box.setStyleSheet(JDXi.UI.Style.QLINEEDIT_ANALOG) else: self.instrument_selection_combo.combo_box.setStyleSheet( JDXi.UI.Style.COMBO_BOX ) # Connect signals - use combo_box for currentIndexChanged self.instrument_selection_combo.combo_box.currentIndexChanged.connect( self.parent.update_instrument_image ) self.instrument_selection_combo.combo_box.currentIndexChanged.connect( self.parent.update_instrument_title ) # --- Load (round button + label, centered) load_row = QHBoxLayout() load_row.addStretch() load_button = self._add_round_action_button( JDXi.UI.Icon.FOLDER_NOTCH_OPEN, "Load", self._on_load_preset, load_row, name=None, ) load_row.addStretch() load_row_widget = QWidget() load_row_widget.setLayout(load_row) selection_layout = create_layout_with_items( [self.instrument_selection_combo, load_row_widget], vertical=True ) layout.addLayout(selection_layout) # Store reference to load button for compatibility self.instrument_selection_combo.load_button = load_button layout.addStretch()
[docs] def _add_cheat_preset_content(self, layout: QVBoxLayout): """Add cheat preset content to the layout (Analog only).""" # Add icon row at the top (centered with stretch on both sides, matching PresetWidget) 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() layout.addLayout(icon_row_container) layout.addSpacing(10) # Add spacing after icon row, matching PresetWidget # Build preset options, values, and categories # Support both "category" and "Category" (LIST has mixed formats) def _preset_category(p: dict) -> str: return p.get("category") or p.get("Category") or "" preset_options = [ f"{preset.get('id', '')} - {preset.get('name', preset.get('Name', ''))}" for preset in JDXi.UI.Preset.Digital.LIST ] # Convert preset IDs to integers for SearchableFilterableComboBox (e.g., "001" -> 1) preset_values = [int(p.get("id", "000")) for p in JDXi.UI.Preset.Digital.LIST] preset_categories = sorted( set( _preset_category(p) for p in JDXi.UI.Preset.Digital.LIST if _preset_category(p) ) ) # Category filter function for presets def cheat_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 JDXi.UI.Preset.Digital.LIST: if preset.get("id") == preset_id_str: return _preset_category(preset) == category return False # Create SearchableFilterableComboBox for cheat preset selection self.cheat_preset_combo_box = SearchableFilterableComboBox( label="Preset", options=preset_options, values=preset_values, categories=preset_categories, category_filter_func=cheat_preset_category_filter, show_label=True, show_search=True, show_category=True, search_placeholder="Search presets...", ) layout.addWidget(self.cheat_preset_combo_box) # Load Preset (round button + label, centered) self._add_centered_round_button( JDXi.UI.Icon.FOLDER_NOTCH_OPEN, "Load Preset", self._load_cheat_preset, layout, name="cheat_load", ) layout.addStretch()
[docs] def _on_load_preset(self): """Handle load button click for normal presets.""" # Get the current value from SearchableFilterableComboBox # The value is the preset ID as integer (e.g., 1 for "001") preset_id_int = self.instrument_selection_combo.value() preset_id = str(preset_id_int).zfill(3) # Convert back to 3-digit format # Emit signal with preset number (preset ID as int) self.parent.load_preset(int(preset_id))
[docs] def _load_cheat_preset(self): """ Load a Digital Synth preset on the Analog Synth channel (Cheat Mode). """ if not hasattr(self.parent, "midi_helper") or not self.parent.midi_helper: log.warning("⚠️ MIDI helper not available for cheat preset loading") return # Get the current value from SearchableFilterableComboBox # The value is the preset ID as integer (e.g., 1 for "001") preset_id_int = self.cheat_preset_combo_box.value() program_number = str(preset_id_int).zfill(3) # Convert back to 3-digit format log.message("=======load_cheat_preset (Cheat Mode)=======") log.parameter("combo box program_number", program_number) # Get MSB, LSB, PC values from the Digital preset list msb = get_preset_parameter_value( "msb", program_number, JDXi.UI.Preset.Digital.LIST ) lsb = get_preset_parameter_value( "lsb", program_number, JDXi.UI.Preset.Digital.LIST ) pc = get_preset_parameter_value( "pc", program_number, JDXi.UI.Preset.Digital.LIST ) if None in [msb, lsb, pc]: log.warning( f"Could not retrieve preset parameters for program {program_number}" ) return log.message("retrieved msb, lsb, pc for cheat preset:") log.parameter("combo box msb", msb) log.parameter("combo box lsb", lsb) log.parameter("combo box pc", pc) log_midi_info(msb, lsb, pc) # Convert to JD-Xi bank format (LSB 65 for presets 129-256) from jdxi_editor.ui.editors.helpers.preset import preset_to_jdxi_bank_pc bank_msb, bank_lsb, midi_pc = preset_to_jdxi_bank_pc(msb, lsb, pc) self.parent.midi_helper.send_bank_select_and_program_change( MidiChannel.ANALOG_SYNTH, bank_msb, bank_lsb, midi_pc, ) # Request data update if hasattr(self.parent, "data_request"): self.parent.data_request()
[docs] def setup_header_layout(self) -> None: """Top layout with title and image ---""" # Ensure setup() has been called first if self.layout is None: self.setup() self.hlayout = QHBoxLayout() # Set proper spacing on horizontal layout self.hlayout.setSpacing(JDXi.UI.Style.SPACING) self.hlayout.addStretch() # Add the horizontal layout to the vertical layout self.layout.addLayout(self.hlayout)
[docs] def add_stretch(self): """Pad both sides by symmetry, supposedly.""" self.hlayout.addStretch() # Add stretch at bottom for vertical centering self.layout.addStretch()