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" }, +]