UI updates
This commit is contained in:
+252
-14
@@ -7,10 +7,14 @@ state shared across them (runtime services, readers, history buffers, timer).
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import deque
|
||||
from datetime import datetime
|
||||
import html
|
||||
import json
|
||||
from pathlib import Path
|
||||
import traceback
|
||||
|
||||
from PyQt6.QtCore import QTimer
|
||||
from PyQt6.QtGui import QTextCursor
|
||||
from PyQt6.QtWidgets import QMainWindow, QMessageBox
|
||||
|
||||
from python_app.gui.controllers.app_window_config_mixin import AppWindowConfigMixin
|
||||
@@ -21,10 +25,12 @@ from python_app.gui.controllers.app_window_snapshot_mixin import AppWindowSnapsh
|
||||
from python_app.gui.controllers.app_window_ui_mixin import AppWindowUiMixin
|
||||
from python_app.gui.preprocess_dialog import PreprocessDialog
|
||||
from python_app.models.dataset_model import ResultCollection, SweepCollection
|
||||
from python_app.models.gui_profile_model import GuiProfileModel
|
||||
from python_app.models.run_config_model import RunConfigModel
|
||||
from python_app.orchestration.config_writer import ConfigWriter
|
||||
from python_app.orchestration.gui_session_state import GuiSessionState, GuiSessionStateStore
|
||||
from python_app.orchestration.live_processing_config import ProcessingLiveConfigWriter
|
||||
from python_app.orchestration.preprocess_assets import PREPROCESS_ASSET_KEYS, preprocess_asset_model
|
||||
from python_app.orchestration.preprocess_assets import VISIBLE_PREPROCESS_ASSET_KEYS, preprocess_asset_model
|
||||
from python_app.orchestration.process_supervisor import ProcessSupervisor
|
||||
from python_app.orchestration.shm_reader import ShmRingReader
|
||||
from python_app.storage.npz_store import NpzStore
|
||||
@@ -46,8 +52,9 @@ class AppWindow(
|
||||
"""Initialize all app subsystems in deterministic order."""
|
||||
super().__init__()
|
||||
|
||||
self._init_paths_and_defaults(project_root)
|
||||
self._init_paths(project_root)
|
||||
self._init_runtime_services()
|
||||
self._init_config_profile_state()
|
||||
self._init_reader_handles()
|
||||
self._init_preprocess_state()
|
||||
self._init_capture_state()
|
||||
@@ -56,19 +63,51 @@ class AppWindow(
|
||||
self._init_polling_timer()
|
||||
self._bootstrap_ui_runtime()
|
||||
|
||||
def _init_paths_and_defaults(self, project_root: Path) -> None:
|
||||
"""Initialize project paths and baseline run configuration."""
|
||||
def _init_paths(self, project_root: Path) -> None:
|
||||
"""Initialize static project paths and startup log queue."""
|
||||
self._project_root = project_root
|
||||
self._defaults_config_path = project_root / "run_config.json"
|
||||
self._defaults_config = RunConfigModel.load_from_path(self._defaults_config_path)
|
||||
self._root_profile_path = project_root / "run_config.json"
|
||||
self._active_profile_path = self._root_profile_path
|
||||
self._pending_startup_log_entries: list[tuple[str, str, str | None]] = []
|
||||
|
||||
def _init_runtime_services(self) -> None:
|
||||
"""Initialize long-lived service objects used by mixins."""
|
||||
runtime_dir = self._project_root / "python_app/runtime"
|
||||
self._runtime_dir = runtime_dir
|
||||
self._store = NpzStore(self._project_root / "python_app/data")
|
||||
self._config_writer = ConfigWriter(runtime_dir)
|
||||
self._supervisor = ProcessSupervisor(self._project_root)
|
||||
self._live_config_writer = ProcessingLiveConfigWriter(runtime_dir / "processing_live.json")
|
||||
self._gui_session_state_store = GuiSessionStateStore(runtime_dir / "gui_session_state.json")
|
||||
|
||||
def _init_config_profile_state(self) -> None:
|
||||
"""Resolve startup profile path, load active profile, and queue fallback notices."""
|
||||
active_profile_path = self._resolve_startup_profile_path()
|
||||
try:
|
||||
profile = GuiProfileModel.load_from_path(active_profile_path)
|
||||
except Exception as exc:
|
||||
if active_profile_path == self._root_profile_path:
|
||||
raise
|
||||
self._queue_startup_log_entry(
|
||||
"WARN",
|
||||
"Failed to load the last selected config profile; falling back to root run_config.json.",
|
||||
details=self._exception_details(exc),
|
||||
)
|
||||
profile = GuiProfileModel.load_from_path(self._root_profile_path)
|
||||
active_profile_path = self._root_profile_path
|
||||
|
||||
self._active_profile_path = active_profile_path
|
||||
self._defaults_config = profile.run_config.clone()
|
||||
if profile.gui is not None:
|
||||
self._gui_defaults = profile.gui
|
||||
else:
|
||||
self._gui_defaults = self._default_gui_state_for_config(self._defaults_config)
|
||||
if active_profile_path != self._root_profile_path:
|
||||
self._queue_startup_log_entry(
|
||||
"INFO",
|
||||
f"Loaded legacy run config without GUI defaults: {active_profile_path}",
|
||||
)
|
||||
self._remember_active_profile_path(active_profile_path, startup=True)
|
||||
|
||||
def _init_reader_handles(self) -> None:
|
||||
"""Initialize SHM readers as detached (not connected) handles."""
|
||||
@@ -79,9 +118,10 @@ class AppWindow(
|
||||
def _init_preprocess_state(self) -> None:
|
||||
"""Initialize preprocessing dialog and selected set names."""
|
||||
self._preprocess_dialog: PreprocessDialog | None = None
|
||||
self._preprocess_set_name = str(self._gui_defaults.preprocess_dialog.set_name)
|
||||
self._selected_preprocess_sets = {
|
||||
key: str(preprocess_asset_model(self._defaults_config, key).set_name)
|
||||
for key in PREPROCESS_ASSET_KEYS
|
||||
for key in VISIBLE_PREPROCESS_ASSET_KEYS
|
||||
}
|
||||
|
||||
def _init_capture_state(self) -> None:
|
||||
@@ -138,6 +178,8 @@ class AppWindow(
|
||||
"""Initialize read/drain loop limits used by polling and snapshot code."""
|
||||
self._max_pop_per_poll = 256
|
||||
self._max_pop_per_snapshot_drain = 4096
|
||||
self._last_reader_error_signature: tuple[str, str] | None = None
|
||||
self._logged_once_keys: set[str] = set()
|
||||
|
||||
def _init_polling_timer(self) -> None:
|
||||
"""Create periodic timer that polls SHM rings for new data."""
|
||||
@@ -148,11 +190,71 @@ class AppWindow(
|
||||
def _bootstrap_ui_runtime(self) -> None:
|
||||
"""Build UI and apply initial runtime-bound state after widgets exist."""
|
||||
self._build_ui()
|
||||
self._flush_pending_startup_log_entries()
|
||||
self._log(f"Active config profile: {self._active_profile_path}")
|
||||
self._refresh_preprocess_summary_labels()
|
||||
self._apply_initial_radar_limits()
|
||||
self._write_live_processing_config()
|
||||
self._timer.start()
|
||||
|
||||
def _resolve_startup_profile_path(self) -> Path:
|
||||
"""Resolve active profile path from session-state or root fallback path."""
|
||||
try:
|
||||
session_state = self._gui_session_state_store.load()
|
||||
except Exception as exc:
|
||||
self._queue_startup_log_entry(
|
||||
"WARN",
|
||||
"Failed to read GUI session-state; using root run_config.json.",
|
||||
details=self._exception_details(exc),
|
||||
)
|
||||
return self._root_profile_path
|
||||
|
||||
raw_path = session_state.last_profile_path.strip()
|
||||
if not raw_path:
|
||||
return self._root_profile_path
|
||||
|
||||
profile_path = Path(raw_path).expanduser()
|
||||
if not profile_path.is_absolute():
|
||||
profile_path = (self._project_root / profile_path).resolve(strict=False)
|
||||
return profile_path
|
||||
|
||||
def _normalize_profile_path(self, path: Path) -> Path:
|
||||
"""Return normalized absolute profile path."""
|
||||
return path.expanduser().resolve(strict=False)
|
||||
|
||||
def _remember_active_profile_path(self, path: Path, *, startup: bool = False) -> None:
|
||||
"""Persist last successfully used config profile path."""
|
||||
normalized_path = self._normalize_profile_path(path)
|
||||
self._active_profile_path = normalized_path
|
||||
try:
|
||||
self._gui_session_state_store.write(GuiSessionState(last_profile_path=str(normalized_path)))
|
||||
except Exception as exc:
|
||||
if startup:
|
||||
self._queue_startup_log_entry(
|
||||
"WARN",
|
||||
"Failed to update GUI session-state with the active config profile path.",
|
||||
details=self._exception_details(exc),
|
||||
)
|
||||
else:
|
||||
self._log_exception(
|
||||
"Failed to update GUI session-state with the active config profile path",
|
||||
exc,
|
||||
level="WARN",
|
||||
)
|
||||
|
||||
def _queue_startup_log_entry(self, level: str, text: str, *, details: str | None = None) -> None:
|
||||
"""Queue startup log entry until log widget exists."""
|
||||
self._pending_startup_log_entries.append((level.upper(), text, details))
|
||||
|
||||
def _flush_pending_startup_log_entries(self) -> None:
|
||||
"""Flush startup log entries into the runtime log box after UI creation."""
|
||||
if not self._pending_startup_log_entries:
|
||||
return
|
||||
|
||||
for level, text, details in self._pending_startup_log_entries:
|
||||
self._append_log_entry(level, text, details=details)
|
||||
self._pending_startup_log_entries.clear()
|
||||
|
||||
def _apply_initial_radar_limits(self) -> None:
|
||||
"""Apply startup radar-limits strategy according to selected radar mode."""
|
||||
if self._radar_mode.currentText() == "native":
|
||||
@@ -160,9 +262,129 @@ class AppWindow(
|
||||
return
|
||||
self._apply_radar_limits_to_ui(None)
|
||||
|
||||
def _log(self, text: str) -> None:
|
||||
"""Append a line to the runtime log panel."""
|
||||
self._log_box.appendPlainText(text)
|
||||
@staticmethod
|
||||
def _escape_log_text(text: str) -> str:
|
||||
"""Escape log text for insertion into rich-text log widget."""
|
||||
return html.escape(text).replace("\n", "<br>")
|
||||
|
||||
@staticmethod
|
||||
def _exception_summary(exc: Exception) -> str:
|
||||
"""Build compact one-line exception summary."""
|
||||
message = str(exc).strip()
|
||||
if message:
|
||||
return f"{type(exc).__name__}: {message}"
|
||||
return type(exc).__name__
|
||||
|
||||
@staticmethod
|
||||
def _exception_details(exc: Exception) -> str:
|
||||
"""Return full chained traceback for error dialogs and log details."""
|
||||
return "".join(traceback.TracebackException.from_exception(exc).format(chain=True)).strip()
|
||||
|
||||
def _append_log_entry(
|
||||
self,
|
||||
level: str,
|
||||
text: str,
|
||||
*,
|
||||
details: str | None = None,
|
||||
once_key: str | None = None,
|
||||
) -> None:
|
||||
"""Append formatted log entry with timestamp and optional details."""
|
||||
if once_key is not None:
|
||||
if once_key in self._logged_once_keys:
|
||||
return
|
||||
self._logged_once_keys.add(once_key)
|
||||
|
||||
level_upper = level.upper()
|
||||
palette = {
|
||||
"INFO": ("#7fb1ff", "#dce8f8", "#8ba2be"),
|
||||
"WARN": ("#f4bf4f", "#f4dca0", "#9f8b55"),
|
||||
"ERROR": ("#ff5f6d", "#ffd1d5", "#b5878c"),
|
||||
}
|
||||
accent_color, message_color, detail_color = palette.get(level_upper, palette["INFO"])
|
||||
timestamp = datetime.now().strftime("%H:%M:%S.%f")[:-3]
|
||||
header_html = (
|
||||
f"<span style='color:{accent_color}; font-weight:700;'>{html.escape(level_upper)}</span>"
|
||||
f" <span style='color:#7f94af;'>{html.escape(timestamp)}</span>"
|
||||
f" <span style='color:{message_color};'>{self._escape_log_text(text)}</span>"
|
||||
)
|
||||
|
||||
body_parts = [header_html]
|
||||
if details:
|
||||
body_parts.append(
|
||||
"<pre style='margin:3px 0 0 16px; color:"
|
||||
f"{detail_color};'>{html.escape(details)}</pre>"
|
||||
)
|
||||
|
||||
entry_html = "<div style='margin:0 0 6px 0;'>" + "".join(body_parts) + "</div>"
|
||||
cursor = self._log_box.textCursor()
|
||||
cursor.movePosition(QTextCursor.MoveOperation.End)
|
||||
self._log_box.setTextCursor(cursor)
|
||||
self._log_box.insertHtml(entry_html)
|
||||
self._log_box.insertPlainText("\n")
|
||||
self._log_box.ensureCursorVisible()
|
||||
|
||||
if level_upper == "ERROR" and hasattr(self, "_status_label"):
|
||||
self._status_label.setText("Status: error")
|
||||
|
||||
def _log(self, text: str, *, once_key: str | None = None) -> None:
|
||||
"""Append informational message to runtime log panel."""
|
||||
self._append_log_entry("INFO", text, once_key=once_key)
|
||||
|
||||
def _log_warning(self, text: str, *, details: str | None = None, once_key: str | None = None) -> None:
|
||||
"""Append warning message to runtime log panel."""
|
||||
self._append_log_entry("WARN", text, details=details, once_key=once_key)
|
||||
|
||||
def _log_error(self, text: str, *, details: str | None = None, once_key: str | None = None) -> None:
|
||||
"""Append error message to runtime log panel."""
|
||||
self._append_log_entry("ERROR", text, details=details, once_key=once_key)
|
||||
|
||||
def _log_exception(self, context: str, exc: Exception, *, level: str = "ERROR") -> tuple[str, str]:
|
||||
"""Log exception with detailed traceback and return `(message, details)`."""
|
||||
message = f"{context}: {self._exception_summary(exc)}"
|
||||
details = self._exception_details(exc)
|
||||
if level.upper() == "WARN":
|
||||
self._log_warning(message, details=details)
|
||||
else:
|
||||
self._log_error(message, details=details)
|
||||
return message, details
|
||||
|
||||
def _process_state_details(self) -> str:
|
||||
"""Return formatted summary of managed pipeline process state."""
|
||||
if not hasattr(self, "_supervisor"):
|
||||
return "Managed processes: unavailable"
|
||||
pid_map = self._supervisor.pids()
|
||||
if not pid_map:
|
||||
return "Managed processes: none"
|
||||
return "Managed processes:\n" + "\n".join(
|
||||
f"- {name}: pid={pid}"
|
||||
for name, pid in sorted(pid_map.items())
|
||||
)
|
||||
|
||||
def _runtime_history_details(self) -> str:
|
||||
"""Return formatted summary of buffered runtime history counts."""
|
||||
return (
|
||||
"Runtime history:\n"
|
||||
f"- raw={len(getattr(self, '_raw_history', []))}\n"
|
||||
f"- preprocessed={len(getattr(self, '_pre_history', []))}\n"
|
||||
f"- results={len(getattr(self, '_result_history', []))}"
|
||||
)
|
||||
|
||||
def _capture_state_details(self) -> str:
|
||||
"""Return formatted summary of active preprocess capture state."""
|
||||
session = getattr(self, "_capture_session", None)
|
||||
if session is None:
|
||||
return "Capture session: none"
|
||||
state = session.state()
|
||||
lines = [
|
||||
"Capture session:",
|
||||
f"- kind={state.kind}",
|
||||
f"- progress={state.captured_count}/{state.total_count}",
|
||||
]
|
||||
if state.current_combo is not None:
|
||||
lines.append(
|
||||
f"- current_combo=input={state.current_combo.input}, output={state.current_combo.output}"
|
||||
)
|
||||
return "\n".join(lines)
|
||||
|
||||
@staticmethod
|
||||
def _load_history_command_seq(config_path: Path) -> int:
|
||||
@@ -180,10 +402,26 @@ class AppWindow(
|
||||
return max(0, int(raw_value))
|
||||
return 0
|
||||
|
||||
def _show_error(self, message: str) -> None:
|
||||
"""Log and present an error in a modal dialog."""
|
||||
self._log(f"ERROR: {message}")
|
||||
QMessageBox.critical(self, "Error", message)
|
||||
def _show_error(self, message: str, *, details: str | None = None) -> None:
|
||||
"""Log and present an error in a modal dialog with optional detail text."""
|
||||
self._log_error(message, details=details)
|
||||
dialog = QMessageBox(self)
|
||||
dialog.setIcon(QMessageBox.Icon.Critical)
|
||||
dialog.setWindowTitle("Error")
|
||||
dialog.setText(message)
|
||||
if details:
|
||||
dialog.setDetailedText(details)
|
||||
dialog.exec()
|
||||
|
||||
def _show_exception(self, context: str, exc: Exception) -> None:
|
||||
"""Log full exception details and show modal dialog with expandable traceback."""
|
||||
message, details = self._log_exception(context, exc, level="ERROR")
|
||||
dialog = QMessageBox(self)
|
||||
dialog.setIcon(QMessageBox.Icon.Critical)
|
||||
dialog.setWindowTitle("Error")
|
||||
dialog.setText(message)
|
||||
dialog.setDetailedText(details)
|
||||
dialog.exec()
|
||||
|
||||
def closeEvent(self, event) -> None: # noqa: N802
|
||||
"""Ensure workers and dialogs are closed before window destruction."""
|
||||
|
||||
Reference in New Issue
Block a user