From 22e4fa770e3830e99b26845ff80f3da787e13eea Mon Sep 17 00:00:00 2001 From: Paul Lipscomb Date: Mon, 29 Jun 2026 21:53:45 -0400 Subject: [PATCH] Multi-select, context menu, section strikethrough, rename/duplicate presets - Multi-select strips with click, Ctrl+click (toggle), Shift+click (range, same zone) - Right-click selected strip: context menu with Cascade CC, Assign Channel, Deselect All - Right-click unselected strip: clears all selections - Selection status label shows count and type (faders/toggles/transport toggles) - Rename preset button added to preset row - Duplicate preset button auto-generates new UUID - Layout panel section buttons show strikethrough when hidden, normal when visible - Renamed Preset Browser to Instrument Browser in UI - Transport strips wired for multi-select and context menu Co-Authored-By: Claude Sonnet 4.6 --- faderwidget.py | 19 +++++-- main.py | 138 ++++++++++++++++++++++++++++++++++++++++-------- togglewidget.py | 19 +++++-- 3 files changed, 145 insertions(+), 31 deletions(-) diff --git a/faderwidget.py b/faderwidget.py index 3abc882..124be98 100644 --- a/faderwidget.py +++ b/faderwidget.py @@ -206,6 +206,7 @@ class FaderWidget(QWidget): self.fader.valueChanged.connect(self.on_fader_moved) self.cc_spin.valueChanged.connect(self.on_cc_changed) self.on_select_callback = None + self.on_context_menu_callback = None self.hw_cc = None # bound hardware CC number self.is_learning = False self.zone = None # None, "left", or "right" @@ -219,15 +220,25 @@ class FaderWidget(QWidget): def eventFilter(self, obj, event): if event.type() == QEvent.Type.MouseButtonPress: - if self.on_select_callback: - self.on_select_callback(self) + if event.button() != Qt.MouseButton.RightButton: + if self.on_select_callback: + self.on_select_callback(self) + elif event.type() == QEvent.Type.ContextMenu: + if self.on_context_menu_callback: + self.on_context_menu_callback(self, event.globalPos()) + return True return False def mousePressEvent(self, event): - if self.on_select_callback: - self.on_select_callback(self) + if event.button() != Qt.MouseButton.RightButton: + if self.on_select_callback: + self.on_select_callback(self) super().mousePressEvent(event) + def contextMenuEvent(self, event): + if self.on_context_menu_callback: + self.on_context_menu_callback(self, event.globalPos()) + def on_pickup_changed(self, state): self.pickup_mode = bool(state) if self.pickup_mode: diff --git a/main.py b/main.py index 770fe38..dd45a6a 100644 --- a/main.py +++ b/main.py @@ -85,7 +85,8 @@ _osc_thread = None #MARK: GLOBALS ready = False -selected_strip = None +selected_strips = [] +last_clicked_strip = None learning_strip = None faders = [] strip_width = 100 @@ -588,8 +589,8 @@ vstack_layout_panel.addWidget(_section_sep) _section_btn_style = ( "QPushButton { background: transparent; border: none; color: #ffffff;" - " font-size: 12px; font-weight: bold; text-align: left; padding: 4px 6px; }" - "QPushButton:!checked { color: #444444; font-weight: normal; }" + " font-size: 12px; font-weight: bold; text-align: left; padding: 4px 6px; text-decoration: none; }" + "QPushButton:!checked { color: #444444; font-weight: normal; text-decoration: line-through; }" ) def _make_section_row(label_text): @@ -600,7 +601,7 @@ def _make_section_row(label_text): btn.setStyleSheet(_section_btn_style) return btn -chk_section_preset_browser = _make_section_row("Preset Browser") +chk_section_preset_browser = _make_section_row("Instrument Browser") chk_section_big_title = _make_section_row("Big Title") chk_section_fader_row = _make_section_row("Fader Row") chk_section_toggle_row = _make_section_row("Toggle Row") @@ -613,7 +614,7 @@ del _btn vstack_layout_panel.addStretch() # MARK: PRESET BROWSER ROW MODULE -label_preset_browser_title = QLabel("Preset Browser") +label_preset_browser_title = QLabel("Instrument Browser") hstack_preset_browser = QHBoxLayout() hstack_preset_browser.setSpacing(8) @@ -672,6 +673,7 @@ for _label, _cc in [("Play", 117), ("Stop", 116), ("Record", 118), ("Arm", 119)] _t.minus_btn.clicked.connect(lambda checked=False, t=_t: transport_remove(t)) _t.learn_btn.clicked.connect(lambda checked=False, t=_t: check_and_start_midi_learn(t)) _t.on_select_callback = lambda strip: check_and_update_selected_strip(strip) + _t.on_context_menu_callback = lambda strip, pos: strip_context_menu(strip, pos) _t.on_zone_change_callback = lambda strip: handle_transport_zone_change(strip) del _t, _label, _cc @@ -1775,13 +1777,105 @@ def check_and_index_strip_labels(): separator_vertical_toggle_zone_left.setVisible(any(t.zone == "left" for t in toggles)) separator_vertical_toggle_zone_right.setVisible(any(t.zone == "right" for t in toggles)) +def clear_all_selections(): + global selected_strips, last_clicked_strip + for s in selected_strips: + s.set_selected(False) + selected_strips = [] + last_clicked_strip = None + label_selected_channel.setText("") + +def _get_zone_strips(strip): + if isinstance(strip, FaderWidget): + pool = faders + elif isinstance(strip, TransportWidget): + pool = transports + else: + pool = toggles + same_zone = [s for s in pool if s.zone == strip.zone] + if strip.zone is not None: + same_zone.sort(key=lambda s: s.zone_index or 0) + return same_zone + def check_and_update_selected_strip(strip): - global selected_strip - if selected_strip and selected_strip != strip: - selected_strip.set_selected(False) - selected_strip = strip - strip.set_selected(True) - label_selected_channel.setText(f"Software item selected: {strip.title_edit.text()}") + global selected_strips, last_clicked_strip + from PyQt6.QtWidgets import QApplication + mods = QApplication.queryKeyboardModifiers() + ctrl = bool(mods & Qt.KeyboardModifier.ControlModifier) + shift = bool(mods & Qt.KeyboardModifier.ShiftModifier) + + if shift and last_clicked_strip and last_clicked_strip != strip: + if type(strip) is not type(last_clicked_strip): + return + if strip.zone != last_clicked_strip.zone: + return + zone_strips = _get_zone_strips(strip) + if last_clicked_strip not in zone_strips or strip not in zone_strips: + return + a, b = zone_strips.index(last_clicked_strip), zone_strips.index(strip) + new_range = zone_strips[min(a,b):max(a,b)+1] + for s in selected_strips: + s.set_selected(False) + selected_strips = new_range + for s in selected_strips: + s.set_selected(True) + elif ctrl: + if selected_strips: + if type(strip) is not type(selected_strips[0]): + return + if strip.zone != selected_strips[0].zone: + return + if strip in selected_strips: + selected_strips.remove(strip) + strip.set_selected(False) + else: + selected_strips.append(strip) + strip.set_selected(True) + last_clicked_strip = strip + else: + for s in selected_strips: + s.set_selected(False) + selected_strips = [strip] + strip.set_selected(True) + last_clicked_strip = strip + + if selected_strips: + s = selected_strips[0] + if isinstance(s, FaderWidget): + kind = "fader" if len(selected_strips) == 1 else "faders" + elif isinstance(s, ToggleWidget): + kind = "toggle" if len(selected_strips) == 1 else "toggles" + elif isinstance(s, TransportWidget): + kind = "transport toggle" if len(selected_strips) == 1 else "transport toggles" + else: + kind = "strip" if len(selected_strips) == 1 else "strips" + label_selected_channel.setText(f"{len(selected_strips)} {kind} selected") + else: + label_selected_channel.setText("") + +def strip_context_menu(strip, global_pos): + if strip not in selected_strips: + clear_all_selections() + return + from PyQt6.QtWidgets import QMenu, QInputDialog + menu = QMenu() + action_cascade = menu.addAction("Cascade CC") + action_channel = menu.addAction("Assign Channel") + menu.addSeparator() + action_deselect = menu.addAction("Deselect All") + action = menu.exec(global_pos) + if action == action_cascade: + start, ok = QInputDialog.getInt(app_window, "Cascade CC", "Starting CC:", 0, 0, 127) + if ok: + for i, s in enumerate(selected_strips): + s.cc_spin.setValue((start + i) % 128) + elif action == action_channel: + ch, ok = QInputDialog.getInt(app_window, "Assign Channel", "Channel (1–16):", 1, 1, 16) + if ok: + for s in selected_strips: + s.ch_spin.setValue(ch) + elif action == action_deselect: + clear_all_selections() def handle_toggle_zone_change(toggle): if toggle.zone is not None and toggle.center_index is None: @@ -1904,6 +1998,7 @@ def toggle_add(insert_after=None): toggle.plus_btn.clicked.connect(lambda: toggle_add(insert_after=toggle)) toggle.minus_btn.clicked.connect(lambda: toggle_remove(toggle)) toggle.on_select_callback = check_and_update_selected_strip + toggle.on_context_menu_callback = strip_context_menu toggle.on_zone_change_callback = handle_toggle_zone_change toggle.learn_btn.clicked.connect(lambda: check_and_start_midi_learn(toggle)) update_window_width() @@ -1911,12 +2006,11 @@ def toggle_add(insert_after=None): _update_toggle_placeholder() def toggle_remove(toggle): - global selected_strip if len(toggles) <= 0: return - if selected_strip == toggle: - selected_strip = None - label_selected_channel.setText("Software item selected: None") + if toggle in selected_strips: + clear_all_selections() + label_selected_channel.setText("") hstack_toggle_center.removeWidget(toggle) toggle.deleteLater() toggles.remove(toggle) @@ -1938,18 +2032,17 @@ def fader_add(insert_after=None): fader.minus_btn.clicked.connect(lambda: fader_remove(fader)) fader.learn_btn.clicked.connect(lambda: check_and_start_midi_learn(fader)) fader.on_select_callback = check_and_update_selected_strip + fader.on_context_menu_callback = strip_context_menu fader.on_zone_change_callback = handle_fader_zone_change update_window_width() check_and_index_strip_labels() _update_fader_placeholder() def fader_remove(fader): - global selected_strip if len(faders) <= 0: return - if selected_strip == fader: - selected_strip = None - label_selected_channel.setText("Software item selected: None") + if fader in selected_strips: + clear_all_selections() hstack_fader_center.removeWidget(fader) fader.deleteLater() faders.remove(fader) @@ -1960,6 +2053,7 @@ def fader_remove(fader): def transport_toggle_add(label, default_cc, insert_after=None): t = TransportWidget(label, default_cc) t.on_select_callback = lambda strip: check_and_update_selected_strip(strip) + t.on_context_menu_callback = strip_context_menu t.learn_btn.clicked.connect(lambda: check_and_start_midi_learn(t)) t.on_zone_change_callback = lambda strip: handle_transport_zone_change(strip) t.plus_btn.clicked.connect(lambda: transport_toggle_add(f"T{len(transports)+1}", 20, insert_after=t)) @@ -1980,12 +2074,10 @@ def transport_toggle_add(label, default_cc, insert_after=None): check_and_index_strip_labels() def transport_remove(t): - global selected_strip if len(transports) <= 1: return - if selected_strip == t: - selected_strip = None - label_selected_channel.setText("Software item selected: None") + if t in selected_strips: + clear_all_selections() for layout in [hstack_transport_zone_left, hstack_transport_center, hstack_transport_zone_right]: layout.removeWidget(t) t.deleteLater() diff --git a/togglewidget.py b/togglewidget.py index 971c3cb..d3a4d01 100644 --- a/togglewidget.py +++ b/togglewidget.py @@ -200,21 +200,32 @@ class ToggleWidget(QWidget): outer.addWidget(self.container) self.on_select_callback = None + self.on_context_menu_callback = None for child in self.findChildren(QWidget): child.installEventFilter(self) def eventFilter(self, obj, event): if event.type() == QEvent.Type.MouseButtonPress: - if self.on_select_callback: - self.on_select_callback(self) + if event.button() != Qt.MouseButton.RightButton: + if self.on_select_callback: + self.on_select_callback(self) + elif event.type() == QEvent.Type.ContextMenu: + if self.on_context_menu_callback: + self.on_context_menu_callback(self, event.globalPos()) + return True return False def mousePressEvent(self, event): - if self.on_select_callback: - self.on_select_callback(self) + if event.button() != Qt.MouseButton.RightButton: + if self.on_select_callback: + self.on_select_callback(self) super().mousePressEvent(event) + def contextMenuEvent(self, event): + if self.on_context_menu_callback: + self.on_context_menu_callback(self, event.globalPos()) + def apply_color(self, hex_color, selected=False): self.current_color = hex_color border = "#00ff88" if selected else "rgba(0,0,0,0.3)"