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:
@@ -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()
|
||||
Reference in New Issue
Block a user