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
+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()