0c7b6b4442
Desktop:
- New dedicated MIDI Controller In port (separate from MIDI In), same CC learn/bind pipeline but never passes notes through; positioned first in the IO column order
- Fixed MIDI In "Not Connected" not persisting across restart (missing save_io_config call)
- Fixed MIDI Out/Transport Out mutual-exclusion incorrectly flagging "Not Connected" as an in-use port
- Renamed "Remote" checkbox to "App" on Fader/Toggle/Transport widgets
- FocusFaderWidget/FocusToggleWidget: new Hardware section (Learn + CC/CH bind) so a physical controller can drive a Channel Focus widget alongside the app/OSC, plus an independent Feedback checkbox that sends translated CC out (for motorized fader / LED sync)
- JL Cooper touch-sense profile ("No Profile" / "MIDI JL Cooper CC Mode") on FaderWidget/FocusFaderWidget: touch channel (value channel - 1, same CC) is filtered out of the value and gates incoming OSC/DAW feedback while touching
- FocusFeedbackWidget: "Custom" checkbox to show a manually-typed static string instead of live OSC feedback, disabling Learn/OSC input and suppressing the iPad broadcast while active
- New daw-config-reaper/ folder with reaper-osc-config-paul-custom.ReaperOSC
iOS:
- New FocusToggleWidget (dedicated Channel Focus toggle, split out of shared ToggleWidget) with colorName-aware darken/border and isMomentary handling — keeps Channel-Focus-only behavior off the regular ToggleWidget
- Arrange mode long-press menu: text alignment (left/center/right) and a 10-color named-palette Text Color picker for TitleWidget/FocusFeedbackWidget, persisted in the saved layout
- FocusFeedbackWidget: Reset Size action, monospaced-digit font fix for timestamp/counter jitter
- New "Cancel Changes" button in Arrange mode — reverts layout/hides/aligns to the state at Arrange-mode entry and restores desktop-side Remote/dest assignments
- Grid line brightness increased for visibility
- Bonjour discovery for desktop auto-connect
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
433 lines
18 KiB
Python
433 lines
18 KiB
Python
import uuid
|
|
|
|
from PyQt6.QtWidgets import (
|
|
QWidget, QVBoxLayout, QHBoxLayout, QPushButton,
|
|
QLabel, QSpinBox, QLineEdit, QFrame, QSizePolicy, QCheckBox
|
|
)
|
|
from PyQt6.QtCore import Qt, QEvent, QTimer
|
|
|
|
from palette import PALETTE_HEX, PALETTE_VIVID
|
|
from styles import TITLE_STYLE
|
|
from zonebutton import ZoneButton
|
|
from colorswatchpopup import ColorSwatchPopup
|
|
from midi_sender import send_cc
|
|
|
|
class ToggleWidget(QWidget):
|
|
def __init__(self, label, default_cc):
|
|
super().__init__()
|
|
self.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Preferred)
|
|
self.setFixedWidth(115)
|
|
self.current_color = PALETTE_HEX.get("Gray", "#263238")
|
|
self.toggle_state = False
|
|
self.trigger_mode = "toggle"
|
|
self.uid = str(uuid.uuid4())
|
|
self.zone = None
|
|
self.center_index = None
|
|
self.zone_index = None
|
|
self.on_zone_change_callback = None
|
|
|
|
outer = QVBoxLayout(self)
|
|
outer.setContentsMargins(0, 0, 0, 0)
|
|
outer.setSpacing(2)
|
|
|
|
self.container = QFrame()
|
|
self.container.setFrameShape(QFrame.Shape.StyledPanel)
|
|
self.apply_color(self.current_color)
|
|
|
|
inner = QVBoxLayout(self.container)
|
|
inner.setContentsMargins(4, 6, 4, 6)
|
|
inner.setSpacing(4)
|
|
inner.setAlignment(Qt.AlignmentFlag.AlignHCenter)
|
|
|
|
_grp_style = "QFrame { border: 1px solid rgba(255,255,255,0.2); border-radius: 4px; }"
|
|
|
|
# Group 1: Learn + input readout
|
|
self.hw_cc = None
|
|
self.hw_channel = None
|
|
self.is_learning = False
|
|
self.grp_learn = QFrame()
|
|
self.grp_learn.setStyleSheet(_grp_style)
|
|
_grp1_layout = QVBoxLayout(self.grp_learn)
|
|
_grp1_layout.setContentsMargins(4, 4, 4, 4)
|
|
_grp1_layout.setSpacing(3)
|
|
|
|
self.learn_btn = QPushButton("Learn")
|
|
self.learn_btn.setFixedHeight(20)
|
|
self.learn_btn.setStyleSheet("border: none;")
|
|
self.learn_btn.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
|
|
self.learn_btn.customContextMenuRequested.connect(self.on_learn_context_menu)
|
|
_grp1_layout.addWidget(self.learn_btn)
|
|
|
|
self.cc_input_lbl = QLabel("")
|
|
self.cc_input_lbl.setAlignment(Qt.AlignmentFlag.AlignHCenter)
|
|
self.cc_input_lbl.setStyleSheet("background-color: #000000; color: #888888; font-size: 10px; padding: 1px; border: none;")
|
|
self.cc_input_lbl.setFixedHeight(16)
|
|
_grp1_layout.addWidget(self.cc_input_lbl)
|
|
|
|
inner.addWidget(self.grp_learn)
|
|
|
|
self.arrow_lbl = QLabel("▼")
|
|
self.arrow_lbl.setAlignment(Qt.AlignmentFlag.AlignHCenter)
|
|
self.arrow_lbl.setStyleSheet("color: rgba(255,255,255,0.3); font-size: 9px; background: transparent;")
|
|
self.arrow_lbl.setFixedHeight(12)
|
|
inner.addWidget(self.arrow_lbl)
|
|
|
|
# Group 2: CC spin + CH spin + output readout
|
|
self.grp_cc = QFrame()
|
|
self.grp_cc.setStyleSheet(_grp_style)
|
|
_grp2_layout = QVBoxLayout(self.grp_cc)
|
|
_grp2_layout.setContentsMargins(4, 4, 4, 4)
|
|
_grp2_layout.setSpacing(3)
|
|
|
|
self.cc_spin = QSpinBox()
|
|
self.cc_spin.setMinimum(0)
|
|
self.cc_spin.setMaximum(127)
|
|
self.cc_spin.setValue(default_cc)
|
|
self.cc_spin.setFixedWidth(55)
|
|
_cc_lbl = QLabel("CC")
|
|
_cc_lbl.setStyleSheet("color: #fff; font-size: 10px; border: none;")
|
|
self.cc_spin_row = QWidget()
|
|
_hbox_cc = QHBoxLayout(self.cc_spin_row)
|
|
_hbox_cc.setContentsMargins(0, 0, 0, 0)
|
|
_hbox_cc.setSpacing(4)
|
|
_hbox_cc.addWidget(_cc_lbl)
|
|
_hbox_cc.addWidget(self.cc_spin)
|
|
_grp2_layout.addWidget(self.cc_spin_row)
|
|
|
|
self.ch_spin = QSpinBox()
|
|
self.ch_spin.setMinimum(1)
|
|
self.ch_spin.setMaximum(16)
|
|
self.ch_spin.setValue(1)
|
|
self.ch_spin.setFixedWidth(55)
|
|
_ch_lbl = QLabel("CH")
|
|
_ch_lbl.setStyleSheet("color: #fff; font-size: 10px; border: none;")
|
|
self.ch_spin_row = QWidget()
|
|
_hbox_ch = QHBoxLayout(self.ch_spin_row)
|
|
_hbox_ch.setContentsMargins(0, 0, 0, 0)
|
|
_hbox_ch.setSpacing(4)
|
|
_hbox_ch.addWidget(_ch_lbl)
|
|
_hbox_ch.addWidget(self.ch_spin)
|
|
_grp2_layout.addWidget(self.ch_spin_row)
|
|
|
|
# Momentary / Toggle mode selector
|
|
trigger_row = QHBoxLayout()
|
|
trigger_row.setContentsMargins(0, 0, 0, 0)
|
|
trigger_row.setSpacing(2)
|
|
self.momentary_btn = QPushButton("Mom.")
|
|
self.momentary_btn.setFixedHeight(18)
|
|
self.momentary_btn.setCheckable(True)
|
|
self.momentary_btn.setChecked(False)
|
|
self.momentary_btn.setStyleSheet("border: none;")
|
|
self.toggle_mode_btn = QPushButton("Tog.")
|
|
self.toggle_mode_btn.setFixedHeight(18)
|
|
self.toggle_mode_btn.setCheckable(True)
|
|
self.toggle_mode_btn.setChecked(True)
|
|
self.toggle_mode_btn.setStyleSheet("font-weight: bold; color: #00e676;")
|
|
self.momentary_btn.clicked.connect(lambda: self.set_trigger_mode("momentary"))
|
|
self.toggle_mode_btn.clicked.connect(lambda: self.set_trigger_mode("toggle"))
|
|
trigger_row.addWidget(self.momentary_btn)
|
|
trigger_row.addWidget(self.toggle_mode_btn)
|
|
_grp2_layout.addLayout(trigger_row)
|
|
|
|
self.cc_output_lbl = QLabel(f"CC{default_cc} [--]")
|
|
self.cc_output_lbl.setAlignment(Qt.AlignmentFlag.AlignHCenter)
|
|
self.cc_output_lbl.setStyleSheet("background-color: #000000; color: #00e676; font-size: 10px; padding: 1px; border: none;")
|
|
self.cc_output_lbl.setFixedHeight(16)
|
|
_grp2_layout.addWidget(self.cc_output_lbl)
|
|
|
|
inner.addWidget(self.grp_cc)
|
|
|
|
# Group 3: Remote routing
|
|
self.grp_remote = QFrame()
|
|
self.grp_remote.setStyleSheet(_grp_style)
|
|
_grp_remote_layout = QVBoxLayout(self.grp_remote)
|
|
_grp_remote_layout.setContentsMargins(4, 4, 4, 4)
|
|
_grp_remote_layout.setSpacing(3)
|
|
|
|
self.remote_checkbox = QCheckBox("App")
|
|
self.remote_checkbox.setChecked(True)
|
|
self.remote_checkbox.stateChanged.connect(lambda _: self.dest_spin.setEnabled(self.remote_checkbox.isChecked()))
|
|
_grp_remote_layout.addWidget(self.remote_checkbox, alignment=Qt.AlignmentFlag.AlignHCenter)
|
|
|
|
self.dest_spin = QSpinBox()
|
|
self.dest_spin.setRange(1, 9)
|
|
self.dest_spin.setValue(1)
|
|
self.dest_spin.setFixedWidth(52)
|
|
_grp_remote_layout.addWidget(self.dest_spin, alignment=Qt.AlignmentFlag.AlignHCenter)
|
|
|
|
inner.addWidget(self.grp_remote)
|
|
|
|
# Toggle button with LED dot overlay
|
|
btn_container = QWidget()
|
|
btn_container.setFixedSize(60, 60)
|
|
|
|
self.btn = QPushButton("", btn_container)
|
|
self.btn.setFixedSize(60, 60)
|
|
self.btn.clicked.connect(self.on_click)
|
|
|
|
self.led_dot = QLabel(btn_container)
|
|
self.led_dot.setFixedSize(6, 6)
|
|
self.led_dot.move(5, 49)
|
|
self.led_dot.setStyleSheet("background-color: #3c3c3c; border-radius: 3px;")
|
|
|
|
inner.addWidget(btn_container, alignment=Qt.AlignmentFlag.AlignHCenter)
|
|
|
|
# Scribble strip
|
|
self.title_edit = QLineEdit(label)
|
|
self.title_edit.setAlignment(Qt.AlignmentFlag.AlignHCenter)
|
|
self.title_edit.setStyleSheet(TITLE_STYLE)
|
|
inner.addWidget(self.title_edit)
|
|
|
|
# Color swatch button
|
|
self.color_name = "Gray"
|
|
self.color_swatch_btn = QPushButton()
|
|
self.color_swatch_btn.setFixedHeight(10)
|
|
self.color_swatch_btn.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
|
|
self.color_swatch_btn.setStyleSheet(f"background-color: {PALETTE_VIVID['Gray']}; border-radius: 2px; border: none;")
|
|
self.color_swatch_btn.clicked.connect(self.show_color_popup)
|
|
inner.addWidget(self.color_swatch_btn)
|
|
|
|
# + / - buttons inside container below color swatch
|
|
toggle_btn_row = QHBoxLayout()
|
|
toggle_btn_row.setContentsMargins(0, 0, 0, 0)
|
|
toggle_btn_row.setSpacing(2)
|
|
self.minus_btn = QPushButton("-")
|
|
self.minus_btn.setFixedSize(30, 20)
|
|
self.plus_btn = QPushButton("+")
|
|
self.plus_btn.setFixedSize(30, 20)
|
|
toggle_btn_row.addWidget(self.minus_btn)
|
|
toggle_btn_row.addStretch()
|
|
toggle_btn_row.addWidget(self.plus_btn)
|
|
inner.addLayout(toggle_btn_row)
|
|
|
|
# Zone button
|
|
self.zone_btn = ZoneButton()
|
|
self.zone_btn.left_callback = lambda active: self.on_zone_checked("left", active)
|
|
self.zone_btn.right_callback = lambda active: self.on_zone_checked("right", active)
|
|
inner.addWidget(self.zone_btn)
|
|
|
|
# Separator before ID label
|
|
id_sep = QWidget()
|
|
id_sep.setFixedHeight(1)
|
|
id_sep.setStyleSheet("background-color: #3a3a3a;")
|
|
inner.addWidget(id_sep)
|
|
|
|
# Channel ID label
|
|
self.id_label = QLabel("")
|
|
self.id_label.setAlignment(Qt.AlignmentFlag.AlignHCenter)
|
|
self.id_label.setStyleSheet("color: #ffffff; font-size: 13px; font-weight: bold; background: transparent;")
|
|
inner.addWidget(self.id_label)
|
|
|
|
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 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 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)"
|
|
border_width = "3px" if selected else "2px"
|
|
self.container.setObjectName("stripContainer")
|
|
self.container.setStyleSheet(f"""
|
|
QFrame#stripContainer {{
|
|
background-color: {hex_color};
|
|
border-radius: 6px;
|
|
border: {border_width} solid {border};
|
|
}}
|
|
""")
|
|
|
|
def set_selected(self, selected):
|
|
self.apply_color(self.current_color, selected=selected)
|
|
|
|
def _cc_input_label(self, vel="--"):
|
|
if self.hw_cc is None:
|
|
return ""
|
|
ch = f" CH{self.hw_channel}" if self.hw_channel is not None else ""
|
|
return f"CC{self.hw_cc}{ch} [{vel}]"
|
|
|
|
def _cc_output_label(self, vel="--"):
|
|
out_cc = self.cc_spin.value()
|
|
out_ch = self.ch_spin.value()
|
|
return f"CC{out_cc} CH{out_ch} [{vel}]"
|
|
|
|
def start_learn(self):
|
|
self.is_learning = True
|
|
self.learn_btn.setText("Listening...")
|
|
self.learn_btn.setStyleSheet("color: orange; font-weight: bold;")
|
|
|
|
def stop_learn(self):
|
|
self.is_learning = False
|
|
self.learn_btn.setStyleSheet("border: none;")
|
|
if self.hw_cc is not None:
|
|
self.learn_btn.setText(f"HW: CC{self.hw_cc}")
|
|
self.cc_input_lbl.setText(self._cc_input_label())
|
|
self.cc_input_lbl.setStyleSheet("background-color: #000000; color: #00e676; font-size: 10px; padding: 1px; border: none;")
|
|
else:
|
|
self.learn_btn.setText("Learn")
|
|
self.cc_input_lbl.setText("")
|
|
self.cc_input_lbl.setStyleSheet("background-color: #000000; color: #888888; font-size: 10px; padding: 1px; border: none;")
|
|
|
|
def bind_hw_cc(self, cc_number, channel=None):
|
|
self.hw_cc = cc_number
|
|
self.hw_channel = channel
|
|
self.stop_learn()
|
|
|
|
def on_learn_context_menu(self, pos):
|
|
from PyQt6.QtWidgets import QMenu
|
|
if self.hw_cc is None:
|
|
return
|
|
menu = QMenu(self)
|
|
clear_action = menu.addAction("Clear Input Assignment")
|
|
action = menu.exec(self.learn_btn.mapToGlobal(pos))
|
|
if action == clear_action:
|
|
self.hw_cc = None
|
|
self.hw_channel = None
|
|
self.is_learning = False
|
|
self.learn_btn.setText("Learn")
|
|
self.learn_btn.setStyleSheet("border: none;")
|
|
self.cc_input_lbl.setText("")
|
|
|
|
def update_id_label(self, zone, index):
|
|
if zone == "left":
|
|
self.id_label.setText(f"L{index + 1}")
|
|
elif zone == "right":
|
|
self.id_label.setText(f"R{index + 1}")
|
|
else:
|
|
self.id_label.setText(f"{index + 1}")
|
|
|
|
def on_zone_checked(self, zone, active):
|
|
if active:
|
|
self.zone = zone
|
|
else:
|
|
self.zone = None
|
|
if self.on_zone_change_callback:
|
|
self.on_zone_change_callback(self)
|
|
|
|
def show_color_popup(self):
|
|
popup = ColorSwatchPopup(self.on_color_change, self)
|
|
btn_pos = self.color_swatch_btn.mapToGlobal(self.color_swatch_btn.rect().bottomLeft())
|
|
popup.move(btn_pos)
|
|
popup.show()
|
|
|
|
def on_color_change(self, name, hex_color=None):
|
|
if hex_color is None:
|
|
hex_color = PALETTE_HEX.get(name, self.current_color)
|
|
self.color_name = name
|
|
self.color_swatch_btn.setStyleSheet(f"background-color: {PALETTE_VIVID.get(self.color_name, hex_color)}; border-radius: 2px; border: none;")
|
|
self.apply_color(hex_color)
|
|
self._update_btn_style()
|
|
|
|
def set_trigger_mode(self, mode):
|
|
self.trigger_mode = mode
|
|
if mode == "momentary":
|
|
self.momentary_btn.setChecked(True)
|
|
self.toggle_mode_btn.setChecked(False)
|
|
self.momentary_btn.setStyleSheet("font-weight: bold; color: #00e676;")
|
|
self.toggle_mode_btn.setStyleSheet("")
|
|
else:
|
|
self.toggle_mode_btn.setChecked(True)
|
|
self.momentary_btn.setChecked(False)
|
|
self.toggle_mode_btn.setStyleSheet("font-weight: bold; color: #00e676;")
|
|
self.momentary_btn.setStyleSheet("")
|
|
|
|
def on_click(self):
|
|
cc_num = self.cc_spin.value()
|
|
out_ch = self.ch_spin.value()
|
|
if self.trigger_mode == "momentary":
|
|
print(f"[toggle momentary] CC{cc_num} CH{out_ch} (127)")
|
|
send_cc(cc_num, 127, channel=out_ch)
|
|
self.cc_output_lbl.setText(self._cc_output_label(127))
|
|
self.led_dot.setStyleSheet("background-color: #00e676; border-radius: 3px;")
|
|
QTimer.singleShot(150, lambda: self.led_dot.setStyleSheet("background-color: #3c3c3c; border-radius: 3px;"))
|
|
else:
|
|
self.toggle_state = not self.toggle_state
|
|
value = 127 if self.toggle_state else 0
|
|
print(f"[toggle] CC{cc_num} CH{out_ch} ({value})")
|
|
send_cc(cc_num, value, channel=out_ch)
|
|
self.cc_output_lbl.setText(self._cc_output_label(value))
|
|
self._update_btn_style()
|
|
|
|
def _update_btn_style(self):
|
|
if self.toggle_state:
|
|
self.led_dot.setStyleSheet("background-color: #00e676; border-radius: 3px;")
|
|
else:
|
|
self.led_dot.setStyleSheet("background-color: #3c3c3c; border-radius: 3px;")
|
|
|
|
def get_state(self):
|
|
return {
|
|
"uid": self.uid,
|
|
"label": self.title_edit.text(),
|
|
"cc": self.cc_spin.value(),
|
|
"ch": self.ch_spin.value(),
|
|
"state": self.toggle_state,
|
|
"color": self.color_name,
|
|
"zone": self.zone,
|
|
"center_index": self.center_index,
|
|
"zone_index": self.zone_index,
|
|
"hw_cc": self.hw_cc,
|
|
"hw_channel": self.hw_channel,
|
|
"trigger_mode": self.trigger_mode,
|
|
"remote": self.remote_checkbox.isChecked(),
|
|
"dest_id": self.dest_spin.value(),
|
|
}
|
|
|
|
def set_state(self, state):
|
|
if "uid" in state:
|
|
self.uid = state["uid"]
|
|
self.title_edit.setText(state.get("label", ""))
|
|
self.cc_spin.setValue(state.get("cc", 1))
|
|
self.ch_spin.setValue(state.get("ch", 1))
|
|
color_name = state.get("color", "Gray")
|
|
hex_color = PALETTE_HEX.get(color_name, "transparent")
|
|
self.color_name = color_name
|
|
self.color_swatch_btn.setStyleSheet(f"background-color: {PALETTE_VIVID.get(self.color_name, hex_color)}; border-radius: 2px; border: none;")
|
|
self.apply_color(hex_color)
|
|
self.toggle_state = state.get("state", False)
|
|
self._update_btn_style()
|
|
value = 127 if self.toggle_state else 0
|
|
self.cc_output_lbl.setText(self._cc_output_label(value))
|
|
self.zone = state.get("zone", None)
|
|
self.center_index = state.get("center_index", None)
|
|
self.zone_index = state.get("zone_index", None)
|
|
self.zone_btn.set_state(self.zone == "left", self.zone == "right")
|
|
self.hw_cc = state.get("hw_cc", None)
|
|
self.hw_channel = state.get("hw_channel", None)
|
|
self.set_trigger_mode(state.get("trigger_mode", "toggle"))
|
|
remote = state.get("remote", True)
|
|
self.remote_checkbox.blockSignals(True)
|
|
self.remote_checkbox.setChecked(remote)
|
|
self.remote_checkbox.blockSignals(False)
|
|
self.dest_spin.blockSignals(True)
|
|
self.dest_spin.setValue(state.get("dest_id", 1))
|
|
self.dest_spin.blockSignals(False)
|
|
self.dest_spin.setEnabled(remote)
|
|
if self.hw_cc is not None:
|
|
self.learn_btn.setText(f"HW: CC{self.hw_cc}")
|
|
self.cc_input_lbl.setText(self._cc_input_label())
|
|
self.cc_input_lbl.setStyleSheet("background-color: #000000; color: #00e676; font-size: 10px; padding: 1px; border: none;")
|
|
else:
|
|
self.learn_btn.setText("Learn")
|
|
self.cc_input_lbl.setText("") |