Source code for jdxi_editor.ui.editors.program.user_programs_widget

"""
User Programs Widget Module

This module defines the `UserProgramsWidget` class, a widget for managing
user programs in a sortable, searchable table with database integration.

Classes:
    UserProgramsWidget(QWidget)
        A widget for displaying and managing user programs.
"""

from typing import Any, Callable, Optional

from decologr import Decologr as log
from PySide6.QtCore import Qt, Signal
from PySide6.QtWidgets import (
    QHBoxLayout,
    QHeaderView,
    QLabel,
    QLineEdit,
    QPushButton,
    QTableWidget,
    QTableWidgetItem,
)

from jdxi_editor.midi.io.helper import MidiIOHelper
from jdxi_editor.midi.program.program import JDXiProgram
from jdxi_editor.ui.common import JDXi, QVBoxLayout, QWidget
from jdxi_editor.ui.editors.helpers.program import calculate_midi_values
from jdxi_editor.ui.editors.helpers.widgets import create_jdxi_button, create_jdxi_row
from jdxi_editor.ui.style import JDXiUIDimensions, JDXiUIStyle
from jdxi_editor.ui.widgets.delegates.play_button import PlayButtonDelegate
from jdxi_editor.ui.widgets.editor.helper import transfer_layout_items


[docs] class UserProgramsWidget(QWidget): """Widget for managing user programs in a database table.""" # Signal emitted when a program is selected/loaded
[docs] program_loaded = Signal(JDXiProgram)
def __init__( self, midi_helper: Optional[MidiIOHelper] = None, channel: int = 16, # Default PROGRAM channel (0-based) parent: Optional[QWidget] = None, on_program_loaded: Optional[Callable[[JDXiProgram], None]] = None, ): """ Initialize the UserProgramsWidget. :param midi_helper: Optional[MidiIOHelper] for MIDI communication :param channel: int MIDI channel (0-based, default 16 for PROGRAM) :param parent: Optional[QWidget] parent widget :param on_program_loaded: Optional callback when program is loaded """ super().__init__(parent)
[docs] self.midi_helper = midi_helper
[docs] self.channel = channel
[docs] self.on_program_loaded_callback = on_program_loaded
# UI components
[docs] self.user_programs_table: Optional[QTableWidget] = None
[docs] self.save_user_programs_button: Optional[QPushButton] = None
self.setup_ui()
[docs] def setup_ui(self) -> None: """Setup the user programs UI.""" layout = QVBoxLayout(self) # Add icon row at the top (transfer items to avoid "already has a parent" errors) icon_row_container = QHBoxLayout() icon_row = JDXi.UI.Icon.create_generic_musical_icon_row() transfer_layout_items(icon_row, icon_row_container) layout.addLayout(icon_row_container) # Search box search_layout = QHBoxLayout() search_label = QLabel("Search:") self.user_programs_search_box = QLineEdit() self.user_programs_search_box.setPlaceholderText( "Search by ID, name, genre, or tone..." ) self.user_programs_search_box.textChanged.connect( lambda text: self.populate_table(text) ) search_layout.addWidget(search_label) search_layout.addWidget(self.user_programs_search_box) layout.addLayout(search_layout) # Create table self.user_programs_table = QTableWidget() self.user_programs_table.setColumnCount(12) self.user_programs_table.setHorizontalHeaderLabels( [ "ID", "Name", "Genre", "Bank", "PC", "MSB", "LSB", "Digital 1", "Digital 2", "Analog", "Drums", "Play", ] ) # Apply custom styling self.user_programs_table.setStyleSheet(self._get_table_style()) # Enable sorting self.user_programs_table.setSortingEnabled(True) # Set column widths header = self.user_programs_table.horizontalHeader() header.setSectionResizeMode(0, QHeaderView.ResizeMode.ResizeToContents) # ID header.setSectionResizeMode(1, QHeaderView.ResizeMode.Stretch) # Name header.setSectionResizeMode(2, QHeaderView.ResizeMode.ResizeToContents) # Genre header.setSectionResizeMode(3, QHeaderView.ResizeMode.ResizeToContents) # Bank header.setSectionResizeMode(4, QHeaderView.ResizeMode.ResizeToContents) # PC header.setSectionResizeMode(5, QHeaderView.ResizeMode.ResizeToContents) # MSB header.setSectionResizeMode(6, QHeaderView.ResizeMode.ResizeToContents) # LSB header.setSectionResizeMode( 7, QHeaderView.ResizeMode.ResizeToContents ) # Digital 1 header.setSectionResizeMode( 8, QHeaderView.ResizeMode.ResizeToContents ) # Digital 2 header.setSectionResizeMode( 9, QHeaderView.ResizeMode.ResizeToContents ) # Analog header.setSectionResizeMode( 10, QHeaderView.ResizeMode.ResizeToContents ) # Drums header.setSectionResizeMode(11, QHeaderView.ResizeMode.ResizeToContents) # Play # Set up Play button delegate for column 11 play_button_delegate = PlayButtonDelegate( self.user_programs_table, play_callback=self._play_user_program ) self.user_programs_table.setItemDelegateForColumn(11, play_button_delegate) # Connect double-click to load program self.user_programs_table.itemDoubleClicked.connect( self._on_user_program_selected ) # Connect single-click to load program (alternative) self.user_programs_table.itemSelectionChanged.connect( self._on_user_program_selection_changed ) layout.addWidget(self.user_programs_table) # Save Changes (round button + label, centered) button_layout = QHBoxLayout() button_layout.addStretch() self.save_user_programs_button = self._add_round_action_button( JDXi.UI.Icon.FLOPPY_DISK, "Save Changes", self.save_changes, button_layout, name="save_user_programs", ) button_layout.addStretch() layout.addLayout(button_layout) # Populate table (with error handling) try: log.message("🔨Calling populate_table()...", scope="UserProgramsWidget") self.populate_table() log.message("✅ Table populated successfully", scope="UserProgramsWidget") except Exception as e: log.error( f"❌ Error populating user programs table: {e}", scope="UserProgramsWidget", ) import traceback log.error(traceback.format_exc())
# Table will be empty but widget will still be visible
[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 _get_table_style(self) -> str: """ Get custom styling for tables with rounded corners and charcoal embossed cells. :return: str CSS style string """ return JDXi.UI.Style.DATABASE_TABLE_STYLE
[docs] def populate_table(self, search_text: str = "") -> None: """ Populate the user programs table from SQLite database. :param search_text: Optional search text to filter programs """ if not self.user_programs_table: log.warning( "User programs table not initialized", scope="UserProgramsWidget" ) return try: from jdxi_editor.ui.programs.database import get_database # Get all user programs from database db = get_database() all_programs = db.get_all_programs() except Exception as e: log.error( f"Error getting programs from database: {e}", scope="UserProgramsWidget" ) all_programs = [] # Filter by search text if provided if search_text: search_lower = search_text.lower() all_programs = [ p for p in all_programs if ( search_lower in p.id.lower() or search_lower in p.name.lower() or (p.genre and search_lower in p.genre.lower()) or (p.digital_1 and search_lower in p.digital_1.lower()) or (p.digital_2 and search_lower in p.digital_2.lower()) or (p.analog and search_lower in p.analog.lower()) or (p.drums and search_lower in p.drums.lower()) ) ] # Clear table try: self.user_programs_table.setRowCount(0) except Exception as e: log.error( f"Error clearing user programs table: {e}", scope="UserProgramsWidget" ) return # Populate table for program in all_programs: row = self.user_programs_table.rowCount() self.user_programs_table.insertRow(row) # Extract bank letter from ID bank_letter = program.id[0] if program.id else "" # Create items self.user_programs_table.setItem(row, 0, QTableWidgetItem(program.id or "")) # Make Name column editable (column 1) name_item = QTableWidgetItem(program.name or "") name_item.setFlags(name_item.flags() | Qt.ItemFlag.ItemIsEditable) self.user_programs_table.setItem(row, 1, name_item) # Make Genre column editable (column 2) genre_item = QTableWidgetItem(program.genre or "") genre_item.setFlags(genre_item.flags() | Qt.ItemFlag.ItemIsEditable) self.user_programs_table.setItem(row, 2, genre_item) self.user_programs_table.setItem(row, 3, QTableWidgetItem(bank_letter)) self.user_programs_table.setItem( row, 4, QTableWidgetItem(str(program.pc) if program.pc is not None else ""), ) self.user_programs_table.setItem( row, 5, QTableWidgetItem(str(program.msb) if program.msb is not None else ""), ) self.user_programs_table.setItem( row, 6, QTableWidgetItem(str(program.lsb) if program.lsb is not None else ""), ) self.user_programs_table.setItem( row, 7, QTableWidgetItem(program.digital_1 or "") ) self.user_programs_table.setItem( row, 8, QTableWidgetItem(program.digital_2 or "") ) self.user_programs_table.setItem( row, 9, QTableWidgetItem(program.analog or "") ) self.user_programs_table.setItem( row, 10, QTableWidgetItem(program.drums or "") ) # Store program object in item data for easy access for col in range(11): item = self.user_programs_table.item(row, col) if item: item.setData(Qt.ItemDataRole.UserRole, program) log.message( f"✅Populated user programs table with {len(all_programs)} programs", scope="UserProgramsWidget", )
[docs] def save_changes(self) -> None: """Save changes made to the user programs table (e.g., genre edits) to the database.""" if not self.user_programs_table: log.warning( "User programs table not initialized", scope="UserProgramsWidget" ) return from jdxi_editor.midi.io.input_handler import add_or_replace_program_and_save from jdxi_editor.ui.programs.database import get_database db = get_database() saved_count = 0 error_count = 0 # Iterate through all rows in the table for row in range(self.user_programs_table.rowCount()): # Get the program object from the first column's user data id_item = self.user_programs_table.item(row, 0) if not id_item: continue program = id_item.data(Qt.ItemDataRole.UserRole) if not program or not isinstance(program, JDXiProgram): continue # Get the updated name from the table (column 1) name_item = self.user_programs_table.item(row, 1) new_name = name_item.text().strip() if name_item else (program.name or "") # Get the updated genre from the table (column 2) genre_item = self.user_programs_table.item(row, 2) new_genre = ( genre_item.text().strip() if genre_item else (program.genre or "") ) # Check if name or genre has changed name_changed = new_name != (program.name or "") genre_changed = new_genre != (program.genre or "") if name_changed or genre_changed: # Create updated program object updated_program = JDXiProgram( id=program.id, name=new_name if new_name else None, genre=new_genre if new_genre else None, pc=program.pc, msb=program.msb, lsb=program.lsb, tempo=program.tempo, measure_length=program.measure_length, scale=program.scale, analog=program.analog, digital_1=program.digital_1, digital_2=program.digital_2, drums=program.drums, ) # Save to database if add_or_replace_program_and_save(updated_program): saved_count += 1 changes = [] if name_changed: changes.append(f"name: '{program.name}' -> '{new_name}'") if genre_changed: changes.append(f"genre: '{program.genre}' -> '{new_genre}'") log.message( f"✅ Updated {program.id}: {', '.join(changes)}", scope="UserProgramsWidget", ) # Update the stored program object in item data for col in range(11): item = self.user_programs_table.item(row, col) if item: item.setData(Qt.ItemDataRole.UserRole, updated_program) else: error_count += 1 log.error( f"❌Failed to save update for {program.id}", scope="UserProgramsWidget", ) # Show summary message if saved_count > 0: log.message( f"✅ Saved {saved_count} program update(s)", scope="UserProgramsWidget" ) if error_count > 0: log.warning( f"⚠️ {error_count} program(s) failed to save", scope="UserProgramsWidget", ) else: if error_count > 0: log.error( f"❌Failed to save {error_count} program(s)", scope="UserProgramsWidget", ) else: log.message("ℹ️No changes to save", scope="UserProgramsWidget")
[docs] def _on_user_program_selected(self, item: QTableWidgetItem) -> None: """ Handle double-click on a program in the user programs table. Loads the program via MIDI Program Change. :param item: The table item that was double-clicked """ self._load_program_from_table(item.row())
[docs] def _on_user_program_selection_changed(self) -> None: """ Handle selection change in the user programs table. Loads the program via MIDI Program Change when a row is selected. """ selected_rows = self.user_programs_table.selectionModel().selectedRows() if selected_rows: row = selected_rows[0].row() self._load_program_from_table(row)
[docs] def _play_user_program(self, index) -> None: """ Callback for Play button delegate - loads and plays the program. :param index: QModelIndex from the delegate """ row = index.row() log.message(f"🎹 Play button clicked for row {row}", scope="UserProgramsWidget") self._load_program_from_table(row)
[docs] def _load_program_from_table(self, row: int) -> None: """ Load a program from the table and send MIDI Program Change. :param row: Row index in the table """ if ( not self.user_programs_table or row < 0 or row >= self.user_programs_table.rowCount() ): return # Get program from first column's user data item = self.user_programs_table.item(row, 0) if not item: return program = item.data(Qt.ItemDataRole.UserRole) if not program or not isinstance(program, JDXiProgram): return # Get program ID and extract bank/number program_id = program.id if not program_id or len(program_id) < 3: log.warning( f" Invalid program ID: {program_id}", scope="UserProgramsWidget" ) return bank_letter = program_id[0] try: bank_number = int(program_id[1:3]) except ValueError: log.warning( f" Invalid program number in ID: {program_id}", scope="UserProgramsWidget", ) return log.message( f"🎹 Loading program from table: {program_id} - {program.name}", scope="UserProgramsWidget", ) # Calculate MIDI values try: msb, lsb, pc = calculate_midi_values(bank_letter, bank_number) except (ValueError, TypeError) as e: log.error( f" Error calculating MIDI values for {program_id}: {e}", scope="UserProgramsWidget", ) return # Send MIDI Program Change if self.midi_helper: log.message( f" Sending Program Change: MSB={msb}, LSB={lsb}, PC={pc}", scope="UserProgramsWidget", ) self.midi_helper.send_bank_select_and_program_change( self.channel, msb, lsb, pc ) # Emit signal and call callback self.program_loaded.emit(program) if self.on_program_loaded_callback: self.on_program_loaded_callback(program)