Initial tablet POC: project restructure + WebSocket/HTTP server + JS client
- Restructured project into /app, /server, /client with run.py entry point - WSServer (QWebSocketServer) with log_signal, start/stop, client tracking - HTTP server serves /client on port 8080 - Vanilla JS client: faders, toggles, transport, drag-to-arrange, lock/save layout - Start Tablet button + floating Tablet log window in app UI - Preset broadcast on load/switch, DAW state relay, hardware sync via widget_update - Widget colors synced from app palette, toggle state fixed (sends 0/127 correctly) - Layout saved per preset UUID back to presets.json Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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()
|
||||
@@ -0,0 +1,474 @@
|
||||
import uuid
|
||||
|
||||
from PyQt6.QtWidgets import (
|
||||
QWidget, QVBoxLayout, QHBoxLayout, QSlider,
|
||||
QLabel, QSpinBox, QLineEdit, QFrame, QSizePolicy,
|
||||
QPushButton, QCheckBox
|
||||
)
|
||||
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
|
||||
|
||||
_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)
|
||||
|
||||
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)
|
||||
|
||||
|
||||
|
||||
# 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 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,
|
||||
"zone": self.zone,
|
||||
"center_index": self.center_index,
|
||||
"zone_index": self.zone_index
|
||||
}
|
||||
|
||||
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)
|
||||
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;")
|
||||
+2961
File diff suppressed because it is too large
Load Diff
@@ -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()
|
||||
@@ -0,0 +1,11 @@
|
||||
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)
|
||||
|
||||
midi_receiver = MidiReceiver()
|
||||
|
||||
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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}")
|
||||
@@ -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}"
|
||||
+113
@@ -0,0 +1,113 @@
|
||||
import json
|
||||
import os
|
||||
|
||||
from PyQt6.QtWidgets import (
|
||||
QApplication, QMainWindow, QWidget, QVBoxLayout,
|
||||
QHBoxLayout, QSlider, QComboBox, QLabel, QPushButton,
|
||||
QSizePolicy, QSpinBox, QLineEdit, QInputDialog, QFrame,
|
||||
QCheckBox, QMessageBox
|
||||
)
|
||||
|
||||
|
||||
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
PRESETS_DIR = os.path.join(BASE_DIR, "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}")
|
||||
@@ -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;")
|
||||
@@ -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;
|
||||
}
|
||||
"""
|
||||
@@ -0,0 +1,403 @@
|
||||
import uuid
|
||||
|
||||
from PyQt6.QtWidgets import (
|
||||
QWidget, QVBoxLayout, QHBoxLayout, QPushButton,
|
||||
QLabel, QSpinBox, QLineEdit, QFrame, QSizePolicy
|
||||
)
|
||||
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)
|
||||
|
||||
# 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,
|
||||
}
|
||||
|
||||
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"))
|
||||
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("")
|
||||
@@ -0,0 +1,406 @@
|
||||
import uuid
|
||||
|
||||
from PyQt6.QtWidgets import (
|
||||
QWidget, QVBoxLayout, QHBoxLayout, QPushButton,
|
||||
QLabel, QSpinBox, QLineEdit, QFrame, QSizePolicy
|
||||
)
|
||||
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"
|
||||
|
||||
# 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,
|
||||
}
|
||||
|
||||
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"))
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user