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)
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
self.image_label.setAlignment(
Qt.AlignmentFlag.AlignVCenter
) # Center align the image
preset_vlayout.addWidget(self.image_label)
# Synth type selection combo box
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)