Restructure app-desktop as self-contained package, add PyInstaller build

Consolidates everything the desktop app depends on into app-desktop/ so it
packages cleanly as a standalone Windows exe:

- app-desktop-macos/ -> app-desktop/src/ (drop macOS-only naming, app now
  targets Windows via PyInstaller too)
- remote-server/ws_server.py and app-presets/ moved into app-desktop/src/
  (both were exclusive dependencies of main.py/presets.py) — fixes a
  PyInstaller "missing module" error for ws_server that only surfaced once
  packaging was attempted, since its old location wasn't on the analyzer's
  search path
- .venv relocated into app-desktop/src/ alongside the code it serves
- run.py moved into src/, path logic simplified now that it's co-located
  with main.py instead of bridging from the repo root
- Deleted app/, server/ — stale __pycache__-only fossils from prior reorgs
- .gitignore: added missing .venv/ entry (was only covering venv/, a
  different name — the real folder was never actually ignored) and
  app-desktop/build|dist/ for PyInstaller output

presets.py: added a frozen-vs-source branch. Running from source is
unchanged (app-presets/ next to the code). A packaged exe can't write to
Program Files, so it uses %APPDATA%\VirtualController\ instead — standard
Windows convention. First launch on a machine with no AppData presets yet
seeds from presets.json bundled into the exe (PyInstaller --add-data,
baked into VirtualController.spec), so a fresh install starts with real
presets instead of empty; every launch after that only touches AppData.

