diff --git a/.gitignore b/.gitignore
index 505a3b1..51bcfcc 100644
--- a/.gitignore
+++ b/.gitignore
@@ -8,3 +8,6 @@ wheels/
# Virtual environments
.venv
+
+# Generated visual review captures
+artifacts/
diff --git a/pyproject.toml b/pyproject.toml
index 77808a0..6c79b73 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,13 +1,19 @@
[project]
name = "dynalab-py"
version = "0.1.0"
-description = "Add your description here"
+description = "Cross-platform desktop UI for dynalab-core signal acquisition and playback"
readme = "README.md"
+license-files = ["LICENSE"]
authors = [
{ name = "Hector van der Aa", email = "hector@h3cx.dev" }
]
requires-python = ">=3.13"
-dependencies = []
+dependencies = [
+ "dynalab-core @ git+https://git.h3cx.dev/Exergie/dynalab-core.git@v0.2.1",
+ "numpy>=2.5.4",
+ "pyqtgraph>=0.14.0",
+ "pyside6>=6.12.0",
+]
[project.scripts]
dynalab-py = "dynalab_py:main"
@@ -15,3 +21,6 @@ dynalab-py = "dynalab_py:main"
[build-system]
requires = ["uv_build>=0.13.0,<0.14.0"]
build-backend = "uv_build"
+
+[tool.uv.build-backend]
+source-include = ["API_BOUNDARIES.md", "src/dynalab_py/icons/*.svg"]
diff --git a/src/dynalab_py/__init__.py b/src/dynalab_py/__init__.py
index 7ba05f3..d595b4c 100644
--- a/src/dynalab_py/__init__.py
+++ b/src/dynalab_py/__init__.py
@@ -1,2 +1,4 @@
-def main() -> None:
- print("Hello from dynalab-py!")
+"""DynaLab desktop application entry point."""
+from .app import MainWindow, main
+
+__all__ = ["MainWindow", "main"]
diff --git a/src/dynalab_py/app.py b/src/dynalab_py/app.py
new file mode 100644
index 0000000..d1e2412
--- /dev/null
+++ b/src/dynalab_py/app.py
@@ -0,0 +1,853 @@
+from pathlib import Path
+from typing import Any
+from uuid import uuid4
+
+from PySide6.QtCore import QByteArray, QSettings, Qt, QTimer
+from PySide6.QtWidgets import (
+ QApplication,
+ QComboBox,
+ QDialog,
+ QDialogButtonBox,
+ QFileDialog,
+ QHBoxLayout,
+ QLabel,
+ QLineEdit,
+ QListWidget,
+ QMainWindow,
+ QMessageBox,
+ QPushButton,
+ QSpinBox,
+ QToolBar,
+ QVBoxLayout,
+ QWidget,
+)
+
+from .core_adapter import CoreAdapter
+from .data_panel import DataPanel
+from .derive_panel import DerivePanel
+from .docking import GridWorkspace, PanelDockWidget
+from .instrument_group import InstrumentGroupPanel
+from .panels import GaugePanel, GraphPanel, NumericPanel, SignalBrowser
+from .session_panel import SessionPanel
+from .theme import apply_theme
+from .workspace import (
+ SYSTEM_PANELS,
+ VISUALIZATION_TYPES,
+ WorkspaceError,
+ load_workspace,
+ new_workspace,
+ save_workspace,
+ validate_workspace,
+)
+
+PANEL_TITLES = {
+ "numeric": "Numeric readout", "gauge": "Gauge", "graph": "Time graph",
+ "numeric_group": "Readout group", "gauge_group": "Gauge group",
+ **{panel["type"]: panel["title"] for panel in SYSTEM_PANELS},
+}
+SYSTEM_IDS = {panel["type"]: panel["id"] for panel in SYSTEM_PANELS}
+
+
+class MainWindow(QMainWindow):
+ def __init__(self) -> None:
+ super().__init__()
+ self.settings = QSettings(
+ QSettings.defaultFormat(), QSettings.Scope.UserScope, "DynaLab", "DynaLab"
+ )
+ try:
+ self.ui_update_hz = max(1, min(60, int(self.settings.value("ui_update_hz", 5))))
+ except (TypeError, ValueError):
+ self.ui_update_hz = 5
+ self.workspace: dict[str, Any] | None = None
+ self.workspace_path: Path | None = None
+ self.docks: dict[str, PanelDockWidget] = {}
+ self.grid: GridWorkspace | None = None
+ self._recording = False
+ self.panel_widgets: dict[str, QWidget] = {}
+ self.core_adapter: CoreAdapter | None = None
+ self._core_started = False
+ self._session_ended = False
+ self._core_busy = False
+ self._plot_busy = False
+ self._plot_request_ids: dict[str, list[str]] = {}
+ self._plot_request_mode: str | None = None
+ self._session_mode: str | None = None
+ self._workspace_generation = 0
+ self._plot_request_generation = 0
+ self._available_signal_ids: set[str] = set()
+ self._signal_names: dict[str, str] = {}
+ self._close_after_stop = False
+ self.setWindowTitle("DynaLab")
+ self.resize(1280, 840)
+ self._build_menus()
+ self._build_session_toolbar()
+ self._show_start_screen()
+ self._refresh_timer = QTimer(self)
+ self._refresh_timer.setTimerType(Qt.TimerType.PreciseTimer)
+ self._refresh_timer.timeout.connect(self._refresh_session)
+ self._playhead_timer = QTimer(self)
+ self._playhead_timer.setTimerType(Qt.TimerType.PreciseTimer)
+ self._playhead_timer.timeout.connect(self._refresh_playback_cursors)
+ self.set_ui_update_rate(self.ui_update_hz)
+ self._refresh_timer.start()
+ self._playhead_timer.start()
+
+ def _build_menus(self) -> None:
+ workspace_menu = self.menuBar().addMenu("Workspace")
+ self.new_action = workspace_menu.addAction("New", self._new_workspace, "Ctrl+N")
+ self.open_action = workspace_menu.addAction("Open…", self._open_dialog, "Ctrl+O")
+ self.save_action = workspace_menu.addAction("Save", self._save_workspace, "Ctrl+S")
+ self.save_action.setEnabled(False)
+ self.save_as_action = workspace_menu.addAction("Save As…", self._save_as)
+ self.save_as_action.setEnabled(False)
+ self.view_menu = self.menuBar().addMenu("View")
+ self.add_panel_menu = self.menuBar().addMenu("Add panel")
+ self.add_panel_actions: dict[str, Any] = {}
+ for panel_type, title in PANEL_TITLES.items():
+ action = self.add_panel_menu.addAction(title)
+ action.triggered.connect(lambda _checked=False, kind=panel_type: self.add_panel(kind))
+ self.add_panel_actions[panel_type] = action
+ self.add_panel_menu.setEnabled(False)
+ settings_menu = self.menuBar().addMenu("Settings")
+ settings_menu.addAction("UI update rate…", self._configure_update_rate)
+
+ def set_ui_update_rate(self, hz: int) -> None:
+ self.ui_update_hz = max(1, min(60, int(hz)))
+ interval = round(1000 / self.ui_update_hz)
+ self._refresh_timer.setInterval(interval)
+ self._playhead_timer.setInterval(interval)
+ for panel in self.panel_widgets.values():
+ if isinstance(panel, DataPanel):
+ panel.set_update_interval(interval)
+ self.settings.setValue("ui_update_hz", self.ui_update_hz)
+
+ def _configure_update_rate(self) -> None:
+ dialog = QDialog(self)
+ dialog.setWindowTitle("UI update rate")
+ layout = QVBoxLayout(dialog)
+ explanation = QLabel(
+ "Refresh values, graphs and playback cursors at this rate.\n"
+ "Sampling and playback speed are unchanged. Heavy graphs may refresh more slowly."
+ )
+ explanation.setWordWrap(True)
+ layout.addWidget(explanation)
+ rate = QSpinBox(dialog)
+ rate.setRange(1, 60)
+ rate.setSuffix(" Hz")
+ rate.setAccessibleName("UI update frequency")
+ rate.setValue(self.ui_update_hz)
+ layout.addWidget(rate)
+ buttons = QDialogButtonBox(
+ QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel,
+ parent=dialog,
+ )
+ buttons.accepted.connect(dialog.accept)
+ buttons.rejected.connect(dialog.reject)
+ layout.addWidget(buttons)
+ if dialog.exec() == QDialog.DialogCode.Accepted:
+ self.set_ui_update_rate(rate.value())
+
+ def _build_session_toolbar(self) -> None:
+ host, port = CoreAdapter.default_endpoint()
+ toolbar = QToolBar("Session", self)
+ toolbar.setObjectName("sessionToolbar")
+ toolbar.setMovable(False)
+ self.addToolBar(toolbar)
+ brand = QLabel("DynaLab")
+ brand.setObjectName("brand")
+ toolbar.addWidget(brand)
+ toolbar.addWidget(QLabel("Server"))
+ self.host_input = QLineEdit(self.settings.value("core_host", host, type=str))
+ self.host_input.setMaximumWidth(150)
+ self.host_input.setAccessibleName("Core server host")
+ toolbar.addWidget(self.host_input)
+ self.port_input = QSpinBox()
+ self.port_input.setMinimumWidth(85)
+ self.port_input.setRange(1, 65535)
+ self.port_input.setValue(self.settings.value("core_port", port, type=int))
+ self.port_input.setAccessibleName("Core server port")
+ toolbar.addWidget(self.port_input)
+ self.start_button = QPushButton("Start session")
+ self.start_button.setObjectName("primary")
+ self.start_button.clicked.connect(self._start_core)
+ toolbar.addWidget(self.start_button)
+ self.stop_button = QPushButton("Stop session")
+ self.stop_button.clicked.connect(self._stop_core)
+ toolbar.addWidget(self.stop_button)
+ self.mode_selector = QComboBox()
+ for label, mode in (("Realtime", "realtime"), ("Offline", "offline"), ("Playback", "playback")):
+ self.mode_selector.addItem(label, mode)
+ self.mode_selector.setAccessibleName("Session mode")
+ self.mode_selector.currentIndexChanged.connect(self._change_mode)
+ toolbar.addSeparator()
+ toolbar.addWidget(QLabel("Mode"))
+ toolbar.addWidget(self.mode_selector)
+ self.session_status = QLabel("Core stopped")
+ self.session_status.setObjectName("sessionStatus")
+ toolbar.addWidget(self.session_status)
+ self._update_session_controls()
+
+ def _session_panel_busy(self) -> bool:
+ return any(
+ isinstance(panel, (SessionPanel, DataPanel, DerivePanel)) and panel.busy
+ for panel in self.panel_widgets.values()
+ )
+
+ def _update_session_controls(self) -> None:
+ editable = not self._core_busy and not self._session_panel_busy()
+ self.new_action.setEnabled(editable)
+ self.open_action.setEnabled(editable)
+ available = self.core_adapter is None and not self._core_busy and not self._session_ended
+ can_stop = (self.core_adapter is not None and not self._core_busy
+ and not self._session_panel_busy())
+ running = can_stop and self._core_started
+ self.host_input.setEnabled(available)
+ self.port_input.setEnabled(available)
+ self.start_button.setEnabled(available)
+ self.stop_button.setEnabled(can_stop)
+ self.mode_selector.setEnabled(running and not self._recording)
+ operation_panels = [p for p in self.panel_widgets.values() if isinstance(p, (SessionPanel, DataPanel, DerivePanel))]
+ for panel in operation_panels:
+ panel.setEnabled(not self._core_busy and not any(other.busy for other in operation_panels if other is not panel))
+ if self.grid is not None:
+ dock = next((self.docks[panel_id] for panel_id, widget in self.panel_widgets.items() if widget is panel), None)
+ if dock is not None:
+ self.grid.fit_panel(dock)
+
+ def _start_core(self) -> None:
+ if self.core_adapter is not None or self._core_busy or self._session_ended:
+ return
+ host = self.host_input.text().strip()
+ try:
+ if not host:
+ raise ValueError("Enter a server host before starting Core.")
+ port = self.port_input.value()
+ self.core_adapter = CoreAdapter(host=host, port=port)
+ except Exception as error:
+ QMessageBox.warning(self, "Could not configure Core", str(error))
+ return
+ self.settings.setValue("core_host", host)
+ self.settings.setValue("core_port", port)
+ self._core_busy = True
+ self.session_status.setText("Starting Core…")
+ self._update_session_controls()
+ self.core_adapter.run_async(self.core_adapter.start, self._on_core_started)
+
+ def _on_core_started(self, _result: Any, error: Exception | None) -> None:
+ self._core_busy = False
+ if error is not None:
+ self._core_started = False
+ self.session_status.setText("Core failed to start")
+ self.statusBar().showMessage(f"Core start failed: {error}")
+ QMessageBox.warning(self, "Could not start Core", str(error))
+ else:
+ self._core_started = True
+ self.statusBar().showMessage("Core session started", 4000)
+ self._refresh_session()
+ self._update_session_controls()
+ if self._close_after_stop:
+ self._stop_core()
+
+ def _stop_core(self) -> None:
+ if self.core_adapter is None or self._core_busy or self._session_panel_busy():
+ return
+ try:
+ recording = self.core_adapter.snapshot()["recording"]
+ except Exception as error:
+ if QMessageBox.question(
+ self, "Recording state unavailable",
+ f"Could not confirm recording state: {error}\nEnd the session anyway? Any unsaved data will be lost.",
+ QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
+ QMessageBox.StandardButton.No,
+ ) != QMessageBox.StandardButton.Yes:
+ self._close_after_stop = False
+ return
+ recording = False
+ if recording and QMessageBox.question(
+ self, "Stop active recording?",
+ "A recording is active. Stop recording and end the session? Save the recording before ending the session if you need to keep it.",
+ QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
+ QMessageBox.StandardButton.No,
+ ) != QMessageBox.StandardButton.Yes:
+ self._close_after_stop = False
+ return
+ self._workspace_generation += 1
+ self._core_busy = True
+ self._refresh_playback_cursors()
+ self.session_status.setText("Stopping Core…")
+ self._update_session_controls()
+ self.core_adapter.run_async(self.core_adapter.stop, self._on_core_stopped)
+
+ def _on_core_stopped(self, stopped: Any, error: Exception | None) -> None:
+ self._core_busy = False
+ self._core_started = False
+ self._session_ended = True
+ self.core_adapter = None
+ if error is not None:
+ message = f"Core shutdown failed: {error}"
+ elif not stopped:
+ message = "Core did not stop completely. Restart the application before using it again."
+ else:
+ message = "Core session stopped. Restart the application to start another session."
+ self.session_status.setText(message)
+ self.statusBar().showMessage(message)
+ self._refresh_session()
+ self._update_session_controls()
+ if error is not None or not stopped:
+ QMessageBox.warning(self, "Core shutdown incomplete", message)
+ if self._close_after_stop:
+ self._close_after_stop = False
+ self.close()
+
+ def _change_mode(self, index: int) -> None:
+ if self.core_adapter is None or self._core_busy or self._session_panel_busy() or index < 0:
+ return
+ mode = self.mode_selector.itemData(index)
+ self._workspace_generation += 1
+ self._core_busy = True
+ self._refresh_playback_cursors()
+ self._update_session_controls()
+ adapter = self.core_adapter
+ adapter.run_async(lambda: adapter.set_mode(mode), self._on_mode_changed)
+
+ def _on_mode_changed(self, _result: Any, error: Exception | None) -> None:
+ self._core_busy = False
+ if error is not None:
+ self.statusBar().showMessage(f"Mode change failed: {error}")
+ QMessageBox.warning(self, "Could not change mode", str(error))
+ self._refresh_session()
+ self._update_session_controls()
+ if self._close_after_stop:
+ self._stop_core()
+
+ def _refresh_session(self) -> None:
+ session_panel = self.panel_widgets.get("dashboard")
+ data_panel = self.panel_widgets.get("data")
+ derive_panel = self.panel_widgets.get("derived")
+ for panel in (session_panel, data_panel, derive_panel):
+ if isinstance(panel, (SessionPanel, DataPanel, DerivePanel)):
+ panel.set_adapter(self.core_adapter)
+ if self.core_adapter is None:
+ self._session_mode = None
+ for panel in (session_panel, data_panel, derive_panel):
+ if isinstance(panel, (SessionPanel, DataPanel, DerivePanel)):
+ panel.refresh(None)
+ if "dashboard" in self.docks:
+ connector_list = self.docks["dashboard"].findChild(QListWidget)
+ if connector_list is not None:
+ connector_list.clear()
+ connector_list.addItem("Start a Core session to view connectors")
+ if "signals" in self.panel_widgets:
+ self.panel_widgets["signals"].refresh(None)
+ self._clear_visualizations()
+ return
+ if self._core_busy:
+ return
+ try:
+ snapshot = self.core_adapter.snapshot()
+ except Exception as error:
+ self._session_mode = None
+ self._clear_visualizations()
+ for panel in (session_panel, data_panel, derive_panel):
+ if isinstance(panel, (SessionPanel, DataPanel, DerivePanel)):
+ panel.refresh(None)
+ self._core_started = False
+ self._update_session_controls()
+ self.session_status.setText("Core status unavailable")
+ self.statusBar().showMessage(f"Could not read Core status: {error}")
+ return
+ unit_workers = {unit["worker"]["name"] for unit in snapshot.get("derive_units", [])}
+ fatal_workers = [worker for worker in snapshot.get("workers", [])
+ if worker["state"] == "failed" and worker["name"] not in unit_workers]
+ self._core_started = snapshot["state"] == "started" and not fatal_workers
+ snapshot["session_usable"] = self._core_started
+ if snapshot["state"] in {"stopped", "stop_incomplete"}:
+ self._session_ended = True
+
+ for panel in (session_panel, data_panel, derive_panel):
+ if isinstance(panel, (SessionPanel, DataPanel, DerivePanel)):
+ panel.refresh(snapshot)
+ self._update_session_controls()
+ if self._close_after_stop and not self._session_panel_busy():
+ self._stop_core()
+ return
+ if not self._core_started:
+ self._clear_visualizations()
+ details = "; ".join(f"{worker['name']}: {worker.get('error') or 'failed'}" for worker in fatal_workers)
+ message = ("Session worker failed" if fatal_workers else f"Core {snapshot['state']}") + ". Restart the application."
+ self.session_status.setText(message)
+ self.statusBar().showMessage(message + (f" {details}" if details else ""))
+ return
+ self._recording = snapshot["recording"]
+ self._update_session_controls()
+ mode = snapshot["mode"]
+ self._session_mode = mode
+ mode_index = self.mode_selector.findData(mode)
+ if mode_index >= 0 and mode_index != self.mode_selector.currentIndex():
+ self.mode_selector.blockSignals(True)
+ self.mode_selector.setCurrentIndex(mode_index)
+ self.mode_selector.blockSignals(False)
+ connectors = snapshot["connectors"]
+ signals = snapshot["signals"]
+ self._available_signal_ids = {signal["id"] for signal in signals}
+ self.session_status.setText(
+ f"Core {snapshot['state']} · "
+ f"{'recording' if snapshot['recording'] else 'idle'}"
+ )
+ self.statusBar().showMessage(
+ f"Core {snapshot['state']} · {mode} · {len(connectors)} connectors · {len(signals)} signals"
+ )
+ if "dashboard" in self.docks:
+ connector_list = self.docks["dashboard"].findChild(QListWidget)
+ if connector_list is not None:
+ connector_list.clear()
+ if connectors:
+ for connector in connectors:
+ connector_list.addItem(
+ f"{connector['name']} — {connector['state']} — "
+ f"{connector['signal_count']} signals"
+ )
+ else:
+ connector_list.addItem("No connectors connected")
+ if "signals" in self.panel_widgets:
+ self.panel_widgets["signals"].refresh(signals)
+ self._refresh_visualizations(signals, mode)
+
+ def _refresh_visualizations(self, signals: list[dict[str, Any]], mode: str) -> None:
+ for panel_id, widget in list(self.panel_widgets.items()):
+ if isinstance(widget, (NumericPanel, GaugePanel, InstrumentGroupPanel)):
+ widget.refresh(signals)
+ self.grid.fit_panel(self.docks[panel_id])
+
+ available_ids = {signal["id"] for signal in signals}
+ visible_graphs: dict[str, list[str]] = {}
+ union_ids: list[str] = []
+ for panel_id, widget in list(self.panel_widgets.items()):
+ if not isinstance(widget, GraphPanel):
+ continue
+ widget.set_signals(signals)
+ selected = [signal_id for signal_id in widget.signal_ids if signal_id in available_ids]
+ dock = self.docks.get(panel_id)
+ if not selected:
+ widget.update_plot({"time_range": None, "data": {}})
+ elif dock is not None and dock.isVisible():
+ visible_graphs[panel_id] = selected
+ for signal_id in selected:
+ if signal_id not in union_ids:
+ union_ids.append(signal_id)
+
+ self._update_panel_titles(signals)
+ if not union_ids or self._plot_busy or self.core_adapter is None:
+ return
+ self._plot_busy = True
+ self._plot_request_ids = {panel_id: list(ids) for panel_id, ids in visible_graphs.items()}
+ self._plot_request_mode = mode
+ self._plot_request_generation = self._workspace_generation
+ self._update_session_controls()
+ adapter = self.core_adapter
+ adapter.run_async(
+ lambda: adapter.plot_snapshot(union_ids),
+ self._on_graph_updated,
+ )
+
+ def _clear_visualizations(self) -> None:
+ self._available_signal_ids.clear()
+ browser = self.panel_widgets.get("signals")
+ if browser is not None:
+ browser.refresh(None)
+ for widget in list(self.panel_widgets.values()):
+ if isinstance(widget, (NumericPanel, GaugePanel, InstrumentGroupPanel)):
+ widget.refresh([])
+ elif isinstance(widget, GraphPanel):
+ widget.set_signals([])
+ widget.update_plot({"time_range": None, "data": {}})
+ self._update_panel_titles([])
+ self._refresh_playback_cursors()
+
+ def _refresh_playback_cursors(self) -> None:
+ graphs = [widget for widget in self.panel_widgets.values() if isinstance(widget, GraphPanel)]
+ if not graphs:
+ return
+ position = None
+ if self.core_adapter is not None and self._core_started and not self._core_busy and self._session_mode == "playback":
+ try:
+ state = self.core_adapter.playback_status()
+ if state["time_range"] is not None:
+ position = int(state["position"])
+ except Exception:
+ # A failed status read must not leave a stale playhead visible.
+ position = None
+ for graph in graphs:
+ graph.set_playback_position(position)
+
+ def _update_panel_titles(self, signals: list[dict[str, Any]]) -> None:
+ self._signal_names.update({signal["id"]: signal["name"] for signal in signals})
+ for panel_id, widget in self.panel_widgets.items():
+ if isinstance(widget, (NumericPanel, GaugePanel)):
+ selected = [widget.choice.signal_id] if widget.choice.signal_id else []
+ elif isinstance(widget, GraphPanel):
+ selected = widget.signal_ids
+ elif isinstance(widget, InstrumentGroupPanel):
+ selected = [item["signal_id"] for item in widget.config()["items"] if item["signal_id"]]
+ else:
+ continue
+ title = " · ".join(dict.fromkeys(self._signal_names.get(signal_id, "Unavailable signal") for signal_id in selected))
+ self.docks[panel_id].set_display_title(title or "Unassigned")
+
+ def _on_graph_updated(self, snapshot: Any, error: Exception | None) -> None:
+ self._plot_busy = False
+ current_request = (
+ self.core_adapter is not None
+ and self._workspace_generation == self._plot_request_generation
+ and self._session_mode == self._plot_request_mode
+ )
+ if error is not None and current_request:
+ self.statusBar().showMessage(f"Could not update graph: {error}")
+ elif (
+ snapshot is not None
+ and self.core_adapter is not None
+ and self._session_mode == self._plot_request_mode
+ and self._workspace_generation == self._plot_request_generation
+ ):
+ for panel_id, requested_ids in self._plot_request_ids.items():
+ graph = self.panel_widgets.get(panel_id)
+ dock = self.docks.get(panel_id)
+ if not isinstance(graph, GraphPanel) or dock is None or not dock.isVisible():
+ continue
+ current = [signal_id for signal_id in graph.signal_ids if signal_id in self._available_signal_ids]
+ if current == requested_ids:
+ graph.update_plot(
+ {
+ **snapshot,
+ "data": {signal_id: snapshot.get("data", {}).get(signal_id, []) for signal_id in current},
+ }
+ )
+ self._plot_request_ids = {}
+ self._plot_request_mode = None
+ self._update_session_controls()
+ if self._close_after_stop:
+ self._stop_core()
+
+ def closeEvent(self, event: Any) -> None:
+ if self.core_adapter is None:
+ for dock in self.docks.values():
+ if dock.isFloating():
+ dock.hide()
+ event.accept()
+ return
+ event.ignore()
+ self._close_after_stop = True
+ if not self._core_busy:
+ self._stop_core()
+
+ def _show_start_screen(self) -> None:
+ self.save_action.setEnabled(False)
+ self.save_as_action.setEnabled(False)
+ self.add_panel_menu.setEnabled(False)
+ page = QWidget()
+ outer = QVBoxLayout(page)
+ outer.setContentsMargins(60, 40, 60, 40)
+ outer.addStretch(1)
+ card = QWidget()
+ card.setMaximumWidth(760)
+ layout = QVBoxLayout(card)
+ layout.setSpacing(18)
+ eyebrow = QLabel("DYNALAB / SIGNAL WORKSPACE")
+ eyebrow.setObjectName("eyebrow")
+ layout.addWidget(eyebrow)
+ title = QLabel("Your signals. In focus.")
+ title.setObjectName("heroTitle")
+ layout.addWidget(title)
+ subtitle = QLabel("Acquire, explore and replay your measurements in one workspace.")
+ subtitle.setObjectName("muted")
+ subtitle.setWordWrap(True)
+ layout.addWidget(subtitle)
+ actions = QHBoxLayout()
+ new_button = QPushButton("Create workspace")
+ new_button.setObjectName("primary")
+ new_button.clicked.connect(self._new_workspace)
+ actions.addWidget(new_button)
+ open_button = QPushButton("Open workspace…")
+ open_button.clicked.connect(self._open_dialog)
+ actions.addWidget(open_button)
+ actions.addStretch()
+ layout.addLayout(actions)
+ layout.addSpacing(16)
+ layout.addWidget(QLabel("Recent workspaces"))
+ recent = QListWidget()
+ recent.setMinimumHeight(150)
+ paths = self._recent_workspaces()
+ for path in paths:
+ recent.addItem(path)
+ if not paths:
+ recent.addItem("No recent workspaces. Create one to get started.")
+ recent.item(0).setFlags(Qt.ItemFlag.NoItemFlags)
+ recent.itemDoubleClicked.connect(lambda item: self._open_path(item.text()))
+ layout.addWidget(recent)
+ outer.addWidget(card, alignment=Qt.AlignmentFlag.AlignHCenter)
+ outer.addStretch(2)
+ self.setCentralWidget(page)
+
+ def _recent_workspaces(self) -> list[str]:
+ paths = self.settings.value("recent_workspaces", [], type=list)
+ existing = [str(Path(path)) for path in paths if Path(path).is_file()]
+ if existing != paths:
+ self.settings.setValue("recent_workspaces", existing)
+ return existing
+
+ def _remember_workspace(self, path: Path) -> None:
+ recent = [str(path), *[p for p in self._recent_workspaces() if p != str(path)]]
+ self.settings.setValue("recent_workspaces", recent[:10])
+
+ def _new_workspace(self) -> None:
+ self._apply_workspace(new_workspace(), None)
+
+ def _open_dialog(self) -> None:
+ path, _ = QFileDialog.getOpenFileName(
+ self, "Open workspace", "", "DynaLab workspace (*.json);;All files (*)"
+ )
+ if path:
+ self._open_path(path)
+
+ def _open_path(self, path: str) -> None:
+ previous, previous_path = self.workspace, self.workspace_path
+ if previous is not None:
+ previous["grid_layout"] = self.grid.save_layout()
+ previous["geometry"] = self.saveGeometry().toBase64().data().decode("ascii")
+ for panel in previous["panels"]:
+ panel["visible"] = self.docks[panel["id"]].user_visible
+ try:
+ data = load_workspace(path)
+ self._apply_workspace(data, Path(path))
+ except (WorkspaceError, OSError, ValueError) as error:
+ if self.workspace is not previous:
+ if previous is None:
+ self._remove_docks()
+ self.workspace = self.workspace_path = None
+ self._show_start_screen()
+ else:
+ self._apply_workspace(previous, previous_path)
+ QMessageBox.warning(self, "Could not open workspace", str(error))
+
+ def _save_workspace(self) -> None:
+ if self.workspace_path is None:
+ self._save_as()
+ return
+ self._save_to(self.workspace_path)
+
+ def _save_as(self) -> None:
+ path, _ = QFileDialog.getSaveFileName(
+ self, "Save workspace", "workspace.json", "DynaLab workspace (*.json)"
+ )
+ if path:
+ target = Path(path)
+ if target.suffix.lower() != ".json":
+ target = target.with_suffix(".json")
+ self._save_to(target)
+
+ def _save_to(self, path: Path) -> None:
+ if self.workspace is None:
+ return
+ self.workspace["grid_layout"] = self.grid.save_layout()
+ self.workspace["geometry"] = self.saveGeometry().toBase64().data().decode("ascii")
+ for panel in self.workspace["panels"]:
+ panel["visible"] = self.docks[panel["id"]].user_visible
+ try:
+ save_workspace(path, self.workspace)
+ except (OSError, WorkspaceError) as error:
+ QMessageBox.warning(self, "Could not save workspace", str(error))
+ return
+ self.workspace_path = path
+ self._remember_workspace(path)
+ self._update_title()
+ self.statusBar().showMessage(f"Saved {path}", 4000)
+
+ def _apply_workspace(self, data: dict[str, Any], path: Path | None) -> None:
+ validate_workspace(data)
+ panels = data["panels"]
+ self._workspace_generation += 1
+ self._signal_names.clear()
+ self._remove_docks()
+ self.workspace = data
+ self.workspace_path = path
+ self.grid = GridWorkspace(self)
+ self.setCentralWidget(self.grid)
+ geometry = data.get("geometry")
+ if geometry:
+ if not self.restoreGeometry(QByteArray.fromBase64(geometry.encode("ascii"))):
+ raise WorkspaceError("Workspace window geometry could not be restored.")
+ self.save_action.setEnabled(True)
+ self.save_as_action.setEnabled(True)
+ self.add_panel_menu.setEnabled(True)
+ self.view_menu.clear()
+ for panel in panels:
+ self._create_panel_dock(panel)
+ self._refresh_view_menu()
+
+ if data.get("grid_layout") is not None:
+ self.grid.restore_layout(data["grid_layout"], self.docks)
+ for panel in panels:
+ if not panel.get("visible", True):
+ self.grid.set_panel_visible(self.docks[panel["id"]], False)
+ self._update_title()
+ if path is not None:
+ self._remember_workspace(path)
+ self._refresh_session()
+
+ def _create_panel_dock(self, panel: dict[str, Any]) -> PanelDockWidget:
+ panel_id = panel["id"]
+ widget = self._panel_content(panel)
+ if isinstance(widget, DataPanel):
+ widget.set_update_interval(round(1000 / self.ui_update_hz))
+ widget.setObjectName("panelBody")
+ widget.setAttribute(Qt.WidgetAttribute.WA_StyledBackground, True)
+ dock = PanelDockWidget(
+ panel_id,
+ panel["title"],
+ self,
+ True,
+ )
+ dock.setObjectName(panel_id)
+ dock.removeRequested.connect(
+ lambda current_id=panel_id, current_dock=dock: self.remove_panel(current_id)
+ if self.docks.get(current_id) is current_dock
+ else None
+ )
+ dock.setWidget(widget)
+ self.docks[panel_id] = dock
+ self.panel_widgets[panel_id] = widget
+ if isinstance(widget, (SessionPanel, DataPanel, DerivePanel)):
+ widget.busyChanged.connect(lambda _busy: self._update_session_controls())
+ if isinstance(widget, (NumericPanel, GaugePanel, GraphPanel, InstrumentGroupPanel)):
+ widget.removeRequested.connect(
+ lambda current_id=panel_id, current_widget=widget: self.remove_panel(current_id)
+ if self.panel_widgets.get(current_id) is current_widget
+ else None
+ )
+ if hasattr(widget, "configChanged"):
+ widget.configChanged.connect(
+ lambda config, current_id=panel_id, current_widget=widget: self._panel_config_changed(current_id, config)
+ if self.panel_widgets.get(current_id) is current_widget
+ else None
+ )
+ self.grid.add_panel(dock, panel["type"])
+ return dock
+
+ def _refresh_view_menu(self) -> None:
+ self.view_menu.clear()
+ if self.workspace is None:
+ return
+ for panel in self.workspace["panels"]:
+ dock = self.docks.get(panel["id"])
+ if dock is not None:
+ self.view_menu.addAction(dock.toggleViewAction())
+
+ def add_panel(self, panel_type: str) -> str:
+ if self.workspace is None:
+ raise RuntimeError("Create or open a workspace before adding a panel.")
+ if panel_type not in PANEL_TITLES:
+ raise ValueError(f"Unsupported panel type: {panel_type!r}")
+ panel_id = SYSTEM_IDS.get(panel_type, f"{panel_type}-{uuid4()}")
+ if panel_id in self.docks:
+ self.grid.set_panel_visible(self.docks[panel_id], True)
+ self.docks[panel_id].raise_()
+ return panel_id
+ existing_titles = {panel["title"] for panel in self.workspace["panels"]}
+ number = 1
+ while f"{PANEL_TITLES[panel_type]} {number}" in existing_titles:
+ number += 1
+ if panel_type == "graph":
+ config = {"signal_ids": [], "follow_latest": True, "zero_baseline": False, "right_zero_baseline": False}
+ elif panel_type.endswith("_group"):
+ config = {"kind": panel_type.removesuffix("_group"), "items": []}
+ elif panel_type in VISUALIZATION_TYPES:
+ config = {"signal_id": None, "decimals": 2}
+ else:
+ config = {}
+ panel = {
+ "id": panel_id,
+ "title": PANEL_TITLES[panel_type] if panel_type in SYSTEM_IDS else f"{PANEL_TITLES[panel_type]} {number}",
+ "type": panel_type,
+ "config": config,
+ "visible": True,
+ }
+ self.workspace["panels"].append(panel)
+ dock = self._create_panel_dock(panel)
+ self._refresh_view_menu()
+ self._refresh_session()
+ self.grid.ensureWidgetVisible(dock, 16, 16)
+ return panel_id
+
+ def remove_panel(self, panel_id: str) -> bool:
+ if self.workspace is None:
+ return False
+ panel = next((item for item in self.workspace["panels"] if item["id"] == panel_id), None)
+ if panel is None:
+ return False
+ widget = self.panel_widgets.get(panel_id)
+ if isinstance(widget, (SessionPanel, DataPanel, DerivePanel)) and widget.busy:
+ self.statusBar().showMessage("Wait for the panel's current operation before removing it.", 5000)
+ return False
+ self.workspace["panels"].remove(panel)
+ widget = self.panel_widgets.pop(panel_id, None)
+ dock = self.docks.pop(panel_id, None)
+ if dock is not None:
+ self.grid.remove_panel(dock)
+ dock.hide()
+ if widget is not None:
+ widget.setParent(None)
+ widget.deleteLater()
+ dock.setParent(None)
+ dock.deleteLater()
+ self._refresh_view_menu()
+ return True
+
+ @staticmethod
+ def _panel_content(panel: dict[str, Any]) -> QWidget:
+ if panel["type"] == "dashboard":
+ return SessionPanel()
+ if panel["type"] == "data":
+ return DataPanel(panel.get("config", {}))
+ if panel["type"] == "derive_manager":
+ return DerivePanel(panel.get("config", {}))
+ if panel["type"] == "signal_browser":
+ return SignalBrowser()
+ if panel["type"] in {"numeric_group", "gauge_group"}:
+ return InstrumentGroupPanel({"kind": panel["type"].removesuffix("_group"), **panel.get("config", {})})
+ if panel["type"] == "numeric":
+ return NumericPanel(panel.get("config", {}))
+ if panel["type"] == "gauge":
+ return GaugePanel(panel.get("config", {}))
+ if panel["type"] == "graph":
+ return GraphPanel(panel.get("config", {}))
+ return QLabel(f"{panel['title']} panel\nNot configured yet")
+
+ def _panel_config_changed(self, panel_id: str, config: dict[str, Any]) -> None:
+ if self.workspace is not None:
+ panel = next((p for p in self.workspace["panels"] if p["id"] == panel_id), None)
+ if panel is not None:
+ panel["config"] = config
+ self._update_panel_titles([])
+
+ def _remove_docks(self) -> None:
+ self.view_menu.clear()
+ self.add_panel_menu.setEnabled(False)
+ for dock in self.docks.values():
+ self.grid.remove_panel(dock)
+ dock.hide()
+ dock.setParent(None)
+ dock.deleteLater()
+ self.docks = {}
+ self.panel_widgets = {}
+
+ def _update_title(self) -> None:
+ name = self.workspace_path.name if self.workspace_path else "Untitled workspace"
+ self.setWindowTitle(f"DynaLab — {name}")
+
+
+def main() -> None:
+ app = QApplication([])
+ apply_theme(app)
+ window = MainWindow()
+ window.show()
+ app.exec()
diff --git a/src/dynalab_py/core_adapter.py b/src/dynalab_py/core_adapter.py
new file mode 100644
index 0000000..934f112
--- /dev/null
+++ b/src/dynalab_py/core_adapter.py
@@ -0,0 +1,285 @@
+from collections.abc import Callable
+from math import ceil
+from pathlib import Path
+from typing import Any
+from uuid import UUID
+
+from dynalab_core import Core, CoreConfig
+from dynalab_core.errors import CoreError
+from PySide6.QtCore import QObject, QRunnable, QThreadPool, Signal
+
+
+class _CallSignals(QObject):
+ finished = Signal(object, object)
+
+
+class _Call(QRunnable):
+ def __init__(self, method: Callable[[], Any]) -> None:
+ super().__init__()
+ self.method = method
+ self.signals = _CallSignals()
+
+ def run(self) -> None:
+ try:
+ self.signals.finished.emit(self.method(), None)
+ except Exception as error:
+ self.signals.finished.emit(None, error)
+
+
+class CoreAdapter:
+ """Own the Core instance and keep its potentially blocking calls off Qt."""
+
+ MAX_DISPLAY_POINTS = 1500
+
+ def __init__(self, host: str | None = None, port: int | None = None) -> None:
+ config: dict[str, Any] = {"playback_mode": "read"}
+ if host is not None:
+ config["host"] = host
+ if port is not None:
+ config["port"] = port
+ self._core = Core(CoreConfig(**config))
+ self._playback_mode = config["playback_mode"]
+ self._pool = QThreadPool.globalInstance()
+ self._calls: set[_Call] = set()
+
+ @staticmethod
+ def default_endpoint() -> tuple[str, int]:
+ config = CoreConfig()
+ return config.host, config.port
+
+ def start(self) -> None:
+ self._core.start()
+
+ def stop(self) -> bool:
+ recording_error: Exception | None = None
+ try:
+ if self._core.get_state() == "started" and self._core.get_recording_state():
+ self._core.stop_recording()
+ except Exception as error:
+ recording_error = error
+ try:
+ stopped = self._core.stop()
+ except Exception as error:
+ if recording_error is not None:
+ raise RuntimeError(
+ f"Could not finalize the active recording ({recording_error}); "
+ f"Core shutdown also failed ({error})"
+ ) from error
+ raise
+ if recording_error is not None:
+ raise RuntimeError(f"Could not finalize the active recording: {recording_error}") from recording_error
+ return stopped
+
+ def set_mode(self, mode: str) -> None:
+ self._core.set_mode(mode)
+
+ def start_recording(self) -> None:
+ self._core.start_recording()
+
+ def stop_recording(self) -> None:
+ self._core.stop_recording()
+
+ def write_archive(self, path: str | Path, source: str = "recording") -> UUID:
+ """Start writing a Core-owned archive and return its operation ID."""
+ target = Path(path)
+ operation_id = self._core.write(
+ target.parent,
+ target.name,
+ source=source,
+ background=True,
+ )
+ if operation_id is None:
+ raise RuntimeError("Core did not return an archive operation ID")
+ return operation_id
+
+ def operation_status(self, operation_id: str | UUID) -> dict[str, Any] | None:
+ status = self._core.get_operation(UUID(str(operation_id)))
+ if status is None:
+ return None
+ return {
+ "id": str(status.id),
+ "name": status.name,
+ "state": status.state,
+ "completed": status.completed,
+ "total": status.total,
+ "progress": status.progress,
+ "message": status.message,
+ "error_code": status.error_code,
+ "error_message": status.error_message,
+ }
+
+ def playback_mode(self) -> str:
+ return self._playback_mode
+
+ def load_processing(self, path: str | Path | None = None) -> UUID:
+ input_path = Path(path) if path is not None else None
+ operation_id = self._core.load_processing_buffer(input_path, background=True)
+ if operation_id is None:
+ raise RuntimeError("Core did not return a processing load operation ID")
+ return operation_id
+
+ def reprocess(self) -> UUID:
+ operation_id = self._core.reprocess(background=True)
+ if operation_id is None:
+ raise RuntimeError("Core did not return a reprocess operation ID")
+ return operation_id
+
+ def load_playback(self, path: str | Path | None = None) -> UUID:
+ input_path = Path(path) if path is not None else None
+ operation_id = self._core.load_playback_engine(input_path, background=True)
+ if operation_id is None:
+ raise RuntimeError("Core did not return a playback load operation ID")
+ return operation_id
+
+ def play(self) -> None:
+ self._core.play()
+
+ def pause(self) -> None:
+ self._core.pause()
+
+ def seek(self, timestamp_ns: int) -> None:
+ self._core.seek(timestamp_ns)
+
+ def playback_status(self) -> dict[str, Any]:
+ return {
+ "position": self._core.get_playback_position(),
+ "playing": self._core.get_playback_state(),
+ "time_range": self._safe_time_range("playback"),
+ }
+
+ def bind_unit(self, path: str | Path) -> str:
+ return str(self._core.bind_derive_unit(file=Path(path)))
+
+ def unbind_unit(self, unit_id: str | UUID) -> None:
+ self._core.unbind_derive_unit(UUID(str(unit_id)))
+
+ def reload_unit(self, unit_id: str | UUID, path: str | Path) -> dict[str, Any]:
+ """Replace a bound unit, reporting when replacement fails after unbind."""
+ self._core.unbind_derive_unit(UUID(str(unit_id)))
+ try:
+ replacement_id = self._core.bind_derive_unit(file=Path(path))
+ except Exception as error:
+ return {
+ "state": "unbound",
+ "unit_id": None,
+ "error_code": getattr(error, "code", None),
+ "error_message": str(error),
+ }
+ return {"state": "bound", "unit_id": str(replacement_id)}
+
+ def _safe_time_range(self, source: str) -> tuple[int, int] | None:
+ try:
+ return self._core.get_time_range(source=source)
+ except CoreError as error:
+ if error.code == "no_recording":
+ return None
+ raise
+
+ def snapshot(self) -> dict[str, Any]:
+ connectors = self._core.get_connectors()
+ signals = self._core.get_all_signal_descriptors()
+ values = self._core.get_all_live_values()
+ recording = self._core.get_recording_state()
+ time_ranges = {
+ source: self._safe_time_range(source)
+ for source in ("recording", "processing", "playback")
+ }
+ return {
+ "state": self._core.get_state(),
+ "mode": self._core.get_mode(),
+ "recording": recording,
+ "recording_available": not recording and time_ranges["recording"] is not None,
+ "processing_available": time_ranges["processing"] is not None,
+ "playback_available": time_ranges["playback"] is not None,
+ "time_ranges": time_ranges,
+ "playback_position": self._core.get_playback_position(),
+ "playback_playing": self._core.get_playback_state(),
+ "connectors": [
+ {"name": item.name, "state": item.state, "signal_count": len(item.signal_ids)}
+ for item in connectors
+ ],
+ "signals": [
+ {
+ "id": str(item.id),
+ "name": item.name,
+ "type": item.type,
+ "unit": item.unit,
+ "min_value": item.min_value,
+ "max_value": item.max_value,
+ "timeout_ms": item.timeout_ms,
+ "origin": item.origin,
+ "value": values.get(item.id),
+ }
+ for item in signals
+ ],
+ "derive_units": [
+ {
+ "id": str(item.id),
+ "input_signal_ids": [str(signal_id) for signal_id in item.input_signal_ids],
+ "output_signal_id": str(item.output_signal_id),
+ "worker": {
+ "name": item.worker.name,
+ "state": item.worker.state,
+ "error": item.worker.error,
+ "queue_size": item.worker.queue_size,
+ "queue_capacity": item.worker.queue_capacity,
+ "dropped_samples": item.worker.dropped_samples,
+ },
+ }
+ for item in self._core.get_derive_units()
+ ],
+ "workers": [
+ {
+ "name": item.name,
+ "state": item.state,
+ "error": item.error,
+ "queue_size": item.queue_size,
+ "queue_capacity": item.queue_capacity,
+ "dropped_samples": item.dropped_samples,
+ }
+ for item in self._core.get_worker_statuses()
+ ],
+ }
+
+ def plot_snapshot(self, signal_ids: list[str]) -> dict[str, Any]:
+ """Read the full active timeline, bounding only display point counts."""
+ try:
+ time_range = self._core.get_time_range()
+ except CoreError as error:
+ if error.code == "no_recording":
+ return {"time_range": None, "data": {}}
+ raise
+ if not time_range or not signal_ids:
+ return {"time_range": time_range, "data": {}}
+ start_ns, end_ns = time_range
+ ids = [UUID(signal_id) for signal_id in signal_ids]
+ data = self._core.get_data_range(ids, start_ns, end_ns)
+ display_data = {}
+ for signal_id, samples in data.items():
+ if len(samples) > self.MAX_DISPLAY_POINTS:
+ stride = ceil((len(samples) - 1) / (self.MAX_DISPLAY_POINTS - 1))
+ reduced = samples[::stride]
+ if reduced[-1] != samples[-1]:
+ reduced.append(samples[-1])
+ samples = reduced
+ display_data[str(signal_id)] = samples
+ return {
+ "time_range": time_range,
+ "data": display_data,
+ }
+
+ def run_async(
+ self, method: Callable[[], Any], finished: Callable[[Any, Exception | None], None]
+ ) -> None:
+ call = _Call(method)
+ self._calls.add(call)
+
+ def deliver(result: Any, error: Exception | None) -> None:
+ try:
+ finished(result, error)
+ finally:
+ call.signals.finished.disconnect(deliver)
+ self._calls.discard(call)
+
+ call.signals.finished.connect(deliver)
+ self._pool.start(call)
diff --git a/src/dynalab_py/data_panel.py b/src/dynalab_py/data_panel.py
new file mode 100644
index 0000000..ae43e7e
--- /dev/null
+++ b/src/dynalab_py/data_panel.py
@@ -0,0 +1,460 @@
+from pathlib import Path
+from typing import Any
+from uuid import UUID
+
+from PySide6.QtCore import Qt, QTimer, Signal
+from PySide6.QtWidgets import (
+ QFileDialog,
+ QLabel,
+ QProgressBar,
+ QPushButton,
+ QSlider,
+ QVBoxLayout,
+ QWidget,
+)
+
+
+class DataPanel(QWidget):
+ """Explicit Offline buffer loading, processing, and saving controls."""
+
+ busyChanged = Signal(bool)
+ configChanged = Signal(dict)
+
+ def __init__(self, config: dict[str, Any] | None = None, parent: QWidget | None = None) -> None:
+ super().__init__(parent)
+ self._adapter: Any | None = None
+ self._config: dict[str, Any] = {}
+ self._session_usable = False
+ self._core_state = "stopped"
+ self._mode = ""
+ self._recording = False
+ self._recording_available = False
+ self._processing_available = False
+ self._playback_available = False
+ self._playback_playing = False
+ self._playback_position = 0
+ self._playback_range: tuple[int, int] | None = None
+ self._duration_ns = 0
+ self._busy = False
+ self._operation_id: str | None = None
+ self._operation_kind = ""
+ self.set_config(config or {})
+
+ self.status = QLabel("Start Core and select Offline mode to manage data")
+ self.status.setObjectName("muted")
+ self.status.setWordWrap(True)
+ self.operation = QLabel("")
+ self.operation.setObjectName("muted")
+ self.operation.setWordWrap(True)
+ self.load_archive_button = QPushButton("Load archive…")
+ self.load_recording_button = QPushButton("Load recording")
+ self.reprocess_button = QPushButton("Reprocess")
+ self.save_button = QPushButton("Save processed…")
+ self.load_processed_button = QPushButton("Load processed result")
+ self.play_button = QPushButton("Play")
+ self.pause_button = QPushButton("Pause")
+ self.position_label = QLabel("Elapsed 0.00 / 0.00 s")
+ self.position_label.setObjectName("muted")
+ self.position_label.setWordWrap(True)
+ self.seek_slider = QSlider(Qt.Orientation.Horizontal)
+ self.seek_slider.setRange(0, 1000)
+ self.seek_slider.setToolTip("Seek by elapsed time from the archive's first sample")
+ self.load_archive_button.clicked.connect(self._choose_archive)
+ self.load_recording_button.clicked.connect(self._load_recording)
+ self.reprocess_button.clicked.connect(self._reprocess)
+ self.save_button.clicked.connect(self._choose_save_path)
+ self.load_processed_button.clicked.connect(self._load_processed_result)
+ self.play_button.clicked.connect(self._play)
+ self.pause_button.clicked.connect(self._pause)
+ self.seek_slider.sliderReleased.connect(self._seek_playback)
+ self.seek_slider.actionTriggered.connect(self._slider_action_triggered)
+ self.progress = QProgressBar()
+ self.progress.setRange(0, 100)
+ self.progress.setValue(0)
+ self.progress.hide()
+ layout = QVBoxLayout(self)
+ layout.addWidget(self.status)
+ layout.addWidget(self.load_archive_button)
+ layout.addWidget(self.load_recording_button)
+ layout.addWidget(self.reprocess_button)
+ layout.addWidget(self.save_button)
+ layout.addWidget(self.load_processed_button)
+ layout.addWidget(self.play_button)
+ layout.addWidget(self.pause_button)
+ layout.addWidget(self.position_label)
+ layout.addWidget(self.seek_slider)
+ layout.addWidget(self.operation)
+ layout.addWidget(self.progress)
+ layout.addStretch(1)
+ self._operation_timer = QTimer(self)
+ self._operation_timer.setInterval(350)
+ self._operation_timer.timeout.connect(self._poll_operation)
+ self._playback_timer = QTimer(self)
+ self._playback_timer.setTimerType(Qt.TimerType.PreciseTimer)
+ self._playback_timer.setInterval(200)
+ self._playback_timer.timeout.connect(self._poll_playback)
+ self._update_controls()
+
+ def set_update_interval(self, interval_ms: int) -> None:
+ self._playback_timer.setInterval(interval_ms)
+
+ def set_config(self, config: dict[str, Any]) -> None:
+ self._config = {
+ "last_archive_path": str(config.get("last_archive_path", "")),
+ "last_save_path": str(config.get("last_save_path", "")),
+ "last_playback_path": str(config.get("last_playback_path", "")),
+ }
+
+ def get_config(self) -> dict[str, Any]:
+ return dict(self._config)
+
+ def set_adapter(self, adapter: Any | None) -> None:
+ if adapter is self._adapter:
+ return
+ self._operation_timer.stop()
+ self._playback_timer.stop()
+ self._adapter = adapter
+ self._core_state = "stopped"
+ self._mode = ""
+ self._recording = False
+ self._recording_available = False
+ self._processing_available = False
+ self._playback_available = False
+ self._playback_playing = False
+ self._playback_position = 0
+ self._playback_range = None
+ self._duration_ns = 0
+ self._operation_id = None
+ self._operation_kind = ""
+ self.progress.hide()
+ self.operation.clear()
+ self._set_busy(False)
+ self._update_controls()
+
+ @property
+ def busy(self) -> bool:
+ return self._busy
+
+ def refresh(self, snapshot: dict[str, Any] | None) -> None:
+ self._session_usable = bool(snapshot and snapshot.get("session_usable", snapshot.get("state") == "started"))
+ if snapshot is None:
+ self._core_state = "stopped"
+ self._mode = ""
+ self._recording = False
+ self._recording_available = False
+ self._processing_available = False
+ self._playback_available = False
+ self._playback_playing = False
+ self._playback_position = 0
+ self._playback_range = None
+ self._duration_ns = 0
+ self._playback_timer.stop()
+ self.status.setText("Start Core and select Offline mode to manage data")
+ self._update_controls()
+ return
+ self._core_state = snapshot.get("state", "")
+ self._mode = snapshot.get("mode", "")
+ self._recording = bool(snapshot.get("recording", False))
+ self._recording_available = bool(snapshot.get("recording_available", False))
+ self._processing_available = bool(snapshot.get("processing_available", False))
+ self._playback_available = bool(snapshot.get("playback_available", False))
+ self._playback_playing = bool(snapshot.get("playback_playing", False))
+ self._playback_position = int(snapshot.get("playback_position", 0))
+ time_ranges = snapshot.get("time_ranges", {})
+ self._playback_range = time_ranges.get("playback")
+ if self._playback_range:
+ self._duration_ns = max(0, self._playback_range[1] - self._playback_range[0])
+ else:
+ self._duration_ns = 0
+ if self._core_state == "started" and self._mode == "playback":
+ if not self._playback_timer.isActive():
+ self._playback_timer.start()
+ else:
+ self._playback_timer.stop()
+ if self._core_state != "started":
+ message = f"Core session state: {self._core_state}"
+ elif self._mode == "playback":
+ self.status.setText(self._playback_help())
+ self._update_position()
+ self._update_controls()
+ return
+ elif self._mode != "offline":
+ message = f"Select Offline mode to manage data · current mode: {self._mode}"
+ elif self._recording:
+ message = "Stop recording before loading or processing data"
+ elif self._processing_available:
+ message = "Offline processing buffer is loaded"
+ elif self._recording_available:
+ message = "No processing buffer loaded · a completed recording is available"
+ else:
+ message = "No processing buffer loaded"
+ self.status.setText(message)
+ self._update_position()
+ self._update_controls()
+
+ def _set_busy(self, busy: bool) -> None:
+ if self._busy == busy:
+ return
+ self._busy = busy
+ self.busyChanged.emit(busy)
+ self._update_controls()
+
+ def _update_controls(self) -> None:
+ running = self._adapter is not None and self._session_usable and self._core_state == "started"
+ offline = self._mode == "offline"
+ playback = self._mode == "playback"
+ enabled = running and offline and not self._recording and not self._busy
+ self.load_archive_button.setVisible(offline or playback)
+ self.load_archive_button.setEnabled(
+ running and (offline or playback) and not self._recording and not self._busy
+ )
+ self.load_recording_button.setVisible(offline)
+ self.load_recording_button.setEnabled(enabled and self._recording_available)
+ self.reprocess_button.setVisible(offline)
+ self.reprocess_button.setEnabled(enabled and self._processing_available)
+ self.save_button.setVisible(offline)
+ self.save_button.setEnabled(enabled and self._processing_available)
+ self.load_processed_button.setVisible(playback)
+ self.load_processed_button.setEnabled(
+ running and playback and self._processing_available and not self._busy
+ )
+ self.play_button.setVisible(playback)
+ self.play_button.setEnabled(
+ running and playback and self._playback_available and not self._playback_playing and not self._busy
+ )
+ self.pause_button.setVisible(playback)
+ self.pause_button.setEnabled(
+ running and playback and self._playback_available and self._playback_playing and not self._busy
+ )
+ self.position_label.setVisible(playback)
+ self.seek_slider.setVisible(playback)
+ self.seek_slider.setEnabled(
+ running and playback and self._playback_available and self._duration_ns > 0 and not self._busy
+ )
+
+ def _playback_help(self) -> str:
+ if not self._playback_available:
+ return "No playback data loaded · load an archive or processed result"
+ playback_mode = self._adapter.playback_mode() if self._adapter is not None else "read"
+ if playback_mode == "read":
+ return (
+ "Playback reads recorded derived values from the loaded archive. To regenerate them, "
+ "switch to Offline, reprocess, then choose Load processed result."
+ )
+ return "Playback uses configured derived units for derived signal values."
+
+ def _update_position(self) -> None:
+ duration = self._duration_ns
+ position = min(duration, max(0, self._playback_position))
+ self.position_label.setText(
+ f"Elapsed {position / 1_000_000_000:.2f} / {duration / 1_000_000_000:.2f} s"
+ )
+ if not self.seek_slider.isSliderDown():
+ value = round(1000 * position / duration) if duration else 0
+ self.seek_slider.setValue(value)
+
+ def _poll_playback(self) -> None:
+ if self._adapter is None or self._core_state != "started" or self._mode != "playback":
+ self._playback_timer.stop()
+ return
+ try:
+ state = self._adapter.playback_status()
+ except Exception as error:
+ self.operation.setText(f"Could not read playback status: {error}")
+ return
+ self._playback_position = int(state["position"])
+ self._playback_playing = bool(state["playing"])
+ self._update_position()
+ self._update_controls()
+
+ def _slider_action_triggered(self, _action: int) -> None:
+ QTimer.singleShot(0, self._seek_after_slider_action)
+
+ def _seek_after_slider_action(self) -> None:
+ if not self.seek_slider.isSliderDown():
+ self._seek_playback()
+
+ def _choose_archive(self) -> None:
+ if self._adapter is None or self._busy or self._mode not in {"offline", "playback"}:
+ return
+ playback = self._mode == "playback"
+ key = "last_playback_path" if playback else "last_archive_path"
+ previous = self._config[key]
+ path, _ = QFileDialog.getOpenFileName(
+ self,
+ "Load playback archive" if playback else "Load Offline archive",
+ previous,
+ "DynaLab archive (*.dlpak);;All files (*)",
+ )
+ if not path:
+ return
+ self._config[key] = path
+ self.configChanged.emit(self.get_config())
+ if playback:
+ self._begin_operation(
+ "playback_load",
+ lambda: self._adapter.load_playback(path),
+ "Loading playback archive…",
+ )
+ else:
+ self._begin_operation("load", lambda: self._adapter.load_processing(path), "Loading Offline archive…")
+
+ def _load_recording(self) -> None:
+ if self._adapter is None or self._busy or not self._recording_available:
+ return
+ self._begin_operation("load", lambda: self._adapter.load_processing(), "Loading recording…")
+
+ def _reprocess(self) -> None:
+ if self._adapter is None or self._busy or not self._processing_available:
+ return
+ self._begin_operation("reprocess", self._adapter.reprocess, "Preparing reprocess…")
+
+ def _load_processed_result(self) -> None:
+ if self._adapter is None or self._busy or not self._processing_available or self._mode != "playback":
+ return
+ self._begin_operation(
+ "playback_load",
+ self._adapter.load_playback,
+ "Loading processed result into Playback…",
+ )
+
+ def _choose_save_path(self) -> None:
+ if self._adapter is None or self._busy or not self._processing_available:
+ return
+ path, _ = QFileDialog.getSaveFileName(
+ self,
+ "Save processed data",
+ self._config["last_save_path"] or "processed.dlpak",
+ "DynaLab archive (*.dlpak)",
+ )
+ if not path:
+ return
+ target = Path(path)
+ if target.suffix.lower() != ".dlpak":
+ target = target.with_name(target.name + ".dlpak")
+ self._config["last_save_path"] = str(target)
+ self.configChanged.emit(self.get_config())
+ self._begin_operation(
+ "save",
+ lambda: self._adapter.write_archive(target, source="processing"),
+ "Preparing archive save…",
+ )
+
+ def _begin_operation(self, kind: str, request: Any, initial_message: str) -> None:
+ if self._adapter is None or self._busy:
+ return
+ self._operation_kind = kind
+ self.operation.setText(initial_message)
+ self.progress.setValue(0)
+ self.progress.show()
+ self._set_busy(True)
+ adapter = self._adapter
+ adapter.run_async(request, self._on_operation_started)
+
+ def _play(self) -> None:
+ self._playback_control("play", "Starting playback…")
+
+ def _pause(self) -> None:
+ self._playback_control("pause", "Pausing playback…")
+
+ def _playback_control(self, action: str, message: str) -> None:
+ if self._adapter is None or self._busy or self._mode != "playback" or not self._playback_available:
+ return
+ self.operation.setText(message)
+ self._set_busy(True)
+ adapter = self._adapter
+ method = adapter.play if action == "play" else adapter.pause
+ adapter.run_async(method, self._on_playback_control)
+
+ def _on_playback_control(self, _result: Any, error: Exception | None) -> None:
+ self._set_busy(False)
+ if error is not None:
+ self.operation.setText(f"Playback control failed: {error}")
+ else:
+ self.operation.clear()
+ self._update_controls()
+
+ def _seek_playback(self) -> None:
+ if (
+ self._adapter is None
+ or self._busy
+ or self._mode != "playback"
+ or not self._playback_available
+ or self._duration_ns <= 0
+ ):
+ return
+ # Core.seek() takes elapsed nanoseconds from the archive's first sample.
+ timestamp_ns = round(self._duration_ns * self.seek_slider.value() / 1000)
+ self.operation.setText("Seeking…")
+ self._set_busy(True)
+ adapter = self._adapter
+ adapter.run_async(lambda: adapter.seek(timestamp_ns), self._on_playback_control)
+
+ def _on_operation_started(self, operation_id: UUID | str | None, error: Exception | None) -> None:
+ if error is not None:
+ self._finish_operation(f"Could not {self._operation_verb()}: {error}", success=False)
+ return
+ if operation_id is None:
+ self._finish_operation("Core did not return an operation ID", success=False)
+ return
+ self._operation_id = str(operation_id)
+ self._poll_operation()
+ if self._operation_id is not None:
+ self._operation_timer.start()
+
+ def _operation_verb(self) -> str:
+ return {
+ "load": "load data",
+ "reprocess": "reprocess data",
+ "save": "save processed data",
+ "playback_load": "load playback data",
+ }.get(self._operation_kind, "complete operation")
+
+ def _poll_operation(self) -> None:
+ if self._adapter is None or self._operation_id is None:
+ return
+ try:
+ status = self._adapter.operation_status(self._operation_id)
+ except Exception as error:
+ self._finish_operation(f"Could not read operation status: {error}", success=False)
+ return
+ if status is None:
+ self._finish_operation("Could not read operation status: operation is unavailable", success=False)
+ return
+ total = status.get("total", 0)
+ completed = status.get("completed", 0)
+ if total:
+ self.progress.setValue(round(100 * completed / total))
+ message = status.get("message") or f"{self._operation_kind.title()} in progress…"
+ self.operation.setText(message)
+ state = status.get("state")
+ if state == "completed":
+ result = {
+ "load": "Data loaded into the Offline processing buffer",
+ "reprocess": "Derived signals reprocessed",
+ "save": f"Saved processed data to {self._config['last_save_path']}",
+ "playback_load": "Playback data loaded",
+ }.get(self._operation_kind, "Operation completed")
+ self._finish_operation(result, success=True)
+ elif state == "failed":
+ detail = status.get("error_message") or status.get("error_code") or "unknown error"
+ self._finish_operation(f"Could not {self._operation_verb()}: {detail}", success=False)
+
+ def _finish_operation(self, message: str, *, success: bool) -> None:
+ kind = self._operation_kind
+ self._operation_timer.stop()
+ self._operation_id = None
+ self._operation_kind = ""
+ self.operation.setText(message)
+ self.progress.hide()
+ if success and kind in {"load", "reprocess"}:
+ self._processing_available = True
+ if success and kind == "playback_load":
+ self._playback_available = True
+ self._playback_position = 0
+ self._playback_range = None
+ self._duration_ns = 0
+ if not self.seek_slider.isSliderDown():
+ self.seek_slider.setValue(0)
+ self._set_busy(False)
+ self._update_controls()
diff --git a/src/dynalab_py/derive_panel.py b/src/dynalab_py/derive_panel.py
new file mode 100644
index 0000000..aae1bba
--- /dev/null
+++ b/src/dynalab_py/derive_panel.py
@@ -0,0 +1,390 @@
+"""Workspace-scoped DLUnit library and binding controls."""
+
+from __future__ import annotations
+
+from pathlib import Path
+from typing import Any
+
+from PySide6.QtCore import QSignalBlocker, Qt, Signal
+from PySide6.QtWidgets import (
+ QFileDialog,
+ QGridLayout,
+ QScrollArea,
+ QLabel,
+ QListWidget,
+ QListWidgetItem,
+ QMessageBox,
+ QPushButton,
+ QVBoxLayout,
+ QWidget,
+)
+
+from .dlunit_editor import DLUnitEditorDialog
+
+
+class DerivePanel(QWidget):
+ busyChanged = Signal(bool)
+ configChanged = Signal(dict)
+
+ def __init__(self, config: dict[str, Any] | None = None, parent: QWidget | None = None) -> None:
+ super().__init__(parent)
+ self._adapter: Any | None = None
+ self._config: dict[str, Any] = {"units": []}
+ self._signals: list[dict[str, Any]] = []
+ self._derive_units: list[dict[str, Any]] = []
+ self._workers: list[dict[str, Any]] = []
+ self._session_usable = False
+ self._core_state = "stopped"
+ self._busy = False
+ self._active_action = ""
+ self._feedback = ""
+ self._base_status = "Start Core to view or bind derived units"
+ self.unit_list = QListWidget()
+ self.unit_list.setObjectName("deriveUnitList")
+ self.unit_list.setMinimumHeight(60)
+ self.unit_list.setMaximumHeight(150)
+ self.status_label = QLabel("Start Core to view or bind derived units")
+ self.status_label.setObjectName("muted")
+ self.status_label.setWordWrap(True)
+ self.unit_details = QLabel("Select a unit to inspect its inputs, output, and worker state")
+ self.unit_details.setObjectName("muted")
+ self.unit_details.setWordWrap(True)
+ self.new_button = QPushButton("New unit…")
+ self.open_button = QPushButton("Open unit…")
+ self.edit_button = QPushButton("Edit unit…")
+ self.bind_button = QPushButton("Bind")
+ self.unbind_button = QPushButton("Unbind")
+ self.reload_button = QPushButton("Reload saved")
+ layout = QVBoxLayout(self)
+ layout.addWidget(QLabel("Derived units"))
+ layout.addWidget(self.unit_list, 1)
+ details = QScrollArea()
+ details.setWidgetResizable(True)
+ details.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
+ details.setMinimumHeight(100)
+ details.setMaximumHeight(170)
+ self.unit_details.setAlignment(Qt.AlignmentFlag.AlignTop)
+ details.setWidget(self.unit_details)
+ layout.addWidget(details)
+ buttons = QGridLayout()
+ for row, pair in enumerate(((self.new_button, self.open_button),
+ (self.edit_button, self.reload_button),
+ (self.bind_button, self.unbind_button))):
+ for column, button in enumerate(pair):
+ buttons.addWidget(button, row, column)
+ layout.addLayout(buttons)
+ layout.addWidget(self.status_label)
+ self.new_button.clicked.connect(self._new_unit)
+ self.open_button.clicked.connect(self._open_unit)
+ self.edit_button.clicked.connect(self._edit_unit)
+ self.bind_button.clicked.connect(self._bind_selected)
+ self.unbind_button.clicked.connect(self._unbind_selected)
+ self.reload_button.clicked.connect(self._reload_selected)
+ self.unit_list.currentItemChanged.connect(self._selection_changed)
+ self.set_config(config or {})
+ self._update_controls()
+
+ @property
+ def busy(self) -> bool:
+ return self._busy
+
+ def set_adapter(self, adapter: Any | None) -> None:
+ if adapter is self._adapter:
+ self._update_controls()
+ return
+ self._adapter = adapter
+ self._core_state = "stopped"
+ self._derive_units = []
+ self._workers = []
+ changed = False
+ for unit in self._config["units"]:
+ if unit["unit_id"] is not None:
+ unit["unit_id"] = None
+ changed = True
+ if changed:
+ self._emit_config()
+ self._render_units()
+ self._update_controls()
+
+ def set_config(self, config: dict[str, Any]) -> None:
+ units: list[dict[str, Any]] = []
+ for entry in config.get("units", []):
+ if not isinstance(entry, dict) or not entry.get("path"):
+ continue
+ units.append(
+ {
+ "path": str(entry["path"]),
+ "unit_id": str(entry["unit_id"]) if entry.get("unit_id") else None,
+ "binding_intent": bool(entry.get("binding_intent", False)),
+ }
+ )
+ self._config = {"units": units}
+ self._render_units()
+
+ def get_config(self) -> dict[str, Any]:
+ return {"units": [dict(item) for item in self._config["units"]]}
+
+ def refresh(self, snapshot: dict[str, Any] | None) -> None:
+ self._session_usable = bool(snapshot and snapshot.get("session_usable", snapshot.get("state") == "started"))
+ old_ids = {item.get("unit_id") for item in self._config["units"]}
+ if snapshot is None:
+ self._core_state = "stopped"
+ self._signals = []
+ self._derive_units = []
+ self._workers = []
+ for item in self._config["units"]:
+ item["unit_id"] = None
+ self._base_status = "Start Core to view or bind derived units"
+ else:
+ self._core_state = str(snapshot.get("state", "stopped"))
+ self._signals = list(snapshot.get("signals", []))
+ self._derive_units = list(snapshot.get("derive_units", []))
+ self._workers = list(snapshot.get("workers", []))
+ live_ids = {str(item.get("id")) for item in self._derive_units}
+ for item in self._config["units"]:
+ if item.get("unit_id") not in live_ids:
+ item["unit_id"] = None
+ state = snapshot.get("state", "stopped")
+ if state != "started":
+ message = f"Core session state: {state}"
+ elif not self._derive_units and not self._config["units"]:
+ message = "No derived units are bound or tracked"
+ else:
+ message = "Bound units are shown with worker state; opened files remain unbound until Bind"
+ self._base_status = message
+ self._render_units()
+ if not self._feedback:
+ self.status_label.setText(self._base_status)
+ if old_ids != {item.get("unit_id") for item in self._config["units"]}:
+ self._emit_config()
+
+ def _emit_config(self) -> None:
+ self.configChanged.emit(self.get_config())
+
+ def _set_busy(self, busy: bool, action: str = "") -> None:
+ if self._busy == busy:
+ if busy:
+ self._active_action = action
+ return
+ self._busy = busy
+ self._active_action = action if busy else ""
+ self.busyChanged.emit(busy)
+ self._update_controls()
+
+ def _set_feedback(self, message: str) -> None:
+ self._feedback = message
+ self.status_label.setText(message)
+
+ def _clear_feedback(self) -> None:
+ self._feedback = ""
+ self.status_label.setText(self._base_status)
+
+ def _selection_changed(self, *_args: Any) -> None:
+ self._feedback = ""
+ self.status_label.setText(self._base_status)
+ self._update_controls()
+ self._update_details()
+
+ def _tracked_item(self, entry: dict[str, Any], bound_id: str | None = None) -> QListWidgetItem:
+ path = Path(entry["path"])
+ unit_id = entry.get("unit_id")
+ bound = next((unit for unit in self._derive_units if str(unit.get("id")) == unit_id), None)
+ if bound:
+ worker = bound.get("worker", {})
+ label = f"{path.name} · bound · {worker.get('state', 'unknown')}"
+ if worker.get("error"):
+ label += f" · {worker['error']}"
+ item = QListWidgetItem(label)
+ item.setToolTip(self._unit_summary(bound, worker))
+ else:
+ label = f"{path.name} · unbound"
+ item = QListWidgetItem(label)
+ item.setData(Qt.ItemDataRole.UserRole, {**entry, "external": False})
+ if not bound:
+ item.setToolTip(str(path))
+ return item
+
+ def _unit_summary(self, unit: dict[str, Any], worker: dict[str, Any]) -> str:
+ signal_names = {str(signal.get("id")): str(signal.get("name", signal.get("id"))) for signal in self._signals}
+ inputs = [
+ f"{signal_names.get(str(signal_id), str(signal_id))} [{signal_id}]"
+ for signal_id in unit.get("input_signal_ids", [])
+ ]
+ output_id = str(unit.get("output_signal_id", ""))
+ output = f"{signal_names.get(output_id, output_id)} [{output_id}]"
+ lines = [f"Inputs: {', '.join(inputs) if inputs else '(none)'}", f"Output: {output}", f"Worker: {worker.get('name', 'unknown')} · {worker.get('state', 'unknown')}"]
+ if worker.get("error"):
+ lines.append(f"Error: {worker['error']}")
+ return "\n".join(lines)
+
+ def _render_units(self) -> None:
+ blocker = QSignalBlocker(self.unit_list)
+ selected = self.unit_list.currentItem().data(Qt.ItemDataRole.UserRole) if self.unit_list.currentItem() else None
+ wanted = (selected or {}).get("path") or (selected or {}).get("unit_id")
+ self.unit_list.clear()
+ bound_by_id = {str(item.get("id")): item for item in self._derive_units}
+ seen_ids: set[str] = set()
+ for entry in self._config["units"]:
+ unit_id = entry.get("unit_id")
+ if unit_id and unit_id in bound_by_id:
+ seen_ids.add(unit_id)
+ item = self._tracked_item(entry)
+ self.unit_list.addItem(item)
+ if wanted and wanted in (entry.get("path"), unit_id):
+ self.unit_list.setCurrentItem(item)
+ signal_by_id = {str(signal.get("id")): signal for signal in self._signals}
+ for unit in self._derive_units:
+ unit_id = str(unit.get("id"))
+ if unit_id in seen_ids:
+ continue
+ output_id = str(unit.get("output_signal_id"))
+ signal = signal_by_id.get(output_id, {})
+ worker = unit.get("worker", {})
+ output_name = signal.get("name", output_id)
+ item = QListWidgetItem(f"{output_name} · external Core unit · {worker.get('state', 'unknown')}")
+ item.setData(Qt.ItemDataRole.UserRole, {"unit_id": unit_id, "path": None, "external": True})
+ item.setToolTip(self._unit_summary(unit, worker) + "\nCore does not expose this unit's saved path or source")
+ if worker.get("error"):
+ item.setToolTip(f"Worker error: {worker['error']}\nPath and source are not exposed by Core")
+ self.unit_list.addItem(item)
+ if wanted == unit_id:
+ self.unit_list.setCurrentItem(item)
+ del blocker
+ self._update_controls()
+ self._update_details()
+
+ def _update_details(self) -> None:
+ selected = self._selected()
+ if selected is None:
+ self.unit_details.setText("Select a unit to inspect its inputs, output, and worker state")
+ return
+ bound = next((item for item in self._derive_units if str(item.get("id")) == selected.get("unit_id")), None)
+ if bound is None:
+ self.unit_details.setText(f"Unit ID: {selected.get('unit_id') or 'not bound'}\nPath: {selected.get('path') or 'not available'}")
+ return
+ worker = bound.get("worker", {})
+ self.unit_details.setText(f"Unit ID: {bound.get('id')}\n{self._unit_summary(bound, worker)}")
+
+ def _selected(self) -> dict[str, Any] | None:
+ item = self.unit_list.currentItem()
+ return item.data(Qt.ItemDataRole.UserRole) if item else None
+
+ def _update_controls(self, *_args: Any) -> None:
+ selected = self._selected()
+ entry = next((unit for unit in self._config["units"] if selected and unit["path"] == selected.get("path")), None)
+ running = self._adapter is not None and self._session_usable and self._core_state == "started" and not self._busy
+ exists = bool(entry and Path(entry["path"]).is_file())
+ bound = bool(selected and selected.get("unit_id") and any(str(unit.get("id")) == selected["unit_id"] for unit in self._derive_units))
+ self.new_button.setEnabled(not self._busy)
+ self.open_button.setEnabled(not self._busy)
+ self.edit_button.setEnabled(not self._busy and exists)
+ self.bind_button.setEnabled(running and exists and not bound)
+ self.unbind_button.setEnabled(running and bound)
+ self.reload_button.setEnabled(running and exists and bound and not bool(selected and selected.get("external", False)))
+
+ def _editor(self, path: str | Path | None) -> None:
+ dialog = DLUnitEditorDialog(path, self._signals, self)
+ if dialog.load_error:
+ QMessageBox.critical(self, "Could not open DLUnit", dialog.load_error)
+ return
+ if path is not None:
+ opened_path = str(Path(path))
+ if not any(unit["path"] == opened_path for unit in self._config["units"]):
+ self._config["units"].append({"path": opened_path, "unit_id": None, "binding_intent": False})
+ self._render_units()
+ self._emit_config()
+ if dialog.exec() != DLUnitEditorDialog.DialogCode.Accepted or not dialog.path:
+ return
+ saved_path = str(dialog.path)
+ entry = next((unit for unit in self._config["units"] if unit["path"] == saved_path), None)
+ if entry is None:
+ self._config["units"].append({"path": saved_path, "unit_id": None, "binding_intent": False})
+ self._render_units()
+ self._emit_config()
+ self._set_feedback("Saved to disk; bind or reload explicitly to apply changes in Core")
+
+ def _new_unit(self) -> None:
+ self._clear_feedback()
+ self._editor(None)
+
+ def _open_unit(self) -> None:
+ self._clear_feedback()
+ filename, _ = QFileDialog.getOpenFileName(self, "Open DLUnit", "", "DLUnit (*.dlunit)")
+ if filename:
+ self._editor(filename)
+
+ def _edit_unit(self) -> None:
+ self._clear_feedback()
+ selected = self._selected()
+ if selected and selected.get("path"):
+ self._editor(selected["path"])
+
+ def _run_async(self, action: str, method: Any, done: Any) -> None:
+ if self._adapter is None or self._busy:
+ return
+ self._set_busy(True, action)
+
+ def finished(result: Any, error: Exception | None) -> None:
+ self._set_busy(False)
+ if error is not None:
+ self._set_feedback(f"{action} failed: {error}")
+ return
+ done(result)
+ self._render_units()
+
+ self._adapter.run_async(method, finished)
+
+ def _bind_selected(self) -> None:
+ selected = self._selected()
+ if not selected or not selected.get("path"):
+ return
+ entry = next((unit for unit in self._config["units"] if unit["path"] == selected["path"]), None)
+ if entry is None:
+ return
+
+ def completed(unit_id: str) -> None:
+ entry["unit_id"] = str(unit_id)
+ entry["binding_intent"] = True
+ self._set_feedback("Unit bound in Core")
+ self._emit_config()
+
+ self._run_async("Bind", lambda: self._adapter.bind_unit(entry["path"]), completed)
+
+ def _unbind_selected(self) -> None:
+ selected = self._selected()
+ if not selected or not selected.get("unit_id"):
+ return
+ unit_id = selected["unit_id"]
+ entry = next((unit for unit in self._config["units"] if unit.get("unit_id") == unit_id), None)
+
+ def completed(_result: Any) -> None:
+ if entry is not None:
+ entry["unit_id"] = None
+ entry["binding_intent"] = False
+ self._emit_config()
+ self._set_feedback("Unit unbound from Core")
+
+ self._run_async("Unbind", lambda: self._adapter.unbind_unit(unit_id), completed)
+
+ def _reload_selected(self) -> None:
+ selected = self._selected()
+ if not selected or selected.get("external") or not selected.get("path") or not selected.get("unit_id"):
+ return
+ old_id = selected["unit_id"]
+ entry = next((unit for unit in self._config["units"] if unit.get("unit_id") == old_id), None)
+ if entry is None:
+ return
+
+ def completed(result: dict[str, Any]) -> None:
+ if result.get("state") == "bound":
+ entry["unit_id"] = str(result["unit_id"])
+ entry["binding_intent"] = True
+ self._set_feedback("Saved unit reloaded and bound in Core")
+ else:
+ entry["unit_id"] = None
+ entry["binding_intent"] = True
+ self._set_feedback(f"Reload failed after unbind; unit is unbound: {result.get('error_message', 'unknown error')}")
+ self._emit_config()
+
+ self._run_async("Reload saved", lambda: self._adapter.reload_unit(old_id, entry["path"]), completed)
+
diff --git a/src/dynalab_py/dlunit_editor.py b/src/dynalab_py/dlunit_editor.py
new file mode 100644
index 0000000..318030b
--- /dev/null
+++ b/src/dynalab_py/dlunit_editor.py
@@ -0,0 +1,300 @@
+"""Small editor for the documented manifest.json/unit.py DLUnit archive."""
+
+from __future__ import annotations
+
+import json
+import os
+import re
+import tempfile
+from pathlib import Path
+from typing import Any
+from uuid import UUID, uuid4
+from zipfile import ZIP_DEFLATED, ZipFile
+
+from PySide6.QtCore import Qt
+from PySide6.QtGui import QColor, QFont, QFontDatabase, QSyntaxHighlighter, QTextCharFormat
+from PySide6.QtWidgets import (
+ QDialog,
+ QDialogButtonBox,
+ QFileDialog,
+ QFormLayout,
+ QLabel,
+ QLineEdit,
+ QListWidget,
+ QListWidgetItem,
+ QMessageBox,
+ QPlainTextEdit,
+ QSplitter,
+ QVBoxLayout,
+ QWidget,
+)
+
+
+class _PythonHighlighter(QSyntaxHighlighter):
+ def __init__(self, document: Any) -> None:
+ super().__init__(document)
+ self._rules: list[tuple[re.Pattern[str], QTextCharFormat]] = []
+ keyword = QTextCharFormat()
+ keyword.setForeground(QColor("#c792ea"))
+ keyword.setFontWeight(QFont.Weight.Bold)
+ self._rules.extend(
+ (re.compile(rf"\b{word}\b"), keyword)
+ for word in ("def", "return", "import", "from", "as", "if", "else", "for", "in", "class", "True", "False", "None")
+ )
+ string = QTextCharFormat()
+ string.setForeground(QColor("#c3e88d"))
+ self._rules.extend(
+ ((re.compile(r"'(?:[^'\\]|\\.)*'"), string), (re.compile(r'"(?:[^"\\]|\\.)*"'), string))
+ )
+ comment = QTextCharFormat()
+ comment.setForeground(QColor("#91a3b8"))
+ self._rules.append((re.compile(r"#[^\n]*"), comment))
+
+ def highlightBlock(self, text: str) -> None:
+ for pattern, style in self._rules:
+ for match in pattern.finditer(text):
+ self.setFormat(match.start(), match.end() - match.start(), style)
+
+
+def _descriptor(value: dict[str, Any], *, default_origin: str = "source") -> dict[str, Any]:
+ result = dict(value)
+ result.pop("value", None)
+ result.setdefault("name", "Signal")
+ result.setdefault("type", "number")
+ result.setdefault("unit", "")
+ result.setdefault("min_value", None)
+ result.setdefault("max_value", None)
+ result.setdefault("timeout_ms", 2000)
+ result.setdefault("origin", default_origin)
+ return result
+
+
+def read_dlunit(path: str | Path) -> tuple[dict[str, Any], str]:
+ with ZipFile(path) as archive:
+ manifest = json.loads(archive.read("manifest.json"))
+ source = archive.read("unit.py").decode("utf-8")
+ if not isinstance(manifest, dict):
+ raise ValueError("manifest.json must contain an object")
+ return manifest, source
+
+
+def write_dlunit(path: str | Path, manifest: dict[str, Any], source: str) -> Path:
+ """Write the public two-entry archive atomically, preserving extra fields."""
+ target = Path(path)
+ if target.suffix.lower() != ".dlunit":
+ target = target.with_suffix(".dlunit")
+ target.parent.mkdir(parents=False, exist_ok=True)
+ temporary: Path | None = None
+ try:
+ with tempfile.NamedTemporaryFile(
+ dir=target.parent, prefix=f".{target.name}.", suffix=".tmp", delete=False
+ ) as handle:
+ temporary = Path(handle.name)
+ with ZipFile(handle, "w", compression=ZIP_DEFLATED) as archive:
+ archive.writestr("manifest.json", json.dumps(manifest, indent=2, ensure_ascii=False) + "\n")
+ archive.writestr("unit.py", source)
+ handle.flush()
+ os.fsync(handle.fileno())
+ temporary.replace(target)
+ finally:
+ if temporary is not None:
+ temporary.unlink(missing_ok=True)
+ return target
+
+
+class DLUnitEditorDialog(QDialog):
+ """Form editor and plain Python source editor for a single DLUnit file."""
+
+ def __init__(
+ self,
+ path: str | Path | None = None,
+ signals: list[dict[str, Any]] | None = None,
+ parent: QWidget | None = None,
+ ) -> None:
+ super().__init__(parent)
+ self.setWindowTitle("DLUnit editor")
+ self.resize(900, 650)
+ self.path = Path(path) if path else None
+ self.load_error: str | None = None
+ self._original_manifest: dict[str, Any] = {}
+ self._original_inputs: dict[str, dict[str, Any]] = {}
+ self._available = {str(item.get("id")): dict(item) for item in (signals or []) if item.get("id")}
+
+ self.trust_warning = QLabel(
+ "Trusted code only: DynaLab Core executes this Python source when the unit is bound."
+ )
+ self.trust_warning.setWordWrap(True)
+ self.unit_uuid = QLineEdit()
+ self.input_list = QListWidget()
+ self.input_list.setDragDropMode(QListWidget.DragDropMode.InternalMove)
+ self.input_list.setToolTip("Inputs are passed to process in this order. Drag to reorder.")
+ self.input_list.setToolTip("Checked inputs are passed to process() in this order; drag to reorder")
+ self.input_list.model().rowsMoved.connect(self._renumber_inputs)
+ self.output_uuid = QLineEdit()
+ self.output_name = QLineEdit()
+ self.output_type = QLineEdit("number")
+ self.output_unit = QLineEdit()
+ self.output_min = QLineEdit()
+ self.output_max = QLineEdit()
+ self.comments = QPlainTextEdit()
+ self.source = QPlainTextEdit()
+ self.source.setFont(QFontDatabase.systemFont(QFontDatabase.SystemFont.FixedFont))
+ self.source.setTabStopDistance(self.source.fontMetrics().horizontalAdvance(" ") * 4)
+ self._highlighter = _PythonHighlighter(self.source.document())
+ form_widget = QWidget()
+ form = QFormLayout(form_widget)
+ form.addRow("Unit UUID", self.unit_uuid)
+ form.addRow("Inputs (ordered)", self.input_list)
+ form.addRow("Output UUID", self.output_uuid)
+ form.addRow("Output name", self.output_name)
+ form.addRow("Output type", self.output_type)
+ form.addRow("Output unit", self.output_unit)
+ form.addRow("Minimum", self.output_min)
+ form.addRow("Maximum", self.output_max)
+ form.addRow("Comments", self.comments)
+ code_widget = QWidget()
+ code_layout = QVBoxLayout(code_widget)
+ code_layout.setContentsMargins(0, 0, 0, 0)
+ code_layout.addWidget(QLabel("Python source (unit.py)"))
+ code_layout.addWidget(self.source, 1)
+ splitter = QSplitter(Qt.Orientation.Horizontal)
+ splitter.addWidget(form_widget)
+ splitter.addWidget(code_widget)
+ splitter.setStretchFactor(0, 1)
+ splitter.setStretchFactor(1, 2)
+ buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Save | QDialogButtonBox.StandardButton.Cancel)
+ buttons.accepted.connect(self._save)
+ buttons.rejected.connect(self.reject)
+ layout = QVBoxLayout(self)
+ layout.addWidget(self.trust_warning)
+ layout.addWidget(splitter, 1)
+ layout.addWidget(buttons)
+
+ if self.path:
+ self._load(self.path)
+ else:
+ self._load_new()
+ self._populate_inputs()
+
+ def _load(self, path: Path) -> None:
+ try:
+ manifest, source = read_dlunit(path)
+ inputs = manifest.get("input_signals", [])
+ output = manifest.get("return_signal", {})
+ if not isinstance(inputs, list) or not all(isinstance(item, dict) for item in inputs) or not isinstance(output, dict):
+ raise ValueError("manifest must contain input_signals and return_signal")
+ self._original_manifest = manifest
+ self._original_inputs = {str(item.get("id")): _descriptor(item) for item in inputs}
+ self.unit_uuid.setText(str(manifest.get("unit_uuid", "")))
+ output = _descriptor(output, default_origin="derived")
+ self.output_uuid.setText(output["id"])
+ self.output_name.setText(str(output["name"]))
+ self.output_type.setText(str(output["type"]))
+ self.output_unit.setText(str(output.get("unit") or ""))
+ self.output_min.setText("" if output.get("min_value") is None else str(output["min_value"]))
+ self.output_max.setText("" if output.get("max_value") is None else str(output["max_value"]))
+ self.comments.setPlainText(str(manifest.get("comments", "")))
+ self.source.setPlainText(source)
+ except Exception as error:
+ self.load_error = str(error)
+
+ def _load_new(self) -> None:
+ self.unit_uuid.setText(str(uuid4()))
+ self.output_uuid.setText(str(uuid4()))
+ self.output_name.setText("Derived signal")
+ self._original_manifest = {"unit_uuid": self.unit_uuid.text(), "input_signals": [], "return_signal": {}, "comments": ""}
+ self.source.setPlainText(
+ "from dynalab_core.protocols.packets.data import ValueDescriptor\n"
+ "from dynalab_core.protocols.packets.handshake import SignalDescriptor\n\n"
+ "def process(result: SignalDescriptor) -> ValueDescriptor:\n"
+ " return ValueDescriptor(result.id, 0.0, 0)\n"
+ )
+
+ def _populate_inputs(self) -> None:
+ descriptors = dict(self._original_inputs)
+ for signal_id, active in self._available.items():
+ if signal_id not in descriptors:
+ descriptors[signal_id] = _descriptor(active)
+ self.input_list.clear()
+ for index, (signal_id, descriptor) in enumerate(descriptors.items(), start=1):
+ item = QListWidgetItem(f"{index}. {descriptor.get('name', signal_id)} · {signal_id}")
+ item.setData(Qt.ItemDataRole.UserRole, descriptor)
+ item.setFlags(item.flags() | Qt.ItemFlag.ItemIsUserCheckable)
+ item.setCheckState(Qt.CheckState.Checked if signal_id in self._original_inputs else Qt.CheckState.Unchecked)
+ if signal_id not in self._available:
+ item.setToolTip("Unavailable in the current mode; retained from the saved manifest")
+ self.input_list.addItem(item)
+
+ def _renumber_inputs(self, *_args: Any) -> None:
+ for index in range(self.input_list.count()):
+ item = self.input_list.item(index)
+ descriptor = item.data(Qt.ItemDataRole.UserRole)
+ item.setText(f"{index + 1}. {descriptor.get('name', descriptor.get('id'))} · {descriptor.get('id')}")
+
+ @staticmethod
+ def _number(text: str, label: str) -> float | None:
+ text = text.strip()
+ if not text:
+ return None
+ try:
+ return float(text)
+ except ValueError as error:
+ raise ValueError(f"{label} must be a number") from error
+
+ def _build_manifest(self) -> dict[str, Any]:
+ unit_id = str(UUID(self.unit_uuid.text().strip()))
+ output_id = str(UUID(self.output_uuid.text().strip()))
+ selected: list[dict[str, Any]] = []
+ for index in range(self.input_list.count()):
+ item = self.input_list.item(index)
+ if item.checkState() == Qt.CheckState.Checked:
+ descriptor = _descriptor(item.data(Qt.ItemDataRole.UserRole))
+ descriptor["id"] = str(UUID(str(descriptor.get("id", ""))))
+ if not str(descriptor.get("name", "")).strip() or descriptor.get("type") not in {"number", "binary"}:
+ raise ValueError("Each input needs a name and a number or binary type")
+ selected.append(descriptor)
+ min_value = self._number(self.output_min.text(), "Minimum")
+ max_value = self._number(self.output_max.text(), "Maximum")
+ if min_value is not None and max_value is not None and min_value > max_value:
+ raise ValueError("Minimum must not exceed maximum")
+ output = _descriptor(self._original_manifest.get("return_signal", {}), default_origin="derived")
+ output.update(
+ {
+ "id": output_id,
+ "name": self.output_name.text().strip(),
+ "type": self.output_type.text().strip(),
+ "unit": self.output_unit.text().strip(),
+ "min_value": min_value,
+ "max_value": max_value,
+ "origin": "derived",
+ }
+ )
+ if not output["name"] or output["type"] not in {"number", "binary"}:
+ raise ValueError("Output name and a number or binary type are required")
+ manifest = dict(self._original_manifest)
+ manifest.update(
+ {
+ "unit_uuid": unit_id,
+ "input_signals": selected,
+ "return_signal": output,
+ "comments": self.comments.toPlainText(),
+ }
+ )
+ return manifest
+
+ def _save(self) -> None:
+ try:
+ manifest = self._build_manifest()
+ target = self.path
+ if target is None:
+ filename, _ = QFileDialog.getSaveFileName(self, "Save DLUnit", "", "DLUnit (*.dlunit)")
+ if not filename:
+ return
+ target = Path(filename)
+ self.path = write_dlunit(target, manifest, self.source.toPlainText())
+ except Exception as error:
+ QMessageBox.warning(self, "Could not save DLUnit", str(error))
+ return
+ self._original_manifest = manifest
+ self.accept()
+
diff --git a/src/dynalab_py/docking.py b/src/dynalab_py/docking.py
new file mode 100644
index 0000000..435cd50
--- /dev/null
+++ b/src/dynalab_py/docking.py
@@ -0,0 +1,1075 @@
+"""Snapped grid workspaces for DynaLab panel windows."""
+
+from __future__ import annotations
+
+from math import ceil
+from uuid import uuid4
+
+from PySide6.QtCore import QEvent, QPoint, QRect, QSize, Qt, Signal
+from PySide6.QtGui import QAction, QColor, QPainter
+from PySide6.QtWidgets import (
+ QDockWidget,
+ QHBoxLayout,
+ QMenu,
+ QScrollArea,
+ QSizePolicy,
+ QTabWidget,
+ QToolButton,
+ QVBoxLayout,
+ QWidget,
+)
+
+CELL_WIDTH = 80
+CELL_HEIGHT = 64
+CELL_GAP = 0
+DRAG_HANDLE_HEIGHT = 16
+GROUP_TAB_HEIGHT = 24
+
+# Minimum and preferred cell spans by panel purpose. Maximum spans keep a
+# single panel usable while leaving the canvas available for other panels.
+_LIMITS: dict[str, tuple[tuple[int, int], tuple[int, int], tuple[int, int]]] = {
+ "dashboard": ((3, 3), (5, 5), (10, 12)),
+ "data": ((4, 3), (5, 5), (10, 14)),
+ "signal_browser": ((4, 3), (5, 6), (10, 16)),
+ "derive_manager": ((4, 3), (5, 6), (10, 16)),
+ "graph": ((4, 3), (7, 5), (12, 12)),
+ "numeric": ((2, 2), (3, 3), (6, 4)),
+ "gauge": ((2, 2), (3, 3), (6, 6)),
+ "instrument_group": ((3, 2), (4, 3), (8, 16)),
+ "gauge_group": ((3, 2), (6, 4), (14, 16)),
+ "numeric_group": ((3, 2), (6, 4), (14, 16)),
+}
+
+
+class _Preview(QWidget):
+ def __init__(self, parent: QWidget) -> None:
+ super().__init__(parent)
+ self.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents)
+ self.setAttribute(Qt.WidgetAttribute.WA_NoSystemBackground)
+ self.setStyleSheet("background: transparent;")
+ self.valid = True
+ self.hide()
+
+ def paintEvent(self, _event) -> None:
+ painter = QPainter(self)
+ color = QColor("#e7e9ec" if self.valid else "#a45b62")
+ color.setAlpha(55)
+ painter.fillRect(self.rect(), color)
+ color.setAlpha(210)
+ painter.setPen(color)
+ painter.drawRect(self.rect().adjusted(1, 1, -2, -2))
+
+
+class _GridCanvas(QWidget):
+ def paintEvent(self, event) -> None:
+ super().paintEvent(event)
+ painter = QPainter(self)
+ painter.fillRect(self.rect(), QColor("#181a1d"))
+ painter.setPen(QColor("#292c31"))
+ for x in range(0, self.width(), CELL_WIDTH):
+ painter.drawLine(x, 0, x, self.height())
+ for y in range(0, self.height(), CELL_HEIGHT):
+ painter.drawLine(0, y, self.width(), y)
+
+
+class _ResizeHandle(QWidget):
+ def __init__(self, dock: PanelDockWidget) -> None:
+ super().__init__(dock)
+ self.dock = dock
+ self.setFixedSize(18, 18)
+ self.setCursor(Qt.CursorShape.SizeFDiagCursor)
+ self.setToolTip("Resize panel by grid cells")
+ self._start: QPoint | None = None
+ self._start_rect: tuple[int, int, int, int] | None = None
+ self._start_geometry: QRect | None = None
+
+ def paintEvent(self, _event) -> None:
+ painter = QPainter(self)
+ painter.setPen(QColor("#8f949b"))
+ for offset in (5, 9, 13):
+ painter.drawLine(17 - offset, 17, 17, 17 - offset)
+
+ def mousePressEvent(self, event) -> None:
+ if event.button() == Qt.MouseButton.LeftButton and self.dock.workspace is not None:
+ self._start = event.globalPosition().toPoint()
+ if self.dock.isFloating():
+ self._start_geometry = self.dock.geometry()
+ self._start_rect = None
+ else:
+ self._start_rect = self.dock.workspace.panel_rect(self.dock)
+ self.dock.workspace._resize_begin(self.dock, self._start, self._start_rect)
+ event.accept()
+
+ def mouseMoveEvent(self, event) -> None:
+ if self._start is not None and self.dock.workspace is not None:
+ if self._start_geometry is not None:
+ delta = event.globalPosition().toPoint() - self._start
+ self.dock.workspace.resize_floating(
+ self.dock,
+ QSize(self._start_geometry.width() + delta.x(), self._start_geometry.height() + delta.y()),
+ )
+ else:
+ self.dock.workspace._resize_update(self.dock, event.globalPosition().toPoint())
+ event.accept()
+
+ def mouseReleaseEvent(self, event) -> None:
+ if self._start is not None and self.dock.workspace is not None:
+ if self._start_geometry is None:
+ self.dock.workspace._resize_finish(self.dock, event.globalPosition().toPoint())
+ self._start = None
+ self._start_rect = None
+ self._start_geometry = None
+ event.accept()
+
+
+class _DragHandle(QWidget):
+ """Compact, text-free drag strip shared by panel and tab-group chrome."""
+
+ def __init__(self, dock: PanelDockWidget, parent: QWidget | None = None) -> None:
+ super().__init__(parent or dock)
+ self.dock = dock
+ self._drag_origin: QPoint | None = None
+ self._drag_rect: tuple[int, int, int, int] | None = None
+ self._floating_origin: QPoint | None = None
+ self._floating_geometry: QRect | None = None
+ self.setObjectName("panelDockDragHandle")
+ self.setFixedHeight(DRAG_HANDLE_HEIGHT)
+ self.setCursor(Qt.CursorShape.OpenHandCursor)
+ self.setAttribute(Qt.WidgetAttribute.WA_StyledBackground, True)
+ self.setStyleSheet("QWidget#panelDockDragHandle { background: #202225; border: none; }")
+ layout = QHBoxLayout(self)
+ layout.setContentsMargins(0, 0, 2, 0)
+ layout.setSpacing(0)
+ self._spacer = QWidget(self)
+ self._spacer.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
+ self._spacer.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents)
+ layout.addWidget(self._spacer, 1)
+ self.button = QToolButton(self)
+ self.button.setObjectName("panelDockPlacementButton")
+ self.button.setText("⋮")
+ self.button.setToolTip("Move, resize, merge, or float this panel")
+ self.button.setAccessibleName(f"Arrange {dock.windowTitle()}")
+ self.button.clicked.connect(dock.show_arrangement_menu)
+ layout.addWidget(self.button)
+
+ def paintEvent(self, _event) -> None:
+ painter = QPainter(self)
+ painter.setPen(Qt.PenStyle.NoPen)
+ painter.setBrush(QColor("#8f949b"))
+ # A narrow pill is an obvious drag affordance without reading as a bar.
+ painter.drawRoundedRect(max(0, (self.width() - 24) // 2), 5, 24, 2, 1, 1)
+
+ def set_embedded(self, embedded: bool) -> None:
+ """Turn this strip into a centered overlay on a panel's own header."""
+ if embedded:
+ self.setFixedSize(64, DRAG_HANDLE_HEIGHT)
+ self.setAttribute(Qt.WidgetAttribute.WA_StyledBackground, False)
+ self.setStyleSheet("background: transparent; border: none;")
+ self.setToolTip("Drag to move this panel")
+ self.setAccessibleName("Move panel")
+ else:
+ self.setMinimumWidth(0)
+ self.setMaximumWidth(16777215)
+ self.setFixedHeight(DRAG_HANDLE_HEIGHT)
+ self.setAttribute(Qt.WidgetAttribute.WA_StyledBackground, True)
+ self.setStyleSheet("QWidget#panelDockDragHandle { background: #202225; border: none; }")
+ self.setToolTip("Move, resize, merge, or float this panel")
+ self.setAccessibleName("Move panel")
+
+ def set_button_left(self, left: bool = True) -> None:
+ self.layout().removeWidget(self.button)
+ if left:
+ self.layout().insertWidget(0, self.button)
+ else:
+ self.layout().addWidget(self.button)
+
+ def _target_dock(self) -> PanelDockWidget:
+ group = getattr(self, "group", None)
+ if group is not None:
+ current = group["tabs"].currentWidget()
+ if isinstance(current, PanelDockWidget):
+ return current
+ return self.dock
+
+ def mousePressEvent(self, event) -> None:
+ dock = self._target_dock()
+ if event.button() == Qt.MouseButton.LeftButton and dock.workspace is not None:
+ point = event.globalPosition().toPoint()
+ if dock.isFloating():
+ self._floating_origin = point
+ self._floating_geometry = dock.geometry()
+ else:
+ self.setCursor(Qt.CursorShape.ClosedHandCursor)
+ self._drag_origin = point
+ self._drag_rect = dock.workspace.panel_rect(dock)
+ dock.workspace._move_begin(dock, self._drag_origin, self._drag_rect)
+ event.accept()
+
+ def mouseMoveEvent(self, event) -> None:
+ if self._floating_origin is not None and self._floating_geometry is not None:
+ delta = event.globalPosition().toPoint() - self._floating_origin
+ self._target_dock().move(self._floating_geometry.topLeft() + delta)
+ event.accept()
+ elif self._drag_origin is not None and self._target_dock().workspace is not None:
+ dock = self._target_dock()
+ dock.workspace._move_update(dock, event.globalPosition().toPoint())
+ event.accept()
+
+ def mouseReleaseEvent(self, event) -> None:
+ if self._floating_origin is not None:
+ self._floating_origin = None
+ self._floating_geometry = None
+ event.accept()
+ elif self._drag_origin is not None and self._target_dock().workspace is not None:
+ dock = self._target_dock()
+ dock.workspace._move_finish(dock, event.globalPosition().toPoint())
+ self._drag_origin = None
+ self._drag_rect = None
+ self.setCursor(Qt.CursorShape.OpenHandCursor)
+ event.accept()
+
+
+class PanelDockWidget(QDockWidget):
+ """A panel window with compact drag and grid-resize handles.
+
+ Constructor and ``removeRequested(str)`` match the app's old panel dock.
+ The parent can keep its ``docks`` mapping and panel-content lifecycle.
+ """
+
+ removeRequested = Signal(str)
+ minimumSizeChanged = Signal()
+
+ def __init__(
+ self,
+ panel_id: str,
+ title: str,
+ parent: QWidget | None = None,
+ removable: bool = False,
+ ) -> None:
+ super().__init__(title, parent)
+ self.panel_id = panel_id
+ self.panel_type = "numeric"
+ self.removable = removable
+ self.display_title = title
+ self.instance_title = title
+ self.workspace: GridWorkspace | None = None
+ self._saved_rect = (0, 0, 3, 3)
+ self.user_visible = True
+ self._view_action = QAction(title, self)
+ self._view_action.setCheckable(True)
+ self._view_action.setChecked(True)
+ self._view_action.toggled.connect(self._view_action_toggled)
+ self._title_bar = _DragHandle(self)
+ self._empty_title_bar = QWidget(self)
+ self._empty_title_bar.setFixedHeight(0)
+ self._embedded_root: QWidget | None = None
+ self._embedded_header_layout: QHBoxLayout | None = None
+ self._embedded_chrome = False
+ self._resize_handle = _ResizeHandle(self)
+ self.setAttribute(Qt.WidgetAttribute.WA_StyledBackground, True)
+ self.setStyleSheet("QDockWidget { background: #202225; border: 1px solid #2c2f33; }")
+ self.setTitleBarWidget(self._title_bar)
+ self.setFeatures(QDockWidget.DockWidgetFeature.DockWidgetClosable)
+
+ def setWidget(self, widget: QWidget) -> None: # noqa: N802 - Qt API
+ super().setWidget(widget)
+ self._find_embedded_header()
+ self._apply_window_chrome()
+ if hasattr(widget, "minimumSizeChanged"):
+ widget.minimumSizeChanged.connect(self.minimumSizeChanged.emit)
+
+ def _find_embedded_header(self) -> None:
+ """Find the panel's existing first-row layout for its arrange button."""
+ root = self.widget()
+ if root is None:
+ return
+ root_layout = root.layout()
+ if root_layout is not None and root_layout.count():
+ first = root_layout.itemAt(0)
+ row = first.widget()
+ if row is not None and isinstance(row.layout(), QHBoxLayout):
+ self._embedded_root = row
+ self._embedded_header_layout = row.layout()
+ return
+ row_layout = first.layout()
+ if isinstance(row_layout, QHBoxLayout):
+ self._embedded_root = root
+ self._embedded_header_layout = row_layout
+ return
+ header = getattr(root, "header", None)
+ if isinstance(header, QWidget) and isinstance(header.layout(), QHBoxLayout):
+ self._embedded_root = root
+ self._embedded_header_layout = header.layout()
+
+ def _apply_window_chrome(self) -> None:
+ if self._embedded_root is None or self._embedded_header_layout is None:
+ self._set_embedded_chrome(False)
+ return
+ self._set_embedded_chrome(True)
+
+ def _set_embedded_chrome(self, enabled: bool) -> None:
+ if enabled and self._embedded_root is not None and self._embedded_header_layout is not None:
+ self.setTitleBarWidget(self._empty_title_bar)
+ self._title_bar.button.hide()
+ self._title_bar.layout().removeWidget(self._title_bar.button)
+ self._embedded_header_layout.removeWidget(self._title_bar.button)
+ self._embedded_header_layout.insertWidget(0, self._title_bar.button)
+ self._title_bar.set_embedded(True)
+ self._title_bar.setParent(self._embedded_root)
+ self._embedded_root.installEventFilter(self)
+ self._title_bar.button.show()
+ self._title_bar.show()
+ self._embedded_chrome = True
+ self._position_embedded_handle()
+ return
+
+ if self._embedded_chrome:
+ self._title_bar.hide()
+ if self._embedded_header_layout is not None:
+ self._embedded_header_layout.removeWidget(self._title_bar.button)
+ self._title_bar.button.setParent(self._title_bar)
+ self._title_bar.set_embedded(False)
+ if self._title_bar.layout().indexOf(self._title_bar.button) < 0:
+ self._title_bar.layout().addWidget(self._title_bar.button)
+ self._embedded_chrome = False
+ self._title_bar.setParent(self)
+ self.setTitleBarWidget(self._title_bar)
+ self._title_bar.show()
+
+ def _position_embedded_handle(self) -> None:
+ root = self._embedded_root
+ if root is not None and self._embedded_chrome:
+ self._title_bar.move(max(0, (root.width() - self._title_bar.width()) // 2), 0)
+ self._title_bar.raise_()
+
+ def eventFilter(self, watched, event) -> bool:
+ if watched is self._embedded_root and event.type() in (QEvent.Type.Resize, QEvent.Type.LayoutRequest):
+ self._position_embedded_handle()
+ return super().eventFilter(watched, event)
+
+ def set_display_title(self, title: str) -> None:
+ """Set the concise label used when this panel appears in a tab group."""
+ self.display_title = title.strip() or "Unassigned"
+ self.setWindowTitle(self.display_title)
+ self._view_action.setText(f"{self.display_title} · {self.instance_title}")
+ self._title_bar.button.setAccessibleName(f"Arrange {self.display_title}")
+ if self.workspace is not None:
+ group_id = self.workspace._group_for.get(self.panel_id)
+ if group_id:
+ tabs = self.workspace._groups[group_id]["tabs"]
+ index = tabs.indexOf(self)
+ if index >= 0:
+ tabs.setTabText(index, self.display_title)
+
+ def resizeEvent(self, event) -> None:
+ super().resizeEvent(event)
+ self._resize_handle.move(self.width() - self._resize_handle.width(), self.height() - self._resize_handle.height())
+ self._resize_handle.raise_()
+
+ def show_arrangement_menu(self) -> None:
+ if self.workspace is None:
+ return
+ self.workspace.show_panel_menu(self)
+
+ def toggleViewAction(self) -> QAction: # noqa: N802 - Qt API
+ return self._view_action
+
+ def _view_action_toggled(self, visible: bool) -> None:
+ if self.workspace is not None:
+ self.workspace.set_panel_visible(self, visible)
+
+ def contextMenuEvent(self, event) -> None:
+ if self.workspace is not None:
+ self.workspace.show_panel_menu(self, event.globalPos())
+ event.accept()
+
+ def closeEvent(self, event) -> None:
+ if self.workspace is not None:
+ self.workspace.set_panel_visible(self, False)
+ event.accept()
+ return
+ self.user_visible = False
+ self._view_action.setChecked(False)
+ super().closeEvent(event)
+
+ def setFloating(self, floating: bool) -> None: # noqa: N802 - Qt API
+ if self.workspace is None:
+ super().setFloating(floating)
+ elif floating:
+ self.workspace.float_panel(self)
+ else:
+ self.workspace.dock_panel(self)
+
+ def isFloating(self) -> bool: # noqa: N802 - Qt API
+ if self.workspace is not None:
+ return self.panel_id in self.workspace._floating
+ return super().isFloating()
+
+ def raise_(self) -> None:
+ if self.workspace is not None and self.panel_id in self.workspace._group_for:
+ group = self.workspace._groups[self.workspace._group_for[self.panel_id]]
+ group["tabs"].setCurrentWidget(self)
+ super().raise_()
+
+
+class GridWorkspace(QScrollArea):
+ """Scrollable cell canvas for movable, bounded DynaLab panel windows.
+
+ Public operations: ``add_panel``, ``remove_panel``, ``set_panel_rect``,
+ ``fit_panel``, ``merge_panels``, ``float_panel``, ``dock_panel``,
+ ``save_layout`` and ``restore_layout``.
+ """
+
+ def __init__(self, parent: QWidget | None = None) -> None:
+ super().__init__(parent)
+ self.setObjectName("gridWorkspace")
+ self.setWidgetResizable(True)
+ self.setFrameShape(QScrollArea.Shape.NoFrame)
+ self.canvas = _GridCanvas(self)
+ self.canvas.setObjectName("gridWorkspaceCanvas")
+ self.canvas.setStyleSheet("QWidget#gridWorkspaceCanvas { background: #181a1d; }")
+ self.canvas.setMinimumSize(1, 1)
+ self.setWidget(self.canvas)
+ self._docks: dict[str, PanelDockWidget] = {}
+ self._groups: dict[str, dict] = {}
+ self._group_for: dict[str, str] = {}
+ self._floating: dict[str, tuple[int, int, int, int]] = {}
+ self._preview = _Preview(self.canvas)
+ self._interaction: dict | None = None
+ self._restoring = False
+
+ def resizeEvent(self, event) -> None:
+ super().resizeEvent(event)
+ self._update_canvas_size()
+
+ def _update_canvas_size(self) -> None:
+ if not hasattr(self, "canvas") or not hasattr(self, "_docks"):
+ return
+ rects = [dock._saved_rect for dock in self._docks.values()]
+ width = max(((x + w) * CELL_WIDTH for x, _y, w, _h in rects), default=1)
+ height = max(((y + h) * CELL_HEIGHT for _x, y, _w, h in rects), default=1)
+ self.canvas.setMinimumSize(width, height)
+
+ def add_panel(self, dock: PanelDockWidget, panel_type: str | None = None) -> None:
+ """Add a panel window and place it in the first clear preferred cells."""
+ if dock.panel_id in self._docks and self._docks[dock.panel_id] is not dock:
+ raise ValueError(f"A panel named {dock.panel_id!r} is already registered.")
+ dock.panel_type = panel_type or getattr(dock, "panel_type", "numeric")
+ dock._find_embedded_header()
+ dock._apply_window_chrome()
+ dock.workspace = self
+ dock.user_visible = True
+ dock.setParent(self.canvas)
+ dock.setWindowFlags(Qt.WindowType.Widget)
+ dock._apply_window_chrome()
+ self._docks[dock.panel_id] = dock
+ # Let Qt finish responsive content layout and minimum-size propagation
+ # before reconciling the rendered dock with its grid rectangle.
+ dock.minimumSizeChanged.connect(
+ lambda current=dock: self.fit_panel(current), Qt.ConnectionType.QueuedConnection
+ )
+ minimum, preferred, _maximum = self._limits(dock)
+ columns = max(1, self.viewport().width() // CELL_WIDTH)
+ preferred_width = min(preferred[0], columns)
+ rect = self._find_free_in_view((preferred_width, preferred[1]), dock, columns)
+ if rect is None:
+ rect = self._find_free_in_view((min(minimum[0], columns), minimum[1]), dock, columns)
+ if rect is None:
+ rect = (0, 0, preferred[0], preferred[1])
+ self._set_rect_internal(dock, rect)
+ dock.show()
+ self.fit_panel(dock)
+ self._grow_canvas_for(rect)
+
+ def remove_panel(self, dock: PanelDockWidget) -> None:
+ """Remove a registered panel, dissolving a tab group when needed."""
+ self._detach_from_group(dock)
+ self._docks.pop(dock.panel_id, None)
+ self._floating.pop(dock.panel_id, None)
+ dock.workspace = None
+ dock.hide()
+ dock.setParent(None)
+ self._update_canvas_size()
+
+ def set_panel_visible(self, dock: PanelDockWidget, visible: bool, *, activate: bool = True) -> None:
+ """Show a panel or its tab, or hide that panel while preserving layout."""
+ dock.user_visible = bool(visible)
+ dock._view_action.blockSignals(True)
+ dock._view_action.setChecked(bool(visible))
+ dock._view_action.blockSignals(False)
+ if visible:
+ if dock.panel_id in self._floating:
+ dock.show()
+ elif dock.panel_id in self._group_for:
+ group = self._groups[self._group_for[dock.panel_id]]
+ group["widget"].show()
+ index = group["tabs"].indexOf(dock)
+ group["tabs"].setTabVisible(index, True)
+ if activate:
+ group["tabs"].setCurrentWidget(dock)
+ dock.show()
+ else:
+ dock.show()
+ else:
+ group_id = self._group_for.get(dock.panel_id)
+ if group_id:
+ group = self._groups[group_id]
+ index = group["tabs"].indexOf(dock)
+ if index >= 0:
+ group["tabs"].setTabVisible(index, False)
+ if not any(self._docks[panel_id].user_visible for panel_id in group["panels"]):
+ group["widget"].hide()
+ dock.hide()
+
+ def make_full_width(self, dock: PanelDockWidget) -> tuple[int, int, int, int]:
+ """Place a graph across the top, shifting occupied grid rows below it."""
+ if dock.panel_id in self._floating:
+ self.dock_panel(dock)
+ columns = max(1, self.viewport().width() // CELL_WIDTH)
+ minimum, _preferred, maximum = self._limits(dock)
+ if maximum[0] < columns:
+ return self.panel_rect(dock)
+ _x, _y, _width, height = dock._saved_rect
+ target = (0, 0, columns, max(height, minimum[1]))
+ occupied = self._occupied(dock)
+ if any(self._overlaps(target, rect) for _key, rect in occupied):
+ units = set(key for key, _rect in occupied)
+ for key in units:
+ if key in self._groups:
+ group = self._groups[key]
+ gx, gy, gw, gh = group["rect"]
+ self._set_group_geometry_with_rect(group, (gx, gy + target[3], gw, gh))
+ elif key in self._floating:
+ fx, fy, fw, fh = self._floating[key]
+ self._floating[key] = (fx, fy + target[3], fw, fh)
+ self._docks[key]._saved_rect = self._floating[key]
+ self._grow_canvas_for(self._floating[key])
+ elif key in self._docks:
+ other = self._docks[key]
+ ox, oy, ow, oh = other._saved_rect
+ self._set_rect_internal(other, (ox, oy + target[3], ow, oh))
+ self._set_rect_internal(dock, target)
+ self._grow_canvas_for(target)
+ return target
+
+ def move_to_top_row(self, dock: PanelDockWidget) -> tuple[int, int, int, int]:
+ """Move a panel toward the top of the grid, resolving collisions."""
+ x, _y, width, height = dock._saved_rect
+ return self.set_panel_rect(dock, (x, 0, width, height))
+
+ def detach_tab(self, dock: PanelDockWidget) -> tuple[int, int, int, int]:
+ """Turn a tab into its own grid window at the nearest free cells."""
+ if dock.panel_id not in self._group_for:
+ return self.panel_rect(dock)
+ visible = dock.user_visible
+ rect = self.panel_rect(dock)
+ self._detach_from_group(dock)
+ placed = self.set_panel_rect(dock, rect)
+ self.set_panel_visible(dock, visible, activate=False)
+ return placed
+
+ def panel_rect(self, dock: PanelDockWidget) -> tuple[int, int, int, int]:
+ return dock._saved_rect
+
+ def set_panel_rect(self, dock: PanelDockWidget, cell_rect: tuple[int, int, int, int] | list[int] | dict) -> tuple[int, int, int, int]:
+ """Set a panel's ``(column,row,width,height)`` cell rectangle.
+
+ Collisions move the panel to the nearest clear location in deterministic
+ row/column order. Spans are clamped to the panel purpose's limits.
+ """
+ if isinstance(cell_rect, dict):
+ rect = tuple(int(cell_rect[key]) for key in ("x", "y", "w", "h"))
+ else:
+ rect = tuple(int(value) for value in cell_rect)
+ if len(rect) != 4:
+ raise ValueError("cell_rect must contain x, y, width, and height")
+ if dock.panel_id in self._group_for:
+ members = [self._docks[panel_id] for panel_id in self._groups[self._group_for[dock.panel_id]]["panels"]]
+ minimum = (
+ max(self._limits(member)[0][0] for member in members),
+ max(self._limits(member)[0][1] for member in members),
+ )
+ maximum = (
+ max(self._limits(member)[2][0] for member in members),
+ max(self._limits(member)[2][1] for member in members),
+ )
+ x, y, width, height = rect
+ rect = (max(0, x), max(0, y), min(maximum[0], max(minimum[0], width)), min(maximum[1], max(minimum[1], height)))
+ else:
+ rect = self._clamp_rect(dock, rect, allow_fit=self._restoring)
+ placed = self._find_free(rect, dock)
+ if placed is None:
+ placed = rect
+ self._set_rect_internal(dock, placed)
+ self._grow_canvas_for(placed)
+ return placed
+
+ def fit_panel(self, dock: PanelDockWidget) -> tuple[int, int, int, int]:
+ """Grow cells enough to fit the panel's content minimum size hint."""
+ if dock.panel_id not in self._docks or dock.widget() is None:
+ return self.panel_rect(dock)
+ minimum = self._limits(dock)[0]
+ x, y, width, height = self.panel_rect(dock)
+ width = max(width, minimum[0])
+ height = max(height, minimum[1])
+ rect = self._find_free((x, y, width, height), dock)
+ self._set_rect_internal(dock, rect or (x, y, width, height))
+ self._grow_canvas_for(dock._saved_rect)
+ return dock._saved_rect
+
+ def merge_panels(self, dock: PanelDockWidget, target: PanelDockWidget) -> None:
+ """Merge two panels into one tab group, preserving their panel IDs."""
+ if dock is target or dock.panel_id not in self._docks or target.panel_id not in self._docks:
+ raise ValueError("Choose two different panels in this workspace")
+ if dock.panel_id in self._floating:
+ self.dock_panel(dock)
+ if target.panel_id in self._floating:
+ self.dock_panel(target)
+ target_group = self._group_for.get(target.panel_id)
+ dock_group = self._group_for.get(dock.panel_id)
+ if target_group and dock_group == target_group:
+ self._groups[target_group]["tabs"].setCurrentWidget(dock)
+ return
+ if target_group:
+ group = self._groups[target_group]
+ group_rect = group["rect"]
+ else:
+ group_id = f"tabs-{uuid4().hex[:10]}"
+ group_rect = self.panel_rect(target)
+ host = QWidget(self.canvas)
+ host.setObjectName(f"{group_id}Host")
+ host.setAttribute(Qt.WidgetAttribute.WA_StyledBackground, True)
+ host.setStyleSheet(f"QWidget#{group_id}Host {{ background: #202225; border: 1px solid #2c2f33; }}")
+ layout = QVBoxLayout(host)
+ layout.setContentsMargins(0, 0, 0, 0)
+ layout.setSpacing(0)
+ tabs = QTabWidget(host)
+ tabs.setObjectName(group_id)
+ tabs.setMovable(True)
+ tabs.setTabsClosable(False)
+ tabs.setDocumentMode(True)
+ tabs.setStyleSheet("QTabWidget::pane { border: 0; } QTabBar::tab { padding: 2px 8px; }")
+ tabs.tabBar().setFixedHeight(GROUP_TAB_HEIGHT)
+ handle = _DragHandle(target, host)
+ group = {"widget": host, "tabs": tabs, "handle": handle, "rect": group_rect, "panels": []}
+ handle.group = group
+ handle.set_button_left(True)
+ handle.button.clicked.disconnect()
+ handle.button.setToolTip("Arrange this tab group")
+ handle.button.setAccessibleName("Arrange tab group")
+ handle.button.clicked.connect(lambda _checked=False, current=handle: self.show_panel_menu(current._target_dock()))
+ layout.addWidget(handle)
+ layout.addWidget(tabs, 1)
+ self._groups[group_id] = group
+ self._group_for[target.panel_id] = group_id
+ self._put_in_tabs(target, tabs)
+ group["panels"].append(target.panel_id)
+ if dock_group:
+ self._detach_from_group(dock)
+ else:
+ dock.hide()
+ self._group_for[dock.panel_id] = target_group or group_id
+ group = self._groups[self._group_for[dock.panel_id]]
+ group["panels"].append(dock.panel_id)
+ self._put_in_tabs(dock, group["tabs"])
+ index = group["tabs"].indexOf(dock)
+ group["tabs"].setTabVisible(index, dock.user_visible)
+ self._set_group_geometry(group)
+ group["tabs"].setCurrentWidget(dock)
+ group["widget"].show()
+ self._grow_canvas_for(group["rect"])
+ self.fit_panel(dock)
+
+ def float_panel(self, dock: PanelDockWidget) -> None:
+ """Open a panel as a native top-level window, keeping its grid position."""
+ if dock.panel_id not in self._docks or dock.panel_id in self._floating:
+ return
+ rect = self.panel_rect(dock)
+ was_visible = dock.user_visible
+ self._detach_from_group(dock)
+ self._floating[dock.panel_id] = rect
+ point = self.canvas.mapToGlobal(self._pixel_rect(rect).topLeft())
+ dock.setParent(None)
+ dock.setWindowFlags(Qt.WindowType.Window)
+ dock.setGeometry(QRect(point, self._pixel_rect(rect).size()))
+ dock.show()
+ if not was_visible:
+ dock.hide()
+ dock.user_visible = False
+
+ def dock_panel(self, dock: PanelDockWidget) -> None:
+ """Return a floating panel to its saved grid rectangle."""
+ rect = self._floating.pop(dock.panel_id, None)
+ if rect is None:
+ return
+ was_visible = dock.user_visible
+ dock.hide()
+ dock.setParent(self.canvas)
+ dock.setWindowFlags(Qt.WindowType.Widget)
+ dock._apply_window_chrome()
+ dock.show()
+ self._set_rect_internal(dock, self._find_free(rect, dock))
+ self.fit_panel(dock)
+ if not was_visible:
+ dock.hide()
+ dock.user_visible = False
+
+ def resize_floating(self, dock: PanelDockWidget, size: QSize) -> QSize:
+ """Resize a floating panel in pixels within its purpose limits."""
+ _minimum, _preferred, maximum = self._limits(dock)
+ minimum = self._limits(dock)[0]
+ min_width = max(dock.widget().minimumWidth() if dock.widget() else 0, minimum[0] * CELL_WIDTH - 2 * CELL_GAP)
+ min_height = max(dock.widget().minimumHeight() if dock.widget() else 0, minimum[1] * CELL_HEIGHT - 2 * CELL_GAP)
+ max_width = max(min_width, maximum[0] * CELL_WIDTH - 2 * CELL_GAP)
+ max_height = max(min_height, maximum[1] * CELL_HEIGHT - 2 * CELL_GAP)
+ actual = QSize(max(min_width, min(max_width, size.width())), max(min_height, min(max_height, size.height())))
+ dock.resize(actual)
+ return actual
+
+ def save_layout(self) -> dict:
+ """Return a JSON-compatible layout record for workspace persistence."""
+ panels = {
+ panel_id: {"type": dock.panel_type, "rect": list(dock._saved_rect)}
+ for panel_id, dock in self._docks.items()
+ }
+ groups = [
+ {
+ "id": group_id,
+ "panels": list(group["panels"]),
+ "rect": list(group["rect"]),
+ "active": group["tabs"].currentWidget().panel_id if group["tabs"].currentWidget() else None,
+ }
+ for group_id, group in self._groups.items()
+ if len(group["panels"]) > 1
+ ]
+ floating = {
+ panel_id: [dock.geometry().x(), dock.geometry().y(), dock.width(), dock.height()]
+ for panel_id, dock in self._docks.items()
+ if panel_id in self._floating
+ }
+ hidden = sorted(panel_id for panel_id, dock in self._docks.items() if not dock.user_visible)
+ return {"version": 1, "panels": panels, "groups": groups, "floating": floating, "hidden": hidden}
+
+ def restore_layout(self, data: dict, docks: dict[str, PanelDockWidget] | None = None) -> None:
+ """Restore saved panel rectangles and tab groups for registered docks."""
+ if not isinstance(data, dict) or data.get("version") != 1 or not isinstance(data.get("panels"), dict):
+ raise ValueError("Unsupported grid layout")
+ source = docks or self._docks
+ self._restoring = True
+ for panel_id, saved in data["panels"].items():
+ dock = source.get(panel_id)
+ if dock is None:
+ continue
+ if dock.panel_id not in self._docks:
+ self.add_panel(dock, saved.get("type", "numeric"))
+ dock.panel_type = saved.get("type", dock.panel_type)
+ # Establish saved width before measuring responsive content height.
+ self._set_rect_internal(dock, tuple(saved.get("rect", self.panel_rect(dock))))
+ self.fit_panel(dock)
+ self._restoring = False
+ for group in data.get("groups", []):
+ members = [source[panel_id] for panel_id in group.get("panels", []) if panel_id in source and panel_id in self._docks]
+ if len(members) > 1:
+ first = members[0]
+ for dock in members[1:]:
+ self.merge_panels(dock, first)
+ if "rect" in group:
+ self._groups[self._group_for[first.panel_id]]["rect"] = tuple(group["rect"])
+ self._set_group_geometry(self._groups[self._group_for[first.panel_id]])
+ active = group.get("active")
+ if active in self._docks:
+ self._groups[self._group_for[first.panel_id]]["tabs"].setCurrentWidget(self._docks[active])
+ # Resolve any collisions in the saved geometry in stable panel order.
+ for dock in sorted(self._docks.values(), key=lambda item: item.panel_id):
+ if dock.panel_id in self._group_for and self._group_for[dock.panel_id] in self._groups:
+ group = self._groups[self._group_for[dock.panel_id]]
+ if dock.panel_id == group["panels"][0]:
+ self._set_rect_internal(dock, self._find_free(group["rect"], dock))
+ elif dock.panel_id not in self._floating:
+ self._set_rect_internal(dock, self._find_free(dock._saved_rect, dock))
+ floating = data.get("floating", {})
+ if isinstance(floating, list):
+ floating = {panel_id: None for panel_id in floating}
+ for panel_id, geometry in floating.items():
+ if panel_id in source and panel_id in self._docks:
+ self.float_panel(source[panel_id])
+ if geometry and len(geometry) == 4:
+ source[panel_id].setGeometry(QRect(*geometry))
+ for panel_id in data.get("hidden", []):
+ if panel_id in self._docks:
+ self.set_panel_visible(self._docks[panel_id], False)
+
+ def show_panel_menu(self, dock: PanelDockWidget, global_pos: QPoint | None = None) -> None:
+ menu = QMenu(dock)
+ menu.addAction("Float panel" if dock.panel_id not in self._floating else "Return to grid", lambda: self.dock_panel(dock) if dock.panel_id in self._floating else self.float_panel(dock))
+ menu.addAction("Hide panel" if dock.user_visible else "Show panel", lambda: self.set_panel_visible(dock, not dock.user_visible))
+ if dock.panel_id in self._group_for:
+ menu.addAction("Detach tab to grid", lambda: self.detach_tab(dock))
+ merge = menu.addMenu("Merge as tab with…")
+ for target in self._docks.values():
+ if target is dock:
+ continue
+ merge.addAction(target.toggleViewAction().text(), lambda _checked=False, other=target: self.merge_panels(dock, other))
+ menu.addSeparator()
+ full_width_action = menu.addAction("Full-width top row", lambda: self.make_full_width(dock))
+ full_width_action.setEnabled(self._limits(dock)[2][0] >= max(1, self.viewport().width() // CELL_WIDTH))
+ menu.addAction("Move toward top row", lambda: self.move_to_top_row(dock))
+ cells = menu.addAction(f"Size: {dock._saved_rect[2]} × {dock._saved_rect[3]} cells")
+ cells.setEnabled(False)
+ minimum, _preferred, maximum = self._limits(dock)
+ cells.setToolTip(f"Panel limits: {minimum[0]}–{maximum[0]} columns × {minimum[1]}–{maximum[1]} rows")
+ if dock.removable:
+ menu.addSeparator()
+ menu.addAction("Remove panel", lambda: dock.removeRequested.emit(dock.panel_id))
+ menu.exec(global_pos or dock.mapToGlobal(QPoint(20, 20)))
+
+ def _limits(self, dock: PanelDockWidget):
+ minimum, preferred, maximum = _LIMITS.get(dock.panel_type, _LIMITS["numeric"])
+ available_columns = max(1, self.viewport().width() // CELL_WIDTH)
+ if dock.panel_type == "graph" or dock.panel_type.endswith("_group"):
+ maximum = (max(maximum[0], available_columns), maximum[1])
+ if dock.panel_id in self._group_for:
+ members = [self._docks[panel_id] for panel_id in self._groups[self._group_for[dock.panel_id]]["panels"]]
+ minimum = (
+ max(self._minimum_cells(member, grouped=True)[0] for member in members),
+ max(self._minimum_cells(member, grouped=True)[1] for member in members),
+ )
+ maximum = (
+ max(maximum[0], *(self._limits_without_group(member)[2][0] for member in members)),
+ max(maximum[1], *(self._limits_without_group(member)[2][1] for member in members)),
+ )
+ else:
+ content_minimum = self._minimum_cells(dock, grouped=False)
+ minimum = (max(minimum[0], content_minimum[0]), max(minimum[1], content_minimum[1]))
+ maximum = (max(maximum[0], minimum[0]), max(maximum[1], minimum[1]))
+ return minimum, preferred, maximum
+
+ def _limits_without_group(self, dock: PanelDockWidget):
+ minimum, preferred, maximum = _LIMITS.get(dock.panel_type, _LIMITS["numeric"])
+ available_columns = max(1, self.viewport().width() // CELL_WIDTH)
+ if dock.panel_type == "graph" or dock.panel_type.endswith("_group"):
+ maximum = (max(maximum[0], available_columns), maximum[1])
+ content_minimum = self._minimum_cells(dock, grouped=False)
+ minimum = (max(minimum[0], content_minimum[0]), max(minimum[1], content_minimum[1]))
+ return minimum, preferred, (max(maximum[0], minimum[0]), max(maximum[1], minimum[1]))
+
+ def _minimum_cells(self, dock: PanelDockWidget, *, grouped: bool) -> tuple[int, int]:
+ widget = dock.widget()
+ if widget is None:
+ content_width = content_height = 0
+ else:
+ hint = widget.minimumSizeHint()
+ explicit_minimum = widget.minimumSize()
+ content_width = max(0, hint.width(), explicit_minimum.width())
+ content_height = max(0, hint.height(), explicit_minimum.height())
+ title_height = DRAG_HANDLE_HEIGHT if not dock._embedded_chrome else 0
+ tab_height = GROUP_TAB_HEIGHT if grouped else 0
+ width_cells = ceil((content_width + 2 * CELL_GAP) / CELL_WIDTH)
+ height_cells = ceil((content_height + title_height + tab_height + 2 * CELL_GAP) / CELL_HEIGHT)
+ return max(1, width_cells), max(1, height_cells)
+
+ def _clamp_rect(self, dock: PanelDockWidget, rect: tuple[int, int, int, int], *, allow_fit: bool = False):
+ x, y, width, height = rect
+ minimum, preferred, maximum = self._limits(dock)
+ max_width = max(maximum[0], width) if allow_fit else maximum[0]
+ max_height = max(maximum[1], height) if allow_fit else maximum[1]
+ return (max(0, x), max(0, y), min(max_width, max(minimum[0], width)), min(max_height, max(minimum[1], height)))
+
+ def _set_rect_internal(self, dock: PanelDockWidget, rect: tuple[int, int, int, int]) -> None:
+ rect = tuple(int(value) for value in rect)
+ dock._saved_rect = rect
+ if dock.panel_id in self._group_for:
+ group = self._groups[self._group_for[dock.panel_id]]
+ group["rect"] = rect
+ self._set_group_geometry(group)
+ elif dock.panel_id not in self._floating:
+ dock.setGeometry(self._pixel_rect(rect))
+ if dock.layout() is not None:
+ dock.layout().setGeometry(dock.rect())
+ self._grow_canvas_for(rect)
+
+ def _pixel_rect(self, rect: tuple[int, int, int, int]) -> QRect:
+ x, y, width, height = rect
+ return QRect(x * CELL_WIDTH, y * CELL_HEIGHT, max(1, width * CELL_WIDTH), max(1, height * CELL_HEIGHT))
+
+ def _grow_canvas_for(self, _rect: tuple[int, int, int, int]) -> None:
+ self._update_canvas_size()
+
+ def _occupied(self, exclude: PanelDockWidget | None = None):
+ if self._restoring:
+ return []
+ seen: set[str] = set()
+ excluded_group = self._group_for.get(exclude.panel_id) if exclude is not None else None
+ rects = []
+ for dock in self._docks.values():
+ if dock is exclude or dock.panel_id in seen:
+ continue
+ if dock.panel_id in self._floating:
+ rects.append((dock.panel_id, self._floating[dock.panel_id]))
+ continue
+ group_id = self._group_for.get(dock.panel_id)
+ if group_id:
+ if group_id == excluded_group:
+ seen.update(self._groups[group_id]["panels"])
+ continue
+ seen.update(self._groups[group_id]["panels"])
+ rects.append((group_id, self._groups[group_id]["rect"]))
+ else:
+ rects.append((dock.panel_id, dock._saved_rect))
+ return rects
+
+ def _find_free(self, rect: tuple[int, int, int, int], exclude: PanelDockWidget | None):
+ x, y, width, height = rect
+ occupied = self._occupied(exclude)
+ distance = 0
+ while True:
+ candidates = []
+ for delta_y in range(-distance, distance + 1):
+ delta_x = distance - abs(delta_y)
+ row = y + delta_y
+ if row < 0:
+ continue
+ candidates.append((row, max(0, x - delta_x)))
+ if delta_x:
+ candidates.append((row, x + delta_x))
+ for row, col in candidates:
+ candidate = (col, row, width, height)
+ if not any(self._overlaps(candidate, other) for _key, other in occupied):
+ return candidate
+ distance += 1
+
+ def _find_free_in_view(self, span: tuple[int, int], exclude: PanelDockWidget, columns: int):
+ width, height = span
+ occupied = self._occupied(exclude)
+ if width > columns:
+ return self._find_free((0, 0, width, height), exclude)
+ row = 0
+ while True:
+ for col in range(0, columns - width + 1):
+ candidate = (col, row, width, height)
+ if not any(self._overlaps(candidate, other) for _key, other in occupied):
+ return candidate
+ row += 1
+
+ @staticmethod
+ def _overlaps(a, b) -> bool:
+ ax, ay, aw, ah = a
+ bx, by, bw, bh = b
+ return ax < bx + bw and bx < ax + aw and ay < by + bh and by < ay + ah
+
+ def _put_in_tabs(self, dock: PanelDockWidget, tabs: QTabWidget) -> None:
+ dock.hide()
+ dock._set_embedded_chrome(False)
+ dock.setTitleBarWidget(dock._empty_title_bar)
+ dock.setParent(tabs)
+ tabs.addTab(dock, dock.display_title)
+ dock.show()
+
+ def _detach_from_group(self, dock: PanelDockWidget) -> None:
+ group_id = self._group_for.pop(dock.panel_id, None)
+ if group_id is None:
+ return
+ group = self._groups[group_id]
+ tabs: QTabWidget = group["tabs"]
+ index = tabs.indexOf(dock)
+ if index >= 0:
+ tabs.removeTab(index)
+ group["panels"].remove(dock.panel_id)
+ dock.hide()
+ dock.setParent(self.canvas)
+ dock._apply_window_chrome()
+ if len(group["panels"]) <= 1:
+ remaining = group["panels"].pop() if group["panels"] else None
+ if remaining is not None:
+ other = self._docks[remaining]
+ tabs.removeTab(tabs.indexOf(other))
+ self._group_for.pop(remaining, None)
+ other.hide()
+ other.setParent(self.canvas)
+ other._apply_window_chrome()
+ self._set_rect_internal(other, group["rect"])
+ other.show()
+ group["widget"].hide()
+ group["widget"].deleteLater()
+ del self._groups[group_id]
+ else:
+ self._set_group_geometry(group)
+ dock.show()
+
+ def _set_group_geometry(self, group: dict) -> None:
+ host: QWidget = group["widget"]
+ host.setGeometry(self._pixel_rect(group["rect"]))
+ host.show()
+ for panel_id in group["panels"]:
+ self._docks[panel_id]._saved_rect = group["rect"]
+
+ def _set_group_geometry_with_rect(self, group: dict, rect: tuple[int, int, int, int]) -> None:
+ group["rect"] = rect
+ self._set_group_geometry(group)
+
+ def _move_begin(self, dock, global_pos, rect) -> None:
+ self._interaction = {"kind": "move", "dock": dock, "origin": global_pos, "rect": rect}
+ self._preview.show()
+ self._preview.raise_()
+ self._move_update(dock, global_pos)
+
+ def _move_update(self, dock, global_pos) -> None:
+ if self._interaction is None:
+ return
+ interaction = self._interaction
+ origin = interaction["origin"]
+ x, y, width, height = interaction["rect"]
+ point_delta = global_pos - origin
+ cell_dx = round(point_delta.x() / CELL_WIDTH)
+ cell_dy = round(point_delta.y() / CELL_HEIGHT)
+ proposed = (max(0, x + cell_dx), max(0, y + cell_dy), width, height)
+ placed = self._find_free(proposed, dock)
+ self._preview.valid = placed is not None
+ self._preview.setGeometry(self._pixel_rect(placed or proposed))
+ self._preview.update()
+
+ def _move_finish(self, dock, global_pos) -> None:
+ self._move_update(dock, global_pos)
+ if self._preview.valid:
+ point = self._preview.pos()
+ col = max(0, round((point.x() - CELL_GAP) / CELL_WIDTH))
+ row = max(0, round((point.y() - CELL_GAP) / CELL_HEIGHT))
+ self.set_panel_rect(dock, (col, row, *self.panel_rect(dock)[2:]))
+ self._preview.hide()
+ self._interaction = None
+
+ def _resize_begin(self, dock, global_pos, rect) -> None:
+ self._interaction = {"kind": "resize", "dock": dock, "origin": global_pos, "rect": rect}
+ self._preview.show()
+ self._preview.raise_()
+ self._resize_update(dock, global_pos)
+
+ def _resize_update(self, dock, global_pos) -> None:
+ if self._interaction is None:
+ return
+ interaction = self._interaction
+ origin = interaction["origin"]
+ x, y, width, height = interaction["rect"]
+ point_delta = global_pos - origin
+ new_width = width + round(point_delta.x() / CELL_WIDTH)
+ new_height = height + round(point_delta.y() / CELL_HEIGHT)
+ proposed = self._clamp_rect(dock, (x, y, new_width, new_height))
+ # Resizing is anchored at the top-left. Do not silently move a panel
+ # away from the pointer when its new rectangle would overlap a neighbor.
+ is_free = not any(self._overlaps(proposed, other) for _key, other in self._occupied(dock))
+ self._preview.valid = is_free
+ self._preview.setGeometry(self._pixel_rect(proposed))
+ self._preview.update()
+
+ def _resize_finish(self, dock, global_pos) -> None:
+ self._resize_update(dock, global_pos)
+ if self._preview.valid:
+ point = self._preview.pos()
+ size = self._preview.size()
+ col = max(0, round((point.x() - CELL_GAP) / CELL_WIDTH))
+ row = max(0, round((point.y() - CELL_GAP) / CELL_HEIGHT))
+ width = max(1, round((size.width() + 2 * CELL_GAP) / CELL_WIDTH))
+ height = max(1, round((size.height() + 2 * CELL_GAP) / CELL_HEIGHT))
+ self.set_panel_rect(dock, (col, row, width, height))
+ self._preview.hide()
+ self._interaction = None
diff --git a/src/dynalab_py/icons/chevron.svg b/src/dynalab_py/icons/chevron.svg
new file mode 100644
index 0000000..7307367
--- /dev/null
+++ b/src/dynalab_py/icons/chevron.svg
@@ -0,0 +1 @@
+
diff --git a/src/dynalab_py/icons/gear.svg b/src/dynalab_py/icons/gear.svg
new file mode 100644
index 0000000..718ea5a
--- /dev/null
+++ b/src/dynalab_py/icons/gear.svg
@@ -0,0 +1,4 @@
+
diff --git a/src/dynalab_py/instrument_group.py b/src/dynalab_py/instrument_group.py
new file mode 100644
index 0000000..a81cc4b
--- /dev/null
+++ b/src/dynalab_py/instrument_group.py
@@ -0,0 +1,232 @@
+"""Resizable collections of independent gauge or numeric panels."""
+
+from math import ceil
+from pathlib import Path
+from typing import Any
+
+from PySide6.QtCore import QSize, Signal
+from PySide6.QtGui import QIcon
+from PySide6.QtWidgets import (
+ QGridLayout,
+ QHBoxLayout,
+ QLabel,
+ QLayout,
+ QMenu,
+ QPushButton,
+ QToolButton,
+ QVBoxLayout,
+ QWidget,
+)
+
+from .panels import GaugePanel, NumericPanel
+
+
+class InstrumentGroupPanel(QWidget):
+ """A grid of independently selectable gauges or numeric readouts."""
+
+ configChanged = Signal(dict)
+ removeRequested = Signal()
+ minimumSizeChanged = Signal()
+
+ _MIN_INSTRUMENT_WIDTH = 190
+
+ def __init__(self, config: dict[str, Any]) -> None:
+ super().__init__()
+ kind = config.get("kind", "gauge")
+ if kind not in {"gauge", "numeric"}:
+ raise ValueError("Instrument group kind must be 'gauge' or 'numeric'.")
+ self.kind = kind
+ self._signals: list[dict[str, Any]] = []
+ self._instruments: list[GaugePanel | NumericPanel] = []
+ self._columns = 0
+ self._reported_minimum: QSize | None = None
+
+ self.title = QLabel("")
+ self.title.hide()
+ self.title.setObjectName("panelHeaderTitle")
+ self.add_button = QPushButton("+")
+ self.add_button.setFixedSize(24, 24)
+ self.add_button.setAccessibleName("Add gauge" if kind == "gauge" else "Add readout")
+ self.add_button.setToolTip(self.add_button.accessibleName())
+ self.add_button.clicked.connect(lambda: self._add_instrument({"signal_id": None}))
+
+ self.settings_menu = QMenu(self)
+ self.settings_menu.addAction("Remove group", self.removeRequested.emit)
+ settings = QToolButton()
+ settings.setObjectName("panelSettingsButton")
+ settings.setIcon(QIcon(str(Path(__file__).parent / "icons" / "gear.svg")))
+ settings.setIconSize(QSize(16, 16))
+ settings.setAccessibleName("Group settings")
+ settings.setToolTip("Configure this group")
+ settings.setPopupMode(QToolButton.ToolButtonPopupMode.InstantPopup)
+ settings.setMenu(self.settings_menu)
+ settings.setAutoRaise(True)
+
+ header = QHBoxLayout()
+ header.addStretch(1)
+ header.addWidget(self.add_button)
+ header.addWidget(settings)
+
+ self.grid = QGridLayout()
+ self.grid.setContentsMargins(0, 0, 0, 0)
+ self.grid.setSpacing(4)
+ self.grid.setSizeConstraint(QLayout.SizeConstraint.SetNoConstraint)
+ self._layout = QVBoxLayout(self)
+ self._layout.setSizeConstraint(QLayout.SizeConstraint.SetNoConstraint)
+ self._layout.addLayout(header)
+ self._layout.addLayout(self.grid)
+ self._layout.setContentsMargins(6, 2, 6, 4)
+ self._layout.setSpacing(2)
+
+ for item in config.get("items", []):
+ if isinstance(item, dict):
+ self._add_instrument(item, emit=False)
+ self._arrange()
+
+ def config(self) -> dict[str, Any]:
+ return {
+ "kind": self.kind,
+ "items": [panel.config() for panel in self._instruments],
+ }
+
+ def refresh(self, signals: list[dict[str, Any]] | None) -> None:
+ self._signals = signals or []
+ for panel in self._instruments:
+ panel.refresh(self._signals)
+ self._arrange()
+ self.updateGeometry()
+
+ def minimumSizeHint(self) -> QSize:
+ width = max(self._available_grid_width(), self._MIN_INSTRUMENT_WIDTH)
+ columns = self._columns_for_width(width)
+ rows = ceil(len(self._instruments) / columns) if self._instruments else 0
+ row_height = self._row_height(width, columns)
+ margins = self._layout.contentsMargins()
+ height = margins.top() + margins.bottom() + self._header_height() + self._layout.spacing() + rows * row_height
+ if rows > 1:
+ height += (rows - 1) * self.grid.verticalSpacing()
+ return QSize(self._header_minimum_width(), height)
+
+ def resizeEvent(self, event: Any) -> None:
+ super().resizeEvent(event)
+ self._arrange()
+
+ def _add_instrument(self, item: dict[str, Any], *, emit: bool = True) -> None:
+ panel_type = GaugePanel if self.kind == "gauge" else NumericPanel
+ panel = panel_type(item)
+ panel.setMinimumWidth(self._MIN_INSTRUMENT_WIDTH)
+ panel.settings_menu.removeAction(panel.settings_menu.actions()[-1])
+ panel.settings_menu.addAction("Remove instrument")
+ panel.settings_menu.actions()[-1].triggered.connect(
+ lambda _checked=False, child=panel: self._remove_instrument(child)
+ )
+ panel.removeRequested.connect(lambda child=panel: self._remove_instrument(child))
+ panel.configChanged.connect(lambda _config, child=panel: self._instrument_changed(child))
+ self._instruments.append(panel)
+ self.grid.addWidget(panel, 0, 0)
+ if self._signals:
+ panel.refresh(self._signals)
+ self._arrange(force_reflow=True, force_minimum_signal=True)
+ self.updateGeometry()
+ if emit:
+ self._emit_config()
+
+ def _remove_instrument(self, panel: GaugePanel | NumericPanel) -> None:
+ if panel not in self._instruments:
+ return
+ self._instruments.remove(panel)
+ self.grid.removeWidget(panel)
+ panel.deleteLater()
+ self._arrange(force_reflow=True, force_minimum_signal=True)
+ self.updateGeometry()
+ self._emit_config()
+
+ def _instrument_changed(self, _panel: GaugePanel | NumericPanel) -> None:
+ self._emit_config()
+
+ def _emit_config(self) -> None:
+ self.configChanged.emit(self.config())
+
+ def _arrange(self, *, force_reflow: bool = False, force_minimum_signal: bool = False) -> None:
+ columns = self._columns_for_width(self._available_grid_width())
+ reflowed = force_reflow or columns != self._columns
+ if reflowed:
+ while self.grid.count():
+ self.grid.takeAt(0)
+ for index, panel in enumerate(self._instruments):
+ self.grid.addWidget(panel, index // columns, index % columns)
+ for column in range(max(columns, self.grid.columnCount())):
+ self.grid.setColumnStretch(column, 1 if column < columns else 0)
+ self._columns = columns
+ self._set_row_heights(columns)
+ minimum = self.minimumSizeHint()
+ size_changed = minimum != self._reported_minimum
+ if force_minimum_signal or reflowed or size_changed:
+ self._reported_minimum = minimum
+ self.updateGeometry()
+ self.minimumSizeChanged.emit()
+
+ def _available_grid_width(self) -> int:
+ outer = self._layout.contentsMargins()
+ inner = self.grid.contentsMargins()
+ return max(
+ self._MIN_INSTRUMENT_WIDTH,
+ self.contentsRect().width()
+ - outer.left()
+ - outer.right()
+ - inner.left()
+ - inner.right(),
+ )
+
+ def _columns_for_width(self, width: int) -> int:
+ spacing = max(0, self.grid.horizontalSpacing())
+ return max(1, (width + spacing) // (self._MIN_INSTRUMENT_WIDTH + spacing))
+
+ def _header_minimum_width(self) -> int:
+ margins = self._layout.contentsMargins()
+ grid_margins = self.grid.contentsMargins()
+ header = self._layout.itemAt(0).layout()
+ header_width = header.sizeHint().width() if header is not None else 0
+ instrument_width = max(
+ (panel.minimumSizeHint().width() for panel in self._instruments),
+ default=self._MIN_INSTRUMENT_WIDTH,
+ )
+ grid_width = (
+ margins.left()
+ + margins.right()
+ + grid_margins.left()
+ + grid_margins.right()
+ + instrument_width
+ )
+ return max(margins.left() + margins.right() + header_width, grid_width)
+
+ def _header_height(self) -> int:
+ header = self._layout.itemAt(0).layout()
+ return header.sizeHint().height() if header is not None else 0
+
+ def _row_height(self, width: int, columns: int) -> int:
+ if not self._instruments:
+ return 0
+ spacing = max(0, self.grid.horizontalSpacing())
+ item_width = max(
+ self._MIN_INSTRUMENT_WIDTH,
+ (width - max(0, columns - 1) * spacing) // columns,
+ )
+ heights = []
+ for panel in self._instruments:
+ hinted = max(panel.minimumSizeHint().height(), panel.sizeHint().height())
+ height_for_width = panel.heightForWidth(item_width)
+ if height_for_width > 0:
+ hinted = max(hinted, height_for_width)
+ heights.append(hinted)
+ return max(heights, default=0)
+
+ def _set_row_heights(self, columns: int) -> None:
+ for row in range(self.grid.rowCount()):
+ self.grid.setRowMinimumHeight(row, 0)
+ if not self._instruments:
+ return
+ row_height = self._row_height(self._available_grid_width(), columns)
+ rows = ceil(len(self._instruments) / columns)
+ for row in range(rows):
+ self.grid.setRowMinimumHeight(row, row_height)
diff --git a/src/dynalab_py/panels.py b/src/dynalab_py/panels.py
new file mode 100644
index 0000000..0872580
--- /dev/null
+++ b/src/dynalab_py/panels.py
@@ -0,0 +1,877 @@
+import math
+from pathlib import Path
+from typing import Any
+
+import pyqtgraph as pg
+from PySide6.QtCore import QRectF, QSize, Qt, Signal
+from PySide6.QtGui import (
+ QColor,
+ QFont,
+ QFontMetrics,
+ QIcon,
+ QPainter,
+ QPainterPath,
+ QPen,
+)
+from PySide6.QtWidgets import (
+ QCheckBox,
+ QComboBox,
+ QGridLayout,
+ QHBoxLayout,
+ QLabel,
+ QLineEdit,
+ QListWidget,
+ QListWidgetItem,
+ QMenu,
+ QPushButton,
+ QSizePolicy,
+ QSpinBox,
+ QToolButton,
+ QVBoxLayout,
+ QWidget,
+ QWidgetAction,
+)
+
+
+class _PanelHeader(QWidget):
+ def __init__(self, title: str, settings_menu: QMenu, parent: QWidget | None = None) -> None:
+ super().__init__(parent)
+ self.label = QLabel(title)
+ self.label.setObjectName("panelHeaderTitle")
+ self.label.setSizePolicy(QSizePolicy.Policy.Ignored, QSizePolicy.Policy.Preferred)
+ self._title = title
+ self.settings = QToolButton()
+ self.settings.setObjectName("panelSettingsButton")
+ self.settings.setIcon(QIcon(str(Path(__file__).parent / "icons" / "gear.svg")))
+ self.settings.setIconSize(QSize(18, 18))
+ self.settings.setAccessibleName("Panel settings")
+ self.settings.setToolTip("Configure this panel")
+ self.settings.setPopupMode(QToolButton.ToolButtonPopupMode.InstantPopup)
+ self.settings.setMenu(settings_menu)
+ self.settings.setAutoRaise(True)
+ layout = QHBoxLayout(self)
+ layout.setContentsMargins(0, 0, 0, 0)
+ layout.addWidget(self.label, 1)
+ layout.addWidget(self.settings)
+
+ def set_title(self, title: str) -> None:
+ self._title = title
+ self.label.setToolTip(title)
+ self._elide_title()
+
+ def resizeEvent(self, event: Any) -> None:
+ super().resizeEvent(event)
+ self._elide_title()
+
+ def _elide_title(self) -> None:
+ self.label.setText(QFontMetrics(self.label.font()).elidedText(
+ self._title,
+ Qt.TextElideMode.ElideRight,
+ max(0, self.width() - self.settings.sizeHint().width() - 8),
+ ))
+
+
+def _settings_menu(parent: QWidget, title: str) -> QMenu:
+ menu = QMenu(parent)
+ heading = QLabel(title)
+ heading.setObjectName("panelSettingsTitle")
+ heading.setContentsMargins(12, 8, 12, 4)
+ action = QWidgetAction(menu)
+ action.setDefaultWidget(heading)
+ menu.addAction(action)
+ return menu
+
+
+def _add_settings_widget(menu: QMenu, widget: QWidget) -> None:
+ action = QWidgetAction(menu)
+ action.setDefaultWidget(widget)
+ menu.addAction(action)
+
+
+class SignalBrowser(QWidget):
+ def __init__(self) -> None:
+ super().__init__()
+ self.signals: list[dict[str, Any]] | None = None
+ self.search = QLineEdit()
+ self.search.setPlaceholderText("Search signals…")
+ self.search.setAccessibleName("Search signals by name or ID")
+ self.items = QListWidget()
+ self.items.setWordWrap(True)
+ layout = QVBoxLayout(self)
+ layout.addWidget(self.search)
+ layout.addWidget(self.items, 1)
+ self.search.textChanged.connect(self._render)
+ self._render()
+
+ def refresh(self, signals: list[dict[str, Any]] | None) -> None:
+ self.signals = signals
+ self._render()
+
+ def _render(self) -> None:
+ selected = self.items.currentItem()
+ selected_id = selected.data(Qt.ItemDataRole.UserRole) if selected is not None else None
+ self.items.clear()
+ if self.signals is None:
+ self.items.addItem("Start a Core session to view signals")
+ return
+ query = self.search.text().strip().casefold()
+ for signal in self.signals:
+ if query and query not in (signal["name"] + " " + signal["id"]).casefold():
+ continue
+ unit = f" {signal['unit']}" if signal["unit"] else ""
+ current = f"{signal['value']:.6g}{unit}" if signal["value"] is not None else "No current value"
+ item = QListWidgetItem(f"{signal['name']} — {current}")
+ item.setData(Qt.ItemDataRole.UserRole, signal["id"])
+ item.setToolTip(f"{signal['id']} · {signal['type']}")
+ self.items.addItem(item)
+ if signal["id"] == selected_id:
+ self.items.setCurrentItem(item)
+ if not self.items.count():
+ self.items.addItem("No matching signals" if query else "No signals available in this mode")
+
+
+class ArcGauge(QWidget):
+ """Read-only gauge with a bounded value fraction in [0, 1]."""
+
+ TRACK = QColor("#3a3d42")
+ ARC = QColor("#b5bec9")
+ TEXT = QColor("#dedfe1")
+ MUTED = QColor("#a0a3a8")
+
+ def __init__(self) -> None:
+ super().__init__()
+ self.fraction: float | None = None
+ self.minimum: float | None = None
+ self.maximum: float | None = None
+ self.value_text = ""
+ self.unit = ""
+ self.decimals = 2
+ self.setMinimumSize(120, 110)
+
+ def set_fraction(self, fraction: float | None) -> None:
+ self.fraction = fraction
+ self.update()
+
+ def set_reading(self, value: float | None, unit: str, minimum: float | None,
+ maximum: float | None, decimals: int) -> None:
+ self.minimum, self.maximum = minimum, maximum
+ self.unit, self.decimals = unit, decimals
+ self.value_text = "—" if value is None else f"{value:.{decimals}f}"
+ if value is None or minimum is None or maximum is None or maximum <= minimum:
+ self.set_fraction(None)
+ else:
+ self.set_fraction(min(1.0, max(0.0, (value - minimum) / (maximum - minimum))))
+
+ def paintEvent(self, _event: Any) -> None:
+ painter = QPainter(self)
+ painter.setRenderHint(QPainter.RenderHint.Antialiasing)
+ diameter = max(1, min(self.width() - 20, self.height() - 28))
+ circle_x = (self.width() - diameter) / 2
+ circle_y = max(4, (self.height() - diameter - 18) / 2)
+ rect = QRectF(circle_x, circle_y, diameter, diameter)
+ pen = QPen(self.TRACK, max(3, min(5, self.width() / 70)), Qt.PenStyle.SolidLine, Qt.PenCapStyle.RoundCap)
+ painter.setPen(pen)
+ path = QPainterPath()
+ path.arcMoveTo(rect, 225)
+ path.arcTo(rect, 225, -270)
+ painter.drawPath(path)
+ if self.fraction is not None:
+ pen.setColor(self.ARC)
+ painter.setPen(pen)
+ path = QPainterPath()
+ path.arcMoveTo(rect, 225)
+ path.arcTo(rect, 225, -270 * self.fraction)
+ painter.drawPath(path)
+ center = rect.center()
+ radius = diameter / 2
+ start_point = (center.x() - radius * .7071, center.y() + radius * .7071)
+ end_point = (center.x() + radius * .7071, center.y() + radius * .7071)
+ label_width = min(78, diameter * .42)
+ label_height = 18
+ label_font = QFont(painter.font())
+ label_font.setPointSize(max(7, min(10, int(diameter / 28))))
+ for label, point, alignment in (
+ ("" if self.minimum is None else f"{self.minimum:g}", start_point, Qt.AlignmentFlag.AlignCenter),
+ ("" if self.maximum is None else f"{self.maximum:g}", end_point, Qt.AlignmentFlag.AlignCenter),
+ ):
+ while label_font.pointSize() > 7 and QFontMetrics(label_font).horizontalAdvance(label) > label_width:
+ label_font.setPointSize(label_font.pointSize() - 1)
+ painter.setFont(label_font)
+ painter.setPen(self.MUTED)
+ label_rect = QRectF(point[0] - label_width / 2, point[1] + 2, label_width, label_height)
+ painter.drawText(label_rect, alignment, label)
+
+ inner_width = diameter * .76
+ value_font = QFont(painter.font())
+ value_font.setPointSize(max(10, min(38, int(diameter * .18))))
+ value_font.setBold(True)
+ while value_font.pointSize() > 10 and QFontMetrics(value_font).horizontalAdvance(self.value_text) > inner_width:
+ value_font.setPointSize(value_font.pointSize() - 1)
+ unit_font = QFont(label_font)
+ unit_font.setPointSize(max(7, min(12, int(diameter * .055))))
+ unit_width = diameter * .62
+ while self.unit and unit_font.pointSize() > 7 and QFontMetrics(unit_font).horizontalAdvance(self.unit) > unit_width:
+ unit_font.setPointSize(unit_font.pointSize() - 1)
+ value_height = QFontMetrics(value_font).height()
+ unit_height = QFontMetrics(unit_font).height() if self.unit else 0
+ stack_height = value_height + unit_height + (2 if self.unit else 0)
+ value_top = center.y() - stack_height / 2
+ value_rect = QRectF(center.x() - inner_width / 2, value_top, inner_width, value_height)
+ painter.setFont(value_font)
+ painter.setPen(self.TEXT)
+ painter.drawText(value_rect, Qt.AlignmentFlag.AlignCenter, self.value_text)
+ if self.unit:
+ painter.setFont(unit_font)
+ painter.setPen(self.MUTED)
+ unit_rect = QRectF(center.x() - unit_width / 2, value_rect.bottom() + 2, unit_width, unit_height)
+ painter.drawText(unit_rect, Qt.AlignmentFlag.AlignCenter, self.unit)
+
+
+class SignalChoice(QComboBox):
+ changed = Signal(object)
+
+ def __init__(self, signal_id: str | None) -> None:
+ super().__init__()
+ self.signal_id = signal_id
+ self._signature: tuple[Any, ...] | None = None
+ self.currentIndexChanged.connect(self._emit_change)
+
+ def set_signals(self, signals: list[dict[str, Any]]) -> None:
+ signature = (self.signal_id, tuple((s["id"], s["name"], s["unit"]) for s in signals))
+ if signature == self._signature:
+ return
+ self._signature = signature
+ self.blockSignals(True)
+ self.clear()
+ self.addItem("Select a signal…", None)
+ available = {signal["id"] for signal in signals}
+ for signal in signals:
+ unit = f" ({signal['unit']})" if signal["unit"] else ""
+ self.addItem(f"{signal['name']}{unit}", signal["id"])
+ if self.signal_id and self.signal_id not in available:
+ self.addItem(f"Unavailable signal ({self.signal_id})", self.signal_id)
+ self.setCurrentIndex(max(0, self.findData(self.signal_id)))
+ self.blockSignals(False)
+
+ def _emit_change(self, index: int) -> None:
+ self.signal_id = self.itemData(index)
+ self.changed.emit(self.signal_id)
+
+
+class NumericPanel(QWidget):
+ configChanged = Signal(dict)
+ removeRequested = Signal()
+
+ def __init__(self, config: dict[str, Any]) -> None:
+ super().__init__()
+ self.decimals = max(0, min(8, int(config.get("decimals", 2))))
+ self._signals: list[dict[str, Any]] = []
+ self.choice = SignalChoice(config.get("signal_id"))
+ self.settings_menu = _settings_menu(self, "Readout settings")
+ self._add_common_settings()
+ precision = QWidget()
+ precision_layout = QHBoxLayout(precision)
+ precision_layout.setContentsMargins(12, 2, 12, 8)
+ precision_layout.addWidget(QLabel("Decimal places"))
+ self.precision = QSpinBox()
+ self.precision.setRange(0, 8)
+ self.precision.setValue(self.decimals)
+ self.precision.valueChanged.connect(self._precision_changed)
+ precision_layout.addWidget(self.precision)
+ _add_settings_widget(self.settings_menu, precision)
+ self.settings_menu.addSeparator()
+ self.settings_menu.addAction("Remove panel", self.removeRequested.emit)
+ self.header = _PanelHeader("Select a signal", self.settings_menu)
+ self.value = QLabel("—")
+ self.value.setObjectName("readoutValue")
+ self.value.setAlignment(Qt.AlignmentFlag.AlignCenter)
+ self.unit = QLabel("")
+ self.unit.setObjectName("muted")
+ self.unit.setAlignment(Qt.AlignmentFlag.AlignCenter)
+ self.unit.setWordWrap(True)
+ layout = QVBoxLayout(self)
+ layout.setContentsMargins(0, 0, 0, 0)
+ layout.setSpacing(2)
+ layout.addWidget(self.header)
+ layout.addStretch(1)
+ layout.addWidget(self.value)
+ layout.addWidget(self.unit)
+ layout.addStretch(1)
+ self.choice.changed.connect(self._changed)
+
+ def _add_common_settings(self) -> None:
+ choice_box = QWidget()
+ choice_layout = QVBoxLayout(choice_box)
+ choice_layout.setContentsMargins(12, 6, 12, 10)
+ choice_layout.addWidget(QLabel("Signal"))
+ choice_layout.addWidget(self.choice)
+ _add_settings_widget(self.settings_menu, choice_box)
+
+ def config(self) -> dict[str, Any]:
+ return {"signal_id": self.choice.signal_id, "decimals": self.decimals}
+
+ def _changed(self, signal_id: str | None) -> None:
+ self.refresh(self._signals)
+ self.configChanged.emit(self.config())
+
+ def _precision_changed(self, decimals: int) -> None:
+ self.decimals = decimals
+ self.configChanged.emit(self.config())
+ self.refresh(self._signals)
+
+ def refresh(self, signals: list[dict[str, Any]]) -> None:
+ self._signals = signals
+ self.choice.set_signals(signals)
+ selected = next((s for s in signals if s["id"] == self.choice.signal_id), None)
+ self.header.set_title(selected["name"] if selected else (f"Unavailable ({self.choice.signal_id})" if self.choice.signal_id else "Select a signal"))
+ if selected is None:
+ self.unit.clear()
+ self.value.setText("Unavailable" if self.choice.signal_id else "—")
+ elif selected["value"] is None:
+ self.unit.setText(selected["unit"] or "")
+ self.value.setText("No current value")
+ else:
+ value = selected["value"]
+ self.value.setText(f"{value:.{self.decimals}f}" if math.isfinite(value) else "—")
+ self.unit.setText(selected["unit"] or "")
+ self._fit_value_font()
+
+ def resizeEvent(self, event: Any) -> None:
+ super().resizeEvent(event)
+ self._fit_value_font()
+
+ def _fit_value_font(self) -> None:
+ available_width = max(40, self.value.width() - 16)
+ font = self.value.font()
+ font.setBold(True)
+ text = self.value.text()
+ point_size = max(10, min(48, int(self.width() * .14)))
+ while point_size > 10:
+ font.setPointSize(point_size)
+ if QFontMetrics(font).horizontalAdvance(text) <= available_width:
+ break
+ point_size -= 1
+ self.value.setFont(font)
+ unit_font = self.unit.font()
+ unit_font.setPointSize(max(8, min(13, int(self.width() * .055))))
+ self.unit.setFont(unit_font)
+
+
+class GaugePanel(QWidget):
+ configChanged = Signal(dict)
+ removeRequested = Signal()
+
+ def __init__(self, config: dict[str, Any]) -> None:
+ super().__init__()
+ self.decimals = max(0, min(8, int(config.get("decimals", 2))))
+ self._signals: list[dict[str, Any]] = []
+ self.choice = SignalChoice(config.get("signal_id"))
+ self.settings_menu = _settings_menu(self, "Gauge settings")
+ choice_box = QWidget()
+ choice_layout = QVBoxLayout(choice_box)
+ choice_layout.setContentsMargins(12, 6, 12, 10)
+ choice_layout.addWidget(QLabel("Signal"))
+ choice_layout.addWidget(self.choice)
+ _add_settings_widget(self.settings_menu, choice_box)
+ precision = QWidget()
+ precision_layout = QHBoxLayout(precision)
+ precision_layout.setContentsMargins(12, 2, 12, 8)
+ precision_layout.addWidget(QLabel("Decimal places"))
+ self.precision = QSpinBox()
+ self.precision.setRange(0, 8)
+ self.precision.setValue(self.decimals)
+ self.precision.valueChanged.connect(self._precision_changed)
+ precision_layout.addWidget(self.precision)
+ _add_settings_widget(self.settings_menu, precision)
+ self.settings_menu.addSeparator()
+ self.settings_menu.addAction("Remove panel", self.removeRequested.emit)
+ self.header = _PanelHeader("Select a signal", self.settings_menu)
+ self.gauge = ArcGauge()
+ layout = QVBoxLayout(self)
+ layout.setContentsMargins(0, 0, 0, 0)
+ layout.setSpacing(0)
+ layout.addWidget(self.header)
+ layout.addWidget(self.gauge, 1)
+ self.choice.changed.connect(self._changed)
+
+ def _changed(self, signal_id: str | None) -> None:
+ self.refresh(self._signals)
+ self.configChanged.emit(self.config())
+
+ def config(self) -> dict[str, Any]:
+ return {"signal_id": self.choice.signal_id, "decimals": self.decimals}
+
+ def _precision_changed(self, decimals: int) -> None:
+ self.decimals = decimals
+ self.configChanged.emit(self.config())
+ self.refresh(self._signals)
+
+ def refresh(self, signals: list[dict[str, Any]]) -> None:
+ self._signals = signals
+ self.choice.set_signals(signals)
+ selected = next((s for s in signals if s["id"] == self.choice.signal_id), None)
+ if selected is None:
+ self.header.set_title(f"Unavailable ({self.choice.signal_id})" if self.choice.signal_id else "Select a signal")
+ self.gauge.set_reading(None, "", None, None, self.decimals)
+ return
+ self.header.set_title(selected["name"])
+ minimum, maximum = selected["min_value"], selected["max_value"]
+ if minimum is not None and not math.isfinite(minimum):
+ minimum = None
+ if maximum is not None and not math.isfinite(maximum):
+ maximum = None
+ value = selected["value"]
+ if value is not None and not math.isfinite(value):
+ value = None
+ self.gauge.set_reading(value, selected["unit"] or "", minimum, maximum, self.decimals)
+
+
+class GraphPanel(QWidget):
+ configChanged = Signal(dict)
+ removeRequested = Signal()
+ COLORS = ("#88a8c9", "#d2aa7d", "#94b39b", "#b29bc6", "#ce9292", "#a0c0c2")
+
+ def __init__(self, config: dict[str, Any]) -> None:
+ super().__init__()
+ self.signal_ids = list(config.get("signal_ids", []))
+ self.follow_latest = bool(config.get("follow_latest", True))
+ self.zero_baseline = bool(config.get("zero_baseline", False))
+ self.right_zero_baseline = bool(config.get("right_zero_baseline", False))
+ self.descriptors: dict[str, dict[str, Any]] = {}
+ self.signal_signature: tuple[Any, ...] = ()
+ self.curves: dict[str, pg.PlotDataItem] = {}
+ self.right_view: pg.ViewBox | None = None
+ self._last_snapshot: dict[str, Any] | None = None
+ self._baseline_auto_ranges = {"left": True, "right": True}
+ self._axis_signal_ids: dict[str, list[str]] = {"left": [], "right": []}
+ self.legend: QWidget | None = None
+ self.plot = pg.PlotWidget()
+ self.plot.setBackground("#202225")
+ self.plot_item = self.plot.getPlotItem()
+ self.playback_position_ns: int | None = None
+ self.playback_cursor = pg.InfiniteLine(
+ angle=90,
+ movable=False,
+ pen=pg.mkPen("#a0a3a8", width=1, style=Qt.PenStyle.DashLine),
+ )
+ self.playback_cursor_label = pg.TextItem(
+ color="#dedfe1", anchor=(0.0, 0.0), fill=(32, 34, 37, 230)
+ )
+ self.playback_cursor_label.setFont(QFont("Sans Serif", 8))
+ self.playback_cursor.setZValue(40)
+ self.playback_cursor_label.setZValue(50)
+ self.plot_item.addItem(self.playback_cursor, ignoreBounds=True)
+ self.plot_item.addItem(self.playback_cursor_label, ignoreBounds=True)
+ self.playback_cursor.hide()
+ self.playback_cursor_label.hide()
+ self.plot_item.vb.sigRangeChanged.connect(lambda *_: self._sync_playback_cursor())
+ self.plot_item.vb.sigRangeChangedManually.connect(
+ lambda *_: self._mark_axis_manual("left")
+ )
+ self.plot_item.vb.setMouseMode(pg.ViewBox.RectMode)
+ self.plot_item.setLabel("bottom", "Elapsed time", units="s")
+ self.plot_item.showGrid(x=True, y=True, alpha=0.25)
+ self._style_axes()
+ self.settings_menu = _settings_menu(self, "Graph settings")
+ self.selection = QListWidget()
+ self.selection.setMinimumWidth(280)
+ self.selection.setMaximumHeight(240)
+ self.selection.itemChanged.connect(self._selection_changed)
+ self.follow = QCheckBox("Follow latest")
+ self.follow.setChecked(self.follow_latest)
+ self.follow.toggled.connect(self._follow_changed)
+ self.fit_button = QPushButton("Reset to fit")
+ self.fit_button.clicked.connect(self._fit)
+ self.zero_baseline_check = QCheckBox("Left axis starts at zero")
+ self.zero_baseline_check.setChecked(self.zero_baseline)
+ self.zero_baseline_check.toggled.connect(self._zero_baseline_changed)
+ self.right_zero_baseline_check = QCheckBox("Right axis starts at zero")
+ self.right_zero_baseline_check.setChecked(self.right_zero_baseline)
+ self.right_zero_baseline_check.toggled.connect(self._right_zero_baseline_changed)
+ selection_box = QWidget()
+ selection_layout = QVBoxLayout(selection_box)
+ selection_layout.setContentsMargins(12, 6, 12, 10)
+ selection_layout.addWidget(QLabel("Signals"))
+ selection_layout.addWidget(self.selection)
+ selection_layout.addSpacing(6)
+ selection_layout.addWidget(self.follow)
+ selection_layout.addWidget(self.fit_button)
+ selection_layout.addWidget(self.zero_baseline_check)
+ selection_layout.addWidget(self.right_zero_baseline_check)
+ explanation = QLabel("One signal uses its native scale. Two signals use independent axes. Three or more are normalized to 0–1 using descriptor bounds when available, otherwise the visible data range.")
+ explanation.setObjectName("muted")
+ explanation.setWordWrap(True)
+ selection_layout.addWidget(explanation)
+ _add_settings_widget(self.settings_menu, selection_box)
+ self.settings_menu.addSeparator()
+ self.settings_menu.addAction("Remove panel", self.removeRequested.emit)
+ self.header = _PanelHeader("", self.settings_menu)
+ self.status = QLabel("Select signals to plot")
+ self.status.setObjectName("muted")
+ self.status.setWordWrap(True)
+ layout = QVBoxLayout(self)
+ layout.setContentsMargins(0, 0, 0, 0)
+ layout.setSpacing(0)
+ top_row = QWidget()
+ top_row_layout = QHBoxLayout(top_row)
+ top_row_layout.setContentsMargins(0, 0, 0, 0)
+ top_row_layout.setSpacing(0)
+ self.legend = QWidget()
+ self.legend_layout = QGridLayout(self.legend)
+ self.legend_layout.setContentsMargins(8, 2, 8, 2)
+ self.legend_layout.setHorizontalSpacing(10)
+ self.legend_layout.setVerticalSpacing(1)
+ top_row_layout.addWidget(self.legend, 1)
+ top_row_layout.addWidget(self.header)
+ layout.addWidget(top_row)
+ layout.addWidget(self.plot, 1)
+ layout.addWidget(self.status)
+ self.plot_item.vb.sigResized.connect(self._update_right_view)
+
+ def _style_axes(self) -> None:
+ for axis_name in ("bottom", "left", "right"):
+ axis = self.plot_item.getAxis(axis_name)
+ axis.setPen("#a0a3a8")
+ axis.setTextPen("#a0a3a8")
+ axis.enableAutoSIPrefix(False)
+
+ @property
+ def separate_axes(self) -> bool:
+ """Whether the current selection uses the automatic dual-axis mode."""
+ return len([i for i in self.signal_ids if i in self.descriptors]) == 2
+
+ def _config(self) -> dict[str, Any]:
+ return {
+ "signal_ids": list(self.signal_ids),
+ "follow_latest": self.follow_latest,
+ "zero_baseline": self.zero_baseline,
+ "right_zero_baseline": self.right_zero_baseline,
+ }
+
+ def config(self) -> dict[str, Any]:
+ return self._config()
+
+ def _emit_config(self) -> None:
+ self.configChanged.emit(self._config())
+
+ def _selection_changed(self, _item: QListWidgetItem) -> None:
+ self.signal_ids = [
+ self.selection.item(i).data(Qt.ItemDataRole.UserRole)
+ for i in range(self.selection.count())
+ if self.selection.item(i).checkState() == Qt.CheckState.Checked
+ ]
+ self._emit_config()
+ self._rebuild_curves()
+
+ def _follow_changed(self, follow: bool) -> None:
+ self.follow_latest = follow
+ self._emit_config()
+
+ def _mark_axis_manual(self, side: str) -> None:
+ self.follow.setChecked(False)
+ # The two views share X: a manual zoom must stop either baseline
+ # from refitting the linked view on the next data refresh.
+ self._baseline_auto_ranges["left"] = False
+ self._baseline_auto_ranges["right"] = False
+
+ def _baseline_enabled(self, side: str) -> bool:
+ count = len(self.curves)
+ if count >= 3:
+ return False
+ if side == "left":
+ return self.zero_baseline
+ return count == 2 and self.right_zero_baseline
+
+ def _zero_baseline_changed(self, enabled: bool) -> None:
+ self.zero_baseline = enabled
+ self._emit_config()
+ self._baseline_auto_ranges["left"] = True
+ self._reapply_last_snapshot()
+
+ def _right_zero_baseline_changed(self, enabled: bool) -> None:
+ self.right_zero_baseline = enabled
+ self._emit_config()
+ self._baseline_auto_ranges["right"] = True
+ self._reapply_last_snapshot()
+
+ def _reapply_last_snapshot(self) -> None:
+ self._apply_baseline_limits()
+ if self._last_snapshot is not None:
+ self.update_plot(self._last_snapshot)
+
+ def _apply_baseline_limits(self) -> None:
+ normalized = len(self.curves) >= 3
+ for side, view in (("left", self.plot_item.vb), ("right", self.right_view)):
+ if view is None:
+ continue
+ if normalized and side == "left":
+ view.setLimits(yMin=0, yMax=1)
+ view.enableAutoRange(axis=pg.ViewBox.YAxis, enable=False)
+ view.setYRange(0, 1, padding=0)
+ continue
+ enabled = self._baseline_enabled(side)
+ view.setLimits(yMin=0 if enabled else None, yMax=None)
+ if enabled:
+ view.enableAutoRange(axis=pg.ViewBox.YAxis, enable=False)
+ self._fit_zero_baseline(side, view)
+ else:
+ view.enableAutoRange(axis=pg.ViewBox.YAxis, enable=True)
+
+ def _fit_zero_baseline(self, side: str, view: pg.ViewBox) -> None:
+ values: list[float] = []
+ for signal_id in self._axis_signal_ids[side]:
+ curve = self.curves.get(signal_id)
+ if curve is None:
+ continue
+ _x, y = curve.getData()
+ if y is not None:
+ values.extend(float(value) for value in y if value is not None and math.isfinite(value))
+ positive_max = max((value for value in values if value > 0), default=0.0)
+ upper = positive_max * 1.05 if positive_max > 0 else 1.0
+ view.setYRange(0.0, upper, padding=0)
+
+ def _fit(self) -> None:
+ self.follow.setChecked(False)
+ self._baseline_auto_ranges["left"] = True
+ self._baseline_auto_ranges["right"] = True
+ self.plot_item.vb.autoRange()
+ if self.right_view is not None:
+ self.right_view.autoRange()
+ self._apply_baseline_limits()
+
+ def set_playback_position(self, position_ns: int | None) -> None:
+ """Show the Core playback position as an elapsed-time playhead."""
+ if position_ns is None:
+ self.playback_position_ns = None
+ self._sync_playback_cursor()
+ return
+ try:
+ position_ns = max(0, int(position_ns))
+ except (TypeError, ValueError, OverflowError):
+ self.playback_position_ns = None
+ self._sync_playback_cursor()
+ return
+ time_range = self._last_snapshot.get("time_range") if self._last_snapshot else None
+ if time_range:
+ duration_ns = max(0, int(time_range[1]) - int(time_range[0]))
+ position_ns = min(position_ns, duration_ns)
+ self.playback_position_ns = position_ns
+ self._sync_playback_cursor()
+
+ def _sync_playback_cursor(self) -> None:
+ """Keep the playhead above plot items without changing their bounds."""
+ if self.playback_cursor.scene() is not self.plot_item.scene():
+ self.plot_item.addItem(self.playback_cursor, ignoreBounds=True)
+ if self.playback_cursor_label.scene() is not self.plot_item.scene():
+ self.plot_item.addItem(self.playback_cursor_label, ignoreBounds=True)
+
+ snapshot = self._last_snapshot
+ time_range = snapshot.get("time_range") if snapshot else None
+ data = snapshot.get("data", {}) if snapshot else {}
+ has_plot_data = bool(time_range) and any(
+ data.get(signal_id) for signal_id in self.curves
+ )
+ visible = self.playback_position_ns is not None and has_plot_data
+ if not visible:
+ self.playback_cursor.hide()
+ self.playback_cursor_label.hide()
+ return
+
+ duration_ns = max(0, int(time_range[1]) - int(time_range[0]))
+ self.playback_position_ns = min(self.playback_position_ns, duration_ns)
+ position_seconds = self.playback_position_ns / 1_000_000_000
+ self.playback_cursor.setValue(position_seconds)
+ self.playback_cursor_label.setText(f"{position_seconds:.1f} s")
+ x_range, y_range = self.plot_item.vb.viewRange()
+ self.playback_cursor_label.setAnchor((1.0 if position_seconds > sum(x_range) / 2 else 0.0, 0.0))
+ y_span = y_range[1] - y_range[0]
+ self.playback_cursor_label.setPos(position_seconds, y_range[1] - y_span * 0.03)
+ self.playback_cursor.show()
+ self.playback_cursor_label.show()
+
+ def set_signals(self, signals: list[dict[str, Any]]) -> None:
+ self.descriptors = {signal["id"]: signal for signal in signals}
+ signature = tuple(
+ (s["id"], s["name"], s["unit"], s["type"], s["min_value"], s["max_value"])
+ for s in signals
+ ) + (("selected", tuple(self.signal_ids)),)
+ if signature == self.signal_signature:
+ return
+ self.signal_signature = signature
+ self.selection.blockSignals(True)
+ self.selection.clear()
+ for signal in signals:
+ unit = f" ({signal['unit']})" if signal["unit"] else ""
+ item = QListWidgetItem(f"{signal['name']}{unit}")
+ item.setData(Qt.ItemDataRole.UserRole, signal["id"])
+ item.setFlags(item.flags() | Qt.ItemFlag.ItemIsUserCheckable)
+ item.setCheckState(
+ Qt.CheckState.Checked if signal["id"] in self.signal_ids else Qt.CheckState.Unchecked
+ )
+ self.selection.addItem(item)
+ for signal_id in self.signal_ids:
+ if signal_id not in self.descriptors:
+ item = QListWidgetItem(f"Unavailable signal ({signal_id})")
+ item.setData(Qt.ItemDataRole.UserRole, signal_id)
+ item.setFlags(item.flags() | Qt.ItemFlag.ItemIsUserCheckable)
+ item.setCheckState(Qt.CheckState.Checked)
+ self.selection.addItem(item)
+ self.selection.blockSignals(False)
+ self._rebuild_curves()
+
+ def _rebuild_curves(self) -> None:
+ while self.legend_layout.count():
+ item = self.legend_layout.takeAt(0)
+ if item.widget() is not None:
+ item.widget().deleteLater()
+ if self.right_view is not None:
+ self.plot_item.scene().removeItem(self.right_view)
+ self.right_view = None
+ self.plot_item.clear()
+ self.plot_item.setLabel("bottom", "Elapsed time", units="s")
+ self.plot_item.setLabel("left", "")
+ self.plot_item.setLabel("right", "")
+ self.plot_item.showGrid(x=True, y=True, alpha=0.2)
+ self._style_axes()
+ self.curves.clear()
+ selected = [i for i in self.signal_ids if i in self.descriptors]
+ dual_axis = len(selected) == 2
+ normalized = len(selected) >= 3
+ self.zero_baseline_check.setEnabled(bool(selected) and not normalized)
+ self.right_zero_baseline_check.setEnabled(dual_axis)
+ self.status.setText("Select available signals to plot" if not selected else "No samples available for the selected signals")
+ if not selected:
+ self._axis_signal_ids = {"left": [], "right": []}
+ self.plot_item.hideAxis("right")
+ self._apply_baseline_limits()
+ self._sync_playback_cursor()
+ return
+
+ self.legend.setVisible(bool(selected))
+ if len(selected) == 1:
+ descriptor = self.descriptors[selected[0]]
+ self.plot_item.setLabel("left", descriptor["name"], units=descriptor["unit"] or "")
+ self.plot_item.hideAxis("right")
+ self.plot_item.setLabel("right", "")
+ elif len(selected) == 2:
+ left, right = (self.descriptors[i] for i in selected)
+ self.plot_item.setLabel("left", left["name"], units=left["unit"] or "")
+ self.plot_item.setLabel("right", right["name"], units=right["unit"] or "")
+ for axis_name, color in (("left", self.COLORS[0]), ("right", self.COLORS[1])):
+ axis = self.plot_item.getAxis(axis_name)
+ axis.setPen(color)
+ axis.setTextPen(color)
+ self.plot_item.showAxis("right")
+ self.right_view = pg.ViewBox()
+ self.right_view.setMouseMode(pg.ViewBox.RectMode)
+ self.right_view.enableAutoRange(axis=pg.ViewBox.YAxis, enable=False)
+ self.right_view.sigRangeChangedManually.connect(
+ lambda *_: self._mark_axis_manual("right")
+ )
+ self.plot_item.scene().addItem(self.right_view)
+ self.plot_item.getAxis("right").linkToView(self.right_view)
+ self.right_view.setXLink(self.plot_item.vb)
+ else:
+ self.plot_item.setLabel("left", "Normalized (0–1)")
+ self.plot_item.hideAxis("right")
+
+ self._axis_signal_ids = {
+ "left": selected[:1] if len(selected) == 2 else selected,
+ "right": selected[1:2] if len(selected) == 2 else [],
+ }
+
+ for index, signal_id in enumerate(selected):
+ descriptor = self.descriptors[signal_id]
+ unit = descriptor["unit"] or ""
+ label = descriptor["name"] + (f" ({unit})" if unit else "")
+ color = self.COLORS[index % len(self.COLORS)]
+ if len(selected) == 2 and index == 1:
+ curve = pg.PlotDataItem(pen=color)
+ self.right_view.addItem(curve)
+ else:
+ curve = self.plot_item.plot(pen=color)
+ self.curves[signal_id] = curve
+ legend_label = QLabel(f"● {label}")
+ legend_label.setWordWrap(True)
+ legend_label.setSizePolicy(QSizePolicy.Policy.Ignored, QSizePolicy.Policy.Preferred)
+ legend_label.setStyleSheet(f"color: {color};")
+ legend_label.setToolTip(label)
+ row, column = divmod(index, 3)
+ self.legend_layout.addWidget(legend_label, row, column)
+ self.legend_layout.setColumnStretch(column, 1)
+ if len(selected) >= 3:
+ bounds = self._descriptor_bounds(descriptor)
+ curve.setToolTip(f"{label}: normalized using {bounds[0]:g} to {bounds[1]:g}" if bounds else f"{label}: normalized using visible data range")
+ self._update_right_view()
+ self._baseline_auto_ranges["left"] = True
+ self._baseline_auto_ranges["right"] = True
+ self._apply_baseline_limits()
+ if len(selected) != 2:
+ self.plot_item.hideAxis("right")
+ self._sync_playback_cursor()
+
+ @staticmethod
+ def _descriptor_bounds(descriptor: dict[str, Any]) -> tuple[float, float] | None:
+ low, high = descriptor.get("min_value"), descriptor.get("max_value")
+ if low is None or high is None:
+ return None
+ try:
+ low, high = float(low), float(high)
+ except (TypeError, ValueError):
+ return None
+ return (low, high) if math.isfinite(low) and math.isfinite(high) and high > low else None
+
+ @classmethod
+ def _normalize_series(cls, descriptor: dict[str, Any], values: list[float]) -> list[float]:
+ """Map finite samples to [0, 1], retaining gaps as NaN."""
+ bounds = cls._descriptor_bounds(descriptor)
+ if bounds is None:
+ finite = [v for v in values if math.isfinite(v)]
+ bounds = (min(finite), max(finite)) if finite else None
+ if bounds is None:
+ return [math.nan for _ in values]
+ low, high = bounds
+ if high == low:
+ return [0.5 if math.isfinite(value) else math.nan for value in values]
+ return [min(1.0, max(0.0, (value - low) / (high - low))) if math.isfinite(value) else math.nan for value in values]
+
+ def _update_right_view(self) -> None:
+ if self.right_view is not None:
+ self.right_view.setGeometry(self.plot_item.vb.sceneBoundingRect())
+ self.right_view.linkedViewChanged(self.plot_item.vb, self.right_view.XAxis)
+
+ def update_plot(self, snapshot: dict[str, Any]) -> None:
+ self._last_snapshot = snapshot
+ time_range = snapshot["time_range"]
+ if not self.signal_ids or not time_range:
+ self.status.setText("No time range or selected signals available")
+ self.status.setVisible(True)
+ for curve in self.curves.values():
+ curve.setData([], [])
+ self._sync_playback_cursor()
+ return
+ origin, end = time_range
+ plotted = False
+ for signal_id, curve in self.curves.items():
+ samples = snapshot["data"].get(signal_id, [])
+ x = [(timestamp - origin) / 1_000_000_000 for timestamp, _ in samples]
+ y = []
+ for _, value in samples:
+ try:
+ y.append(float(value) if value is not None else math.nan)
+ except (TypeError, ValueError):
+ y.append(math.nan)
+ if len(self.curves) >= 3:
+ y = self._normalize_series(self.descriptors[signal_id], y)
+ curve.setData(x, y)
+ plotted |= bool(samples)
+ for side, view in (("left", self.plot_item.vb), ("right", self.right_view)):
+ if view is not None and self._baseline_enabled(side) and self._baseline_auto_ranges[side]:
+ view.setLimits(yMin=0)
+ self._fit_zero_baseline(side, view)
+ if self.follow_latest:
+ end_seconds = (end - origin) / 1_000_000_000
+ self.plot_item.setXRange(0, max(0.001, end_seconds), padding=0)
+ self.status.setText("No samples available for the selected signals" if not plotted else "")
+ self.status.setVisible(not plotted)
+ self._sync_playback_cursor()
diff --git a/src/dynalab_py/session_panel.py b/src/dynalab_py/session_panel.py
new file mode 100644
index 0000000..b9a457c
--- /dev/null
+++ b/src/dynalab_py/session_panel.py
@@ -0,0 +1,253 @@
+from pathlib import Path
+from typing import Any
+from uuid import UUID
+
+from PySide6.QtCore import QTimer, Signal
+from PySide6.QtWidgets import (
+ QFileDialog,
+ QHBoxLayout,
+ QLabel,
+ QListWidget,
+ QProgressBar,
+ QPushButton,
+ QVBoxLayout,
+ QWidget,
+)
+
+
+class SessionPanel(QWidget):
+ """Explicit recording controls and Core-managed recording archive save."""
+
+ busyChanged = Signal(bool)
+
+ def __init__(self, parent: QWidget | None = None) -> None:
+ super().__init__(parent)
+ self._adapter: Any | None = None
+ self._mode = "realtime"
+ self._session_usable = False
+ self._core_state = "stopped"
+ self._recording = False
+ self._recording_available = False
+ self._busy = False
+ self._operation_id: str | None = None
+ self._output_path: Path | None = None
+
+ self.status = QLabel("Start a Core session to control recording")
+ self.status.setObjectName("muted")
+ self.status.setWordWrap(True)
+ self.operation = QLabel("")
+ self.operation.setObjectName("muted")
+ self.operation.setWordWrap(True)
+ self.workers = QLabel("Workers: no session")
+ self.workers.setObjectName("muted")
+ self.workers.setWordWrap(True)
+ self.connectors = QListWidget()
+ self.connectors.setWordWrap(True)
+ self.connectors.addItem("Start a Core session to view connectors")
+ self.start_button = QPushButton("Start recording")
+ self.stop_button = QPushButton("Stop recording")
+ self.save_button = QPushButton("Save recording…")
+ self.start_button.clicked.connect(self._start_recording)
+ self.stop_button.clicked.connect(self._stop_recording)
+ self.save_button.clicked.connect(self._choose_save_path)
+ recording_buttons = QHBoxLayout()
+ recording_buttons.addWidget(self.start_button)
+ recording_buttons.addWidget(self.stop_button)
+ self.progress = QProgressBar()
+ self.progress.setRange(0, 100)
+ self.progress.setValue(0)
+ self.progress.hide()
+ layout = QVBoxLayout(self)
+ layout.addWidget(self.status)
+ layout.addWidget(QLabel("Connector status"))
+ layout.addWidget(self.connectors, 1)
+ layout.addWidget(self.workers)
+ layout.addLayout(recording_buttons)
+ layout.addWidget(self.save_button)
+ layout.addWidget(self.operation)
+ layout.addWidget(self.progress)
+
+ self._operation_timer = QTimer(self)
+ self._operation_timer.setInterval(300)
+ self._operation_timer.timeout.connect(self._poll_operation)
+ self._update_controls()
+
+ @property
+ def recording(self) -> bool:
+ return self._recording
+
+ @property
+ def busy(self) -> bool:
+ return self._busy
+
+ def set_adapter(self, adapter: Any | None) -> None:
+ if adapter is self._adapter:
+ return
+ self._operation_timer.stop()
+ self._adapter = adapter
+ self._mode = "realtime"
+ self._core_state = "stopped"
+ self._recording = False
+ self._recording_available = False
+ self._operation_id = None
+ self._output_path = None
+ self.progress.hide()
+ self.operation.clear()
+ self._set_busy(False)
+ self._update_controls()
+
+ def refresh(self, snapshot: dict[str, Any] | None) -> None:
+ self._session_usable = bool(snapshot and snapshot.get("session_usable", snapshot.get("state") == "started"))
+ if snapshot is None:
+ self.workers.setText("Workers: no session")
+ self._core_state = "stopped"
+ self._recording = False
+ self.status.setText("Start a Core session to control recording")
+ self._update_controls()
+ return
+ workers = snapshot.get("workers", [])
+ failures = [worker for worker in workers if worker["state"] in {"failed", "blocked"}]
+ self.workers.setText(
+ "Worker attention: " + "; ".join(f"{w['name']} · {w['state']} · {w.get('error') or 'no details'}" for w in failures)
+ if failures else f"Workers: {len(workers)} · no failures reported"
+ )
+ self.workers.setToolTip("\n".join(
+ f"{w['name']}: {w['state']} · queue {w.get('queue_size', 0)}/{w.get('queue_capacity', 0)} · dropped {w.get('dropped_samples', 0)}"
+ for w in workers
+ ))
+ previous_recording = self._recording
+ self._mode = snapshot.get("mode", "")
+ self._core_state = snapshot.get("state", "")
+ self._recording = bool(snapshot.get("recording", False))
+ if "recording_available" in snapshot:
+ self._recording_available = bool(snapshot["recording_available"])
+ elif previous_recording and not self._recording:
+ self._recording_available = True
+ if self._core_state != "started":
+ message = f"Core session state: {self._core_state}"
+ elif self._mode != "realtime":
+ message = f"Recording is available in Realtime mode · current mode: {self._mode}"
+ elif self._recording:
+ message = "Recording in progress"
+ elif self._recording_available:
+ message = "Recording stopped and ready to save"
+ else:
+ message = "Idle · no completed recording to save"
+ self.status.setText(message)
+ self._update_controls()
+
+ def _set_busy(self, busy: bool) -> None:
+ if self._busy == busy:
+ return
+ self._busy = busy
+ self.busyChanged.emit(busy)
+ self._update_controls()
+
+ def _update_controls(self) -> None:
+ running = self._adapter is not None and self._session_usable and self._core_state == "started"
+ realtime = self._mode == "realtime"
+ self.start_button.setEnabled(running and realtime and not self._recording and not self._busy)
+ self.stop_button.setEnabled(running and self._recording and not self._busy)
+ self.save_button.setEnabled(
+ running and self._recording_available and not self._recording and not self._busy
+ )
+
+ def _start_recording(self) -> None:
+ if self._adapter is None or self._busy or self._mode != "realtime":
+ return
+ self.operation.setText("Starting recording…")
+ self._set_busy(True)
+ self._adapter.run_async(self._adapter.start_recording, self._on_recording_started)
+
+ def _on_recording_started(self, _result: Any, error: Exception | None) -> None:
+ if error is None:
+ self._recording = True
+ self._recording_available = False
+ self.operation.setText("Recording started")
+ else:
+ self.operation.setText(f"Could not start recording: {error}")
+ self._set_busy(False)
+ self._update_controls()
+
+ def _stop_recording(self) -> None:
+ if self._adapter is None or self._busy or not self._recording:
+ return
+ self.operation.setText("Stopping recording…")
+ self._set_busy(True)
+ self._adapter.run_async(self._adapter.stop_recording, self._on_recording_stopped)
+
+ def _on_recording_stopped(self, _result: Any, error: Exception | None) -> None:
+ if error is None:
+ self._recording = False
+ self._recording_available = True
+ self.operation.setText("Recording stopped")
+ else:
+ self.operation.setText(f"Could not stop recording: {error}")
+ self._set_busy(False)
+ self._update_controls()
+
+ def _choose_save_path(self) -> None:
+ if self._adapter is None or self._busy or not self._recording_available:
+ return
+ path, _ = QFileDialog.getSaveFileName(
+ self,
+ "Save recording",
+ "recording.dlpak",
+ "DynaLab archive (*.dlpak)",
+ )
+ if not path:
+ return
+ target = Path(path)
+ if target.suffix.lower() != ".dlpak":
+ target = target.with_name(target.name + ".dlpak")
+ self._output_path = target
+ self.operation.setText(f"Preparing {target.name}…")
+ self.progress.setValue(0)
+ self.progress.show()
+ self._set_busy(True)
+ adapter = self._adapter
+ adapter.run_async(lambda: adapter.write_archive(target, source="recording"), self._on_write_started)
+
+ def _on_write_started(self, operation_id: UUID | str | None, error: Exception | None) -> None:
+ if error is not None:
+ self._finish_write(f"Could not save recording: {error}")
+ return
+ if operation_id is None:
+ self._finish_write("Could not save recording: Core returned no operation ID")
+ return
+ self._operation_id = str(operation_id)
+ self.operation.setText("Saving recording…")
+ self._poll_operation()
+ if self._operation_id is not None:
+ self._operation_timer.start()
+
+ def _poll_operation(self) -> None:
+ if self._adapter is None or self._operation_id is None:
+ return
+ try:
+ status = self._adapter.operation_status(self._operation_id)
+ except Exception as error:
+ self._finish_write(f"Could not read save status: {error}")
+ return
+ if status is None:
+ self._finish_write("Could not read save status: operation is unavailable")
+ return
+ total = status.get("total", 0)
+ completed = status.get("completed", 0)
+ if total:
+ self.progress.setValue(round(100 * completed / total))
+ message = status.get("message") or "Saving recording…"
+ self.operation.setText(message)
+ if status.get("state") == "completed":
+ self._finish_write(f"Saved recording to {self._output_path}")
+ elif status.get("state") == "failed":
+ detail = status.get("error_message") or status.get("error_code") or "unknown error"
+ self._finish_write(f"Could not save recording: {detail}")
+
+ def _finish_write(self, message: str) -> None:
+ self._operation_timer.stop()
+ self._operation_id = None
+ self.operation.setText(message)
+ self.progress.hide()
+ self._set_busy(False)
+ self._update_controls()
diff --git a/src/dynalab_py/theme.py b/src/dynalab_py/theme.py
new file mode 100644
index 0000000..6b852ad
--- /dev/null
+++ b/src/dynalab_py/theme.py
@@ -0,0 +1,94 @@
+"""Quiet neutral palette and compact desktop controls."""
+from pathlib import Path
+
+from PySide6.QtGui import QColor, QFont, QPalette
+from PySide6.QtWidgets import QApplication
+
+
+def apply_theme(app: QApplication) -> None:
+ app.setStyle("Fusion")
+ palette = QPalette()
+ for role, color in {
+ QPalette.ColorRole.Window: "#181a1d",
+ QPalette.ColorRole.WindowText: "#dedfe1",
+ QPalette.ColorRole.Base: "#202225",
+ QPalette.ColorRole.AlternateBase: "#26292d",
+ QPalette.ColorRole.Text: "#dedfe1",
+ QPalette.ColorRole.Button: "#2b2e33",
+ QPalette.ColorRole.ButtonText: "#dedfe1",
+ QPalette.ColorRole.Highlight: "#484e57",
+ QPalette.ColorRole.HighlightedText: "#ffffff",
+ QPalette.ColorRole.ToolTipBase: "#292c31",
+ QPalette.ColorRole.ToolTipText: "#dedfe1",
+ QPalette.ColorRole.Link: "#bcc5d0",
+ }.items():
+ palette.setColor(role, QColor(color))
+ for role in (QPalette.ColorRole.Text, QPalette.ColorRole.ButtonText):
+ palette.setColor(QPalette.ColorGroup.Disabled, role, QColor("#70747c"))
+ app.setPalette(palette)
+ app.setFont(QFont("Sans Serif", 10))
+ app.setStyleSheet("""
+ QWidget { color: #dedfe1; }
+ QMainWindow, QDialog { background: #181a1d; }
+ QToolBar { background: #202225; border: 0; spacing: 8px; padding: 6px 8px; }
+ QMenuBar { background: #181a1d; padding: 0 6px; }
+ QMenuBar::item { padding: 5px 10px; }
+ QMenuBar::item:selected, QMenu::item:selected { background: #34383e; }
+ QMenu { background: #26292d; border: 1px solid #3b3f46; padding: 4px; }
+ QMenu::item { padding: 5px 20px; }
+ QDockWidget { border: 0; font-weight: 400; }
+ QDockWidget::title { padding: 0; margin: 0; border: 0; }
+ QDockWidget > QWidget, QWidget#panelBody { background: #202225; }
+ QLabel { background: transparent; }
+ QLabel#brand { font-size: 14px; font-weight: 600; padding-right: 8px; }
+ QLabel#eyebrow { color: #a0a3a8; font-size: 10px; letter-spacing: 1px; }
+ QLabel#heroTitle { font-size: 30px; font-weight: 500; }
+ QLabel#muted, QLabel#sessionStatus { color: #a0a3a8; }
+ QLabel#readoutValue { color: #dedfe1; }
+ QLabel#sessionStatus { padding: 2px; }
+ QPushButton { background: #2b2e33; border: 1px solid #41454d; border-radius: 3px;
+ padding: 5px 9px; font-weight: 400; }
+ QPushButton:hover { background: #34383e; border-color: #696f79; }
+ QPushButton:pressed { background: #24272b; }
+ QPushButton:disabled { color: #70747c; background: #222428; border-color: #303339; }
+ QPushButton#primary { background: #41474f; border-color: #555d68; }
+ QPushButton#primary:hover { background: #4d545e; }
+ QPushButton#primary:disabled { background: #292c31; color: #70747c; border-color: #303339; }
+ QToolButton { border: 0; border-radius: 2px; padding: 2px; }
+ QToolButton:hover, QToolButton:checked { background: #34383e; }
+ QToolButton#panelSettingsButton { background: transparent; padding: 2px;
+ min-width: 20px; min-height: 20px; }
+ QToolButton#panelSettingsButton:hover, QToolButton#panelSettingsButton:checked { background: #34383e; }
+ QToolButton#panelSettingsButton::menu-indicator { image: none; width: 0; }
+ QLabel#panelHeaderTitle { color: #aeb2b9; font-size: 12px; font-weight: 400; }
+ QLabel#panelSettingsTitle { color: #dedfe1; font-weight: 500; }
+ QLineEdit, QSpinBox, QComboBox, QPlainTextEdit, QTextEdit {
+ background: #181a1d; border: 1px solid #3d424a; border-radius: 3px;
+ padding: 4px 6px; selection-background-color: #484e57; }
+ QLineEdit:focus, QSpinBox:focus, QComboBox:focus, QPlainTextEdit:focus { border-color: #8c939e; }
+ QComboBox QAbstractItemView { background: #26292d; selection-background-color: #484e57; }
+ QListWidget, QTreeWidget, QTableWidget { background: #202225; border: 0;
+ border-radius: 0; padding: 2px; outline: none; }
+ QListWidget::item { padding: 5px; }
+ QListWidget::item:selected { background: #383e46; }
+ QListWidget::item:hover { background: #2a2e34; }
+ QTabWidget::pane { border: 0; background: #202225; }
+ QTabBar::tab { background: #202225; color: #969ba4; padding: 4px 10px;
+ border: 0; border-bottom: 1px solid transparent; }
+ QTabBar::tab:selected { color: #dedfe1; border-bottom-color: #a2aab6; background: #292d33; }
+ QCheckBox { spacing: 5px; }
+ QCheckBox::indicator { width: 13px; height: 13px; }
+ QStatusBar { background: #202225; color: #969ba4; border: 0; }
+ QStatusBar::item { border: 0; }
+ QScrollBar:vertical { background: #181a1d; width: 8px; }
+ QScrollBar:horizontal { background: #181a1d; height: 8px; }
+ QScrollBar::handle { background: #454b54; min-height: 20px; min-width: 20px; border-radius: 2px; }
+ QScrollBar::add-line, QScrollBar::sub-line { height: 0; width: 0; }
+ """)
+ arrow = (Path(__file__).parent / "icons" / "chevron.svg").as_posix()
+ app.setStyleSheet(app.styleSheet() + """
+ QComboBox { padding-right: 24px; }
+ QComboBox::drop-down { subcontrol-origin: padding; subcontrol-position: top right;
+ width: 20px; border: 0; }
+ QComboBox::down-arrow { image: url(ARROW_PATH); width: 10px; height: 7px; }
+ """.replace("ARROW_PATH", arrow))
diff --git a/src/dynalab_py/workspace.py b/src/dynalab_py/workspace.py
new file mode 100644
index 0000000..fc20490
--- /dev/null
+++ b/src/dynalab_py/workspace.py
@@ -0,0 +1,205 @@
+"""Small versioned workspace persistence and validation."""
+
+import base64
+import json
+from pathlib import Path
+from tempfile import NamedTemporaryFile
+from typing import Any
+
+VERSION = 6
+SYSTEM_PANELS = [
+ {"id": "dashboard", "title": "Dashboard", "type": "dashboard", "config": {}},
+ {"id": "data", "title": "Data / playback", "type": "data", "config": {}},
+ {"id": "signals", "title": "Signal browser", "type": "signal_browser", "config": {}},
+ {"id": "derived", "title": "Derived units", "type": "derive_manager", "config": {}},
+]
+VISUALIZATION_TYPES = {"numeric", "gauge", "graph", "numeric_group", "gauge_group"}
+SYSTEM_TYPES = {panel["type"] for panel in SYSTEM_PANELS}
+SUPPORTED_PANEL_TYPES = SYSTEM_TYPES | VISUALIZATION_TYPES
+_SYSTEM_TYPES_BY_ID = {panel["id"]: panel["type"] for panel in SYSTEM_PANELS}
+_RESERVED_TYPES_BY_ID = {**_SYSTEM_TYPES_BY_ID, "numeric": "numeric", "gauge": "gauge", "graph": "graph"}
+DEFAULT_PANELS = [
+ *SYSTEM_PANELS,
+ {"id": "numeric", "title": "Numeric readout", "type": "numeric", "config": {"signal_id": None, "decimals": 2}},
+ {"id": "gauge", "title": "Gauge", "type": "gauge", "config": {"signal_id": None, "decimals": 2}},
+ {
+ "id": "graph",
+ "title": "Time graph",
+ "type": "graph",
+ "config": {"signal_ids": [], "follow_latest": True, "zero_baseline": False, "right_zero_baseline": False},
+ },
+]
+
+
+class WorkspaceError(ValueError):
+ pass
+
+
+def new_workspace() -> dict[str, Any]:
+ return {"version": VERSION, "panels": []}
+
+
+def _validate_panels(panels: list[Any]) -> None:
+ panel_ids: set[str] = set()
+ for panel in panels:
+ if (
+ not isinstance(panel, dict)
+ or not all(isinstance(panel.get(key), str) and panel[key] for key in ("id", "title", "type"))
+ or not isinstance(panel.get("config", {}), dict)
+ or ("visible" in panel and not isinstance(panel["visible"], bool))
+ ):
+ raise WorkspaceError("Workspace contains an invalid panel definition.")
+ panel_id = panel["id"]
+ panel_type = panel["type"]
+ if panel_id in panel_ids:
+ raise WorkspaceError("Workspace contains duplicate panel IDs.")
+ panel_ids.add(panel_id)
+ if panel_id in _RESERVED_TYPES_BY_ID:
+ if panel_type != _RESERVED_TYPES_BY_ID[panel_id]:
+ raise WorkspaceError(f"Reserved panel ID {panel_id!r} has the wrong type.")
+ elif panel_type not in VISUALIZATION_TYPES:
+ raise WorkspaceError(f"Unsupported panel type {panel_type!r}.")
+
+ config = panel.get("config", {})
+ if panel_type in {"numeric", "gauge"} and config.get("signal_id") is not None and not isinstance(
+ config["signal_id"], str
+ ):
+ raise WorkspaceError("Panel signal selections must be IDs or null.")
+ if panel_type in {"numeric", "gauge"} and (
+ type(config.get("decimals", 2)) is not int or not 0 <= config.get("decimals", 2) <= 8
+ ):
+ raise WorkspaceError("Display decimal precision must be between 0 and 8.")
+ if panel_type in {"numeric_group", "gauge_group"}:
+ kind = panel_type.removesuffix("_group")
+ items = config.get("items", [])
+ if config.get("kind", kind) != kind or not isinstance(items, list) or any(
+ not isinstance(item, dict)
+ or (item.get("signal_id") is not None and not isinstance(item["signal_id"], str))
+ or type(item.get("decimals", 2)) is not int or not 0 <= item.get("decimals", 2) <= 8
+ for item in items
+ ):
+ raise WorkspaceError("Instrument group settings are invalid.")
+ if panel_type == "derive_manager":
+ units = config.get("units", [])
+ if not isinstance(units, list) or any(
+ not isinstance(unit, dict)
+ or not isinstance(unit.get("path"), str)
+ or (unit.get("unit_id") is not None and not isinstance(unit["unit_id"], str))
+ or not isinstance(unit.get("binding_intent", False), bool)
+ for unit in units
+ ):
+ raise WorkspaceError("Derived-unit library settings are invalid.")
+ if panel_type == "graph" and (
+ not isinstance(config.get("signal_ids", []), list)
+ or not all(isinstance(signal_id, str) for signal_id in config.get("signal_ids", []))
+ or len(config.get("signal_ids", [])) != len(set(config.get("signal_ids", [])))
+ or not isinstance(config.get("follow_latest", True), bool)
+ or not isinstance(config.get("zero_baseline", False), bool)
+ or not isinstance(config.get("right_zero_baseline", False), bool)
+ ):
+ raise WorkspaceError("Graph panel settings are invalid.")
+
+
+
+def _validate_grid(layout: Any, panels: list[dict[str, Any]]) -> None:
+ def valid_rect(rect: Any) -> bool:
+ return (
+ isinstance(rect, list) and len(rect) == 4
+ and all(type(value) is int and 0 <= value <= 10000 for value in rect)
+ and rect[2] > 0 and rect[3] > 0
+ )
+
+ if not isinstance(layout, dict) or layout.get("version") != 1 or not isinstance(layout.get("panels"), dict):
+ raise WorkspaceError("Workspace grid layout is invalid.")
+ panel_types = {panel["id"]: panel["type"] for panel in panels}
+ if set(layout["panels"]) != set(panel_types):
+ raise WorkspaceError("Workspace grid does not match its panel list.")
+ for panel_id, item in layout["panels"].items():
+ if not isinstance(item, dict) or item.get("type") != panel_types[panel_id] or not valid_rect(item.get("rect")):
+ raise WorkspaceError("Workspace grid panel placement is invalid.")
+ groups = layout.get("groups", [])
+ if not isinstance(groups, list):
+ raise WorkspaceError("Workspace tab groups are invalid.")
+ grouped: set[str] = set()
+ group_ids: set[str] = set()
+ for group in groups:
+ if not isinstance(group, dict):
+ raise WorkspaceError("Workspace tab group is invalid.")
+ members = group.get("panels")
+ group_id = group.get("id")
+ if (
+ not isinstance(group_id, str) or not group_id or group_id in group_ids
+ or not isinstance(members, list) or len(members) < 2
+ or any(not isinstance(member, str) or member not in panel_types or member in grouped for member in members)
+ or len(set(members)) != len(members) or not valid_rect(group.get("rect"))
+ or (group.get("active") is not None and group["active"] not in members)
+ ):
+ raise WorkspaceError("Workspace tab group is invalid.")
+ grouped.update(members)
+ group_ids.add(group_id)
+ floating = layout.get("floating", {})
+ if not isinstance(floating, dict) or any(
+ item not in panel_types or item in grouped for item in floating
+ ):
+ raise WorkspaceError("Workspace floating panels are invalid.")
+ if any(
+ not isinstance(rect, list) or len(rect) != 4
+ or any(type(value) is not int for value in rect) or rect[2] <= 0 or rect[3] <= 0
+ for rect in floating.values()
+ ):
+ raise WorkspaceError("Workspace floating geometry is invalid.")
+ hidden = layout.get("hidden", [])
+ if not isinstance(hidden, list) or any(not isinstance(item, str) or item not in panel_types for item in hidden):
+ raise WorkspaceError("Workspace hidden panels are invalid.")
+
+
+def validate_workspace(data: dict[str, Any]) -> None:
+ """Validate a current workspace before the window mutates its dock state."""
+ if not isinstance(data, dict) or data.get("version") != VERSION:
+ raise WorkspaceError(f"Unsupported workspace format (expected version {VERSION}).")
+ panels = data.get("panels")
+ if not isinstance(panels, list):
+ raise WorkspaceError("Workspace must contain a panel list.")
+ _validate_panels(panels)
+ if "grid_layout" in data:
+ _validate_grid(data["grid_layout"], panels)
+ for key in ("geometry",):
+ if key in data and data[key] is not None and not isinstance(data[key], str):
+ raise WorkspaceError(f"Workspace field {key!r} must be encoded text.")
+ if data.get(key):
+ try:
+ base64.b64decode(data[key], validate=True)
+ except ValueError as error:
+ raise WorkspaceError(f"Workspace field {key!r} is invalid.") from error
+
+
+def load_workspace(path: str | Path) -> dict[str, Any]:
+ try:
+ data = json.loads(Path(path).read_text(encoding="utf-8"))
+ except (OSError, UnicodeError, json.JSONDecodeError) as error:
+ raise WorkspaceError(f"Could not open workspace: {error}") from error
+
+ validate_workspace(data)
+ return data
+
+
+def save_workspace(path: str | Path, data: dict[str, Any]) -> None:
+ validate_workspace(data)
+ target = Path(path)
+ text = json.dumps(data, indent=2) + "\n"
+ temporary: Path | None = None
+ try:
+ with NamedTemporaryFile(
+ mode="w",
+ encoding="utf-8",
+ dir=target.parent,
+ prefix=f".{target.name}.",
+ suffix=".tmp",
+ delete=False,
+ ) as stream:
+ temporary = Path(stream.name)
+ stream.write(text)
+ temporary.replace(target)
+ finally:
+ if temporary is not None:
+ temporary.unlink(missing_ok=True)
diff --git a/uv.lock b/uv.lock
index 7ae12b1..18e9d86 100644
--- a/uv.lock
+++ b/uv.lock
@@ -2,7 +2,395 @@ version = 1
revision = 5
requires-python = ">=3.13"
+[[package]]
+name = "annotated-types"
+version = "0.8.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/5f/56/a8120250d128bed162cd73c76d45f6ef9991f3e068f62a8ee060afa3104a/annotated_types-0.8.0.tar.gz", hash = "sha256:13b2beaad985e05e2d6407ee4c4f35590b11f8d693a258a561055cac8f64cab7", size = 15893, upload-time = "2026-07-23T20:16:13.995Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/99/91/8acff4f5e50511b911bbccb72b8628a49c68ce14148cd9f6431094859a90/annotated_types-0.8.0-py3-none-any.whl", hash = "sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0", size = 13427, upload-time = "2026-07-23T20:16:12.938Z" },
+]
+
+[[package]]
+name = "colorama"
+version = "0.4.6"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
+]
+
+[[package]]
+name = "dynalab-core"
+version = "0.2.0"
+source = { git = "https://git.h3cx.dev/Exergie/dynalab-core.git?rev=v0.2.1#db5d509b49c80c362683affab95823c6d80059ab" }
+dependencies = [
+ { name = "psutil" },
+ { name = "psutils" },
+ { name = "pydantic" },
+]
+
[[package]]
name = "dynalab-py"
version = "0.1.0"
source = { editable = "." }
+dependencies = [
+ { name = "dynalab-core" },
+ { name = "numpy" },
+ { name = "pyqtgraph" },
+ { name = "pyside6" },
+]
+
+[package.metadata]
+requires-dist = [
+ { name = "dynalab-core", git = "https://git.h3cx.dev/Exergie/dynalab-core.git?rev=v0.2.1" },
+ { name = "numpy", specifier = ">=2.5.4" },
+ { name = "pyqtgraph", specifier = ">=0.14.0" },
+ { name = "pyside6", specifier = ">=6.12.0" },
+]
+
+[[package]]
+name = "numpy"
+version = "2.5.4"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/95/b0/c7453d0b6e2073c3264468b106ee1563750cecc910965e67357e3698c83e/numpy-2.5.4.tar.gz", hash = "sha256:9a94cf751c9ad8ebaa835bcd3d40dacf8534ad086b88c38029b65123c7999d2a", size = 20866315, upload-time = "2026-10-10T20:05:31.422Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/67/14/1c3ee0118a8fce08565a5d8482631608426a33af10a01077fada5dc7c119/numpy-2.5.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:2377da2dd3ba2c1200956acbab2a358c83b8e1f8531191672d1cd6ad83250d53", size = 16997729, upload-time = "2026-10-10T20:03:09.291Z" },
+ { url = "https://files.pythonhosted.org/packages/83/8c/b0ea9477fb1f0d4484bbc5cba21678cc9969704d8d7f3f158d1db35f8e14/numpy-2.5.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7415db95818b39ec475a5eea54d9e3b6bc83e3912158e46da3438cdce399804d", size = 12009826, upload-time = "2026-10-10T20:03:11.946Z" },
+ { url = "https://files.pythonhosted.org/packages/e2/84/6a3d75b3ba3dfe84ac0053450753d1e6d250a8bf80f66474cc46d1fb643f/numpy-2.5.4-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:6d6a71b9d9a97c03633aa12565ef2825ffa036cc1d99cfd50dacf0f128af4fe2", size = 5445803, upload-time = "2026-10-10T20:03:14.329Z" },
+ { url = "https://files.pythonhosted.org/packages/61/18/bb993f267ca20b376e07092a16793a5b31ed3138751e9ba480011a14d742/numpy-2.5.4-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:d8200f16437b289a5bb927c6e184eccc3e8389bc0070fea4cd5b9e13c1757959", size = 6786220, upload-time = "2026-10-10T20:03:16.602Z" },
+ { url = "https://files.pythonhosted.org/packages/db/b6/135bb0953b61dc21c6cafa14b424ae666944e4899cf140e00c2b322a1a45/numpy-2.5.4-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c2e71b04c6cad90026e544501bbe0ab9290fa8a4d845e7e8c0d124fb429c988", size = 15689178, upload-time = "2026-10-10T20:03:18.721Z" },
+ { url = "https://files.pythonhosted.org/packages/da/24/3bd070f3269dc609d8f26b2643f62ef91bb415841c0b294805aaf7fe06da/numpy-2.5.4-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6ffa07666f8da0eef81d149934a626d0d95fbd6838432a33e66245423a9062c0", size = 16718044, upload-time = "2026-10-10T20:03:21.386Z" },
+ { url = "https://files.pythonhosted.org/packages/c7/8e/9d15bd356b0a019c965312b1a3c6a727cac4cae5bc40045fbc12ce4cff9c/numpy-2.5.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:2fa3328f784fc8277fc48026f6cad516f5c561c5d8e2e39b3c9e0c8f23223b34", size = 17048364, upload-time = "2026-10-10T20:03:24.468Z" },
+ { url = "https://files.pythonhosted.org/packages/dc/fe/9d5b560db964f15871885f2250795d15945f8699e17ef90c0c2ff4c875b2/numpy-2.5.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b86966fbe4ad7de710422175572bcdc75fdedadfb54bc6fab7deabccddd7780b", size = 18474904, upload-time = "2026-10-10T20:03:27.895Z" },
+ { url = "https://files.pythonhosted.org/packages/e9/98/d27552990f1bd611ef3e7466adadc78312ea2df63b83aad47fdc3d3ca8df/numpy-2.5.4-cp313-cp313-win32.whl", hash = "sha256:5258bc06526964be5face2fc6f756857a3f24f21ec3e72ca131337a75b165d6c", size = 6134537, upload-time = "2026-10-10T20:03:30.511Z" },
+ { url = "https://files.pythonhosted.org/packages/90/8c/140a40398a66b4471211be1affdb6ed24c486d581bd28d07b7f2fcb69540/numpy-2.5.4-cp313-cp313-win_amd64.whl", hash = "sha256:8b4d2fd2d34e5f8c9235ee787de5631a37a28402b15cb80814df973d2be54129", size = 12566113, upload-time = "2026-10-10T20:03:32.612Z" },
+ { url = "https://files.pythonhosted.org/packages/34/52/01d205e5e8ccb27b2b0b141e801f22b830198c979111b0fa44771438d9a9/numpy-2.5.4-cp313-cp313-win_arm64.whl", hash = "sha256:bc39ac66a7a9a3fbd6134fda43136b60ffde99c8f4501e64e0d2b24da137babf", size = 10519523, upload-time = "2026-10-10T20:03:35.163Z" },
+ { url = "https://files.pythonhosted.org/packages/99/ba/005cb5edd580d2f84d7ca3206b92dc17d4388e56e6f87ffe8f2762f83139/numpy-2.5.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:c668b2f0d651605b58892644b0e302c7157f7159544227758c896982ef384b18", size = 17005499, upload-time = "2026-10-10T20:03:37.961Z" },
+ { url = "https://files.pythonhosted.org/packages/f3/49/fee7587c33ee35f7977f9051d7f2023d4e7246d62710c80f20c2361ea232/numpy-2.5.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ffa6ce09a1c6a08e9667dd9c97aa0b14184e8d18f2a14b78b2a2328c9147f076", size = 12019666, upload-time = "2026-10-10T20:03:40.606Z" },
+ { url = "https://files.pythonhosted.org/packages/d5/b2/c6ce165acffceb15a82c07b9cc77d391f86b3f379ba62911908ae5d34b91/numpy-2.5.4-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:956555e0603a4d38019ae6925711cb9dc43195c076a928accf7ea5d50bddfe53", size = 5455617, upload-time = "2026-10-10T20:03:43.138Z" },
+ { url = "https://files.pythonhosted.org/packages/77/7f/dd85ce260a669a89be06842cf355d7353a33e6cfbc590fb8ebb947d88dc9/numpy-2.5.4-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:2c2c4afffdeb7920e445028dd71eb932cac3e704792e964bc2a232426d4f1255", size = 6791932, upload-time = "2026-10-10T20:03:44.874Z" },
+ { url = "https://files.pythonhosted.org/packages/63/d6/34b0a2b0741386a63025a65a2c09caaaaaad6d0ca95b66cd65c30dd7fcb5/numpy-2.5.4-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4054173604cd8658796053f1f3bc0befb68ec1c0762c57fdad61e199256a8617", size = 15710899, upload-time = "2026-10-10T20:03:46.839Z" },
+ { url = "https://files.pythonhosted.org/packages/16/d5/928078d2b28f26829b138b4a6c3980045022fb409f570657a224ae60ef4e/numpy-2.5.4-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d549420b8858885cea8838a727842249218b9c1da24dd517e25c9c7a948310a3", size = 16721710, upload-time = "2026-10-10T20:03:49.489Z" },
+ { url = "https://files.pythonhosted.org/packages/f9/cf/673fd1b8f4cd78eb6320e87ec4c90ac19c095644259e3749853a405c70f4/numpy-2.5.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:823874a507a84af050493b622affde94b6f7c3a0dc22cb2801381bc03b871c00", size = 17066182, upload-time = "2026-10-10T20:03:52.25Z" },
+ { url = "https://files.pythonhosted.org/packages/f3/92/a77b5061b1b3e2643928c37976d79ee173e1b171ed158b7a3c61056b41bc/numpy-2.5.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:4e263278bfb5ee6409db8aedbc4cc32973b1b82bc1e8d3c668551d04d83a7e37", size = 18480315, upload-time = "2026-10-10T20:03:55.39Z" },
+ { url = "https://files.pythonhosted.org/packages/bb/1d/1486ef3d3fb2279fd93c4c43c1bbbf1ca389a19816696684409f71babaab/numpy-2.5.4-cp314-cp314-win32.whl", hash = "sha256:cfd73180400042a7c532d30c5e287bdd03c59ff9ee1b4c0316af0539e29dfe23", size = 6185739, upload-time = "2026-10-10T20:03:58.186Z" },
+ { url = "https://files.pythonhosted.org/packages/52/9a/e1e512ebc948d5b9dd33b08736760f0ebbed2848fd4eda1f553088a6dcee/numpy-2.5.4-cp314-cp314-win_amd64.whl", hash = "sha256:2ca144f15135b6212a5c47b1e2aeca6e412f102f95a2d5d88d8aec77eb255de3", size = 12703552, upload-time = "2026-10-10T20:04:00.28Z" },
+ { url = "https://files.pythonhosted.org/packages/2c/05/de709a982d7bbcd688a3fad71f002e9ff80c2db39e03ee726609b610f1d1/numpy-2.5.4-cp314-cp314-win_arm64.whl", hash = "sha256:468397ba3c64427474706e5c9123fe266395496714dc684294eac75cd4930d1e", size = 10803901, upload-time = "2026-10-10T20:04:02.659Z" },
+ { url = "https://files.pythonhosted.org/packages/13/34/083570ada3bb2a30fbe5d77c8c6fef9141144a15d33e6f793a67e9749ab8/numpy-2.5.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1ef3aa6d7e29bb13677323114280b05acc57607fa2300e66432d665d5418a162", size = 12138695, upload-time = "2026-10-10T20:04:05.012Z" },
+ { url = "https://files.pythonhosted.org/packages/94/06/1f9c24db48eef0c2d1207e3b11fffb0478e39dfd8c1e1be7476936885eed/numpy-2.5.4-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:98b053943e5a0474ec0da309d2cb9d3f18ea57f8a2067c2ab7b5f763d1068380", size = 5574615, upload-time = "2026-10-10T20:04:07.316Z" },
+ { url = "https://files.pythonhosted.org/packages/da/0f/593fba2e1560e949123bc7d2fc48b5893d56e58cd4bd5a273d2fbf60b220/numpy-2.5.4-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:b64a85f40e154983960a4167d4c1d57a50c7f109b3d3264a3a984154e90a8454", size = 6889383, upload-time = "2026-10-10T20:04:09.918Z" },
+ { url = "https://files.pythonhosted.org/packages/eb/9f/b799dfdce4e05e80ed4bc815c71ff343a11533b2c0ffc221cae8538cda63/numpy-2.5.4-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a813ed7719bf45463c51779e6a98d0385fe905e48447526938a4b8337333d551", size = 15753763, upload-time = "2026-10-10T20:04:12.278Z" },
+ { url = "https://files.pythonhosted.org/packages/34/88/16c5f12f86f5ad2817c4d103205131fc6c8acb3d1878af05a1a4f23ec859/numpy-2.5.4-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c9b80cdf5cedba0e90d93fa5f9a333c4d65bd545cd669b71bb97ce2b703c9d73", size = 16757212, upload-time = "2026-10-10T20:04:14.799Z" },
+ { url = "https://files.pythonhosted.org/packages/ff/4f/a1fe40e18a898e6a5089f4f0d891f0a493eb0574d5b34458f0fbe5aa3e5c/numpy-2.5.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:2199ed071f460487c8db2c0e5c0b564494190edb4772fe80f9aad88b2604def5", size = 17116471, upload-time = "2026-10-10T20:04:17.58Z" },
+ { url = "https://files.pythonhosted.org/packages/aa/46/e923a11c78e65c1722e7aaad817c06bd591324174b9d28ce5d31eee4d432/numpy-2.5.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:64f9c9878c1938476365e11ccfb6b770f3b9e5f045ccddc514235041e6959365", size = 18524063, upload-time = "2026-10-10T20:04:20.365Z" },
+ { url = "https://files.pythonhosted.org/packages/5a/fa/84ab064514440c1f64a1b21088f2c82756defdd05e07c75ab233899565b2/numpy-2.5.4-cp314-cp314t-win32.whl", hash = "sha256:64d1c8ac28a4077cf987e0a71a7a0ef7e2df70722f07f0baa42dbb7eb6938647", size = 6340926, upload-time = "2026-10-10T20:04:22.865Z" },
+ { url = "https://files.pythonhosted.org/packages/7e/7e/6cd886876f435b10685db9b9f7eeb70356f99e052116f4e5f11c5792c714/numpy-2.5.4-cp314-cp314t-win_amd64.whl", hash = "sha256:067374eb538c34c745436365cf7b0112595c1d326f21ce4ff340f61230239fbb", size = 12901584, upload-time = "2026-10-10T20:04:24.99Z" },
+ { url = "https://files.pythonhosted.org/packages/38/1b/3c1684f6a06f7307f2335fca6e486cb162847fb97e91d65f8eb5cabad213/numpy-2.5.4-cp314-cp314t-win_arm64.whl", hash = "sha256:e94aef2c639da4a960ad0db8e06471208d8589974953d78b61d345b4eb99e394", size = 10891152, upload-time = "2026-10-10T20:04:27.52Z" },
+ { url = "https://files.pythonhosted.org/packages/08/f4/3224deff3af2bef6bc0b175369698d8cb348f3d91d9bb0286cd5c9eae9e0/numpy-2.5.4-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:8dddfbee2e68d26d0d7d7d9cb247b1fd4409241cce32d815a11d97ec2cfde179", size = 17003231, upload-time = "2026-10-10T20:04:30.021Z" },
+ { url = "https://files.pythonhosted.org/packages/be/75/fee0b8c6d94b44b2fdfae74f6a4ad5a138739589a8aebaec28ce4e713ed5/numpy-2.5.4-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:81e3420b27048b65eb14c3acf0c174a8cb0e023277716110347d2dcb26026dad", size = 12018300, upload-time = "2026-10-10T20:04:32.519Z" },
+ { url = "https://files.pythonhosted.org/packages/47/c0/d0b335a499a04b65f532c3f034346ef390f81299060f928492dabc1e0272/numpy-2.5.4-cp315-cp315-macosx_14_0_arm64.whl", hash = "sha256:0b4724a19de67bea8cfc4970798efa78bcbbe2ac2613cfac16721a42d44de2a5", size = 5454250, upload-time = "2026-10-10T20:04:34.943Z" },
+ { url = "https://files.pythonhosted.org/packages/5a/0e/461b3783c03d668052e6a21b01b673db6ffcb7831fd32d9aa5368c1cd426/numpy-2.5.4-cp315-cp315-macosx_14_0_x86_64.whl", hash = "sha256:2132418bf8dd124a427ca9e6a1daf9ee1a87185344c95119ceae868b99466da1", size = 6789644, upload-time = "2026-10-10T20:04:37.258Z" },
+ { url = "https://files.pythonhosted.org/packages/b3/02/5dad269b02166965a7b4ca14adaddd75dbee0de42435bfecf561b84ba5a6/numpy-2.5.4-cp315-cp315-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:325518d4245b9e331387702aa58c2ce1dc4cdcbb41dfb4ccd5dcbc7e08db1266", size = 15704353, upload-time = "2026-10-10T20:04:39.616Z" },
+ { url = "https://files.pythonhosted.org/packages/93/3a/01360c8036822ed9f7aa32189a77d1476567ec1e8e1383522389e4faac45/numpy-2.5.4-cp315-cp315-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:56733449d2544178beaa4545cee357370440cf056c197f9c7bfb19dbfdd0e86d", size = 16718648, upload-time = "2026-10-10T20:04:42.383Z" },
+ { url = "https://files.pythonhosted.org/packages/7d/5c/b863a2c093c4d6f21a597fcaf24ead0835c09ab16a8312d5a5a8868af683/numpy-2.5.4-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:5ec3753760c1a6d8bb91200666e545c3a9728e6269dfb5d6ce02340996698aa3", size = 17059053, upload-time = "2026-10-10T20:04:44.976Z" },
+ { url = "https://files.pythonhosted.org/packages/0a/60/ced4f57f9a1258a0af74f17cb0b0c2700b5c67cd6678823c803b263e4df3/numpy-2.5.4-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:b1185012870173de7ae33d370bd45b1cf5baee747ea4b97036b65f4e93016877", size = 18477406, upload-time = "2026-10-10T20:04:47.863Z" },
+ { url = "https://files.pythonhosted.org/packages/f9/bd/0ef22dafaafcc7d4bb3ca26b8d2afbd55dedad8eaba99a8c864e1997456f/numpy-2.5.4-cp315-cp315-win32.whl", hash = "sha256:298eca75243f2cbbfdb460560b9fb2a1792a33cf2ab4286efd43d92e8d3df508", size = 6185133, upload-time = "2026-10-10T20:04:50.467Z" },
+ { url = "https://files.pythonhosted.org/packages/50/bc/d2651b155ecc608a77e6f4d15495c11f14f19bb98f8bf0c5b0d38f86dda1/numpy-2.5.4-cp315-cp315-win_amd64.whl", hash = "sha256:332f3378fe077dd850e677ec01bdcc4f22368fb5d50ef10b2c79230b1bf5a592", size = 12703085, upload-time = "2026-10-10T20:04:52.63Z" },
+ { url = "https://files.pythonhosted.org/packages/dc/d2/45e404f8abb26fb9eda12b94012936873e827b1be76f2ee7890be128312e/numpy-2.5.4-cp315-cp315-win_arm64.whl", hash = "sha256:d4cccbbc78717966f764cd3af4fb70276fa01fc7a2688af11c78901fa5c04f05", size = 10801451, upload-time = "2026-10-10T20:04:55.677Z" },
+ { url = "https://files.pythonhosted.org/packages/c6/c3/2ae14e09cfdb67dc187a342e15308a21c15bf4d2071f8079e6aee5fe56dc/numpy-2.5.4-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:950ea81d57ef070665581b6e1b5f6a029306423cd1739c5b95fe78aa30db6b9d", size = 17097121, upload-time = "2026-10-10T20:04:58.403Z" },
+ { url = "https://files.pythonhosted.org/packages/f5/cf/305ae624ef8a039414317224abe9ec9c2fe7ea3c2e1cf204d43ff6b2ffb9/numpy-2.5.4-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:c05ede731b03fb1b7591faca9389ade3267d2bddf1ad8882bb3f2cc5e101694f", size = 12135439, upload-time = "2026-10-10T20:05:01.65Z" },
+ { url = "https://files.pythonhosted.org/packages/a9/a8/f75c63813aef95827bb2c0d13b12803016853056e8792c280058cdbfe783/numpy-2.5.4-cp315-cp315t-macosx_14_0_arm64.whl", hash = "sha256:5fbf7141bbfd63aea22f435c9062a032b9ea0082fe9845dad7f021d3f1234e71", size = 5571451, upload-time = "2026-10-10T20:05:04.135Z" },
+ { url = "https://files.pythonhosted.org/packages/6f/0f/f17763f983868b5c49b4101ebd7e00760bd1769478a6bb6a8de6e085bbac/numpy-2.5.4-cp315-cp315t-macosx_14_0_x86_64.whl", hash = "sha256:3573cd22564692a5b899ec344e5d5b9cc4576f2985b96f22af3564ed54f2710f", size = 6883356, upload-time = "2026-10-10T20:05:06.249Z" },
+ { url = "https://files.pythonhosted.org/packages/67/a7/8af04c5a79e047996cfa38854dcfbececdd0343a7c933a46fdd03ef6f5da/numpy-2.5.4-cp315-cp315t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6c109eac9cd439193678f69d70733c1108487546ca8eafc107b510ae10c1aecd", size = 15750991, upload-time = "2026-10-10T20:05:08.376Z" },
+ { url = "https://files.pythonhosted.org/packages/57/7a/648254290d0c504faa8f2d07aa206660c728802c781a6f3fc68ab7cb5d71/numpy-2.5.4-cp315-cp315t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:80d6ef6e8620eb2c2b4c4caad50b5935d6db3cde2d51581b55dcc79e14016d1d", size = 16757675, upload-time = "2026-10-10T20:05:11.393Z" },
+ { url = "https://files.pythonhosted.org/packages/b8/fe/4a8c3cdb0c70400cfe4c5bec42d3099a5673802a95064614b33e07b82aa1/numpy-2.5.4-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:77045a4b175bbf5316ec08003880804336c78f92281a1b72222b274ea85ec5ac", size = 17113846, upload-time = "2026-10-10T20:05:14.49Z" },
+ { url = "https://files.pythonhosted.org/packages/1b/7e/619692bb67778702c0e9eb2d468568a7573f4e269386ea61aed01ee4e557/numpy-2.5.4-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:0f02a46e49cfb6c73bdb7aea1c0d3461dbae9aba613542b65f657cd3d17b9fab", size = 18522915, upload-time = "2026-10-10T20:05:17.33Z" },
+ { url = "https://files.pythonhosted.org/packages/b7/b5/4da41c328788f575838f97a098fe8ca691ebc6f6fd73ad4a262ee40b184d/numpy-2.5.4-cp315-cp315t-win32.whl", hash = "sha256:ad62a416ddcf863bf44bba76fbf6b53366ab0692e294f51cae4b5fbe0d246788", size = 6335804, upload-time = "2026-10-10T20:05:19.921Z" },
+ { url = "https://files.pythonhosted.org/packages/98/94/6482ddfa3d312490cb9358f375bf2ad56427dbea8769187158e94d653753/numpy-2.5.4-cp315-cp315t-win_amd64.whl", hash = "sha256:38f47be9f74ab870d2633b5456ae519c43758a8d1fd05342f0ce4ecc034396ee", size = 12890095, upload-time = "2026-10-10T20:05:21.875Z" },
+ { url = "https://files.pythonhosted.org/packages/48/7f/c2d1b436b6e7cfebac140c2579a298344b85f2991a2ce5c3615cefb29400/numpy-2.5.4-cp315-cp315t-win_arm64.whl", hash = "sha256:7a14a461d9340f1b46b8648578aed9cdb8b3b018a8fac6c1dde2c9192a01a87f", size = 10883718, upload-time = "2026-10-10T20:05:28.547Z" },
+]
+
+[[package]]
+name = "psutil"
+version = "7.2.2"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/aa/c6/d1ddf4abb55e93cebc4f2ed8b5d6dbad109ecb8d63748dd2b20ab5e57ebe/psutil-7.2.2.tar.gz", hash = "sha256:0746f5f8d406af344fd547f1c8daa5f5c33dbc293bb8d6a16d80b4bb88f59372", size = 493740, upload-time = "2026-01-28T18:14:54.428Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/51/08/510cbdb69c25a96f4ae523f733cdc963ae654904e8db864c07585ef99875/psutil-7.2.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:2edccc433cbfa046b980b0df0171cd25bcaeb3a68fe9022db0979e7aa74a826b", size = 130595, upload-time = "2026-01-28T18:14:57.293Z" },
+ { url = "https://files.pythonhosted.org/packages/d6/f5/97baea3fe7a5a9af7436301f85490905379b1c6f2dd51fe3ecf24b4c5fbf/psutil-7.2.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e78c8603dcd9a04c7364f1a3e670cea95d51ee865e4efb3556a3a63adef958ea", size = 131082, upload-time = "2026-01-28T18:14:59.732Z" },
+ { url = "https://files.pythonhosted.org/packages/37/d6/246513fbf9fa174af531f28412297dd05241d97a75911ac8febefa1a53c6/psutil-7.2.2-cp313-cp313t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1a571f2330c966c62aeda00dd24620425d4b0cc86881c89861fbc04549e5dc63", size = 181476, upload-time = "2026-01-28T18:15:01.884Z" },
+ { url = "https://files.pythonhosted.org/packages/b8/b5/9182c9af3836cca61696dabe4fd1304e17bc56cb62f17439e1154f225dd3/psutil-7.2.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:917e891983ca3c1887b4ef36447b1e0873e70c933afc831c6b6da078ba474312", size = 184062, upload-time = "2026-01-28T18:15:04.436Z" },
+ { url = "https://files.pythonhosted.org/packages/16/ba/0756dca669f5a9300d0cbcbfae9a4c30e446dfc7440ffe43ded5724bfd93/psutil-7.2.2-cp313-cp313t-win_amd64.whl", hash = "sha256:ab486563df44c17f5173621c7b198955bd6b613fb87c71c161f827d3fb149a9b", size = 139893, upload-time = "2026-01-28T18:15:06.378Z" },
+ { url = "https://files.pythonhosted.org/packages/1c/61/8fa0e26f33623b49949346de05ec1ddaad02ed8ba64af45f40a147dbfa97/psutil-7.2.2-cp313-cp313t-win_arm64.whl", hash = "sha256:ae0aefdd8796a7737eccea863f80f81e468a1e4cf14d926bd9b6f5f2d5f90ca9", size = 135589, upload-time = "2026-01-28T18:15:08.03Z" },
+ { url = "https://files.pythonhosted.org/packages/81/69/ef179ab5ca24f32acc1dac0c247fd6a13b501fd5534dbae0e05a1c48b66d/psutil-7.2.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:eed63d3b4d62449571547b60578c5b2c4bcccc5387148db46e0c2313dad0ee00", size = 130664, upload-time = "2026-01-28T18:15:09.469Z" },
+ { url = "https://files.pythonhosted.org/packages/7b/64/665248b557a236d3fa9efc378d60d95ef56dd0a490c2cd37dafc7660d4a9/psutil-7.2.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7b6d09433a10592ce39b13d7be5a54fbac1d1228ed29abc880fb23df7cb694c9", size = 131087, upload-time = "2026-01-28T18:15:11.724Z" },
+ { url = "https://files.pythonhosted.org/packages/d5/2e/e6782744700d6759ebce3043dcfa661fb61e2fb752b91cdeae9af12c2178/psutil-7.2.2-cp314-cp314t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1fa4ecf83bcdf6e6c8f4449aff98eefb5d0604bf88cb883d7da3d8d2d909546a", size = 182383, upload-time = "2026-01-28T18:15:13.445Z" },
+ { url = "https://files.pythonhosted.org/packages/57/49/0a41cefd10cb7505cdc04dab3eacf24c0c2cb158a998b8c7b1d27ee2c1f5/psutil-7.2.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e452c464a02e7dc7822a05d25db4cde564444a67e58539a00f929c51eddda0cf", size = 185210, upload-time = "2026-01-28T18:15:16.002Z" },
+ { url = "https://files.pythonhosted.org/packages/dd/2c/ff9bfb544f283ba5f83ba725a3c5fec6d6b10b8f27ac1dc641c473dc390d/psutil-7.2.2-cp314-cp314t-win_amd64.whl", hash = "sha256:c7663d4e37f13e884d13994247449e9f8f574bc4655d509c3b95e9ec9e2b9dc1", size = 141228, upload-time = "2026-01-28T18:15:18.385Z" },
+ { url = "https://files.pythonhosted.org/packages/f2/fc/f8d9c31db14fcec13748d373e668bc3bed94d9077dbc17fb0eebc073233c/psutil-7.2.2-cp314-cp314t-win_arm64.whl", hash = "sha256:11fe5a4f613759764e79c65cf11ebdf26e33d6dd34336f8a337aa2996d71c841", size = 136284, upload-time = "2026-01-28T18:15:19.912Z" },
+ { url = "https://files.pythonhosted.org/packages/e7/36/5ee6e05c9bd427237b11b3937ad82bb8ad2752d72c6969314590dd0c2f6e/psutil-7.2.2-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:ed0cace939114f62738d808fdcecd4c869222507e266e574799e9c0faa17d486", size = 129090, upload-time = "2026-01-28T18:15:22.168Z" },
+ { url = "https://files.pythonhosted.org/packages/80/c4/f5af4c1ca8c1eeb2e92ccca14ce8effdeec651d5ab6053c589b074eda6e1/psutil-7.2.2-cp36-abi3-macosx_11_0_arm64.whl", hash = "sha256:1a7b04c10f32cc88ab39cbf606e117fd74721c831c98a27dc04578deb0c16979", size = 129859, upload-time = "2026-01-28T18:15:23.795Z" },
+ { url = "https://files.pythonhosted.org/packages/b5/70/5d8df3b09e25bce090399cf48e452d25c935ab72dad19406c77f4e828045/psutil-7.2.2-cp36-abi3-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:076a2d2f923fd4821644f5ba89f059523da90dc9014e85f8e45a5774ca5bc6f9", size = 155560, upload-time = "2026-01-28T18:15:25.976Z" },
+ { url = "https://files.pythonhosted.org/packages/63/65/37648c0c158dc222aba51c089eb3bdfa238e621674dc42d48706e639204f/psutil-7.2.2-cp36-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b0726cecd84f9474419d67252add4ac0cd9811b04d61123054b9fb6f57df6e9e", size = 156997, upload-time = "2026-01-28T18:15:27.794Z" },
+ { url = "https://files.pythonhosted.org/packages/8e/13/125093eadae863ce03c6ffdbae9929430d116a246ef69866dad94da3bfbc/psutil-7.2.2-cp36-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:fd04ef36b4a6d599bbdb225dd1d3f51e00105f6d48a28f006da7f9822f2606d8", size = 148972, upload-time = "2026-01-28T18:15:29.342Z" },
+ { url = "https://files.pythonhosted.org/packages/04/78/0acd37ca84ce3ddffaa92ef0f571e073faa6d8ff1f0559ab1272188ea2be/psutil-7.2.2-cp36-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:b58fabe35e80b264a4e3bb23e6b96f9e45a3df7fb7eed419ac0e5947c61e47cc", size = 148266, upload-time = "2026-01-28T18:15:31.597Z" },
+ { url = "https://files.pythonhosted.org/packages/b4/90/e2159492b5426be0c1fef7acba807a03511f97c5f86b3caeda6ad92351a7/psutil-7.2.2-cp37-abi3-win_amd64.whl", hash = "sha256:eb7e81434c8d223ec4a219b5fc1c47d0417b12be7ea866e24fb5ad6e84b3d988", size = 137737, upload-time = "2026-01-28T18:15:33.849Z" },
+ { url = "https://files.pythonhosted.org/packages/8c/c7/7bb2e321574b10df20cbde462a94e2b71d05f9bbda251ef27d104668306a/psutil-7.2.2-cp37-abi3-win_arm64.whl", hash = "sha256:8c233660f575a5a89e6d4cb65d9f938126312bca76d8fe087b947b3a1aaac9ee", size = 134617, upload-time = "2026-01-28T18:15:36.514Z" },
+]
+
+[[package]]
+name = "psutils"
+version = "3.3.17"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "puremagic" },
+ { name = "pypdf" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/a0/6b/735ab226ab8e2ee6b99182312c7346a2e4bd3e825cfdf127f22ce75e853f/psutils-3.3.17.tar.gz", hash = "sha256:3ff76cc9f9fb535ec553d1591de56d6dbaa0d9f4df86778f6a49731ebd764cdf", size = 1255066, upload-time = "2026-09-07T13:15:05.562Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/96/c6/84c07085ebc0130e1bc7f1957e10ad42ea53f2f65177e107a5c76323b78c/psutils-3.3.17-py3-none-any.whl", hash = "sha256:de0289f2eac5966c2481a02cfa6eed0dc0f8e67115356eb44dbab429610f5970", size = 55328, upload-time = "2026-09-07T13:15:02.882Z" },
+]
+
+[[package]]
+name = "puremagic"
+version = "2.2.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/24/74/ce5987ab9b8aec4ced06e2723ebb604205c9eb58abdad91453da93166380/puremagic-2.2.0.tar.gz", hash = "sha256:eb4bddf07c177c4b434554b92165b67449f5a51e152b976202d6254498810eef", size = 1189752, upload-time = "2026-04-08T01:39:55.562Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/95/81/314320aeffd88dadeac553ff9eb10f54507ab41deccd0c69f6221d254a0a/puremagic-2.2.0-py3-none-any.whl", hash = "sha256:c4f7ed7307f056c787199acfda839555921be1df13abba61e8e6db0c787ae1d0", size = 71971, upload-time = "2026-04-08T01:39:54.169Z" },
+]
+
+[[package]]
+name = "pydantic"
+version = "2.14.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "annotated-types" },
+ { name = "pydantic-core" },
+ { name = "typing-extensions" },
+ { name = "typing-inspection" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/7c/0b/8e10b2e693af8ec54346a14caf36221334775975a747c9ceaa3f8371d96d/pydantic-2.14.1.tar.gz", hash = "sha256:94f478203dd03404682a1ada216965651dd74b1d2d5ffd62e00e0837caab5c26", size = 900362, upload-time = "2026-10-11T18:37:55.396Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/35/ea/a56b9fe5066f3537b7882f77e9c5dfb26d8c2eefdaed9b5fc73d57b4dc22/pydantic-2.14.1-py3-none-any.whl", hash = "sha256:9195d967ec791692a04438115466764fb8b9a27b31f14a760437694f40d6b454", size = 488106, upload-time = "2026-10-11T18:37:53.437Z" },
+]
+
+[[package]]
+name = "pydantic-core"
+version = "2.50.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "typing-extensions" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/a6/24/af4ec4be49fbc810f35b0bdcacc3d433b56bbfa469fd3bfd4ae116cd9bf1/pydantic_core-2.50.1.tar.gz", hash = "sha256:e50d7b94baac6c7d09927fa5ca5800a0c7ee5015c7fcff65beb3a1931b5a6e09", size = 513515, upload-time = "2026-10-11T18:35:44.82Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/ae/57/0e237d7091d2cd44a35d243b7344227f90440166860469cd036afc23a04d/pydantic_core-2.50.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:d5e062c01286d861fd6a1c4ff6e063547b3e713067f2df033c0ff97ac2ca006b", size = 2222774, upload-time = "2026-10-11T18:32:43.103Z" },
+ { url = "https://files.pythonhosted.org/packages/f7/b9/c720e56858d4e1539503297ed37063e0c08e0f3541c41b777f6a800f75fa/pydantic_core-2.50.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0c003c3b7f49debb893d2d85ae099ac5959c9839e2f330fadb1fcdf7a6594482", size = 2024738, upload-time = "2026-10-11T18:32:44.587Z" },
+ { url = "https://files.pythonhosted.org/packages/4d/b8/fbfc25875219cc060e613170ff10e850c6d8924beb4910da57c2ee3ba1d2/pydantic_core-2.50.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:409e0ea40ec30d9158f33574fd758e689f6045a0f2596701828c27816ca9687d", size = 2047049, upload-time = "2026-10-11T18:32:46.164Z" },
+ { url = "https://files.pythonhosted.org/packages/d7/43/34210124d504c553688f2f04b48500b131237528ac545b445e0d6d30e0d5/pydantic_core-2.50.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:131059670f1d2444269b8585cb888963994871932447c08b39ac6a51fcfef658", size = 2173415, upload-time = "2026-10-11T18:32:47.722Z" },
+ { url = "https://files.pythonhosted.org/packages/65/cf/6e178e8fdc11da5965bef980983bf46326a42f436871dd51ba0a57f39df1/pydantic_core-2.50.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6dbcbee53bf17196a7f745aa9bf5a9603953a1e365b1f020be3207c676a3e7c4", size = 2361719, upload-time = "2026-10-11T18:32:49.217Z" },
+ { url = "https://files.pythonhosted.org/packages/11/14/bd5169d356aa91bf777e28ba0b281c192d286d03c2812c9c9db023a2f00e/pydantic_core-2.50.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:325c23f3e35cfbf0fe3486fa5f7260d1e45885173002d30a28ca019994124255", size = 2439803, upload-time = "2026-10-11T18:32:51.025Z" },
+ { url = "https://files.pythonhosted.org/packages/1c/bc/d79d000e5203ebef39af839f2ce77a777fcad6e08ad26af9a2fcd114ffc8/pydantic_core-2.50.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:17e722e156d0444ecaefbe640bdb60928752bf2013e2b7a11cdb099aaae19bec", size = 2158402, upload-time = "2026-10-11T18:32:52.714Z" },
+ { url = "https://files.pythonhosted.org/packages/1b/a5/4902cb5fd599422c130bd3124ab31ee8b771199bd56662702eed07d3fec7/pydantic_core-2.50.1-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:aa8224f10880d9bf1b5993988ba153d42a8b4f3f4f511f93b1f09c93ff613c72", size = 2229755, upload-time = "2026-10-11T18:32:54.126Z" },
+ { url = "https://files.pythonhosted.org/packages/1a/f5/c1481f8669f6060d89110c9b1374173fc8767ca276b84f4870bd8acd5e3f/pydantic_core-2.50.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:41bc8237121bd8dc8d888dfd6279fc166ffc88c1f1bf3a8bf00869680533ca4c", size = 2242303, upload-time = "2026-10-11T18:32:55.641Z" },
+ { url = "https://files.pythonhosted.org/packages/04/f9/77fc3c7653ba9b6e42049e17e96b25388187d4a7c274ab1cb07f58ac8419/pydantic_core-2.50.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:45c6266d071c241f2a168d45bf8c54344f0effce35e7e6b73afdec11f3687568", size = 2278345, upload-time = "2026-10-11T18:32:57.38Z" },
+ { url = "https://files.pythonhosted.org/packages/95/9b/0579c5d12e7f2b16b27e6782427987341fcce07b0725e02ddb0b74add0c4/pydantic_core-2.50.1-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:1deeacb112d14d3f4fcb16b165f7dbaf76c70ba6e82f37ba042bdab51970a0b8", size = 2449947, upload-time = "2026-10-11T18:32:58.896Z" },
+ { url = "https://files.pythonhosted.org/packages/a8/ac/1b677db91eba54cc5922f4de6edc46ba45c2bd712dfdc0130382d158e214/pydantic_core-2.50.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:1c96fd793b73d1b92e65570132505498fe7b21eaef73cdf74e67e5dfba7ac9e4", size = 2476889, upload-time = "2026-10-11T18:33:00.665Z" },
+ { url = "https://files.pythonhosted.org/packages/4d/2a/3a9f6624ee3ea9ccba5249dde11418bb4c35780a7a92608f8768bd3fea39/pydantic_core-2.50.1-cp313-cp313-win32.whl", hash = "sha256:06ead20d39ffd6f2f6f2a8f8a6de67ff8bb1b4f14a8a30e058502514ee2ac685", size = 2101118, upload-time = "2026-10-11T18:33:02.329Z" },
+ { url = "https://files.pythonhosted.org/packages/2d/1f/323f78ddd9d9938aac420c1abb4e8ba799fc8bdab0acc67c0593b837c979/pydantic_core-2.50.1-cp313-cp313-win_amd64.whl", hash = "sha256:7816e98acc08119dc0f340ab167048ecc54126316330c1f0caf7c6756c88e28f", size = 2170840, upload-time = "2026-10-11T18:33:03.919Z" },
+ { url = "https://files.pythonhosted.org/packages/cb/09/8497c52a739ae425c3ac2f7f56414cbc711c67d374346174f40fe2062644/pydantic_core-2.50.1-cp313-cp313-win_arm64.whl", hash = "sha256:c17799a62c142d61b8a3c51752a7cbc87fe2ad4ccfab10e628a77b405075c662", size = 2161633, upload-time = "2026-10-11T18:33:05.518Z" },
+ { url = "https://files.pythonhosted.org/packages/49/33/28b96e81677153715e3eafb9f26663a80841e859fde282a380359b0d3fa1/pydantic_core-2.50.1-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:1cf41f1ae3fa155cf167a72689ad044bcc1e3c97e064123677149bdfb5dafc4a", size = 2224428, upload-time = "2026-10-11T18:33:07.153Z" },
+ { url = "https://files.pythonhosted.org/packages/94/40/15c06410c9b7b8da5805d27b64e09bd3f900e986728106db2689dbc51513/pydantic_core-2.50.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4df197990c15b5a37c5a277d131d9f2c67de6133f2e5dafd80d9bba4b99f46f9", size = 2023740, upload-time = "2026-10-11T18:33:08.763Z" },
+ { url = "https://files.pythonhosted.org/packages/a1/4e/5eb629f6efc2a27e421d789dd4bfacbb5f6d09c80c28b13d1e87d73163d5/pydantic_core-2.50.1-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0036473f5583e6a60e50b8b21651511564277a3f05cc5dab8cf579f552cd5f6c", size = 2049435, upload-time = "2026-10-11T18:33:10.366Z" },
+ { url = "https://files.pythonhosted.org/packages/ba/8e/f195aebec49ad12318876ac2368c197a7939f5d52f8e156d2238ef8a4588/pydantic_core-2.50.1-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:992c3514ec891fa7858099183e4d64e6bd5a5d4ff452fae29df22faa77a006bb", size = 2172975, upload-time = "2026-10-11T18:33:12.368Z" },
+ { url = "https://files.pythonhosted.org/packages/e0/f5/7ad9fb83010cd5ea0948409db105676ed779c4e709e2d3d89b9fe2558794/pydantic_core-2.50.1-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:739dc730e6be3bd5ec2f4ab5cfc7eb047cc45fc1497b3bafec74ff2ed07df597", size = 2364197, upload-time = "2026-10-11T18:33:14.244Z" },
+ { url = "https://files.pythonhosted.org/packages/ca/fb/bf0aab3e78301d202b82a0322ec968cd703b11fc0623fde9a28708936f62/pydantic_core-2.50.1-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:32fad3a91e51b6d2039c572db04a5a873260b399f6bd62c3552671fa7a4a2899", size = 2444259, upload-time = "2026-10-11T18:33:15.79Z" },
+ { url = "https://files.pythonhosted.org/packages/45/35/38f6d6564fae57d9b12e5676dfa947e0e7d5c46dbeaa7eb3352261d6d299/pydantic_core-2.50.1-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:42b54c2c90ad348b5e3a85e03e715d572c1fde357ef104cdfe3b03b697a404ea", size = 2159857, upload-time = "2026-10-11T18:33:17.51Z" },
+ { url = "https://files.pythonhosted.org/packages/65/a0/fb0a3ca10f139dcf765b2d312d10cf0f65c57c59993229d00ad12b14ceff/pydantic_core-2.50.1-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:2df1ff41884de2bc4b307bafd7c40a691094fad2ff8e767e5b45a319257bcf4e", size = 2233954, upload-time = "2026-10-11T18:33:19.591Z" },
+ { url = "https://files.pythonhosted.org/packages/8c/6a/e63842252702aa4ec6e6b7178ca85e541a77459076592c23ebe2d9840b33/pydantic_core-2.50.1-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fe90228920fd8ff2be62622b6bb8a2b11acd65046d50c6b130614b5879605a20", size = 2243876, upload-time = "2026-10-11T18:33:21.393Z" },
+ { url = "https://files.pythonhosted.org/packages/39/25/5991cf8318b37e0dfab47b87541619a1df8501a793cba0d978846cba37a7/pydantic_core-2.50.1-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:844b869f118e22a41a091bdcedda8a71bc1b0f62c38d1a0c3211cece47e1d8fc", size = 2277908, upload-time = "2026-10-11T18:33:23.324Z" },
+ { url = "https://files.pythonhosted.org/packages/a7/3e/3ee8baaa6cc25a6961c69168cf9ff0f002d56f4e0d541ad6724c18fb61f3/pydantic_core-2.50.1-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:2eb75304506894a281d346220a4f7481a1b8729577c5ed2a05395991966a8396", size = 2449285, upload-time = "2026-10-11T18:33:24.942Z" },
+ { url = "https://files.pythonhosted.org/packages/c0/c7/acbec6deac13fe697a80c275a9b6661db62c4d323bac8a47347b1f39c7cd/pydantic_core-2.50.1-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:6b20a4bffabdad0db2927ac034ae3b8a681b1f7a0182f3e60b479ad2fde21ebb", size = 2479013, upload-time = "2026-10-11T18:33:26.925Z" },
+ { url = "https://files.pythonhosted.org/packages/10/08/21a3f237b264389f6219d053ee78fc1b5c2fd402c1b1cb2b6cb8b85f9834/pydantic_core-2.50.1-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:99ba9bc2b8062ea0c326a990f7f00e6530c23579de66dd246e72c4cafef950a5", size = 1484400, upload-time = "2026-10-11T18:33:28.693Z" },
+ { url = "https://files.pythonhosted.org/packages/09/ce/077a6d262d12ef09108377ac0717f029f420d773ace63cafd6143af75568/pydantic_core-2.50.1-cp314-cp314-win32.whl", hash = "sha256:cf356f70551d40374eaffb1aa63f1eb6d2006681cbd7a9faea173ce0f4dd7cd2", size = 2103222, upload-time = "2026-10-11T18:33:30.326Z" },
+ { url = "https://files.pythonhosted.org/packages/14/4c/350a2415209c43d670eb71d3c040f332c04a30583d39e311b05c7ac15762/pydantic_core-2.50.1-cp314-cp314-win_amd64.whl", hash = "sha256:d32f3acc081cc3923386d88f422cde8892335e95f034e0104bb4cf9310d9915f", size = 2171586, upload-time = "2026-10-11T18:33:32.139Z" },
+ { url = "https://files.pythonhosted.org/packages/bf/92/9bea6ca96580a0902fed366f064f0829e404f41889b34543279a1162b888/pydantic_core-2.50.1-cp314-cp314-win_arm64.whl", hash = "sha256:bed5163e03b98bc1fa2eb05d74c63d9c5c95d8ed6254985481640fbf5e237dea", size = 2155773, upload-time = "2026-10-11T18:33:33.896Z" },
+ { url = "https://files.pythonhosted.org/packages/86/8c/f121f073cf32bdd5cba7e6230b1ce0ac845b0cf43e44dd597d95af272db2/pydantic_core-2.50.1-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:9572c1369e9c9da2d64a7b7992c786d90ff295abc93964cfe3125e4290768070", size = 2234017, upload-time = "2026-10-11T18:33:35.588Z" },
+ { url = "https://files.pythonhosted.org/packages/a4/69/2bb2bcd6146cffe98d760a46c42ae71efd5151d9b2f9c9bf6619a3b32083/pydantic_core-2.50.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2005207aafe1231315718bf6ed5d064a7300fb4772754af35ee72fc68159492e", size = 2001016, upload-time = "2026-10-11T18:33:37.57Z" },
+ { url = "https://files.pythonhosted.org/packages/20/b3/fbf854c7d07ec114260c26e9e2071a4381740f9ae09641dbfcbdf2a18c45/pydantic_core-2.50.1-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:64f6047f62a6c5ae08d0a6afb035667aa2d97c3d20d69762e034c5ea144d92a5", size = 2023015, upload-time = "2026-10-11T18:33:39.433Z" },
+ { url = "https://files.pythonhosted.org/packages/65/20/6de55b2f92cdb614b745c6e9ced639fc4fd7e1e77604825a88bdece6fcbd/pydantic_core-2.50.1-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:1ef800dd7d85bcdadf4c3076e4c94e43939493558a3b69a1ea830c706d4617bb", size = 2192543, upload-time = "2026-10-11T18:33:41.381Z" },
+ { url = "https://files.pythonhosted.org/packages/0c/d9/19e91c94bd5c405945ce2f15526808aea37e162c253160de8ed7bf70b406/pydantic_core-2.50.1-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b0135bcdcaa0f23573f286e4cb5e0fd2962700964ed13df085b85f2b97aeab9e", size = 2388704, upload-time = "2026-10-11T18:33:43.167Z" },
+ { url = "https://files.pythonhosted.org/packages/b6/bc/2e24c8415eae1a25ee5a5484946a917123bad2e9d01689a759236928175a/pydantic_core-2.50.1-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0b3a6f334c6a2345ca15318ff894502a90012536404b37c844a976c76c846e0b", size = 2464118, upload-time = "2026-10-11T18:33:44.856Z" },
+ { url = "https://files.pythonhosted.org/packages/bd/1f/945b8053cb061c64e102bcaf7bfb9ed740c0bd4349f9bb978a7d4ddff4ab/pydantic_core-2.50.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:06e01fbbfdb9be777b316a71b6c49efaf4a08b615d0a98d678cda3023f79d019", size = 2150142, upload-time = "2026-10-11T18:33:46.661Z" },
+ { url = "https://files.pythonhosted.org/packages/2b/78/96a3e50bf0d64aaae781107eb9335b7529d05a85793ed6e7241c4cd1d931/pydantic_core-2.50.1-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:a29a061fec0b4e2d714f277e70a3a18125ecff803f2fea6eade2f2e53711d112", size = 2245577, upload-time = "2026-10-11T18:33:48.574Z" },
+ { url = "https://files.pythonhosted.org/packages/7a/5d/a6038a0322232758a6ebfa709f4ca14a60bf5b93940cd0b345056a557da4/pydantic_core-2.50.1-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f5187624823423e1d1b82b1072ac41dc837389e18d3d0572cc19bbee46cd550a", size = 2263728, upload-time = "2026-10-11T18:33:50.527Z" },
+ { url = "https://files.pythonhosted.org/packages/0d/4b/76ded3333a457a9344c4f2d63f2d82d0101654b05178fa4ddfe7d3627674/pydantic_core-2.50.1-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:3e46a9eb0a0901dd6275e6b06ac3a464885ef350ec4121fe486869de8053e4bb", size = 2293630, upload-time = "2026-10-11T18:33:52.362Z" },
+ { url = "https://files.pythonhosted.org/packages/a9/00/9eca378335c9c1b72bc779bf6e4c2a82ce784f14ec48a0e87102c9870e05/pydantic_core-2.50.1-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:756d669f04e62ec4148ecfe22be6a4484d9b1181a6ef32e205ebfd200540858b", size = 2468048, upload-time = "2026-10-11T18:33:54.118Z" },
+ { url = "https://files.pythonhosted.org/packages/35/ca/e3832e9cf93651251de43c8c5c029ed680a3c1a0a1b17ee26c81bed08cbd/pydantic_core-2.50.1-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:c516cc5367ca3448995d42cb994bf3f4c9002d2a7c22eac9622551269ad1b807", size = 2512159, upload-time = "2026-10-11T18:33:55.99Z" },
+ { url = "https://files.pythonhosted.org/packages/4b/f2/773469b5a10a39116a2cb17edaf6d722f017dd8da07161b371465311c542/pydantic_core-2.50.1-cp314-cp314t-win32.whl", hash = "sha256:9d1bed94af6a63835461f3cf7502058eb166c58c4778e11d0f433cfb1bd69e19", size = 2098406, upload-time = "2026-10-11T18:33:58.043Z" },
+ { url = "https://files.pythonhosted.org/packages/ee/42/0bb74f8f25204b259b11ab7c12dc7f180893b6bd706118b385147fb6efd5/pydantic_core-2.50.1-cp314-cp314t-win_amd64.whl", hash = "sha256:c8dce1f1e0e5358b682a6ad3fa5e31b31d4560997b8e61417e9217c8d60f8a0c", size = 2148481, upload-time = "2026-10-11T18:33:59.913Z" },
+ { url = "https://files.pythonhosted.org/packages/47/0d/d801646c9679a4e630e15cf521d4108b93854b42a6e6e02391bd4c6b1095/pydantic_core-2.50.1-cp314-cp314t-win_arm64.whl", hash = "sha256:ceff0acc940be2715bd6ad17b24c0e5304abf44f6efd0f81ee8499e640f9dc86", size = 2174972, upload-time = "2026-10-11T18:34:01.875Z" },
+ { url = "https://files.pythonhosted.org/packages/b2/84/23984b763d8862a02a13d27a44b6e8169428fd85ecfed88f54c29108604d/pydantic_core-2.50.1-cp315-cp315-macosx_10_12_x86_64.whl", hash = "sha256:8a6791afa2245e6c6b180122d105941644f5bd410bb18623b408808cc41a3102", size = 2224599, upload-time = "2026-10-11T18:34:04.016Z" },
+ { url = "https://files.pythonhosted.org/packages/4d/90/a63cf8586abc1d1a3f6d9b18f0224789ebf003851f092ebda3c7c863fd32/pydantic_core-2.50.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:84f34323a61a365b4e9295de6028474754829aaddd59c7bf1a040e7487ef8f3c", size = 2024191, upload-time = "2026-10-11T18:34:05.901Z" },
+ { url = "https://files.pythonhosted.org/packages/a6/6a/f34bff9808ffb4907fbf5f5040457d253cacf2da7fce9efbc99ca1b1a44d/pydantic_core-2.50.1-cp315-cp315-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:23edad659e8dbd8ca7e4e877fe6c81573abbdf215bd25a68b53e1272f58b80c7", size = 2048252, upload-time = "2026-10-11T18:34:07.757Z" },
+ { url = "https://files.pythonhosted.org/packages/b2/54/13f419bf1eb59852003818e25935aaf175687d978f4c4bca70e08fe40a3b/pydantic_core-2.50.1-cp315-cp315-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a5fce22f1e87d181e924e12da7d81cfe031fb3881a5ddf26ad28f141756ca43", size = 2173376, upload-time = "2026-10-11T18:34:09.592Z" },
+ { url = "https://files.pythonhosted.org/packages/6d/6c/b5a34d24cd0c81669d8f8339d74e6815abcf2f8fb48ab4b49b84c09be1d5/pydantic_core-2.50.1-cp315-cp315-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c73622ef819328873b53109ee4f77ceb598bffedd02daf916102be3228866b78", size = 2364559, upload-time = "2026-10-11T18:34:11.534Z" },
+ { url = "https://files.pythonhosted.org/packages/eb/8d/d64d6216a8df365082665927ff923f183056f9049fee08e9777c9ac05296/pydantic_core-2.50.1-cp315-cp315-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ce8c25ca38cc0e3d7753ba180808de2c0c8cb24eae0df64491e40921454e9831", size = 2444697, upload-time = "2026-10-11T18:34:13.535Z" },
+ { url = "https://files.pythonhosted.org/packages/b8/0d/1b1149f60a00ea21ba5f70e28acbd40feb4598af414f80f53c921fac07c9/pydantic_core-2.50.1-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7689580e72a642ab5ec64d5f55b2e33636fa43b4ebe63c0c2c965ef307c7d1aa", size = 2159961, upload-time = "2026-10-11T18:34:15.56Z" },
+ { url = "https://files.pythonhosted.org/packages/1e/35/f236549299dcc78e71e945495d7ec67e78d20844d0999c60601685003031/pydantic_core-2.50.1-cp315-cp315-manylinux_2_31_riscv64.whl", hash = "sha256:d5c0e32fdbce7f1e8ef4d11f655694bf5f4175c757a9f1dc2be09b8864e5bcf5", size = 2234581, upload-time = "2026-10-11T18:34:17.502Z" },
+ { url = "https://files.pythonhosted.org/packages/6a/85/26901a490522b7f75ef9bb9a7afb73e5bb550f0e1cb5b99a0069183e2eb9/pydantic_core-2.50.1-cp315-cp315-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:40f523349960fa30f3ea51404308ff50f9997a90df639590f47a057c1f32b415", size = 2244090, upload-time = "2026-10-11T18:34:19.402Z" },
+ { url = "https://files.pythonhosted.org/packages/ca/2c/481bcfc70ceeb77a778ca6e5b705fe592cb64735c108157f81b7dd9c280e/pydantic_core-2.50.1-cp315-cp315-musllinux_1_1_aarch64.whl", hash = "sha256:d4193206b6587047437f6f11d7e776df23e1c1e23af2a54d9347275614791e10", size = 2277948, upload-time = "2026-10-11T18:34:21.317Z" },
+ { url = "https://files.pythonhosted.org/packages/24/eb/f1e09333faa7ba447cde967310f758430cc7c5e987bdab5228816c70030d/pydantic_core-2.50.1-cp315-cp315-musllinux_1_1_armv7l.whl", hash = "sha256:84bc765b282a9d5b7fe0348b8648904f25a6a04b2139da52b1dd30c8ac3a2c8f", size = 2449688, upload-time = "2026-10-11T18:34:23.321Z" },
+ { url = "https://files.pythonhosted.org/packages/d1/b3/036bde636db8f76d92996e81aefc75678ab5cec4a07eea1ad0c72a893fc3/pydantic_core-2.50.1-cp315-cp315-musllinux_1_1_x86_64.whl", hash = "sha256:ed1e728b39a383c81035b2459cfcb35d99dfb01f7d6ebe3a913bc1cc5b81e459", size = 2479286, upload-time = "2026-10-11T18:34:25.214Z" },
+ { url = "https://files.pythonhosted.org/packages/d0/a3/07f018294ee18d144afeb6df1a47d9e92960f014be45c5ed13519fa5af95/pydantic_core-2.50.1-cp315-cp315-win32.whl", hash = "sha256:bc94f474417604bd383d2cd445d071b07dd55fedceed3ce33407bf1fcc107290", size = 2103683, upload-time = "2026-10-11T18:34:27.42Z" },
+ { url = "https://files.pythonhosted.org/packages/5f/98/f9bd7e1f9b6709f155acb9ef826d9c3884fe925f811fd8e55b9b52280bad/pydantic_core-2.50.1-cp315-cp315-win_amd64.whl", hash = "sha256:983a662de2571cb2502fc8ff47b6770b03d025d2eb314c92f77b3f07c74720ed", size = 2171681, upload-time = "2026-10-11T18:34:29.508Z" },
+ { url = "https://files.pythonhosted.org/packages/10/87/4bb3e1e7f385c076ab5af4d6dd0571b22042cd8207eb810d5b9fef15ae31/pydantic_core-2.50.1-cp315-cp315-win_arm64.whl", hash = "sha256:94845ff54dc5193f228cab81b2662a04bfbb892e95bdc15edf7399000ce57d54", size = 2156251, upload-time = "2026-10-11T18:34:31.455Z" },
+ { url = "https://files.pythonhosted.org/packages/47/47/83643225b08f2aef6c8cc4bbe6e3f79c5e139c4364a6e450d0f399d87774/pydantic_core-2.50.1-cp315-cp315t-macosx_10_12_x86_64.whl", hash = "sha256:4a53d13cdfbedbfa87f08b83c1a0a5efcc767d785a4b41934fa9cb672670493a", size = 2234363, upload-time = "2026-10-11T18:34:33.65Z" },
+ { url = "https://files.pythonhosted.org/packages/5e/66/127ca649ba2f2462039dc1e694c6c01e00a3689f023a617364d3507e6d97/pydantic_core-2.50.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:efbecf43d321f7b9281441f1f213f7c21c66988b0e06c2730ba13ed47a46bb08", size = 2000972, upload-time = "2026-10-11T18:34:36.037Z" },
+ { url = "https://files.pythonhosted.org/packages/5e/4f/e421e0a5d653b1203b090b2e48745976988d7338a647940704b5b9c2b399/pydantic_core-2.50.1-cp315-cp315t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bc1f08f68dac9f9e83845a8039880aba2ab553eb9b2259c3243a313182c253fe", size = 2022925, upload-time = "2026-10-11T18:34:37.972Z" },
+ { url = "https://files.pythonhosted.org/packages/d5/4e/ea5568e2491e1a71100f15ae8c2d01ef52db42a184a2d4e716bc79e5eb8f/pydantic_core-2.50.1-cp315-cp315t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5dfe41f232befddb9c4377f6cfc702b51595e2d78ed082672adf8758d2c4619f", size = 2192867, upload-time = "2026-10-11T18:34:39.921Z" },
+ { url = "https://files.pythonhosted.org/packages/ae/5e/3b8c3a35acbe219909ada5defad5d7d9fed845fb2b37bd5ec518f453c119/pydantic_core-2.50.1-cp315-cp315t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:adc06d218a1cadfd2ec4628424d7d79ce4eba69c2965e7e7b55106f0da5208c8", size = 2388687, upload-time = "2026-10-11T18:34:42.184Z" },
+ { url = "https://files.pythonhosted.org/packages/b6/aa/7889b4e515f91a2e8c0ae6b5081fec0feb30c4398d1434e14793c60f173a/pydantic_core-2.50.1-cp315-cp315t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2cf91809d0721ab81592ba67bea7694821679c10b1a2e3c3460082b286c1918a", size = 2464415, upload-time = "2026-10-11T18:34:44.384Z" },
+ { url = "https://files.pythonhosted.org/packages/08/78/93449e628eb8a6fdcce3eff9043081179d1bc7ce6f1bff32dc5006f41e00/pydantic_core-2.50.1-cp315-cp315t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:23923ab9292c40da026330b1ecf4dc2618c8e86e0422e5d1fbf50d94d64ca4f8", size = 2150823, upload-time = "2026-10-11T18:34:46.392Z" },
+ { url = "https://files.pythonhosted.org/packages/f4/f1/72c5bc129fceb0d00f05dc1e67f518c1728de1928c55f81fc13d7690de39/pydantic_core-2.50.1-cp315-cp315t-manylinux_2_31_riscv64.whl", hash = "sha256:f3377c8c2b3ce898423c5e5dd94c7982e30aa7717a7e6ab2470b9de364963709", size = 2245806, upload-time = "2026-10-11T18:34:48.805Z" },
+ { url = "https://files.pythonhosted.org/packages/28/2a/922a0e78f3aa6ab837b59f88190233fb8dad546c17995fde9bb3ed3b9b49/pydantic_core-2.50.1-cp315-cp315t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:455a773617b5913bf5c20d0692e5787b119e52c4d40ea644ca31f5758fd31be2", size = 2264160, upload-time = "2026-10-11T18:34:50.876Z" },
+ { url = "https://files.pythonhosted.org/packages/52/a8/0f1449e3e1b20941c9372faa18e7b6a092e30cdfa841902473f7989532ac/pydantic_core-2.50.1-cp315-cp315t-musllinux_1_1_aarch64.whl", hash = "sha256:1a9006395dece0e32e704c315eff8a00bede494f6108546cfc5539c89fef4f9a", size = 2294031, upload-time = "2026-10-11T18:34:52.876Z" },
+ { url = "https://files.pythonhosted.org/packages/d1/d0/1031f492857de70355fb16524bbb03efce5fd34c93ed4a1ec60be07e0d4b/pydantic_core-2.50.1-cp315-cp315t-musllinux_1_1_armv7l.whl", hash = "sha256:d2d82aa62521c55ddfb000ae70f88cdd8de974078f6024e821dfe5addd0c818f", size = 2468423, upload-time = "2026-10-11T18:34:54.995Z" },
+ { url = "https://files.pythonhosted.org/packages/46/52/269ffffa645b8e47906395a39cf9db1151ae9fa4bcd7f47b960bc31baf37/pydantic_core-2.50.1-cp315-cp315t-musllinux_1_1_x86_64.whl", hash = "sha256:009634b83993777ddcd69cad0ffcace43dabde692109528e35f0fde91e386a8b", size = 2512598, upload-time = "2026-10-11T18:34:57.149Z" },
+ { url = "https://files.pythonhosted.org/packages/31/5c/e47e28281f20326ff6f3c31d626f0a83e615d2d94ba41bb0ad6ec237184a/pydantic_core-2.50.1-cp315-cp315t-win32.whl", hash = "sha256:3fde4fdc6487a58d944ca87cf5adc95d5f266e872c19599f5f4c0a8a1b1f9f9f", size = 2098535, upload-time = "2026-10-11T18:34:59.313Z" },
+ { url = "https://files.pythonhosted.org/packages/03/ad/759e181e69c1b472c60b2049e5f61d5da1deefdd5ce1df4bb2ebcf771f25/pydantic_core-2.50.1-cp315-cp315t-win_amd64.whl", hash = "sha256:1c8632d4ac04e6f91128fca584b3a8a507d81604c24eeaaad00d4be42765c32b", size = 2148645, upload-time = "2026-10-11T18:35:01.571Z" },
+ { url = "https://files.pythonhosted.org/packages/6d/56/8a702c27e5be9f47e5f19d8669227424290e4c024e7c370279cbaf244b4e/pydantic_core-2.50.1-cp315-cp315t-win_arm64.whl", hash = "sha256:c3ede305158e75510be50869b319550ab072008c13d64d4ab1e094fb286b6f44", size = 2175297, upload-time = "2026-10-11T18:35:04.079Z" },
+]
+
+[[package]]
+name = "pypdf"
+version = "6.20.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/e2/c1/da25a099164cf4b210d63b957c902ad687139f4b8c12c20aec7953a4a266/pypdf-6.20.1.tar.gz", hash = "sha256:28f5a9d2fdc2749264612d94e6a58de54c11d730d9f0cabf8ad34117c4942b45", size = 7075352, upload-time = "2026-10-12T16:14:24.784Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/71/f8/4cbd09988b4b158260b7e0df38bf16f19e998bf0e257a18661a8da04280e/pypdf-6.20.1-py3-none-any.whl", hash = "sha256:aa5a55ddcffdc5e5ab291d5decb23f6383f4e56f8e3263dc39af41fff03885ad", size = 402665, upload-time = "2026-10-12T16:14:22.556Z" },
+]
+
+[[package]]
+name = "pyqtgraph"
+version = "0.14.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "colorama" },
+ { name = "numpy" },
+]
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/32/36/4c242f81fdcbfa4fb62a5645f6af79191f4097a0577bd5460c24f19cc4ef/pyqtgraph-0.14.0-py3-none-any.whl", hash = "sha256:7abb7c3e17362add64f8711b474dffac5e7b0e9245abdf992e9a44119b7aa4f5", size = 1924755, upload-time = "2025-11-16T19:43:22.251Z" },
+]
+
+[[package]]
+name = "pyside6"
+version = "6.12.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "pyside6-addons" },
+ { name = "pyside6-essentials" },
+ { name = "pyside6-pdf" },
+ { name = "pyside6-webengine" },
+]
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/fa/db/0722bbb81c3515dd0847fd66a7fb4760b13ad59c89e40b27b362ab5624e2/pyside6-6.12.0-cp310-abi3-macosx_14_0_universal2.whl", hash = "sha256:efea4c2f2aa54403c45de825865a902a1960a1cc9d5aaa92d4ebb8f4c4cd0708", size = 577475, upload-time = "2026-10-08T09:11:15.222Z" },
+ { url = "https://files.pythonhosted.org/packages/2f/53/db0cabed1e70d0bc4666b8cbc2611b72bff2f9d2c4813820182621a8c6ee/pyside6-6.12.0-cp310-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:d13ed4d0053cc20f3e4e0fdbf12790a9b7f958d7e22c0843411ecfa8c799e2a9", size = 577709, upload-time = "2026-10-08T09:11:17.345Z" },
+ { url = "https://files.pythonhosted.org/packages/4d/8c/dfd4f339285d2e6c31067789b9b7dd2cee26c098ecc78ac7200fb04aa344/pyside6-6.12.0-cp310-abi3-manylinux_2_39_aarch64.whl", hash = "sha256:8920e163f60493e18822d20f1e01c7c4bcf9c1c5cfbc68284788896f692594f8", size = 577708, upload-time = "2026-10-08T09:11:18.705Z" },
+ { url = "https://files.pythonhosted.org/packages/a1/da/8e8514fc29ea916c3cd71a524480b77409e19ed12d709e8a5d73be089b81/pyside6-6.12.0-cp310-abi3-win_amd64.whl", hash = "sha256:31a8d2893f2f28ab483b1534dde0438f27af6e234593de30425cb4729052d8f8", size = 578958, upload-time = "2026-10-08T09:11:20.179Z" },
+ { url = "https://files.pythonhosted.org/packages/5f/2e/3ebafaf7f4ccea6f6ea9d017f7fc7d9484b79e6b31d1cb92f5f5262055f0/pyside6-6.12.0-cp310-abi3-win_arm64.whl", hash = "sha256:22e2ec6407f32b61955a28f91ab55676e60017210285c28f0c381bfeed389414", size = 562743, upload-time = "2026-10-08T09:11:21.552Z" },
+]
+
+[[package]]
+name = "pyside6-addons"
+version = "6.12.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "pyside6-essentials" },
+]
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/c3/16/c401d6455dbf49df37a8b142883e12d446abf51919c3e0ef5d9570e29160/pyside6_addons-6.12.0-cp310-abi3-macosx_14_0_universal2.whl", hash = "sha256:1d9bcd294ce1c9829194c7d13b01f771a2254b86cb3462cc3399321d2797fdac", size = 90545550, upload-time = "2026-10-08T09:08:14.778Z" },
+ { url = "https://files.pythonhosted.org/packages/ff/3e/0d6c5934de575286ac0699a701d4155048185035b8da0ee78d57a38bc7ef/pyside6_addons-6.12.0-cp310-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:4ab38c0017f0453671a9313d3a53ca1c0e378924f54b07e596bcea94a9642391", size = 59192702, upload-time = "2026-10-08T09:08:21.13Z" },
+ { url = "https://files.pythonhosted.org/packages/94/14/b6c5683614601448c3ea8f19613ca378db75158ba594b662a8a8041e3e5f/pyside6_addons-6.12.0-cp310-abi3-manylinux_2_39_aarch64.whl", hash = "sha256:48cd0b639c06c9f79685f340ccae4319614b66d6efcdf06c2ce3836790d8c46e", size = 63646547, upload-time = "2026-10-08T09:08:26.921Z" },
+ { url = "https://files.pythonhosted.org/packages/02/41/ba3d52fe2acd942ebf71eb02b3bdf0897f7f6f4ce8f52289cb50228e48d3/pyside6_addons-6.12.0-cp310-abi3-win_amd64.whl", hash = "sha256:98d8d62010da6605344cee83e5c96fb846170a1f2d924523f10f5d92078b02b0", size = 40522402, upload-time = "2026-10-08T09:08:31.678Z" },
+ { url = "https://files.pythonhosted.org/packages/ea/73/860ab764368cd59b1a09cb891a9a4519425395ad1f6c47d83fde908fe603/pyside6_addons-6.12.0-cp310-abi3-win_arm64.whl", hash = "sha256:3c079cbaad2ad28bc5bfa92fd6bac4ba28d622b825825e3b4220f0682599b2db", size = 35940682, upload-time = "2026-10-08T09:08:35.424Z" },
+]
+
+[[package]]
+name = "pyside6-essentials"
+version = "6.12.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "shiboken6" },
+]
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/e1/90/d528f8dd3f9f20712bc719e10c698d27c998e1dd76ed3f33b6fb606d0972/pyside6_essentials-6.12.0-cp310-abi3-macosx_14_0_universal2.whl", hash = "sha256:0867b709a724db28b161227c7d6ecb5ed10cc09226a53542417ac6dee1308239", size = 116002775, upload-time = "2026-10-08T09:09:54.453Z" },
+ { url = "https://files.pythonhosted.org/packages/7b/3c/7597d8dcb36aa8315427635ec6ec9d850ffbad4ce78b9118ac854889f2d3/pyside6_essentials-6.12.0-cp310-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:16f4b5e41daf49235ae048458243da9efe5f3d8040f9cc5aa454ce69893d3bd9", size = 84346433, upload-time = "2026-10-08T09:10:02.825Z" },
+ { url = "https://files.pythonhosted.org/packages/5e/4e/f4fd4c016719be832ec49d3c87940459e87fd666179a0a199a3998d07916/pyside6_essentials-6.12.0-cp310-abi3-manylinux_2_39_aarch64.whl", hash = "sha256:08c5841063fc1df69b7fa1ee405f539e66576a83fc48004be3e2c5e35ccbfa69", size = 83810102, upload-time = "2026-10-08T09:10:09.13Z" },
+ { url = "https://files.pythonhosted.org/packages/03/61/9a7e32a37b224f68506785bfb0f288d516098ec71681782d72b695a6c95d/pyside6_essentials-6.12.0-cp310-abi3-win_amd64.whl", hash = "sha256:c9a95102aa23c1f86516a30d5a9be37552d324eee7e567eb7508973f1218cb42", size = 82153795, upload-time = "2026-10-08T09:10:15.104Z" },
+ { url = "https://files.pythonhosted.org/packages/92/11/43afb3d0e7c25dd09f0eb7e44f1283dadb5fa8d8b517249fe3fde02e6702/pyside6_essentials-6.12.0-cp310-abi3-win_arm64.whl", hash = "sha256:c76aa689989bf9bb9b45535e0a81c6733294df720b0eed1308fa30d2606b98cc", size = 62073456, upload-time = "2026-10-08T09:10:20.202Z" },
+]
+
+[[package]]
+name = "pyside6-pdf"
+version = "6.12.0.140"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "pyside6-essentials" },
+]
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/58/33/23949c3b48af4b96b618e0502b7313ac52a782615db8dceae5bc2c516831/pyside6_pdf-6.12.0.140-cp310-abi3-macosx_14_0_universal2.whl", hash = "sha256:74287989dd24a82ac1604e27a2d4530a0ab71ff03cdf878c16fdbcf5144373fb", size = 8644803, upload-time = "2026-10-08T09:07:36.716Z" },
+ { url = "https://files.pythonhosted.org/packages/b4/f0/9b4f3cb0506641ea53210c7b635703f1c2c1e81c27074438105ff118f8e7/pyside6_pdf-6.12.0.140-cp310-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:0c94370b6b9d65fbfe54f9d6d25df0677db0cdcfd0a842a63958a3603a42007c", size = 2843877, upload-time = "2026-10-08T09:07:39.208Z" },
+ { url = "https://files.pythonhosted.org/packages/39/cb/4a8cf3edb3f1b2396935573c02255be75f5605dcc77e9e3a201a6d6f26d7/pyside6_pdf-6.12.0.140-cp310-abi3-manylinux_2_39_aarch64.whl", hash = "sha256:2b63ee1f8ce3d3b2b648be2ebda483dd0fbca8d44c92a6c6f924033ac7fbde8c", size = 2837165, upload-time = "2026-10-08T09:07:40.756Z" },
+ { url = "https://files.pythonhosted.org/packages/0e/8a/3f5eab9eb9e6486f0334b100322bbd0aff432dd90ad7977a3910979a163f/pyside6_pdf-6.12.0.140-cp310-abi3-win_amd64.whl", hash = "sha256:4971ba1a0ca15e7c29cfd808e9600aeb46b2305dad01456eb09fa3197166268e", size = 2983516, upload-time = "2026-10-08T09:07:42.312Z" },
+ { url = "https://files.pythonhosted.org/packages/b2/87/5c29bdaf47bf0b0cee7f467e86ffeb07be5bf8aa2afd2ff6f0785d0515b4/pyside6_pdf-6.12.0.140-cp310-abi3-win_arm64.whl", hash = "sha256:ce6e4d858df1eb4e2adcbd8ba212f3611ce60f9978881fe10d417f36013a6ff9", size = 2758117, upload-time = "2026-10-08T09:07:43.936Z" },
+]
+
+[[package]]
+name = "pyside6-webengine"
+version = "6.12.0.140"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "pyside6-addons" },
+]
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/08/37/3b50067bdcba5c22e20a8d7a029c919850c6d94f8398175b330c3b8765ad/pyside6_webengine-6.12.0.140-cp310-abi3-macosx_14_0_universal2.whl", hash = "sha256:9a1de2da360d1817c08e9400a2a2fc5234beb3022a22123e15355169e183074e", size = 249778242, upload-time = "2026-10-08T09:06:03.602Z" },
+ { url = "https://files.pythonhosted.org/packages/ce/e0/cb8b68b962afc253709ed61555d0a4f297fd6786c0e77921d2e951504210/pyside6_webengine-6.12.0.140-cp310-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:c041c9921d7c91c9e51a2c027f434d05c3c59baa2c8f17b71dc8194e77791f06", size = 116741921, upload-time = "2026-10-08T09:06:13.408Z" },
+ { url = "https://files.pythonhosted.org/packages/1b/34/75944ed126293868fcbcc589fa2254e3310016920e5d32d21c29be021524/pyside6_webengine-6.12.0.140-cp310-abi3-manylinux_2_39_aarch64.whl", hash = "sha256:66a842ccb7f06fa7e357f6d4d63bc2739e3cbc1f93583f314e38c43024fe5f74", size = 114053128, upload-time = "2026-10-08T09:06:21.275Z" },
+ { url = "https://files.pythonhosted.org/packages/21/0d/260c7c286f2d4e8a49c099152a385414a872f022117f937824ca0a3f38e5/pyside6_webengine-6.12.0.140-cp310-abi3-win_amd64.whl", hash = "sha256:f9b61b1d457a591ee590a6b80515f25a3e61e2576a2a0a70566380f9d9de4ab0", size = 131697708, upload-time = "2026-10-08T09:06:29.606Z" },
+ { url = "https://files.pythonhosted.org/packages/76/34/7a2d1d7b95c96adf173c77d1862c2587789f7afb8754430fcfc2c9e4e47a/pyside6_webengine-6.12.0.140-cp310-abi3-win_arm64.whl", hash = "sha256:44d1fac2a057a71b8f947fc5177a95fab8a0ea4e2fda068f223a00fd0193a8c7", size = 65723, upload-time = "2026-10-08T09:06:32.559Z" },
+]
+
+[[package]]
+name = "shiboken6"
+version = "6.12.0"
+source = { registry = "https://pypi.org/simple" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/b5/45/11536fba5f50a57671eb14441c6098096f3e57074e54c9d7d66039a5831e/shiboken6-6.12.0-cp310-abi3-macosx_14_0_universal2.whl", hash = "sha256:47105e05baf57d35453e07d240bbe59cb4b8d9565377ea5134ff9fb37d4f5c02", size = 479653, upload-time = "2026-10-08T09:10:41.506Z" },
+ { url = "https://files.pythonhosted.org/packages/49/c2/a04fa003b4360e99d8e3fc2829abacf37396ace63ea1cd616402209ec7c6/shiboken6-6.12.0-cp310-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:3bde565bb0890044b5c7f63808adddede5abc241e9395a2bfed61919884d6240", size = 279268, upload-time = "2026-10-08T09:10:42.982Z" },
+ { url = "https://files.pythonhosted.org/packages/97/ca/a4415e6218e0dff764fe2b2d30db2fc8f499a08ace07634232ade3f73adc/shiboken6-6.12.0-cp310-abi3-manylinux_2_39_aarch64.whl", hash = "sha256:ff72a72b3277b418902d562b987b33a6f6818bed9fe40a49d634d09bc67f620e", size = 276562, upload-time = "2026-10-08T09:10:44.25Z" },
+ { url = "https://files.pythonhosted.org/packages/2a/b6/bee97e7a2edeab72aafabb46ee5adc939790a638d107860b4cad7a20e407/shiboken6-6.12.0-cp310-abi3-win_amd64.whl", hash = "sha256:a1906cb8116869178c64b6bab52623bbc8b4ceed7b30bfc3c9aa0f6e67f6438e", size = 1265476, upload-time = "2026-10-08T09:10:45.627Z" },
+ { url = "https://files.pythonhosted.org/packages/8c/d5/5894d35f8a470ca8acff0d1cd8f15b1c94cbfdfe0ce4a6ec0fcf10b95bd1/shiboken6-6.12.0-cp310-abi3-win_arm64.whl", hash = "sha256:da382e68f0815b31b6dc2bce11a465b148c4f42c37b91546a361de1576567d10", size = 1838851, upload-time = "2026-10-08T09:10:47.123Z" },
+]
+
+[[package]]
+name = "typing-extensions"
+version = "4.16.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" },
+]
+
+[[package]]
+name = "typing-inspection"
+version = "0.4.4"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "typing-extensions" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/a3/26/b09b8010994eccc3c09092e6b34058f36a460eea2d4c3e8b910c695975a0/typing_inspection-0.4.4.tar.gz", hash = "sha256:547274fa6b0a561ccf549cc9524b999a578e737d015d8709d021f9d0d13bea47", size = 76928, upload-time = "2026-08-12T12:37:25.997Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/67/81/4add07e5172b7ac40d8ed5ff580409a7801a4fe26d529bdd915401dabfbe/typing_inspection-0.4.4-py3-none-any.whl", hash = "sha256:65b8397ba37ccbce054456aaccddfc91e6e3083c92824df348d96ca832f3f147", size = 14750, upload-time = "2026-08-12T12:37:24.648Z" },
+]