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 <noreply@anthropic.com>
This commit is contained in:
Paul Lipscomb
2026-06-29 21:53:45 -04:00
parent 327ca7e00a
commit 22e4fa770e
3 changed files with 145 additions and 31 deletions
+15 -4
View File
@@ -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:
+115 -23
View File
@@ -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 (116):", 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()
+15 -4
View File
@@ -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)"