import uuid from PyQt6.QtWidgets import ( QWidget, QVBoxLayout, QHBoxLayout, QSlider, QLabel, QLineEdit, QFrame, QSizePolicy, QPushButton, QCheckBox, QSpinBox, QComboBox ) from PyQt6.QtCore import Qt, QEvent, QTimer from palette import ( PALETTE_NAMES, PALETTE_HEX, PALETTE_VIVID, lighten_hex, darken_hex, ) from styles import FADER_STYLE, TITLE_STYLE from zonebutton import ZoneButton from colorswatchpopup import ColorSwatchPopup from osc_sender import send_osc_message from midi_sender import send_cc from extension_sender import send_to_extension class FocusFaderWidget(QWidget): """Direct DAW-focus fader — OSC-only output, shown on any iPad in Channel Focus mode, no hardware learn input.""" def __init__(self, label): super().__init__() self.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Maximum) self.setFixedWidth(115) self.current_color = PALETTE_HEX.get("Gray", "#263238") self.uid = str(uuid.uuid4()) # Pickup mode state self.pickup_mode = False self.last_sent = 64 self.prev_position = 64 self.hunting = False self.is_learning = False self.is_learning_name = False 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: OSC input (feedback from the DAW) self.grp_input = QFrame() self.grp_input.setStyleSheet(_grp_style) _grp_input_layout = QVBoxLayout(self.grp_input) _grp_input_layout.setContentsMargins(4, 4, 4, 4) _grp_input_layout.setSpacing(3) self.learn_btn = QPushButton("Learn") self.learn_btn.setFixedHeight(20) self.learn_btn.setStyleSheet("border: none;") _grp_input_layout.addWidget(self.learn_btn) self.osc_addr_in_edit = QLineEdit("/track/volume") self.osc_addr_in_edit.setAlignment(Qt.AlignmentFlag.AlignHCenter) self.osc_addr_in_edit.setPlaceholderText("/address") self.osc_addr_in_edit.setStyleSheet("border: none;") _grp_input_layout.addWidget(self.osc_addr_in_edit) 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) _grp_input_layout.addWidget(self.cc_input_lbl) inner.addWidget(self.grp_input) 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: OSC output controls + readout (sent to the DAW on fader change) 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) # OSC / Ext mode selector mode_row = QHBoxLayout() mode_row.setContentsMargins(0, 0, 0, 0) mode_row.setSpacing(2) self.osc_mode_btn = QPushButton("OSC") self.osc_mode_btn.setFixedHeight(18) self.osc_mode_btn.setCheckable(True) self.osc_mode_btn.setChecked(True) self.osc_mode_btn.setStyleSheet("border: none;") self.ext_mode_btn = QPushButton("Ext") self.ext_mode_btn.setFixedHeight(18) self.ext_mode_btn.setCheckable(True) self.ext_mode_btn.setChecked(False) self.ext_mode_btn.setStyleSheet("border: none;") self.osc_mode_btn.clicked.connect(lambda: self.set_output_mode("osc")) self.ext_mode_btn.clicked.connect(lambda: self.set_output_mode("ext")) mode_row.addWidget(self.osc_mode_btn) mode_row.addWidget(self.ext_mode_btn) _grp2_layout.addLayout(mode_row) self.output_mode = "osc" self._last_ext_value = None # tracks previous fader value, for delta-on-move in Ext mode # OSC address field self.osc_addr_out_edit = QLineEdit("/track/volume") self.osc_addr_out_edit.setAlignment(Qt.AlignmentFlag.AlignHCenter) self.osc_addr_out_edit.setPlaceholderText("/address") self.osc_addr_out_edit.setStyleSheet("border: none;") _grp2_layout.addWidget(self.osc_addr_out_edit) # Output readout self.cc_output_lbl = QLabel(self._osc_output_label(64)) 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: Hardware — optional physical MIDI CC control alongside the # app/OSC control (e.g. a JL Cooper motorized fader), positioned like # grp_remote on the regular widgets rather than up with the OSC learn # section. "Hardware" binds a hw CC that can move this fader; independently, # "Feedback" sends a translated CC back out on every value change (from # hardware, OSC feedback, or the app) so a motorized fader's position stays # in sync — separate switches since you may want feedback without hardware # input, or vice versa. self.hw_cc = None self.hw_channel = None self.is_learning_hw = False # Touch sense: some motorized fader hardware (e.g. JL Cooper) sends # touch on/off using the SAME CC as the fader's value, just on # hw_channel - 1 (e.g. value on CH16, touch on CH15). "Off" (default) # ignores this distinction entirely. While touching, incoming OSC/DAW # feedback is ignored (see receive_osc_value) so the motor doesn't # fight your hand. self.touch_sense_mode = "off" self.is_touching = False self.grp_hardware = QFrame() self.grp_hardware.setStyleSheet(_grp_style) _grp_hw_layout = QVBoxLayout(self.grp_hardware) _grp_hw_layout.setContentsMargins(4, 4, 4, 4) _grp_hw_layout.setSpacing(3) self.hardware_checkbox = QCheckBox("Hardware") self.hardware_checkbox.setChecked(False) self.hardware_checkbox.stateChanged.connect(lambda _: self._update_hw_visibility()) _grp_hw_layout.addWidget(self.hardware_checkbox, alignment=Qt.AlignmentFlag.AlignHCenter) self.hw_learn_btn = QPushButton("Learn") self.hw_learn_btn.setFixedHeight(20) self.hw_learn_btn.setStyleSheet("border: none;") _grp_hw_layout.addWidget(self.hw_learn_btn) self.hw_cc_input_lbl = QLabel("") self.hw_cc_input_lbl.setAlignment(Qt.AlignmentFlag.AlignHCenter) self.hw_cc_input_lbl.setStyleSheet("background-color: #000000; color: #888888; font-size: 10px; padding: 1px; border: none;") self.hw_cc_input_lbl.setFixedHeight(16) _grp_hw_layout.addWidget(self.hw_cc_input_lbl) self.touch_sense_combo = QComboBox() self.touch_sense_combo.addItems(["No Profile", "MIDI JL Cooper CC Mode"]) self.touch_sense_combo.setStyleSheet("font-size: 10px;") self.touch_sense_combo.currentIndexChanged.connect(self._on_touch_sense_changed) _grp_hw_layout.addWidget(self.touch_sense_combo) self.feedback_checkbox = QCheckBox("Feedback") self.feedback_checkbox.setChecked(False) self.feedback_checkbox.stateChanged.connect(lambda _: self._update_hw_visibility()) _grp_hw_layout.addWidget(self.feedback_checkbox, alignment=Qt.AlignmentFlag.AlignHCenter) self.hw_cc_spin = QSpinBox() self.hw_cc_spin.setMinimum(0) self.hw_cc_spin.setMaximum(127) self.hw_cc_spin.setValue(7) self.hw_cc_spin.setFixedWidth(55) _hw_cc_lbl = QLabel("CC") _hw_cc_lbl.setStyleSheet("color: #fff; font-size: 10px; border: none;") self.hw_cc_spin_row = QWidget() _hbox_hw_cc = QHBoxLayout(self.hw_cc_spin_row) _hbox_hw_cc.setContentsMargins(0, 0, 0, 0) _hbox_hw_cc.setSpacing(4) _hbox_hw_cc.addWidget(_hw_cc_lbl) _hbox_hw_cc.addWidget(self.hw_cc_spin) _grp_hw_layout.addWidget(self.hw_cc_spin_row) self.hw_ch_spin = QSpinBox() self.hw_ch_spin.setMinimum(1) self.hw_ch_spin.setMaximum(16) self.hw_ch_spin.setValue(1) self.hw_ch_spin.setFixedWidth(55) _hw_ch_lbl = QLabel("CH") _hw_ch_lbl.setStyleSheet("color: #fff; font-size: 10px; border: none;") self.hw_ch_spin_row = QWidget() _hbox_hw_ch = QHBoxLayout(self.hw_ch_spin_row) _hbox_hw_ch.setContentsMargins(0, 0, 0, 0) _hbox_hw_ch.setSpacing(4) _hbox_hw_ch.addWidget(_hw_ch_lbl) _hbox_hw_ch.addWidget(self.hw_ch_spin) _grp_hw_layout.addWidget(self.hw_ch_spin_row) self.hw_cc_output_lbl = QLabel(f"CC{self.hw_cc_spin.value()} CH{self.hw_ch_spin.value()} [--]") self.hw_cc_output_lbl.setAlignment(Qt.AlignmentFlag.AlignHCenter) self.hw_cc_output_lbl.setStyleSheet("background-color: #000000; color: #00e676; font-size: 10px; padding: 1px; border: none;") self.hw_cc_output_lbl.setFixedHeight(16) _grp_hw_layout.addWidget(self.hw_cc_output_lbl) inner.addWidget(self.grp_hardware) # Fader self.fader = QSlider(Qt.Orientation.Vertical) self.fader.setMinimum(0) self.fader.setMaximum(127) self.fader.setValue(64) self.fader.setMinimumHeight(200) self.fader.setFixedWidth(40) self.fader.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Expanding) self.fader.setStyleSheet(FADER_STYLE) inner.addWidget(self.fader, alignment=Qt.AlignmentFlag.AlignHCenter) # Readout kept for pickup blink logic but not shown self.readout = QLabel(f"{self.osc_addr_out_edit.text()} → {self.fader.value()}") self.readout.setAlignment(Qt.AlignmentFlag.AlignHCenter) self.readout.setVisible(False) # Pickup checkbox self.pickup_checkbox = QCheckBox("Pickup") self.pickup_checkbox.setChecked(False) self.pickup_checkbox.stateChanged.connect(self.on_pickup_changed) inner.addWidget(self.pickup_checkbox, alignment=Qt.AlignmentFlag.AlignHCenter) # Track name learn (binds the scribble strip to live OSC track-name feedback) self.name_learn_btn = QPushButton("Learn Name") self.name_learn_btn.setFixedHeight(18) self.name_learn_btn.setStyleSheet("border: none;") inner.addWidget(self.name_learn_btn) self.osc_addr_name_edit = QLineEdit("/track/name") self.osc_addr_name_edit.setAlignment(Qt.AlignmentFlag.AlignHCenter) self.osc_addr_name_edit.setPlaceholderText("/address") self.osc_addr_name_edit.setStyleSheet("border: none;") inner.addWidget(self.osc_addr_name_edit) # 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 btn_row = QHBoxLayout() btn_row.setContentsMargins(0, 0, 0, 0) btn_row.setSpacing(2) self.minus_btn = QPushButton("-") self.minus_btn.setFixedSize(30, 20) self.plus_btn = QPushButton("+") self.plus_btn.setFixedSize(30, 20) btn_row.addWidget(self.minus_btn) btn_row.addStretch() btn_row.addWidget(self.plus_btn) inner.addLayout(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.fader.valueChanged.connect(self.on_fader_moved) self.on_select_callback = None self.on_context_menu_callback = None self.zone = None # None, "left", or "right" self.center_index = None # saved position in center row before zoning self.zone_index = None # position within zone slot self.on_zone_change_callback = None self._update_hw_visibility() # Install event filter on all children to forward clicks to select 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 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.setText("Learn") self.learn_btn.setStyleSheet("") def bind_osc_addr(self, address): self.osc_addr_in_edit.setText(address) self.stop_learn() def _update_hw_visibility(self): hw_on = self.hardware_checkbox.isChecked() self.hw_learn_btn.setVisible(hw_on) self.hw_cc_input_lbl.setVisible(hw_on) fb_on = self.feedback_checkbox.isChecked() self.hw_cc_spin_row.setVisible(fb_on) self.hw_ch_spin_row.setVisible(fb_on) self.hw_cc_output_lbl.setVisible(fb_on) def start_hw_learn(self): self.is_learning_hw = True self.hw_learn_btn.setText("Listening...") self.hw_learn_btn.setStyleSheet("color: orange; font-weight: bold;") def stop_hw_learn(self): self.is_learning_hw = False self.hw_learn_btn.setStyleSheet("") if self.hw_cc is not None: self.hw_learn_btn.setText(f"HW: CC{self.hw_cc}") self.hw_cc_input_lbl.setText(self._hw_cc_input_label()) self.hw_cc_input_lbl.setStyleSheet("background-color: #000000; color: #00e676; font-size: 10px; padding: 1px;") else: self.hw_learn_btn.setText("Learn") self.hw_cc_input_lbl.setText("") self.hw_cc_input_lbl.setStyleSheet("background-color: #000000; color: #888888; font-size: 10px; padding: 1px;") def bind_hw_cc(self, cc_number, channel=None): self.hw_cc = cc_number self.hw_channel = channel self.stop_hw_learn() def _hw_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 _on_touch_sense_changed(self, index): self.touch_sense_mode = "jl_cooper" if index == 1 else "off" self.is_touching = False def set_output_mode(self, mode): self.output_mode = mode if mode == "osc": self.osc_mode_btn.setChecked(True) self.ext_mode_btn.setChecked(False) self.osc_mode_btn.setStyleSheet("font-weight: bold; color: #00e676;") self.ext_mode_btn.setStyleSheet("") self.osc_addr_out_edit.setVisible(True) self.cc_output_lbl.setText(self._osc_output_label(self.fader.value())) else: self.ext_mode_btn.setChecked(True) self.osc_mode_btn.setChecked(False) self.ext_mode_btn.setStyleSheet("font-weight: bold; color: #00e676;") self.osc_mode_btn.setStyleSheet("") self.osc_addr_out_edit.setVisible(False) self.cc_output_lbl.setText("Ext [--]") self._last_ext_value = None # reset delta tracking on mode switch def receive_osc_value(self, value): """Update the fader to reflect feedback from the DAW, without echoing it back out.""" if self.is_touching: # Hardware currently owns the value — don't let DAW/OSC feedback # fight the motor while a hand is physically on the fader. return fader_value = max(0, min(127, round(value * 127))) self.cc_input_lbl.setText(f"[{value:.2f}]") self.cc_input_lbl.setStyleSheet("background-color: #000000; color: #00e676; font-size: 10px; padding: 1px; border: none;") self.fader.blockSignals(True) self.fader.setValue(fader_value) self.fader.blockSignals(False) def start_learn_name(self): self.is_learning_name = True self.name_learn_btn.setText("Listening...") self.name_learn_btn.setStyleSheet("color: orange; font-weight: bold;") def stop_learn_name(self): self.is_learning_name = False self.name_learn_btn.setText("Learn Name") self.name_learn_btn.setStyleSheet("") def bind_osc_name_addr(self, address): self.osc_addr_name_edit.setText(address) self.stop_learn_name() def receive_osc_name(self, name): """Update the scribble strip to reflect the live track name from the DAW.""" self.title_edit.setText(name) def on_pickup_changed(self, state): self.pickup_mode = bool(state) if self.pickup_mode: self.hunting = True self.prev_position = self.fader.value() self.pickup_checkbox.setStyleSheet("color: orange; font-weight: bold;") self._start_blink() else: self.hunting = False self.pickup_checkbox.setStyleSheet("color: black;") self._stop_blink() self._set_readout_style("normal") def _set_readout_style(self, mode): if mode == "normal": self.cc_output_lbl.setStyleSheet("background-color: #000000; color: #00e676; font-size: 10px; padding: 1px;") self.pickup_checkbox.setStyleSheet("color: black;") if not self.pickup_mode else None elif mode == "orange": self.cc_output_lbl.setStyleSheet("background-color: #000000; color: orange; font-size: 10px; font-weight: bold; padding: 1px;") elif mode == "clear": self.cc_output_lbl.setStyleSheet("background-color: #000000; color: transparent; font-size: 10px; padding: 1px;") def _start_blink(self): if not hasattr(self, '_blink_timer'): self._blink_timer = QTimer() self._blink_timer.timeout.connect(self._do_blink) self._blink_state = False self._blink_timer.start(400) def _stop_blink(self): if hasattr(self, '_blink_timer'): self._blink_timer.stop() self._set_readout_style("normal") def _do_blink(self): self._blink_state = not self._blink_state if self._blink_state: self._set_readout_style("orange") else: self._set_readout_style("clear") def on_fader_moved(self, value): if self.output_mode == "ext": self.cc_output_lbl.setText(f"Ext [{value}]") else: self.cc_output_lbl.setText(self._osc_output_label(value)) if self.pickup_mode and self.hunting: crossed = ( (self.prev_position < self.last_sent <= value) or (self.prev_position > self.last_sent >= value) ) self.prev_position = value if crossed: self.hunting = False self._stop_blink() self._set_readout_style("orange") self.last_sent = value self._emit_value(value) else: self.readout.setText(f"[{value}]") return else: self.last_sent = value self.prev_position = value self._emit_value(value) self.readout.setText(f"[{value}]") def _emit_value(self, value): """Sends the current fader value out via whichever protocol is selected. Ext mode sends a delta (change since last move), not the absolute position — the extension's UDP protocol only understands deltas added to the current baseline, not absolute targets.""" if self.output_mode == "ext": if self._last_ext_value is None: self._last_ext_value = value delta = (value - self._last_ext_value) / 127.0 self._last_ext_value = value if delta != 0: send_to_extension(delta) else: addr = self.osc_addr_out_edit.text().strip() or "/track/volume" send_osc_message(addr, value / 127.0) self._send_hw_feedback(value) def _send_hw_feedback(self, value): """Sends a translated MIDI CC out (e.g. so a motorized fader's position tracks this channel's value), independent of whether hardware CC input is also enabled — you may want feedback without hardware input, or vice versa.""" if not self.feedback_checkbox.isChecked(): return out_cc = self.hw_cc_spin.value() out_ch = self.hw_ch_spin.value() send_cc(out_cc, value, channel=out_ch) self.hw_cc_output_lbl.setText(f"CC{out_cc} CH{out_ch} [{value}]") 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 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) def _osc_output_label(self, value="--"): addr = self.osc_addr_out_edit.text().strip() or "/track/volume" if isinstance(value, (int, float)): return f"{addr} [{value / 127.0:.2f}]" return f"{addr} [{value}]" def get_state(self): return { "uid": self.uid, "label": self.title_edit.text(), "value": self.fader.value(), "color": self.color_name, "pickup": self.pickup_checkbox.isChecked(), "last_sent": self.last_sent, "osc_addr_in": self.osc_addr_in_edit.text(), "osc_addr_out": self.osc_addr_out_edit.text(), "osc_addr_name": self.osc_addr_name_edit.text(), "zone": self.zone, "center_index": self.center_index, "zone_index": self.zone_index, "hardware_enabled": self.hardware_checkbox.isChecked(), "feedback_enabled": self.feedback_checkbox.isChecked(), "hw_cc": self.hw_cc, "hw_channel": self.hw_channel, "hw_out_cc": self.hw_cc_spin.value(), "hw_out_ch": self.hw_ch_spin.value(), "touch_sense_mode": self.touch_sense_mode, "output_mode": self.output_mode, } def set_state(self, state): if "uid" in state: self.uid = state["uid"] self.title_edit.setText(state.get("label", "")) self.last_sent = state.get("last_sent", 64) self.fader.setValue(state.get("value", 64)) 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) pickup = state.get("pickup", False) self.pickup_checkbox.setChecked(pickup) if pickup: self.hunting = True self._start_blink() 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.osc_addr_in_edit.setText(state.get("osc_addr_in", "/track/volume")) self.osc_addr_out_edit.setText(state.get("osc_addr_out", "/track/volume")) self.osc_addr_name_edit.setText(state.get("osc_addr_name", "/track/name")) self.hw_cc = state.get("hw_cc", None) self.hw_channel = state.get("hw_channel", None) self.hw_cc_spin.setValue(state.get("hw_out_cc", 7)) self.hw_ch_spin.setValue(state.get("hw_out_ch", 1)) self.stop_hw_learn() self.hardware_checkbox.setChecked(state.get("hardware_enabled", False)) self.feedback_checkbox.setChecked(state.get("feedback_enabled", False)) # jl_cooper_off/jl_cooper_on were a short-lived three-state naming; # both collapse to the single jl_cooper mode. saved_touch_mode = state.get("touch_sense_mode", "off") self.touch_sense_mode = "jl_cooper" if saved_touch_mode in ("jl_cooper", "jl_cooper_off", "jl_cooper_on") else "off" self.touch_sense_combo.blockSignals(True) self.touch_sense_combo.setCurrentIndex(1 if self.touch_sense_mode == "jl_cooper" else 0) self.touch_sense_combo.blockSignals(False) self.is_touching = False self._update_hw_visibility() self.set_output_mode(state.get("output_mode", "osc"))