Verified: exe builds clean (no missing-module warnings), launches with a
real window, and a clean first-launch correctly seeds AppData from the
bundled presets.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Paul Lipscomb
2026-07-31 18:50:35 -05:00
parent 60c8255c2d
commit a3e6f6415f
28 changed files with 79 additions and 14 deletions
+44
View File
@@ -0,0 +1,44 @@
# -*- mode: python ; coding: utf-8 -*-
a = Analysis(
['src\\main.py'],
pathex=[],
binaries=[],
datas=[('src/app-presets/presets.json', 'app-presets')],
hiddenimports=[],
hookspath=[],
hooksconfig={},
runtime_hooks=[],
excludes=[],
noarchive=False,
optimize=0,
)
pyz = PYZ(a.pure)
exe = EXE(
pyz,
a.scripts,
[],
exclude_binaries=True,
name='VirtualController',
debug=False,
bootloader_ignore_signals=False,
strip=False,
upx=True,
console=False,
disable_windowed_traceback=False,
argv_emulation=False,
target_arch=None,
codesign_identity=None,
entitlements_file=None,
)
coll = COLLECT(
exe,
a.binaries,
a.datas,
strip=False,
upx=True,
upx_exclude=[],
name='VirtualController',
)
File diff suppressed because it is too large Load Diff
+31
View File
@@ -0,0 +1,31 @@
from PyQt6.QtWidgets import QWidget, QHBoxLayout, QPushButton
from PyQt6.QtCore import Qt
from palette import PALETTE
class ColorSwatchPopup(QWidget):
def __init__(self, callback, parent=None):
super().__init__(parent, Qt.WindowType.Popup)
self.callback = callback
layout = QHBoxLayout(self)
layout.setContentsMargins(4, 4, 4, 4)
layout.setSpacing(4)
for name, hex_color in PALETTE:
btn = QPushButton()
btn.setFixedSize(22, 22)
btn.setStyleSheet(f"""
QPushButton {{
background-color: {hex_color};
border-radius: 3px;
border: 1px solid rgba(255,255,255,0.2);
}}
QPushButton:hover {{
border: 2px solid #00ff88;
}}
""")
btn.clicked.connect(lambda checked, n=name, c=hex_color: self.pick(n, c))
layout.addWidget(btn)
self.setStyleSheet("background-color: #2d2d2d; border-radius: 4px;")
def pick(self, name, hex_color):
self.callback(name, hex_color)
self.close()
+47
View File
@@ -0,0 +1,47 @@
import socket
_ext_socket = None
_ext_ip = "127.0.0.1"
_ext_port = 9124
_ext_enabled = False
def get_extension_socket():
global _ext_socket
if _ext_socket is None:
_ext_socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
return _ext_socket
def set_extension_target(ip=None, port=None):
global _ext_socket, _ext_ip, _ext_port
if ip is not None:
_ext_ip = ip
if port is not None:
_ext_port = port
_ext_socket = None
def set_extension_enabled(enabled):
global _ext_enabled
_ext_enabled = enabled
def is_extension_enabled():
return _ext_enabled
def send_to_extension(value):
"""Sends a plain numeric delta to extension-reaper-macos over UDP.
No-op (kill switch) unless set_extension_enabled(True) has been called."""
if not _ext_enabled:
return
try:
get_extension_socket().sendto(str(value).encode(), (_ext_ip, _ext_port))
except Exception as e:
print(f"[Extension send] Error: {e}")
+530
View File
@@ -0,0 +1,530 @@
import uuid
from PyQt6.QtWidgets import (
QWidget, QVBoxLayout, QHBoxLayout, QSlider,
QLabel, QSpinBox, QLineEdit, QFrame, QSizePolicy,
QPushButton, QCheckBox, 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 midi_sender import send_cc
class FaderWidget(QWidget):
def __init__(self, label, default_cc):
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
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)
# Learn / CC / CH group
self.hw_cc = None
self.hw_channel = None
self.is_learning = 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, matching prior behavior.
# is_touching is tracked either way but only acted on by hardware with
# a real feedback path to gate (see FocusFaderWidget.receive_osc_value).
self.touch_sense_mode = "off"
self.is_touching = False
_grp_style = "QFrame { border: 1px solid rgba(255,255,255,0.2); border-radius: 4px; }"
# Group 1: Learn + input readout (border always visible; learn_btn hideable)
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)
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)
_grp1_layout.addWidget(self.touch_sense_combo)
inner.addWidget(self.grp_learn)
_arrow_lbl = QLabel("")
_arrow_lbl.setAlignment(Qt.AlignmentFlag.AlignHCenter)
_arrow_lbl.setStyleSheet("color: rgba(255,255,255,0.3); font-size: 9px; background: transparent;")
_arrow_lbl.setFixedHeight(12)
inner.addWidget(_arrow_lbl)
# Group 2: CC spin + CH spin + output readout (border always visible; spins hideable)
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)
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)
# 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
# readout kept for pickup blink logic but not shown
self.readout = QLabel(f"CC {default_cc}{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)
# 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.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"
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
# 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 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):
cc_num = self.cc_spin.value()
self.cc_output_lbl.setText(self._cc_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
send_cc(cc_num, value, channel=self.ch_spin.value())
else:
self.readout.setText(f"CC {cc_num}{value}")
return
else:
self.last_sent = value
self.prev_position = value
send_cc(cc_num, value, channel=self.ch_spin.value())
self.readout.setText(f"CC {cc_num}{value}")
def on_cc_changed(self):
cc_num = self.cc_spin.value()
value = self.fader.value()
self.readout.setText(f"CC {cc_num}{value}")
self.cc_output_lbl.setText(self._cc_output_label(value))
if self.pickup_mode:
self.hunting = True
self._start_blink()
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 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 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("")
self.cc_input_lbl.setText("")
self.cc_input_lbl.setStyleSheet("background-color: #000000; color: #888888; font-size: 10px; padding: 1px;")
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 start_learn(self):
self.is_learning = True
self.learn_btn.setText("Listening...")
self.learn_btn.setStyleSheet("color: orange; font-weight: bold;")
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 stop_learn(self):
self.is_learning = False
self.learn_btn.setStyleSheet("")
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;")
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;")
def bind_hw_cc(self, cc_number, channel=None):
self.hw_cc = cc_number
self.hw_channel = channel
self.stop_learn()
def _on_touch_sense_changed(self, index):
self.touch_sense_mode = "jl_cooper" if index == 1 else "off"
self.is_touching = False
def get_state(self):
return {
"uid": self.uid,
"label": self.title_edit.text(),
"cc": self.cc_spin.value(),
"ch": self.ch_spin.value(),
"value": self.fader.value(),
"color": self.color_name,
"pickup": self.pickup_checkbox.isChecked(),
"last_sent": self.last_sent,
"hw_cc": self.hw_cc,
"hw_channel": self.hw_channel,
"touch_sense_mode": self.touch_sense_mode,
"zone": self.zone,
"center_index": self.center_index,
"zone_index": self.zone_index,
"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))
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.hw_cc = state.get("hw_cc", None)
self.hw_channel = state.get("hw_channel", None)
# 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
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;")
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;")
+663
View File
@@ -0,0 +1,663 @@
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"))
+226
View File
@@ -0,0 +1,226 @@
import uuid
from PyQt6.QtWidgets import (
QWidget, QVBoxLayout, QHBoxLayout, QPushButton,
QLabel, QLineEdit, QFrame, QSizePolicy, QCheckBox
)
from PyQt6.QtCore import Qt, QEvent
from palette import PALETTE_HEX, PALETTE_VIVID
from styles import TITLE_STYLE
from colorswatchpopup import ColorSwatchPopup
class FocusFeedbackWidget(QWidget):
"""Read-only OSC feedback display for Channel Focus — Learn an address, show whatever value arrives. No output, no interaction."""
def __init__(self, label):
super().__init__()
self.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Preferred)
self.setFixedWidth(115)
self.current_color = PALETTE_HEX.get("Gray", "#263238")
self.uid = str(uuid.uuid4())
self.is_learning = 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("/3/trackname")
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)
# Custom: shows a fixed, manually-typed string instead of live OSC
# feedback — disables Learn and the OSC address input while checked.
self.custom_checkbox = QCheckBox("Custom")
self.custom_checkbox.setChecked(False)
self.custom_checkbox.stateChanged.connect(self._on_custom_changed)
_grp_input_layout.addWidget(self.custom_checkbox, alignment=Qt.AlignmentFlag.AlignHCenter)
self.custom_text_edit = QLineEdit("")
self.custom_text_edit.setAlignment(Qt.AlignmentFlag.AlignHCenter)
self.custom_text_edit.setPlaceholderText("Custom text")
self.custom_text_edit.setStyleSheet("border: none;")
self.custom_text_edit.textChanged.connect(self._on_custom_text_changed)
self.custom_text_edit.setVisible(False)
_grp_input_layout.addWidget(self.custom_text_edit)
inner.addWidget(self.grp_input)
# Readout — the live feedback value
self.readout_lbl = QLabel("")
self.readout_lbl.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.readout_lbl.setWordWrap(True)
self.readout_lbl.setStyleSheet("background-color: #000000; color: #00e676; font-size: 14px; font-weight: bold; padding: 6px 2px; border-radius: 4px;")
self.readout_lbl.setMinimumHeight(48)
inner.addWidget(self.readout_lbl)
# Scribble strip (caption — what this feedback represents, e.g. "Track Name")
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)
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 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_custom_visibility(self):
is_custom = self.custom_checkbox.isChecked()
self.learn_btn.setVisible(not is_custom)
self.osc_addr_in_edit.setVisible(not is_custom)
self.custom_text_edit.setVisible(is_custom)
def _on_custom_changed(self, _state):
self._update_custom_visibility()
if self.custom_checkbox.isChecked():
self.readout_lbl.setText(self.custom_text_edit.text())
def _on_custom_text_changed(self, text):
if self.custom_checkbox.isChecked():
self.readout_lbl.setText(text)
def receive_osc_value(self, value):
"""Display whatever feedback arrives on the bound address, verbatim."""
if self.custom_checkbox.isChecked():
return
self.readout_lbl.setText(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 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 get_state(self):
return {
"uid": self.uid,
"label": self.title_edit.text(),
"text": self.readout_lbl.text(),
"color": self.color_name,
"osc_addr_in": self.osc_addr_in_edit.text(),
"custom_enabled": self.custom_checkbox.isChecked(),
"custom_text": self.custom_text_edit.text(),
}
def set_state(self, state):
if "uid" in state:
self.uid = state["uid"]
self.title_edit.setText(state.get("label", ""))
self.readout_lbl.setText(state.get("text", ""))
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.osc_addr_in_edit.setText(state.get("osc_addr_in", "/3/trackname"))
self.custom_text_edit.setText(state.get("custom_text", ""))
self.custom_checkbox.setChecked(state.get("custom_enabled", False))
self._update_custom_visibility()
+491
View File
@@ -0,0 +1,491 @@
import uuid
from PyQt6.QtWidgets import (
QWidget, QVBoxLayout, QHBoxLayout, QPushButton,
QLabel, QLineEdit, QFrame, QSizePolicy, QCheckBox, QSpinBox
)
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 osc_sender import send_osc_message
from midi_sender import send_cc
class FocusToggleWidget(QWidget):
"""Direct DAW-focus toggle — 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.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
self.is_learning = 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/mute")
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 click)
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)
# 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.osc_addr_out_edit = QLineEdit("/track/mute")
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)
self.cc_output_lbl = QLabel(self._osc_output_label("--"))
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 button), positioned like grp_remote
# on the regular widgets rather than up with the OSC learn section.
# "Hardware" binds a hw CC/note that can trigger this toggle; independently,
# "Feedback" sends a translated CC back out on every state change (from
# hardware, OSC feedback, or the app) so hardware LEDs/motors stay in
# sync — separate switches since you may want one without the other.
self.hw_cc = None
self.hw_channel = None
self.is_learning_hw = 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.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)
# 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
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
self._update_hw_visibility()
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 _send_hw_feedback(self, value):
"""Sends a translated MIDI CC out (e.g. so a hardware button's LED
tracks this toggle's state), independent of whether hardware CC input
is also enabled."""
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 receive_osc_value(self, value):
"""Update the toggle to reflect feedback from the DAW, without echoing it back out."""
self.toggle_state = value >= 0.5
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._update_btn_style()
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)
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):
addr = self.osc_addr_out_edit.text().strip() or "/track/mute"
if self.trigger_mode == "momentary":
send_osc_message(addr, 1.0)
self.cc_output_lbl.setText(self._osc_output_label(1.0))
self.led_dot.setStyleSheet("background-color: #00e676; border-radius: 3px;")
QTimer.singleShot(150, lambda: self.led_dot.setStyleSheet("background-color: #3c3c3c; border-radius: 3px;"))
self._send_hw_feedback(127)
else:
self.toggle_state = not self.toggle_state
value = 1.0 if self.toggle_state else 0.0
send_osc_message(addr, value)
self.cc_output_lbl.setText(self._osc_output_label(value))
self._update_btn_style()
self._send_hw_feedback(127 if self.toggle_state else 0)
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 _osc_output_label(self, value="--"):
addr = self.osc_addr_out_edit.text().strip() or "/track/mute"
if isinstance(value, (int, float)):
return f"{addr} [{value:.1f}]"
return f"{addr} [{value}]"
def get_state(self):
return {
"uid": self.uid,
"label": self.title_edit.text(),
"state": self.toggle_state,
"color": self.color_name,
"zone": self.zone,
"center_index": self.center_index,
"zone_index": self.zone_index,
"trigger_mode": self.trigger_mode,
"osc_addr_in": self.osc_addr_in_edit.text(),
"osc_addr_out": self.osc_addr_out_edit.text(),
"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(),
}
def set_state(self, state):
if "uid" in state:
self.uid = state["uid"]
self.title_edit.setText(state.get("label", ""))
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()
self.cc_output_lbl.setText(self._osc_output_label(1.0 if self.toggle_state else 0.0))
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.set_trigger_mode(state.get("trigger_mode", "toggle"))
self.osc_addr_in_edit.setText(state.get("osc_addr_in", "/track/mute"))
self.osc_addr_out_edit.setText(state.get("osc_addr_out", "/track/mute"))
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))
self._update_hw_visibility()
File diff suppressed because it is too large Load Diff
+33
View File
@@ -0,0 +1,33 @@
import rtmidi
def get_available_input_ports():
"""Returns list of (display_name, original_index, available) for all ports."""
try:
all_ports = rtmidi.MidiIn().get_ports()
except:
return []
result = []
for i, name in enumerate(all_ports):
try:
test = rtmidi.MidiIn()
test.open_port(i)
test.close_port()
del test
result.append((name, i, True))
except:
print(f"Port in use, skipping: {name}")
result.append((f"{name} [in use by another program]", i, False))
return result
def open_midi_input_port(original_index, callback):
midi_in = rtmidi.MidiIn()
midi_in.open_port(original_index)
midi_in.set_callback(callback)
return midi_in
def close_midi_input_port(midi_in):
if midi_in and midi_in.is_port_open():
midi_in.close_port()
+12
View File
@@ -0,0 +1,12 @@
from PyQt6.QtCore import QObject, pyqtSignal
class MidiReceiver(QObject):
midi_in_signal = pyqtSignal(list)
midi_out_signal = pyqtSignal(list)
transport_out_signal = pyqtSignal()
hw_cc_signal = pyqtSignal(str, int)
controller_in_signal = pyqtSignal(list)
midi_receiver = MidiReceiver()
+27
View File
@@ -0,0 +1,27 @@
import rtmidi
from midi_receiver import midi_receiver
MIDI_CH = 0
TRANSPORT_MIDI_CH = 0
midi_out = rtmidi.MidiOut()
transport_midi_out = rtmidi.MidiOut()
available_ports = midi_out.get_ports()
print("Available MIDI ports:", available_ports)
def send_cc(cc_number, value, channel=None):
ch = (channel - 1) if channel is not None else MIDI_CH
msg = [0xB0 | ch, cc_number, value]
if midi_out.is_port_open():
midi_out.send_message(msg)
midi_receiver.midi_out_signal.emit(msg)
def send_transport_cc(cc_number, value):
msg = [0xB0 | TRANSPORT_MIDI_CH, cc_number, value]
if transport_midi_out.is_port_open():
transport_midi_out.send_message(msg)
midi_receiver.midi_out_signal.emit(msg)
View File
+16
View File
@@ -0,0 +1,16 @@
from PyQt6.QtCore import QObject, pyqtSignal
class OSCReceiver(QObject):
track_name_signal = pyqtSignal(str)
preset_name_signal = pyqtSignal(str)
tempo_signal = pyqtSignal(float)
position_signal = pyqtSignal(str)
play_signal = pyqtSignal(int)
record_signal = pyqtSignal(int)
raw_message_signal = pyqtSignal(str, str)
osc_out_signal = pyqtSignal(str, str)
uuid_preset = pyqtSignal(str)
bar_signal = pyqtSignal(str)
osc_receiver = OSCReceiver()
+37
View File
@@ -0,0 +1,37 @@
from pythonosc import udp_client
from osc_receiver import osc_receiver
_osc_client = None
_osc_send_port = 8000
_osc_send_ip = "127.0.0.1"
def get_osc_client():
global _osc_client
if _osc_client is None:
_osc_client = udp_client.SimpleUDPClient(_osc_send_ip, _osc_send_port)
return _osc_client
def set_osc_target(ip=None, port=None):
global _osc_client, _osc_send_ip, _osc_send_port
if ip is not None:
_osc_send_ip = ip
if port is not None:
_osc_send_port = port
_osc_client = None
def send_osc_message(address, value=1.0):
try:
get_osc_client().send_message(address, float(value))
osc_receiver.osc_out_signal.emit(address, str(value))
print(f"[OSC] -> {address} {value}")
except Exception as e:
print(f"[OSC send] Error: {e}")
+45
View File
@@ -0,0 +1,45 @@
PALETTE = [
("Red", "#e53935"),
("Orange", "#fb8c00"),
("Yellow", "#fdd835"),
("Green", "#43a047"),
("Teal", "#00897b"),
("Blue", "#1e88e5"),
("Purple", "#8e24aa"),
("Pink", "#e91e63"),
("White", "#f5f5f5"),
("Gray", "#4a5568"),
]
PALETTE_NAMES = [p[0] for p in PALETTE]
PALETTE_HEX = {p[0]: p[1] for p in PALETTE}
PALETTE_VIVID = {
"Red": "#7f0000",
"Orange": "#7f3000",
"Yellow": "#7f6000",
"Green": "#1b5e20",
"Teal": "#004d40",
"Blue": "#0d2b6e",
"Purple": "#4a0072",
"Pink": "#7f004d",
"White": "#9e9e9e",
"Gray": "#263238",
}
def lighten_hex(hex_color, amount=0):
hex_color = hex_color.lstrip("#")
r, g, b = int(hex_color[0:2], 16), int(hex_color[2:4], 16), int(hex_color[4:6], 16)
r = min(255, r + amount)
g = min(255, g + amount)
b = min(255, b + amount)
return f"#{r:02x}{g:02x}{b:02x}"
def darken_hex(hex_color, amount=60):
hex_color = hex_color.lstrip("#")
r, g, b = int(hex_color[0:2], 16), int(hex_color[2:4], 16), int(hex_color[4:6], 16)
r = max(0, r - amount)
g = max(0, g - amount)
b = max(0, b - amount)
return f"#{r:02x}{g:02x}{b:02x}"
+132
View File
@@ -0,0 +1,132 @@
import json
import os
from PyQt6.QtWidgets import (
QApplication, QMainWindow, QWidget, QVBoxLayout,
QHBoxLayout, QSlider, QComboBox, QLabel, QPushButton,
QSizePolicy, QSpinBox, QLineEdit, QInputDialog, QFrame,
QCheckBox, QMessageBox
)
import shutil
import sys
if getattr(sys, "frozen", False):
# Packaged exe: Program Files (or wherever it's installed) is not
# writable by a normal user, so presets live in the per-user AppData
# folder instead — standard Windows convention for app-written data.
PRESETS_DIR = os.path.join(os.environ["APPDATA"], "VirtualController")
PRESETS_FILE = os.path.join(PRESETS_DIR, "presets.json")
os.makedirs(PRESETS_DIR, exist_ok=True)
if not os.path.exists(PRESETS_FILE):
# First launch on this machine: seed from the presets.json bundled
# into the exe (sys._MEIPASS), so a fresh install starts with the
# real presets instead of empty. Only happens once — after this,
# AppData is the only copy that's ever read or written.
_bundled = os.path.join(sys._MEIPASS, "app-presets", "presets.json")
if os.path.exists(_bundled):
shutil.copyfile(_bundled, PRESETS_FILE)
else:
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
PRESETS_DIR = os.path.join(BASE_DIR, "app-presets")
PRESETS_FILE = os.path.join(PRESETS_DIR, "presets.json")
os.makedirs(PRESETS_DIR, exist_ok=True)
def load_presets_file():
if os.path.exists(PRESETS_FILE):
with open(PRESETS_FILE, "r") as f:
return json.load(f)
return {"__last_used__": None, "presets": {}}
def save_presets_file(data):
with open(PRESETS_FILE, "w") as f:
json.dump(data, f, indent=2)
def ensure_preset_structure(presets):
if "presets" not in presets:
return {"__last_used__": None, "presets": presets, "transport_preset": [], "transport_show_hide": False, "io_config": {}}
if "transport_preset" not in presets:
presets["transport_preset"] = []
if "transport_show_hide" not in presets:
presets["transport_show_hide"] = False
if "io_config" not in presets:
presets["io_config"] = {}
return presets
def on_preset_selected(menu_presets: QComboBox, app_window: QMainWindow, ready: bool, loaded_presets: dict, on_load=None):
selectedOption = menu_presets.currentText()
if ready and selectedOption and selectedOption in loaded_presets["presets"]:
loaded_presets["__last_used__"] = selectedOption
save_presets_file(loaded_presets)
if on_load:
on_load(loaded_presets["presets"][selectedOption])
def save_preset(app_window: QMainWindow, menu_presets: QComboBox, loaded_presets: dict, on_save=None, on_build=None, on_save_as=None):
selectedOption = menu_presets.currentText()
if not selectedOption or selectedOption not in loaded_presets["presets"]:
if on_save_as:
on_save_as()
return
loaded_presets["presets"][selectedOption] = on_build(selectedOption) if on_build else {}
loaded_presets["__last_used__"] = selectedOption
save_presets_file(loaded_presets)
if on_save:
on_save()
print(f"Saved preset: {selectedOption}")
def save_preset_as(app_window: QMainWindow, menu_presets: QComboBox, loaded_presets: dict, on_save=None, on_refresh=None, on_build=None):
print(f"save as button tapped")
selectedOption = menu_presets.currentText()
selectedOption, ok = QInputDialog.getText(app_window, "Save As", "Preset name:")
if not ok or not selectedOption.strip():
return
selectedOption = selectedOption.strip()
loaded_presets["presets"][selectedOption] = on_build(selectedOption) if on_build else {}
loaded_presets["__last_used__"] = selectedOption
save_presets_file(loaded_presets)
if on_refresh:
on_refresh()
menu_presets.setCurrentText(selectedOption)
if on_save:
on_save()
print(f"Saved preset as: {selectedOption}")
def delete_preset(app_window: QMainWindow, menu_presets: QComboBox, loaded_presets: dict, on_refresh=None):
selectedOption = menu_presets.currentText()
if not selectedOption or selectedOption not in loaded_presets["presets"]:
return
msg = QMessageBox(app_window)
msg.setWindowTitle("Remove Preset")
msg.setText(f'Are you sure you want to remove "{selectedOption}"?')
msg.setIcon(QMessageBox.Icon.NoIcon)
msg.setStandardButtons(QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No)
reply = msg.exec()
if reply != QMessageBox.StandardButton.Yes:
return
del loaded_presets["presets"][selectedOption]
if loaded_presets["__last_used__"] == selectedOption:
loaded_presets["__last_used__"] = None
save_presets_file(loaded_presets)
if on_refresh:
on_refresh()
print(f"Deleted preset: {selectedOption}")
+256
View File
@@ -0,0 +1,256 @@
from PyQt6.QtWidgets import (
QWidget, QVBoxLayout, QHBoxLayout, QPushButton,
QLabel, QSpinBox, QLineEdit, QFrame, QSizePolicy
)
from PyQt6.QtCore import Qt
from styles import TITLE_STYLE
from midi_sender import send_cc
class PresetWidget(QWidget):
def __init__(self, label="Preset", default_cc=1):
super().__init__()
self.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Preferred)
self.setFixedWidth(115)
self.hw_cc = None
self.hw_channel = None
self.is_learning = False
outer = QVBoxLayout(self)
outer.setContentsMargins(0, 0, 0, 0)
outer.setSpacing(2)
self.container = QFrame()
self.container.setFrameShape(QFrame.Shape.StyledPanel)
self.container.setObjectName("presetContainer")
self.container.setStyleSheet("""
QFrame#presetContainer {
background-color: #4a5568;
border-radius: 6px;
border: 2px solid rgba(0,0,0,0.3);
}
""")
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.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)
self.learn_btn.clicked.connect(self.start_learn)
_grp1_layout.addWidget(self.learn_btn)
self.trigger_mode = "momentary"
self.toggle_state = False
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: Mom./Tog. + 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)
# Momentary / Toggle 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(True)
self.toggle_mode_btn = QPushButton("Tog.")
self.toggle_mode_btn.setFixedHeight(18)
self.toggle_mode_btn.setCheckable(True)
self.toggle_mode_btn.setChecked(False)
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_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)
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)
# Trigger button
self.btn = QPushButton(label)
self.btn.setFixedHeight(40)
self.btn.clicked.connect(self.on_click)
inner.addWidget(self.btn)
# Scribble strip
self.title_edit = QLineEdit(label)
self.title_edit.setAlignment(Qt.AlignmentFlag.AlignHCenter)
self.title_edit.setStyleSheet(TITLE_STYLE)
self.title_edit.textChanged.connect(self.btn.setText)
inner.addWidget(self.title_edit)
outer.addWidget(self.container)
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="--"):
return f"CC{self.cc_spin.value()} CH{self.ch_spin.value()} [{vel}]"
def start_learn(self):
self.is_learning = True
self.learn_btn.setText("Listening...")
self.learn_btn.setStyleSheet("color: orange; font-weight: bold; border: none;")
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("")
self.cc_input_lbl.setStyleSheet("background-color: #000000; color: #888888; font-size: 10px; padding: 1px; border: none;")
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 _fire(self):
out_cc = self.cc_spin.value()
out_ch = self.ch_spin.value()
if self.trigger_mode == "momentary":
out_val = 127
else:
self.toggle_state = not self.toggle_state
out_val = 127 if self.toggle_state else 0
print(f"[preset] CC{out_cc} CH{out_ch} ({out_val})")
send_cc(out_cc, out_val, channel=out_ch)
self.cc_output_lbl.setText(self._cc_output_label(out_val))
def on_click(self):
self._fire()
def on_hw_trigger(self, value):
self.cc_input_lbl.setText(self._cc_input_label(value))
self._fire()
def get_state(self):
return {
"label": self.title_edit.text(),
"cc": self.cc_spin.value(),
"ch": self.ch_spin.value(),
"hw_cc": self.hw_cc,
"hw_channel": self.hw_channel,
"trigger_mode": self.trigger_mode,
}
def set_state(self, state):
self.title_edit.setText(state.get("label", self.title_edit.text()))
self.cc_spin.setValue(state.get("cc", 1))
self.ch_spin.setValue(state.get("ch", 1))
self.hw_cc = state.get("hw_cc", None)
self.hw_channel = state.get("hw_channel", None)
self.set_trigger_mode(state.get("trigger_mode", "momentary"))
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;")
+5
View File
@@ -0,0 +1,5 @@
import os
import runpy
_here = os.path.dirname(os.path.abspath(__file__))
runpy.run_path(os.path.join(_here, "main.py"), run_name="__main__")
+32
View File
@@ -0,0 +1,32 @@
FADER_STYLE = """
QSlider::groove:vertical {
background: #1e1e1e;
width: 8px;
border-radius: 4px;
}
QSlider::handle:vertical {
background: #bbbbbb;
border: 1px solid #222222;
height: 39px;
width: 24px;
margin: 0 -8px;
border-radius: 3px;
}
QSlider::handle:vertical:hover {
background: #dddddd;
border: 1px solid #111111;
}
"""
TITLE_STYLE = """
QLineEdit {
background-color: #000000;
border: none;
color: #ffffff;
font-weight: bold;
padding: 2px;
}
QLineEdit:focus {
border: 1px solid #555;
}
"""
+433
View File
@@ -0,0 +1,433 @@
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("")
+436
View File
@@ -0,0 +1,436 @@
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_transport_cc
from osc_sender import send_osc_message
class TransportWidget(QWidget):
"""Transport button strip — same pattern as ToggleWidget."""
def __init__(self, label, default_cc):
super().__init__()
self.uid = str(uuid.uuid4())
self.zone = None
self.center_index = None
self.zone_index = None
self.toggle_state = False
self.hw_cc = None
self.is_learning = False
self.current_color = PALETTE_HEX.get("Gray", "#263238")
self.color_name = "Gray"
self.on_select_callback = None
self.on_zone_change_callback = None
self._label = label
self.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Maximum)
self.setFixedWidth(100)
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.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: output controls + 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)
# MIDI / OSC mode selector
mode_row = QHBoxLayout()
mode_row.setContentsMargins(0, 0, 0, 0)
mode_row.setSpacing(2)
self.midi_btn = QPushButton("MIDI")
self.midi_btn.setFixedHeight(18)
self.midi_btn.setCheckable(True)
self.midi_btn.setChecked(True)
self.midi_btn.setStyleSheet("border: none;")
self.osc_btn = QPushButton("OSC")
self.osc_btn.setFixedHeight(18)
self.osc_btn.setCheckable(True)
self.osc_btn.setChecked(False)
self.osc_btn.setStyleSheet("border: none;")
self.midi_btn.clicked.connect(lambda: self.set_output_mode("midi"))
self.osc_btn.clicked.connect(lambda: self.set_output_mode("osc"))
mode_row.addWidget(self.midi_btn)
mode_row.addWidget(self.osc_btn)
_grp2_layout.addLayout(mode_row)
# Momentary / Toggle 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(True)
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(False)
self.toggle_mode_btn.setStyleSheet("border: none;")
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.trigger_mode = "momentary"
# CC spin (MIDI mode)
self.cc_spin = QSpinBox()
self.cc_spin.setMinimum(1)
self.cc_spin.setMaximum(127)
self.cc_spin.setValue(default_cc)
self.cc_spin.setFixedWidth(75)
_grp2_layout.addWidget(self.cc_spin, alignment=Qt.AlignmentFlag.AlignHCenter)
# OSC address field (hidden by default)
self.osc_addr_edit = QLineEdit("/play")
self.osc_addr_edit.setAlignment(Qt.AlignmentFlag.AlignHCenter)
self.osc_addr_edit.setPlaceholderText("/address")
self.osc_addr_edit.setVisible(False)
self.osc_addr_edit.setStyleSheet("border: none;")
_grp2_layout.addWidget(self.osc_addr_edit)
# Output readout — always visible, content adapts to mode
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)
self.output_mode = "midi"
# 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)
# Transport button with LED dot
btn_container = QWidget()
btn_container.setFixedSize(60, 60)
self.btn = QPushButton(label, 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)
self.title_edit.textChanged.connect(self.btn.setText)
inner.addWidget(self.title_edit)
# Color swatch
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 + ID label
self.id_sep = QWidget()
self.id_sep.setFixedHeight(1)
self.id_sep.setStyleSheet("background-color: #3a3a3a;")
inner.addWidget(self.id_sep)
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)
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)
return False
def mousePressEvent(self, event):
if self.on_select_callback:
self.on_select_callback(self)
super().mousePressEvent(event)
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 set_output_mode(self, mode):
self.output_mode = mode
if mode == "midi":
self.midi_btn.setChecked(True)
self.osc_btn.setChecked(False)
self.midi_btn.setStyleSheet("font-weight: bold; color: #00e676;")
self.osc_btn.setStyleSheet("")
self.cc_spin.setVisible(True)
self.osc_addr_edit.setVisible(False)
self.cc_output_lbl.setText(f"CC{self.cc_spin.value()} [--]")
else:
self.osc_btn.setChecked(True)
self.midi_btn.setChecked(False)
self.osc_btn.setStyleSheet("font-weight: bold; color: #00e676;")
self.midi_btn.setStyleSheet("")
self.cc_spin.setVisible(False)
self.osc_addr_edit.setVisible(True)
addr = self.osc_addr_edit.text().strip() or "/play"
self.cc_output_lbl.setText(f"{addr} [--]")
def on_click(self):
if self.trigger_mode == "momentary":
if self.output_mode == "osc":
addr = self.osc_addr_edit.text().strip() or "/play"
send_osc_message(addr, 1.0)
self.cc_output_lbl.setText(f"{addr} [1.0]")
else:
cc_num = self.cc_spin.value()
send_transport_cc(cc_num, 127)
self.cc_output_lbl.setText(f"CC{cc_num} [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
if self.output_mode == "osc":
addr = self.osc_addr_edit.text().strip() or "/play"
osc_val = 1.0 if self.toggle_state else 0.0
send_osc_message(addr, osc_val)
self.cc_output_lbl.setText(f"{addr} [{osc_val}]")
else:
cc_num = self.cc_spin.value()
send_transport_cc(cc_num, value)
self.cc_output_lbl.setText(f"CC{cc_num} [{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 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):
text = f"L{index+1}" if zone == "left" else f"R{index+1}" if zone == "right" else f"{index+1}"
self.id_label.setText(text)
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(name, hex_color)}; border-radius: 2px; border: none;")
self.apply_color(hex_color)
def on_zone_checked(self, zone, active):
self.zone = zone if active else None
if self.on_zone_change_callback:
self.on_zone_change_callback(self)
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("")
if self.hw_cc is not None:
self.learn_btn.setText(f"HW: CC{self.hw_cc}")
self.cc_input_lbl.setText(f"In CC{self.hw_cc} [--]")
self.cc_input_lbl.setStyleSheet("background-color: #000000; color: #00e676; font-size: 10px; padding: 1px;")
else:
self.learn_btn.setText("Learn")
self.cc_input_lbl.setText("")
def bind_hw_cc(self, cc_number, channel=None):
self.hw_cc = cc_number
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.learn_btn.setText("Learn")
self.learn_btn.setStyleSheet("")
self.cc_input_lbl.setText("")
def get_state(self):
return {
"uid": self.uid,
"label": self.title_edit.text(),
"cc": self.cc_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,
"output_mode": self.output_mode,
"osc_addr": self.osc_addr_edit.text(),
"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._label))
self.cc_spin.setValue(state.get("cc", 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(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()
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)
if self.hw_cc is not None:
self.learn_btn.setText(f"HW: CC{self.hw_cc}")
self.cc_input_lbl.setText(f"In CC{self.hw_cc} [--]")
self.cc_input_lbl.setStyleSheet("background-color: #000000; color: #00e676; font-size: 10px; padding: 1px;")
self.osc_addr_edit.setText(state.get("osc_addr", "/play"))
self.set_output_mode(state.get("output_mode", "midi"))
self.set_trigger_mode(state.get("trigger_mode", "momentary"))
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)
+108
View File
@@ -0,0 +1,108 @@
import json
from PyQt6.QtCore import QObject, pyqtSignal
from PyQt6.QtWebSockets import QWebSocketServer
from PyQt6.QtNetwork import QHostAddress
class WSServer(QObject):
control_received = pyqtSignal(str, int)
control_f_received = pyqtSignal(str, float)
layout_saved = pyqtSignal(str, object)
widget_visibility_received = pyqtSignal(str, int)
client_connected = pyqtSignal()
log_signal = pyqtSignal(str)
raw_in_signal = pyqtSignal(str)
def __init__(self, port=8765, parent=None):
super().__init__(parent)
self._clients = []
self._port = port
self._server = QWebSocketServer("VC", QWebSocketServer.SslMode.NonSecureMode, self)
if self._server.listen(QHostAddress.SpecialAddress.AnyIPv4, port):
self.log_signal.emit(f"WS listening on :{port}")
else:
self.log_signal.emit(f"WS failed to bind :{port}")
self._server.newConnection.connect(self._on_new_connection)
def _on_new_connection(self):
client = self._server.nextPendingConnection()
peer = client.peerAddress().toString()
self._clients.append(client)
client.textMessageReceived.connect(self._on_message)
client.disconnected.connect(lambda c=client, p=peer: self._on_disconnect(c, p))
self.log_signal.emit(f"client connected {peer} ({len(self._clients)} total)")
self.client_connected.emit()
def _on_disconnect(self, client, peer):
if client in self._clients:
self._clients.remove(client)
client.deleteLater()
self.log_signal.emit(f"client disconnected {peer} ({len(self._clients)} remaining)")
def _on_message(self, message):
self.raw_in_signal.emit(message)
try:
data = json.loads(message)
ev = data.get("event")
if ev == "control":
uid = data["uid"]
val = int(data["value"])
self.log_signal.emit(f"control uid={uid[:8]}… val={val}")
self.control_received.emit(uid, val)
elif ev == "control_f":
uid = data["uid"]
val = float(data["value"])
self.log_signal.emit(f"control_f uid={uid[:8]}… val={val:.4f}")
self.control_f_received.emit(uid, val)
elif ev == "save_layout":
n = len(data.get("layout", {}))
self.log_signal.emit(f"layout saved preset={data['preset_uuid'][:8]}{n} widgets")
self.layout_saved.emit(data["preset_uuid"], data["layout"])
elif ev == "widget_visibility":
uid = data["uid"]
dest_id = int(data.get("dest_id", 0))
self.log_signal.emit(f"widget visibility uid={uid[:8]}… dest_id={dest_id}")
self.widget_visibility_received.emit(uid, dest_id)
else:
self.log_signal.emit(f"unknown event: {ev}")
except Exception as e:
self.log_signal.emit(f"bad message: {e}")
@property
def has_clients(self) -> bool:
return len(self._clients) > 0
def broadcast(self, data: dict):
if not self._clients:
return
msg = json.dumps(data)
for client in list(self._clients):
client.sendTextMessage(msg)
def broadcast_preset(self, snapshot: dict, layout: dict):
name = snapshot.get("preset_name", "")
self.log_signal.emit(f"broadcast preset '{name}'{len(self._clients)} client(s)")
# layout omitted — owned and persisted client-side, keyed by preset_uuid
self.broadcast({"event": "preset", "data": snapshot})
def broadcast_widget_update(self, uid: str, value: int):
self.broadcast({"event": "widget_update", "uid": uid, "value": value})
def broadcast_widget_update_f(self, uid: str, value: float):
self.broadcast({"event": "widget_update_f", "uid": uid, "value": value})
def broadcast_widget_label(self, uid: str, label: str):
self.broadcast({"event": "widget_label", "uid": uid, "label": label})
def broadcast_widget_feedback(self, uid: str, text: str):
self.broadcast({"event": "widget_feedback", "uid": uid, "text": text})
def broadcast_daw_state(self, **kwargs):
self.broadcast({"event": "daw_state", **kwargs})
def stop(self):
self._server.close()
for client in list(self._clients):
client.close()
self._clients.clear()
self.log_signal.emit("WS server stopped")
+88
View File
@@ -0,0 +1,88 @@
from PyQt6.QtWidgets import QPushButton
from PyQt6.QtCore import Qt
from PyQt6.QtGui import QPainter, QColor, QFont
class ZoneButton(QPushButton):
"""Split L/R zone button. Left half = L, Right half = R."""
def __init__(self, parent=None):
super().__init__(parent)
self.left_active = False
self.right_active = False
self.setFixedHeight(20)
self.left_callback = None
self.right_callback = None
self._update_style()
def mousePressEvent(self, event):
if event.button() == Qt.MouseButton.LeftButton:
if event.position().x() < self.width() / 2:
self.left_active = not self.left_active
if self.left_active:
self.right_active = False
if self.left_callback:
self.left_callback(self.left_active)
else:
self.right_active = not self.right_active
if self.right_active:
self.left_active = False
if self.right_callback:
self.right_callback(self.right_active)
self._update_style()
def set_state(self, left, right):
self.left_active = left
self.right_active = right
self._update_style()
def _update_style(self):
l_color = "#ff8c00" if self.left_active else "#2a2a2a"
r_color = "#00c853" if self.right_active else "#2a2a2a"
self.setStyleSheet(f"""
QPushButton {{
background: qlineargradient(
x1:0, y1:0, x2:1, y2:0,
stop:0 {l_color},
stop:0.499 {l_color},
stop:0.5 {r_color},
stop:1 {r_color}
);
border-top-left-radius: 4px;
border-bottom-left-radius: 4px;
border-top-right-radius: 4px;
border-bottom-right-radius: 4px;
border: 1px solid #444;
color: #aaaaaa;
font-size: 9px;
font-weight: bold;
}}
""")
self.setText("")
self.update()
def paintEvent(self, event):
super().paintEvent(event)
painter = QPainter(self)
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
mid = self.width() // 2
painter.setPen(QColor("#666666"))
painter.drawLine(mid, 3, mid, self.height() - 3)
painter.setPen(QColor("#ffffff"))
font = QFont()
font.setPointSize(7)
font.setBold(True)
painter.setFont(font)
painter.drawText(0, 0, mid, self.height(), 0x0084, "L")
painter.drawText(mid, 0, mid, self.height(), 0x0084, "R")
painter.end()