Source code for jdxi_editor.ui.widgets.midi.track_viewer

"""
Midi Track Viewer
"""

from copy import deepcopy

import mido
import qtawesome as qta
from decologr import Decologr as log
from PySide6.QtCore import Qt
from PySide6.QtWidgets import (
    QHBoxLayout,
    QLabel,
    QLayout,
    QLineEdit,
    QMessageBox,
    QPushButton,
    QScrollArea,
    QSlider,
)

from jdxi_editor.ui.common import JDXi, QVBoxLayout, QWidget
from jdxi_editor.ui.preset.tone.digital.list import JDXiPresetToneListDigital
from jdxi_editor.ui.style.factory import generate_sequencer_button_style
from jdxi_editor.ui.widgets.midi.draggable_track_row import DraggableTrackRow
from jdxi_editor.ui.widgets.midi.spin_box.spin_box import MidiSpinBox
from jdxi_editor.ui.widgets.midi.time_ruler import TimeRulerWidget
from jdxi_editor.ui.widgets.midi.track import MidiTrackWidget
from jdxi_editor.ui.widgets.midi.utils import get_first_channel
from picomidi.constant import Midi
from picomidi.message.type import MidoMetaMessageType, MidoMessageType


[docs] class MidiTrackViewer(QWidget): """ MidiTrackViewer """ def __init__(self, parent: QWidget = None): super().__init__(parent)
[docs] self.midi_file = None
[docs] self.event_index = None
[docs] self.ruler = TimeRulerWidget()
[docs] self.midi_track_widgets = {} # MidiTrackWidget()
[docs] self.muted_tracks: set[int] = set() # To track muted tracks
[docs] self._draggable_rows = {}
# To track muted channels
[docs] self.muted_channels: set[int] = set()
# Per-track editors for batch apply
[docs] self._track_name_edits: dict[int, QLineEdit] = {}
[docs] self.track_channel_spins: dict[int, MidiSpinBox] = {}
# Main layout main_layout = QVBoxLayout(self) main_layout.setSpacing(0) main_layout.setContentsMargins(0, 0, 0, 0) # Scrollable content widget
[docs] self.scroll_content = QWidget()
scroll_layout = QVBoxLayout(self.scroll_content) scroll_layout.setSpacing(0) scroll_layout.setContentsMargins(0, 0, 0, 0) # scroll_layout.addWidget(self.ruler) ruler_container = QWidget() ruler_layout = QHBoxLayout(ruler_container) ruler_layout.setContentsMargins(0, 0, 0, 0) ruler_layout.setSpacing(0) left_spacer = QWidget() left_spacer.setFixedWidth( self.get_track_controls_width() ) # same width as controls ruler_layout.addWidget(left_spacer) ruler_layout.addWidget(self.ruler, stretch=1) scroll_layout.addWidget(ruler_container) # Add Mute Buttons for channels 1-16
[docs] self.mute_buttons = {}
mute_layout = QHBoxLayout() mute_layout.addWidget(QLabel("Mute Channels:")) for ch in range(1, 17): btn = QPushButton(f"{ch}") btn.setCheckable(True) btn.setFixedWidth(30) btn.toggled.connect( lambda checked, c=ch: self.toggle_channel_mute(c, checked) ) btn.setStyleSheet( generate_sequencer_button_style(True, checked_means_inactive=True) ) self.mute_buttons[ch] = btn mute_layout.addWidget(btn) # scroll_layout.addLayout(mute_layout) # Scroll area scroll_area = QScrollArea() scroll_area.setWidgetResizable(True) scroll_area.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOn) scroll_area.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOn) scroll_area.setWidget(self.scroll_content) # Add scroll area to main layout main_layout.addWidget(scroll_area) # Track zoom slider
[docs] self.track_zoom_slider = QSlider(Qt.Horizontal)
self.track_zoom_slider.setRange(1, 100) self.track_zoom_slider.setValue(50) self.track_zoom_slider.valueChanged.connect(self.update_track_zoom) self.update_track_zoom(self.track_zoom_slider.value()) # Add zoom slider to layout main_layout.addWidget(QLabel("Track Zoom")) main_layout.addWidget(self.track_zoom_slider) self.setLayout(main_layout)
[docs] def clear(self): """Clear the MIDI track view and reset state.""" # Clear MIDI data self.midi_file = None self.event_index = None # Unmute all channels for ch, btn in self.mute_buttons.items(): if btn.isChecked(): btn.setChecked(False) self.muted_channels.clear() self.muted_tracks.clear() self._track_name_edits.clear() self.track_channel_spins.clear() self._draggable_rows.clear() # Remove track widgets for track_key, track_widget in self.midi_track_widgets.copy().items(): track_widget.setParent(None) track_widget.deleteLater() self.midi_track_widgets.clear() # Remove layouts/items from the channel controls layout if hasattr(self, "channel_controls_vlayout"): layout = self.channel_controls_vlayout while layout.count(): item = layout.takeAt(0) widget = item.widget() if widget: widget.setParent(None) widget.deleteLater() elif item.layout(): self._clear_layout(item.layout()) # See helper below # Reset zoom slider to default self.track_zoom_slider.setValue(50) # Optional: force a redraw self.update()
[docs] def _clear_layout(self, layout: QLayout): """ _clear_layout :param layout: :return: Recursively clear a layout and its children. """ while layout.count(): item = layout.takeAt(0) widget = item.widget() if widget: widget.setParent(None) widget.deleteLater() elif item.layout(): self._clear_layout(item.layout())
[docs] def clear_old(self): """Clear the MIDI track view and reset state.""" # Clear MIDI data self.midi_file = None self.event_index = None # Unmute all channels for ch, btn in self.mute_buttons.items(): if btn.isChecked(): btn.setChecked(False) self.muted_channels.clear() self.muted_tracks.clear() # Remove track widgets for track_key, track_widget in self.midi_track_widgets.copy().items(): track_widget.setParent(None) track_widget.deleteLater() self.midi_track_widgets.clear() # Reset zoom slider to default self.track_zoom_slider.setValue(50)
[docs] def update_track_zoom(self, width: int): """ update_track_zoom :param width: int slider value (1–100) used to set content width :return: None """ min_content_width = self.get_track_controls_width() + 200 content_width = max(min_content_width, width * 80) self.scroll_content.setFixedWidth(content_width) self.scroll_content.updateGeometry()
[docs] def toggle_channel_mute(self, channel: int, is_muted: bool) -> None: """ Toggle mute state for a specific MIDI channel. :param channel: int MIDI channel (1-16) :param is_muted: bool is the channel muted? :return: None """ if is_muted: self.muted_channels.add(channel) else: self.muted_channels.discard(channel) # Update this channel's button style (unmuted = red border, muted = grey) if channel in self.mute_buttons: self.mute_buttons[channel].setStyleSheet( generate_sequencer_button_style( not is_muted, checked_means_inactive=True ) ) # Notify all track widgets for widget in self.midi_track_widgets.values(): widget.update_muted_channels(self.muted_channels) print(f"Muted channels updated: {self.muted_channels}")
[docs] def update_muted_channels(self, muted_channels: set[int]) -> None: """ Called when the global mute state is updated. """ self.muted_channels = muted_channels print(f"Muted channels updated: {self.muted_channels}") self.update() # trigger repaint or UI change if needed
[docs] def toggle_track_mute(self, track: int, is_muted: bool) -> None: """ Toggle mute state for a specific MIDI track. :param track: int MIDI channel (1-16) :param is_muted: bool is the channel muted? :return: None """ if is_muted: self.muted_tracks.add(track) else: self.muted_tracks.discard(track) # Notify all track widgets for widget in self.midi_track_widgets.values(): widget.update_muted_tracks(self.muted_tracks) print(f"Muted tracks updated: {self.muted_tracks}")
[docs] def update_muted_tracks(self, muted_tracks: set[int]) -> None: """ Called when the global mute state is updated. """ self.muted_tracks = muted_tracks print(f"Muted tracks updated: {self.muted_tracks}") self.update() # trigger repaint or UI change if needed
[docs] def mute_track(self, track_index: int) -> None: """ Mute a specific track :param track_index: int :return: None """ if not (0 <= track_index < len(self.midi_file.tracks)): raise IndexError("Invalid track index") track_widget = self.midi_track_widgets[track_index] track_widget.muted = not track_widget.muted self.toggle_track_mute(track_index, track_widget.muted)
[docs] def delete_track(self, track_index: int) -> None: """ Ask user to confirm and delete a specific MIDI track and its widget. :param track_index: int :return: None """ if not (0 <= track_index < len(self.midi_file.tracks)): raise IndexError("Invalid track index") # Optional: Get the track name to show in dialog track = self.midi_file.tracks[track_index] track_name = getattr(track, "name", f"Track {track_index + 1}") # Show confirmation dialog reply = QMessageBox.question( self, "Delete Track?", f"Are you sure you want to delete '{track_name}'?", QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, ) if reply == QMessageBox.StandardButton.Yes: del self.midi_file.tracks[track_index] self.midi_track_widgets.pop(track_index) # Optional: update UI if needed self.refresh_track_list()
[docs] def change_track_name(self, track_index: int, new_name: str) -> None: """ Change the name of a specific MIDI track. :param track_index: int :param new_name: str :return: None """ if not (0 <= track_index < len(self.midi_file.tracks)): raise IndexError("Invalid track index") midi_file_copy = deepcopy(self.midi_file) # Deep copy of the MIDI file track_copy = midi_file_copy.tracks[track_index] self.set_track_name(track_copy, new_name) log.message(f"Renamed track {track_index} to {new_name}") self.set_midi_file(midi_file_copy) # Replace with modified file
[docs] def set_track_name(self, track, new_name): for msg in track: if msg.type == "track_name": msg.name = new_name # If not found, insert it at the beginning track.insert( 0, mido.MetaMessage( MidoMetaMessageType.TRACK_NAME.value, name=new_name, time=0 ), ) return track
[docs] def change_track_channel(self, track_index: int, new_channel: int) -> None: """ Change the MIDI channel of a specific track. :param track_index: int :param new_channel: int :return: None """ if not (0 <= new_channel <= 15): raise ValueError("MIDI channel must be between 0 and 15") if not (0 <= track_index < len(self.midi_file.tracks)): raise IndexError("Invalid track index") old_channel = get_first_channel(self.midi_file.tracks[track_index]) log.message( f"Changing track {track_index} channel from {old_channel} to {new_channel}" ) new_midi = mido.MidiFile() new_midi.ticks_per_beat = self.midi_file.ticks_per_beat for i, t in enumerate(self.midi_file.tracks): new_track = mido.MidiTrack() for msg in t: msg_copy = msg.copy() # Only change channel for channel messages if i == track_index and hasattr(msg_copy, "channel"): msg_copy.channel = new_channel new_track.append(msg_copy) new_midi.tracks.append(new_track) new_channel_tested = get_first_channel(new_midi.tracks[track_index]) log.message( f"Changed track {track_index} channel from {old_channel} to {new_channel_tested}" ) self.set_midi_file(new_midi)
[docs] def make_apply_slot(self, track_index: int, spin_box: MidiSpinBox) -> callable: """ Create a slot for applying changes to the track channel. :param track_index: int Track index to modify :param spin_box: MidiSpinBox Spin box for selecting the channel :return: callable function to apply changes """ log.message(f"Track index: {track_index}, Spin box value: {spin_box.value()}") return lambda: self.change_track_channel( track_index, spin_box.value() + Midi.channel.DISPLAY_TO_BINARY )
[docs] def make_apply_name(self, track_name: str, text_edit: QLineEdit) -> callable: """ Create a slot for applying changes to the track channel. :param track_name: str Track name to modify :param text_edit: QLineEdit for selecting the name :return: callable function to apply changes """ log.message(f"Track index: {track_name}, Text: {text_edit.text()}") return lambda: self.change_track_name(track_name, text_edit.text())
[docs] def set_midi_file(self, midi_file: mido.MidiFile) -> None: """ Set the MIDI file for the widget and create channel controls. :param midi_file: :return: None """ self.midi_file = midi_file self.ruler.set_midi_file(midi_file) # Clear existing selectors if reloading if not hasattr(self, "channel_controls_vlayout"): self.channel_controls_vlayout = QVBoxLayout() self.scroll_content.layout().addLayout(self.channel_controls_vlayout) else: self.clear_layout(self.channel_controls_vlayout) self.midi_track_widgets = {} self._draggable_rows = {} # Create each track widget and add it to the layout for i, track in enumerate(midi_file.tracks): hlayout = QHBoxLayout() first_channel = get_first_channel(track) + Midi.channel.BINARY_TO_DISPLAY # Optional: Get the track name to show in dialog track = self.midi_file.tracks[i] track_name = getattr( track, "name", f"Track {i + Midi.channel.BINARY_TO_DISPLAY}" ) icon_names = { 10: "fa5s.drum", } colors = {3: JDXi.UI.Style.ACCENT_ANALOG} color = colors.get( first_channel, JDXi.UI.Style.ACCENT ) # Default color if not specified icon_name = icon_names.get( first_channel, "mdi.piano" ) # Default icon if not specified # Add QLabel for track number and channel pixmap = qta.icon(icon_name, color=color).pixmap( JDXi.UI.Style.TRACK_ICON_PIXMAP_SIZE, JDXi.UI.Style.TRACK_ICON_PIXMAP_SIZE, ) track_number_label = QLabel(f"{i + 1}") track_number_label.setFixedWidth(JDXi.UI.Style.BUTTON_TRACK_WIDTH) track_number_label.setFixedHeight(JDXi.UI.Style.BUTTON_TRACK_WIDTH) hlayout.addWidget(track_number_label) icon_label = QLabel() icon_label.setPixmap(pixmap) icon_label.setFixedWidth( JDXi.UI.Style.TRACK_ICON_PIXMAP_SIZE ) # Add some padding hlayout.addWidget(icon_label) label_vlayout = QVBoxLayout() label_vlayout.setContentsMargins(0, 0, 0, 0) label_vlayout.setSpacing(0) hlayout.addLayout(label_vlayout) line_label_row = QHBoxLayout() label_vlayout.addLayout(line_label_row) # Add QLineEdit for track label track_name_line_edit = QLineEdit() track_name_line_edit.setText(track_name) track_name_line_edit.setFixedWidth(JDXi.UI.Style.TRACK_LABEL_WIDTH) track_name_line_edit.setToolTip("Track Name") track_name_line_edit.setStyleSheet( "QLineEdit { background-color: transparent; border: none; }" ) track_name_line_edit.setAlignment(Qt.AlignLeft) temp_row = QHBoxLayout() line_label_row.addLayout(temp_row) # temp_row.addWidget(track_number_label) temp_row.addWidget(track_name_line_edit) # temp_row.addWidget(track_channel_label) self._track_name_edits[i] = track_name_line_edit # Add QSpinBox for selecting the MIDI channel spin = MidiSpinBox() spin.setToolTip( "Select MIDI Channel for Track, then click 'Apply' to save changes" ) spin.setValue(first_channel) # Offset for digital spin.setFixedWidth(JDXi.UI.Style.TRACK_SPINBOX_WIDTH) spin.setPrefix("Ch") line_label_row.addWidget(spin) self.track_channel_spins[i] = spin button_hlayout = QHBoxLayout() label_vlayout.addLayout(button_hlayout) apply_icon = JDXi.UI.Icon.get_icon( JDXi.UI.Icon.SAVE, color=JDXi.UI.Style.FOREGROUND ) apply_button = QPushButton() apply_button.setIcon(apply_icon) apply_button.setToolTip("Apply changes to Track Channel") apply_button.setFixedWidth(JDXi.UI.Style.BUTTON_TRACK_WIDTH) apply_button.setFixedHeight(JDXi.UI.Style.BUTTON_TRACK_WIDTH) apply_button.clicked.connect(self.make_apply_slot(i, spin)) apply_button.clicked.connect( lambda _, tr=i, le=track_name_line_edit: self.change_track_name( tr, le.text() ) ) button_hlayout.addWidget(apply_button) mute_icon = JDXi.UI.Icon.get_icon( JDXi.UI.Icon.MUTE, color=JDXi.UI.Style.FOREGROUND ) mute_button = QPushButton() mute_button.setIcon(mute_icon) mute_button.setToolTip("Mute Track") mute_button.setFixedWidth(JDXi.UI.Style.BUTTON_TRACK_WIDTH) mute_button.setFixedHeight(JDXi.UI.Style.BUTTON_TRACK_WIDTH) mute_button.setCheckable(True) mute_button.clicked.connect( lambda _, tr=i: self.mute_track(tr) ) # Send internal value (0–15) mute_button.toggled.connect( lambda checked, tr=i: self.toggle_track_mute(tr, checked) ) button_hlayout.addWidget(mute_button) delete_icon = JDXi.UI.Icon.get_icon( JDXi.UI.Icon.DELETE, color=JDXi.UI.Style.FOREGROUND ) delete_button = QPushButton() delete_button.setIcon(delete_icon) delete_button.setToolTip("Delete Track") delete_button.setFixedWidth(JDXi.UI.Style.BUTTON_TRACK_WIDTH) delete_button.setCheckable(True) delete_button.clicked.connect( lambda _, tr=i: self.delete_track(tr) ) # Send internal value (0–15) button_hlayout.addWidget(delete_button) # Add the MidiTrackWidget for the specific track self.midi_track_widgets[i] = MidiTrackWidget( track=track, track_number=i, total_length=midi_file.length ) # Initialize the dictionary hlayout.addWidget(self.midi_track_widgets[i]) # Wrap the layout in a draggable row widget draggable_row = DraggableTrackRow(i, hlayout, self.scroll_content) draggable_row.track_moved.connect(self.move_track) self._draggable_rows[i] = draggable_row self.channel_controls_vlayout.addWidget(draggable_row) # Global Apply button (Apply Presets is in Track Classification group) apply_all_layout = QHBoxLayout() apply_all_layout.addStretch() apply_all_btn = QPushButton("Apply Changes") apply_all_btn.setToolTip("Apply all Track Name and MIDI Channel changes") apply_all_btn.clicked.connect(self.apply_all_track_changes) apply_all_layout.addWidget(apply_all_btn) self.channel_controls_vlayout.addLayout(apply_all_layout) self.channel_controls_vlayout.addStretch() self.update_track_zoom(self.track_zoom_slider.value())
[docs] def get_track_controls_width(self) -> int: """ Returns the estimated total width of all controls to the left of the MidiTrackWidget. """ # Fixed widths from layout: # QLabels: JDXi.Style.ICON_PIXMAP_SIZE, JDXi.Style.TRACK_LABEL_WIDTH , QSpinBox: JDXi.Style.TRACK_MUTE_BUTTON_WIDTH, Apply: JDXi.Style.TRACK_MUTE_BUTTON_WIDTH, Mute: JDXi.Style.TRACK_MUTE_BUTTON_WIDTH, Delete: JDXi.Style.TRACK_MUTE_BUTTON_WIDTH + margins (~10) return ( JDXi.UI.Style.ICON_PIXMAP_SIZE + JDXi.UI.Style.TRACK_LABEL_WIDTH + (JDXi.UI.Style.BUTTON_TRACK_WIDTH * 4) + 10 ) # = 2JDXi.Style.TRACK_MUTE_BUTTON_WIDTH
[docs] def clear_layout(self, layout: QLayout) -> None: while layout.count(): item = layout.takeAt(0) widget = item.widget() if widget: widget.setParent(None) widget.deleteLater() elif item.layout(): self.clear_layout(item.layout())
[docs] def refresh_track_list(self): """ refresh_track_list :return: """ self.update()
[docs] def apply_all_track_changes(self) -> None: """Apply all Track Name and Channel changes in one operation.""" if not self.midi_file: return # Build a new MIDI file with updated channels new_midi = mido.MidiFile() new_midi.ticks_per_beat = self.midi_file.ticks_per_beat for i, t in enumerate(self.midi_file.tracks): desired_display_channel = ( self.track_channel_spins.get(i).value() if i in self.track_channel_spins else None ) desired_channel = ( None if desired_display_channel is None else desired_display_channel + Midi.channel.DISPLAY_TO_BINARY ) new_track = mido.MidiTrack() for msg in t: msg_copy = msg.copy() if desired_channel is not None and hasattr(msg_copy, "channel"): msg_copy.channel = desired_channel new_track.append(msg_copy) # Set or update track name new_name = ( self._track_name_edits.get(i).text() if i in self._track_name_edits else None ) if new_name: # Remove existing track_name meta to avoid duplicates filtered = [ m for m in new_track if not (m.is_meta and getattr(m, "type", "") == "track_name") ] new_track.clear() # Insert a track_name at the very beginning new_track.append( mido.MetaMessage( MidoMetaMessageType.TRACK_NAME.value, name=new_name, time=0 ) ) for m in filtered: new_track.append(m) new_midi.tracks.append(new_track) self.set_midi_file(new_midi)
# Channel (1-16) → JD-Xi preset number (e.g. 159 Picked Bass, 162 Piano, 1 JP8 Strings)
[docs] _CHANNEL_PRESET_MAP = { 1: 159, # Picked Bass 2: 162, # JD Piano 1 3: 1, # JP8 Strings1 (Cheat Preset #1) }
[docs] def apply_channel_presets(self) -> None: """Insert Bank Select and Program Change at the start of each track based on channel.""" log.message( scope=self.__class__.__name__, message="apply_channel_presets (file update) called", ) if not self.midi_file: log.message( scope=self.__class__.__name__, message="Early return: no midi_file", ) return pc_map = JDXiPresetToneListDigital.PROGRAM_CHANGE new_midi = mido.MidiFile() new_midi.ticks_per_beat = self.midi_file.ticks_per_beat for i, track in enumerate(self.midi_file.tracks): desired_display_ch = ( self.track_channel_spins.get(i).value() if i in self.track_channel_spins else None ) if desired_display_ch is None: new_track = mido.MidiTrack() for msg in track: new_track.append(msg.copy()) new_midi.tracks.append(new_track) continue preset_num = self._CHANNEL_PRESET_MAP.get(desired_display_ch) channel = desired_display_ch + Midi.channel.DISPLAY_TO_BINARY if preset_num is None or preset_num not in pc_map: new_track = mido.MidiTrack() for msg in track: msg_copy = msg.copy() if hasattr(msg_copy, "channel"): msg_copy.channel = channel new_track.append(msg_copy) new_midi.tracks.append(new_track) continue spec = pc_map[preset_num] msb = spec["MSB"] lsb = spec["LSB"] pc = spec["PC"] msgs = [ mido.Message( MidoMessageType.CONTROL_CHANGE.value, control=0, value=msb, channel=channel, time=0, ), mido.Message( MidoMessageType.CONTROL_CHANGE.value, control=32, value=lsb, channel=channel, time=0, ), mido.Message( MidoMessageType.PROGRAM_CHANGE.value, program=max(0, pc - 1), channel=channel, time=0, ), ] new_track = mido.MidiTrack() for m in msgs: new_track.append(m) for msg in track: msg_copy = msg.copy() if hasattr(msg_copy, "channel"): msg_copy.channel = channel new_track.append(msg_copy) new_midi.tracks.append(new_track) log.parameter( scope=self.__class__.__name__, message="Tracks with presets inserted", parameter=len(new_midi.tracks), ) self.set_midi_file(new_midi)
[docs] def get_muted_channels(self): return self.muted_channels
[docs] def get_muted_tracks(self): return self.muted_tracks
[docs] def move_track(self, from_index: int, to_index: int) -> None: """ Move a track from one position to another in the MIDI file. :param from_index: Source track index :param to_index: Target track index """ if not self.midi_file: return if from_index == to_index: return if not (0 <= from_index < len(self.midi_file.tracks)): log.warning(f"Invalid from_index: {from_index}") return if not (0 <= to_index < len(self.midi_file.tracks)): log.warning(f"Invalid to_index: {to_index}") return log.message(f"Moving track {from_index + 1} to position {to_index + 1}") # Reorder tracks in MIDI file tracks = list(self.midi_file.tracks) track_to_move = tracks.pop(from_index) tracks.insert(to_index, track_to_move) # Create new MIDI file with reordered tracks new_midi = mido.MidiFile() new_midi.ticks_per_beat = self.midi_file.ticks_per_beat new_midi.tracks = tracks # Update the MIDI file and refresh the UI self.midi_file = new_midi # Rebuild the track mappings to reflect new order # We need to preserve the track name edits and channel spins, but remap them old_name_edits = self._track_name_edits.copy() old_channel_spins = self.track_channel_spins.copy() old_track_widgets = self.midi_track_widgets.copy() # Clear current mappings self._track_name_edits.clear() self.track_channel_spins.clear() self.midi_track_widgets.clear() self._draggable_rows.clear() # Remap: the track that was at from_index is now at to_index # All tracks between from_index and to_index shift for i in range(len(tracks)): if i == to_index: # This is the moved track old_idx = from_index elif from_index < to_index: # Moving down: tracks between from_index and to_index shift up if from_index < i <= to_index: old_idx = i - 1 else: old_idx = i else: # Moving up: tracks between to_index and from_index shift down if to_index <= i < from_index: old_idx = i + 1 else: old_idx = i # Copy mappings from old index to new index if old_idx in old_name_edits: self._track_name_edits[i] = old_name_edits[old_idx] if old_idx in old_channel_spins: self.track_channel_spins[i] = old_channel_spins[old_idx] if old_idx in old_track_widgets: self.midi_track_widgets[i] = old_track_widgets[old_idx] # Refresh the UI self.set_midi_file(new_midi) log.message(f"Track moved successfully")