From cb79b60c5a40d37c744de5d336d41e08fdef356c Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Sun, 11 Oct 2026 03:14:32 -0400 Subject: [PATCH 01/17] U3+L4: receipt - generic Qt machinery leaves OpenHCS Co-Authored-By: Claude Opus 5.5 --- .../U3-generic-qt-machinery.md | 87 +++++++++++++++++++ 1 file changed, 87 insertions(+) create mode 100644 docs/refactor/consolidation-20261009/U3-generic-qt-machinery.md diff --git a/docs/refactor/consolidation-20261009/U3-generic-qt-machinery.md b/docs/refactor/consolidation-20261009/U3-generic-qt-machinery.md new file mode 100644 index 0000000000..19e643271d --- /dev/null +++ b/docs/refactor/consolidation-20261009/U3-generic-qt-machinery.md @@ -0,0 +1,87 @@ +# U3 + L4: Generic Qt machinery leaves OpenHCS + +**Head audited:** `openhcs` `main` at `84e7a2904` (#1193); `pyqt-reactive` `main` at `269e896` (0.4.0); `ObjectState` `main` at `c75a457` (1.3.1); `python-introspect` `main` at `4bb7d13` (0.2.5). **Rules:** [00-RULES.md](00-RULES.md). **Step 3** (U3 merges with L4, [04-ARCHITECTURE.md](04-ARCHITECTURE.md#the-ui-one-session-interface-thin-renderers-surfaces-u1-to-u5)). +**Shared abstractions** ([02-SHARED-ABSTRACTIONS.md](02-SHARED-ABSTRACTIONS.md)). *Builds:* `objectstate.ScopeKey`, `objectstate.inspection`, view fields; pyqt-reactive's editable table, grid selector, time-travel widgets, config window, dock and shortcut layout, open `PathCacheKey` and `PathNameRole` families. *Uses:* `AutoRegisterMeta`, `IsomorphicDataclassRowPathPolicy`, `ObjectStateRegistry` subscriptions. + +Nothing in this surface had landed before this file was written; there is no closure note. + +## What is wrong + +**`openhcs/pyqt_gui` (33.9k lines) carries Qt machinery that knows nothing about microscopy, copies of library code, and an event bus beside ObjectState's own change subscriptions; ObjectState and pyqt-reactive restate one scope grammar as `::` string operations at 50 sites.** + +Re-measured at `84e7a2904`: + +| Item | Lines | Evidence | +|---|---|---| +| `core/path_cache.py` | 199 | Verbatim copy of `pyqt_reactive/core/path_cache.py` cut down to one key, `PLATE_IMPORT`. Both modules build their own `UnifiedPathCache` over the same file (`reactor_providers.py:319` points the library at OpenHCS's `path_cache.json`), so each overwrites the other's entries. The library's `PathCacheKey` enum (17 members) restates OpenHCS's file kinds (`FUNCTION_PATTERNS`, `PIPELINE_FILES`, `STEP_SETTINGS`, `DEBUG_FILES`, six `*_BROWSER` keys), most unused, plus four "backward compatibility alias" functions. `PathNameRole` (`enhanced_path_widget.py:176`) is an enum with behaviour whose `PIPELINE`/`STEP`/`FUNCTION` members name OpenHCS pickle formats F1 deleted. | +| `core/xdg_paths.py` legacy migration | ~210 | `migrate_all_legacy_cache_files`, `migrate_cache_file`, `migrate_cache_directory`, `get_legacy_openhcs_dir` and a legacy fallback inside each `get_*_file_path` move files out of a pre-XDG `~/.openhcs` (rule 1; F1 recorded the path-cache case for L4). | +| Editable table in `widgets/source_bindings_editor.py:136-1454` | ~1,320 | C3 left it in place "owned by L4": the `CellEditor` family, `DataclassFieldColumn(s)`, `RowSuggestion`, `StructuredSelectorCellWidget`/`Dialog`, `EditableTableProgrammaticUpdateGuard`, `EditableTableItem`, `EditableTableController`, `EditableTableLayout`. Its only OpenHCS import is `core/field_label.FieldLabel`, a generic `Annotated` label marker. | +| Header-timer leak | — | `ManagerStatusController._restart_scrolling` (`pyqt_reactive/widgets/shared/manager_status_controller.py:117`) stops the old marquee timer and drops the reference, but the `QTimer` stays a child of the manager widget; every status message too wide for the header adds one more live child object for the widget's lifetime. | +| Grid selector `widgets/shared/plate_view_widget.py` | 1,278 | Generic grid-of-buttons selection (bounds, click/drag/rectangle/row/column/invert, aspect-ratio container). Domain ties: six calls of `require_grid_axis()` (`:155,185,203,1130` and the noun at `:522,526,982,1026`), the "Plate View" title, and a subdirectory selector whose `active_subdir` nobody reads (`:851`). `partition_filter_widget` is never assigned, so `sync_to/from_partition_filter` (`:497-514,1272-1278`) is dead. Three enums dispatch behaviour through side tables (`PlateSelectionEventTarget` → `EVENT_ROUTE_BY_KEY` `:727`, `PlateSubdirectoryMode` → `MODE_HANDLERS` `:297`, `WellButtonState` → `WELL_BUTTON_STYLE_COLORS` `:72`; rule 1a). | +| Time travel | ~1,150 | `widgets/shared/time_travel_widget.py` (373), `windows/snapshot_browser_window.py` (268), `services/time_travel_navigation.py` (182), `MainWindowTimeTravelWorkflow` and `TimeTravelShortcutEventFilter` (`main_window_workflows.py:87-109,853-907`), and the navigation driver in `main.py:1105-1255`. Domain ties: the hidden `"__plates__"` scope written twice (`time_travel_widget.py:49`, `snapshot_browser_window.py:92`), `parse_function_scope_ref` parsing `functionstep_` tokens out of `::` strings (`:75-89`), and the dual-editor tab choice (`main.py:1257-1282`). | +| Config windows | ~1,370 | `windows/config_window.py` (844) is generic apart from the `""`→`global_config` window-id alias (`:487-490`) and its code-document renderer; `config_edit_session.py` (168) imports one OpenHCS helper (`LazyDataclassFieldEmissionState.retain_only_authored_paths`, which uses only ObjectState); `services/config_window_code_document.py` (85); `ui/shared/code_editor_form_updater.py` (269, ObjectState only). `OPENHCS_USE_EXTERNAL_EDITOR` is read in three places, two of them inside pyqt-reactive (`function_list_editor.py:1089`, `manager_action_controller.py:208`). | +| Dock and shortcut layout | ~420 | `MainWindowDockFloatController`, `MainWindowDockPane`, `MainWindowDockLayoutStore`, the generic half of `MainWindowEmbeddedWidgets`, `QtShortcutSequenceAuthority`, `CodeEditorFocusAuthority`, `MainWindowShortcutLifecycle` (`main_window_workflows.py:59-195,270-612`). | +| `GlobalEventBus` | ~100 | `services/service_adapter.py:22-32`, `GuiEventBusBroadcaster` (28), emitted at `pipeline_editor.py:391,614` and `plate_manager.py:342`, received only by `DualEditorWindow` (`:701-876`). `pipeline_changed` restates the `{plate}::pipeline` ObjectState's `step_scope_ids`; `config_changed` re-notifies a write the session already made to scope `""`. pyqt-reactive's `AbstractManagerWidget` requires `get_event_bus()` and never reads it (`abstract_manager_widget.py:690`). | +| `::` scope grammar | 50 sites | Join child, root, parent, ancestors, is-descendant, depth and last-token are written by hand in ObjectState (`object_state_registry.py:435,647,699,820,1366`; `object_state.py:698,2319,2463`; `context_manager.py:344,379`), pyqt-reactive (17 sites; `scope_token_service.py:447` matches `/plate1` as a parent of `/plate10`) and OpenHCS (`SCOPE_SEGMENT_SEPARATOR` and 20 sites). | +| ObjectState inspection | ~400 | `ObjectStateFieldSemanticProjection` (`ui_bridge_object_state.py:237-516`) and the provenance effects (`:181-234`) read raw/resolved values, dirty and signature state, provenance and type names with only ObjectState and python-introspect; the `*`/`_` marker rule is written four times (`ui_bridge_windows.py:201,257,1938`, `ui_bridge_object_state.py:~312`). | +| View state outside ObjectState | — | ObjectState has no field that is excluded from history and dirty detection. Session selection (`session.py:322-326`) and widget selections live on plain attributes. | +| Dead ObjectState API | 19 + params | `objectstate.history_migration.HistoryMigration` and the `migration=` parameter of `import_history_from_dict`/`load_history_from_file` have no consumer after U5 (recorded by U5). | + +## Target + +- **python-introspect 0.2.6:** `FieldLabel` and `declared_field_label` beside `AnnotationChoices`. `openhcs/core/field_label.py` is deleted. +- **ObjectState:** + - `ScopeKey(str)` owns the scope grammar: `ScopeKey.of(*parts)`, `child(token)`, `parent`, `root`, `ancestors()`, `contains(other)`, `depth`, `last`, `parts`, and `SEPARATOR` (declared once). The registry normalises every scope id it stores or is asked for to a `ScopeKey`; every `::` operation in the three code bases calls it. It is a `str` subclass, so history documents and dict keys keep their encoding. + - `objectstate.inspection`: `FieldInspection` (raw and resolved value, dirty, signature diff, inherited, provenance, value and declared type names, the `*`/`_` markers) and `ScopeInspection` (field listing, history header). The UI bridge maps them to its DTOs; no rule is written twice. + - View fields: `Annotated[T, ViewField()]` on an ObjectState dataclass field keeps it out of snapshots, dirty detection and saved values. The image browser's selected wells and the dataset-list selection are the first consumers. + - `HistoryMigration` and `migration=` are deleted; a history whose `format_version` is not current is refused. +- **pyqt-reactive:** + - `core/path_cache.py`: `PathCacheKey` is an open `AutoRegisterMeta` family whose stored key is the snake-case class name; the library declares `General`, `FileSelection`, `DirectorySelection`, `CodeEditor`. `PathNameRole` becomes a family (`DirectoryPathRole`, `FilePathRole`). The compatibility aliases and unused keys are deleted. OpenHCS declares `PlateImport(PathCacheKey)` beside its one use. + - `ManagerStatusController` owns one marquee timer for its lifetime and restarts it. + - `widgets/editable_table.py`: the editable table block, unchanged except the label lookup through `declared_field_label`. + - `widgets/grid_selector.py`: `GridSelectorWidget` over a `GridAddressing` protocol (`index`, `row_label`, `default_grid`); cell states, event targets and drag modes are class families. + - `widgets/time_travel.py` and `windows/snapshot_browser_window.py`: the widget, browser, navigation targets, `TimeTravelCommands`, the shortcut filter and the post-restore window navigator; hidden scopes and the child-scope owner are injected. + - `windows/config_window.py`: `ConfigWindow`, `ConfigEditSession`, `ConfigCodeDocumentDriver` over an injected render/parse protocol, `CodeEditorFormUpdater`, `ExternalCodeEditorPreference` (one environment variable name, read once). + - `widgets/shared/dock_layout.py`: dock pane, float controller, pane registry, layout store (key and version as parameters), shortcut lifecycle generic over the config type. + - `AbstractManagerWidget` stops reading `get_event_bus()`. +- **OpenHCS keeps only declarations and domain glue:** `PlateImport`, a `GridAddressed`→`GridAddressing` adapter and the well naming, the hidden dataset-list scope and step-scope owner for time travel, the dual-editor tab choice, the code-document renderer, the window specs and dock topology, `ShortcutConfig`. `GlobalEventBus`, `GuiEventBusBroadcaster` and `get_event_bus` are deleted; `DualEditorWindow` subscribes to its pipeline ObjectState. + +## Persisted state + +- `path_cache.json` (runtime convenience): stored keys are derived from class names and equal the old enum values for the kept contexts (`plate_import`, `general`, `file_selection`, `directory_selection`, `code_editor`), so the file loads unchanged; entries for deleted contexts are ignored. +- ObjectState history files (runtime state, reset on upgrade per U5): scope ids keep their `::` text. View fields are no longer written into snapshots. +- QSettings dock layout (`main_window/dock_layout_state`, version 2): key and version unchanged. + +## Guards + +`tests/unit/pyqt_gui/test_generic_qt_machinery_guards.py`: +- `openhcs/core/path_cache.py`, `openhcs/core/field_label.py`, `time_travel_widget.py`, `snapshot_browser_window.py`, `time_travel_navigation.py`, `config_edit_session.py`, `config_window_code_document.py`, `code_editor_form_updater.py`, `gui_event_bus_broadcast.py` do not exist; no module under `openhcs/` defines `GlobalEventBus`, `get_event_bus`, `EditableTableController`, `CellEditor`, `PathCacheKey` (except subclasses), `MainWindowDockFloatController`, `MainWindowDockLayoutStore`, `TimeTravelShortcutEventFilter`, `AspectRatioContainer`. +- No `"::"` string literal in `openhcs/`, `external/ObjectState/src` or `external/pyqt-reactive/src` outside `objectstate/scope_key.py`, except reStructuredText directives and the execution-id prefix. +- `openhcs/core/xdg_paths.py` defines no `*legacy*`/`migrate_*` name. +- pyqt-reactive: no `Enum` subclass in `core/path_cache.py`, `enhanced_path_widget.py` or `grid_selector.py`. + +## Tests + +- Library: family tests for `PathCacheKey` (an application-declared context round-trips through the file) and `PathNameRole`; the marquee leak (repeated long messages leave one timer); `ScopeKey` grammar (including `/plate1` vs `/plate10`); `FieldInspection` over a lazy dataclass; view fields absent from snapshots and dirty state; moved editable-table, grid, time-travel, config-window and dock tests with stub domain adapters. +- OpenHCS: the source-binding editor, config-window, dock, time-travel navigation and UI-bridge tests keep their behavioural assertions against the moved classes; tests of `GlobalEventBus` wiring are replaced by one dual-editor test driven by a pipeline ObjectState change. + +## New-case experiments + +- A new remembered dialog context: today an enum member in OpenHCS's copy or in the library. After: one `PathCacheKey` subclass where it is used. +- A new editable record table in any pyqt-reactive application: today copy 1,320 lines out of OpenHCS. After: `EditableTableController(table, DataclassFieldColumns.of(Row), ...)`. +- A non-microscopy grid (remote-sensing tiles): today the widget calls the microscopy-shaped `require_grid_axis()`. After: one `GridAddressing` adapter. +- A new child-scope rule (`parent`, `is within`): today a hand-written `split("::")` at the call site. After: a `ScopeKey` method call. + +## Done when + +The guards pass; the three library PRs are open with their CI green; OpenHCS points its submodules at the library branch heads and its UI, source-binding editor, config-window, dock, time-travel and UI-bridge tests pass offscreen; the structural ratchet and NRA show no regression. + +## Reassigned + +| Item | Evidence | Belongs to | +|---|---|---| +| UI-bridge server and window introspection (`ui_bridge_server.py` 623, the generic parts of `ui_bridge_windows.py` ~2.3k and `ui_agent_bridge.py` ~1.1k) | Every result factory returns `openhcs.agent.dto.ui_bridge` types and `AgentError`; the server dispatches through `openhcs.agent.services.ui_bridge_service.UiBridgeOperation`. Moving them into pyqt-reactive first would ship ~2.9k lines of DTO and contract code inside a Qt package, which U4 then moves out again. | U4 (DTO and contract tier first, then the Qt server beside it) | +| Bridge widget ids (`plate_manager`) and `WorkflowTerminalStateCriterion` (recorded by U2) | bridge descriptor vocabulary | U4, with the bridge | +| `PyQtServiceAdapter.get_current_color_scheme` as a library protocol (recorded by U5) | called by pyqt-reactive `abstract_manager_widget.py:685` | U4 (service-adapter protocol in the toolkit-neutral tier) | +| pyqt-reactive `PatternDataManager.extract_func_and_kwargs` returns `(None, {})` for a three-member tuple (recorded by U5) | `pattern_data_manager.py:120` | done here: it raises `TypeError` | +| `QApplication` teardown segfault after the main window closes (recorded by U5) | 1 in 5 runs, no Python frame | not reproduced in this surface's offscreen runs; stays recorded under U4 (generic Qt lifecycle) | From 4cf3586014fff42883d8fadccdc64ba8d3e65629 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Sun, 11 Oct 2026 03:23:44 -0400 Subject: [PATCH 02/17] L4: editable table and path cache contexts come from pyqt-reactive - The editable-table block of the source-binding editor (cell editor family, dataclass-derived columns, record picker, table controller and layout) now lives in pyqt_reactive.widgets.editable_table; the editor imports it. - FieldLabel comes from python-introspect; core/field_label.py is deleted. - core/path_cache.py, a verbatim copy of the library module, is deleted. The plate manager declares PlateImport, a PathCacheKey subclass, beside its one use; the stored key is unchanged (plate_import). - xdg_paths no longer migrates files out of a pre-XDG ~/.openhcs directory. Co-Authored-By: Claude Opus 5.5 --- external/pyqt-reactive | 2 +- external/python-introspect | 2 +- openhcs/core/field_label.py | 16 - openhcs/core/path_cache.py | 199 --- openhcs/core/source_bindings.py | 2 +- openhcs/core/xdg_paths.py | 237 +-- openhcs/pyqt_gui/services/service_adapter.py | 8 +- openhcs/pyqt_gui/widgets/plate_manager.py | 8 +- .../widgets/source_bindings_editor.py | 1417 +---------------- openhcs/utils/recache_function_registry.py | 6 - .../pyqt_gui/test_one_persistence_format.py | 10 +- .../pyqt_gui/test_source_bindings_editor.py | 98 +- .../test_source_bindings_editor_guards.py | 5 +- tests/unit/test_axis_family_guards.py | 4 +- 14 files changed, 76 insertions(+), 1938 deletions(-) delete mode 100644 openhcs/core/field_label.py delete mode 100644 openhcs/core/path_cache.py diff --git a/external/pyqt-reactive b/external/pyqt-reactive index 269e896ba8..096934678b 160000 --- a/external/pyqt-reactive +++ b/external/pyqt-reactive @@ -1 +1 @@ -Subproject commit 269e896ba8ec3050ae305e4ac9c46a1c01c2cb9b +Subproject commit 096934678b37126f9946f64296e7fd6cec654e16 diff --git a/external/python-introspect b/external/python-introspect index 4bb7d132d7..d8071f0f14 160000 --- a/external/python-introspect +++ b/external/python-introspect @@ -1 +1 @@ -Subproject commit 4bb7d132d7a789c0ab9eaea556b4e4628b26737b +Subproject commit d8071f0f14471416c3ee27b5dadd7b463382c3ff diff --git a/openhcs/core/field_label.py b/openhcs/core/field_label.py deleted file mode 100644 index 7060848a18..0000000000 --- a/openhcs/core/field_label.py +++ /dev/null @@ -1,16 +0,0 @@ -"""Presentation label declared on a dataclass field through ``Annotated``.""" - -from __future__ import annotations - -from dataclasses import dataclass - - -@dataclass(frozen=True, slots=True) -class FieldLabel: - """Display label for a field whose name does not read well to users. - - Declared as ``Annotated[T, FieldLabel("...")]``; interfaces derive every other - label from the field name and every tooltip from the field docstring. - """ - - text: str diff --git a/openhcs/core/path_cache.py b/openhcs/core/path_cache.py deleted file mode 100644 index 190f022066..0000000000 --- a/openhcs/core/path_cache.py +++ /dev/null @@ -1,199 +0,0 @@ -""" -Unified Path Cache System - -Provides shared path caching functionality for both TUI and PyQt GUI implementations. -Persists last used paths across application runs for improved user experience. -""" - -import json -import logging -from pathlib import Path -from typing import Dict, Optional -from enum import Enum - -logger = logging.getLogger(__name__) - - -class PathCacheKey(Enum): - """ - Enumeration of path cache keys for different UI contexts. - - Used to maintain separate cached paths for different file operations - across both TUI and PyQt GUI implementations. - """ - - PLATE_IMPORT = "plate_import" - - -class UnifiedPathCache: - """ - Unified path cache for persisting directory paths across application sessions. - - Provides consistent caching behavior for both TUI browser widgets and - PyQt GUI file dialogs. - """ - - def __init__(self, cache_file: Optional[Path] = None): - """ - Initialize path cache. - - Args: - cache_file: Optional custom cache file location - """ - if cache_file is None: - from openhcs.core.xdg_paths import get_data_file_path - - cache_file = get_data_file_path("path_cache.json") - - self.cache_file = cache_file - self._cache: Dict[str, str] = {} - self._load_cache() - logger.debug(f"UnifiedPathCache initialized with cache file: {self.cache_file}") - - def _load_cache(self) -> None: - """Load cache from disk.""" - try: - if self.cache_file.exists(): - with open(self.cache_file, "r") as f: - self._cache = json.load(f) - logger.debug(f"Loaded path cache with {len(self._cache)} entries") - else: - logger.debug("No existing path cache found, starting fresh") - except (json.JSONDecodeError, OSError) as e: - logger.warning(f"Failed to load path cache: {e}") - self._cache = {} - - def _save_cache(self) -> None: - """Save cache to disk.""" - try: - # Ensure cache directory exists - self.cache_file.parent.mkdir(parents=True, exist_ok=True) - - with open(self.cache_file, "w") as f: - json.dump(self._cache, f, indent=2) - logger.debug(f"Saved path cache with {len(self._cache)} entries") - except OSError as e: - logger.warning(f"Failed to save path cache: {e}") - - def get_cached_path(self, key: PathCacheKey) -> Optional[Path]: - """ - Get cached path for a specific key. - - Args: - key: PathCacheKey identifying the context - - Returns: - Cached Path if exists and valid, None otherwise - """ - cached_str = self._cache.get(key.value) - if cached_str: - cached_path = Path(cached_str) - if cached_path.exists(): - logger.debug(f"Retrieved cached path for {key.value}: {cached_path}") - return cached_path - else: - # Remove invalid cached path - logger.debug( - f"Removing invalid cached path for {key.value}: {cached_path}" - ) - del self._cache[key.value] - self._save_cache() - - return None - - def set_cached_path(self, key: PathCacheKey, path: Path) -> None: - """ - Set cached path for a specific key. - - Args: - key: PathCacheKey identifying the context - path: Path to cache - """ - if path and path.exists(): - self._cache[key.value] = str(path) - self._save_cache() - logger.debug(f"Cached path for {key.value}: {path}") - else: - logger.warning( - f"Attempted to cache non-existent path for {key.value}: {path}" - ) - - def get_initial_path( - self, key: PathCacheKey, fallback: Optional[Path] = None - ) -> Path: - """ - Get initial path with intelligent fallback hierarchy. - - Args: - key: PathCacheKey identifying the context - fallback: Optional fallback path if cached path unavailable - - Returns: - Best available path (cached > fallback > home directory) - """ - # Try cached path first - cached = self.get_cached_path(key) - if cached: - return cached - - # Try fallback - if fallback and fallback.exists(): - return fallback - - # Ultimate fallback to home directory - return Path.home() - - def clear_cache(self) -> None: - """Clear all cached paths.""" - self._cache.clear() - self._save_cache() - logger.info("Cleared all cached paths") - - def remove_cached_path(self, key: PathCacheKey) -> None: - """ - Remove specific cached path. - - Args: - key: PathCacheKey to remove - """ - if key.value in self._cache: - del self._cache[key.value] - self._save_cache() - logger.debug(f"Removed cached path for {key.value}") - - -# Global cache instance -_global_path_cache: Optional[UnifiedPathCache] = None - - -def get_path_cache() -> UnifiedPathCache: - """Get global path cache instance.""" - global _global_path_cache - if _global_path_cache is None: - _global_path_cache = UnifiedPathCache() - return _global_path_cache - - -def cache_path(key: PathCacheKey, path: Path) -> None: - """ - Convenience function to cache a path. - - Args: - key: PathCacheKey identifying the context - path: Path to cache - """ - get_path_cache().set_cached_path(key, path) - - -def get_initial_path(key: PathCacheKey, fallback: Optional[Path] = None) -> Path: - """ - Convenience function to get initial path with fallback. - - Args: - key: PathCacheKey identifying the context - fallback: Optional fallback path - - Returns: - Best available path (cached > fallback > home directory) - """ - return get_path_cache().get_initial_path(key, fallback) diff --git a/openhcs/core/source_bindings.py b/openhcs/core/source_bindings.py index 8ff126a170..3f41826ad0 100644 --- a/openhcs/core/source_bindings.py +++ b/openhcs/core/source_bindings.py @@ -32,7 +32,7 @@ ImageArtifactType, ) from openhcs.core.component_set import ComponentSet -from openhcs.core.field_label import FieldLabel +from python_introspect import FieldLabel from openhcs.core.runtime_tabular_values import FieldSpec from openhcs.core.source_metadata import ( SourceMetadataFields, diff --git a/openhcs/core/xdg_paths.py b/openhcs/core/xdg_paths.py index 449e793570..3a928558e3 100644 --- a/openhcs/core/xdg_paths.py +++ b/openhcs/core/xdg_paths.py @@ -5,17 +5,10 @@ - Data: ~/.local/share/openhcs/ - Cache: ~/.local/share/openhcs/cache/ - Config: ~/.local/share/openhcs/config/ - -Includes migration utilities to move existing cache files from legacy ~/.openhcs/ location. """ -import logging import os -import shutil from pathlib import Path -from typing import Optional - -logger = logging.getLogger(__name__) def get_openhcs_data_dir(*, create: bool = True) -> Path: @@ -62,231 +55,19 @@ def get_openhcs_log_dir(*, create: bool = True) -> Path: return get_openhcs_data_dir(create=create) / "logs" -def get_legacy_openhcs_dir() -> Path: - """ - Get the legacy OpenHCS directory (~/.openhcs/). - - Returns: - Path to ~/.openhcs/ - """ - return Path.home() / ".openhcs" - - -def migrate_cache_file(legacy_path: Path, new_path: Path, description: str) -> bool: - """ - Migrate a cache file from legacy location to XDG-compliant location. - - Args: - legacy_path: Path to the legacy cache file - new_path: Path to the new XDG-compliant location - description: Human-readable description of the cache file - - Returns: - True if migration was performed, False if no migration needed - """ - if not legacy_path.exists(): - logger.debug(f"No legacy {description} found at {legacy_path}") - return False - - if new_path.exists(): - logger.debug( - f"XDG {description} already exists at {new_path}, skipping migration" - ) - return False - - try: - # Ensure target directory exists - new_path.parent.mkdir(parents=True, exist_ok=True) - - # Copy the file to new location - shutil.copy2(legacy_path, new_path) - logger.info(f"Migrated {description} from {legacy_path} to {new_path}") - - # Remove the legacy file - legacy_path.unlink() - logger.debug(f"Removed legacy {description} at {legacy_path}") +def get_cache_file_path(filename: str) -> Path: + """Return the path of a cache file under the OpenHCS cache directory.""" - return True + return get_openhcs_cache_dir() / filename - except Exception as e: - logger.warning( - f"Failed to migrate {description} from {legacy_path} to {new_path}: {e}" - ) - return False +def get_config_file_path(filename: str) -> Path: + """Return the path of a config file under the OpenHCS config directory.""" -def migrate_cache_directory(legacy_dir: Path, new_dir: Path, description: str) -> bool: - """ - Migrate an entire cache directory from legacy location to XDG-compliant location. - - Args: - legacy_dir: Path to the legacy cache directory - new_dir: Path to the new XDG-compliant directory - description: Human-readable description of the cache directory - - Returns: - True if migration was performed, False if no migration needed - """ - if not legacy_dir.exists(): - logger.debug(f"No legacy {description} found at {legacy_dir}") - return False - - if new_dir.exists() and any(new_dir.iterdir()): - logger.debug( - f"XDG {description} already exists and is not empty at {new_dir}, skipping migration" - ) - return False - - try: - # Ensure target parent directory exists - new_dir.parent.mkdir(parents=True, exist_ok=True) - - # Copy the entire directory to new location - if new_dir.exists(): - shutil.rmtree(new_dir) - shutil.copytree(legacy_dir, new_dir) - logger.info(f"Migrated {description} from {legacy_dir} to {new_dir}") - - # Remove the legacy directory - shutil.rmtree(legacy_dir) - logger.debug(f"Removed legacy {description} at {legacy_dir}") - - return True - - except Exception as e: - logger.warning( - f"Failed to migrate {description} from {legacy_dir} to {new_dir}: {e}" - ) - return False - - -def migrate_all_legacy_cache_files() -> None: - """ - Migrate all known legacy cache files to XDG-compliant locations. - - This function should be called during application startup to ensure - smooth transition from legacy cache locations. - """ - legacy_dir = get_legacy_openhcs_dir() - - if not legacy_dir.exists(): - logger.debug("No legacy ~/.openhcs directory found, no migration needed") - return - - logger.info("Checking for legacy cache files to migrate to XDG locations...") - - # Migrate individual cache files - migrations = [ - ( - legacy_dir / "path_cache.json", - get_openhcs_data_dir() / "path_cache.json", - "path cache", - ), - ( - legacy_dir / "global_config.config", - get_openhcs_config_dir() / "global_config.config", - "global config cache", - ), - ] - - migrated_files = 0 - for legacy_path, new_path, description in migrations: - if migrate_cache_file(legacy_path, new_path, description): - migrated_files += 1 - - # Migrate cache directory - legacy_cache_dir = legacy_dir / "cache" - new_cache_dir = get_openhcs_cache_dir() - if migrate_cache_directory(legacy_cache_dir, new_cache_dir, "cache directory"): - migrated_files += 1 - - # Clean up empty legacy directory - try: - if legacy_dir.exists() and not any(legacy_dir.iterdir()): - legacy_dir.rmdir() - logger.info(f"Removed empty legacy directory {legacy_dir}") - except Exception as e: - logger.debug(f"Could not remove legacy directory {legacy_dir}: {e}") - - if migrated_files > 0: - logger.info( - f"Successfully migrated {migrated_files} cache files/directories to XDG locations" - ) - else: - logger.debug("No legacy cache files found to migrate") - - -def get_cache_file_path(filename: str, legacy_filename: Optional[str] = None) -> Path: - """ - Get path for a cache file, with automatic migration from legacy location. - - Args: - filename: Name of the cache file in XDG location - legacy_filename: Optional different filename in legacy location - - Returns: - Path to the cache file in XDG location - """ - new_path = get_openhcs_cache_dir() / filename - - # Check if we need to migrate from legacy location - if not new_path.exists(): - legacy_filename = legacy_filename or filename - legacy_path = get_legacy_openhcs_dir() / "cache" / legacy_filename - - if legacy_path.exists(): - migrate_cache_file(legacy_path, new_path, f"cache file {filename}") - - return new_path - - -def get_config_file_path(filename: str, legacy_filename: Optional[str] = None) -> Path: - """ - Get path for a config file, with automatic migration from legacy location. - - Args: - filename: Name of the config file in XDG location - legacy_filename: Optional different filename in legacy location - - Returns: - Path to the config file in XDG location - """ - new_path = get_openhcs_config_dir() / filename - - # Check if we need to migrate from legacy location - if not new_path.exists(): - legacy_filename = legacy_filename or filename - legacy_path = get_legacy_openhcs_dir() / legacy_filename - - if legacy_path.exists(): - migrate_cache_file(legacy_path, new_path, f"config file {filename}") - - return new_path - - -def get_data_file_path( - filename: str, legacy_filename: Optional[str] = None, *, create: bool = True -) -> Path: - """ - Get path for a data file, with automatic migration from legacy location. - - Args: - filename: Name of the data file in XDG location - legacy_filename: Optional different filename in legacy location - - Returns: - Path to the data file in XDG location - """ - new_path = get_openhcs_data_dir(create=create) / filename - if not create: - return new_path + return get_openhcs_config_dir() / filename - # Check if we need to migrate from legacy location - if not new_path.exists(): - legacy_filename = legacy_filename or filename - legacy_path = get_legacy_openhcs_dir() / legacy_filename - if legacy_path.exists(): - migrate_cache_file(legacy_path, new_path, f"data file {filename}") +def get_data_file_path(filename: str, *, create: bool = True) -> Path: + """Return the path of a data file under the OpenHCS data directory.""" - return new_path + return get_openhcs_data_dir(create=create) / filename diff --git a/openhcs/pyqt_gui/services/service_adapter.py b/openhcs/pyqt_gui/services/service_adapter.py index 8b8a2aed93..dd2dfd1961 100644 --- a/openhcs/pyqt_gui/services/service_adapter.py +++ b/openhcs/pyqt_gui/services/service_adapter.py @@ -6,15 +6,11 @@ from PyQt6.QtCore import QObject, pyqtSignal from PyQt6.QtWidgets import QApplication, QFileDialog, QMessageBox, QWidget +from pyqt_reactive.core.path_cache import PathCacheKey, cache_path, get_initial_path from pyqt_reactive.services.async_operation_executor import AsyncOperationExecutor from pyqt_reactive.services.ui_thread_dispatch import UiThreadDispatcher from pyqt_reactive.theming import ColorScheme, ThemeManager -from openhcs.core.path_cache import ( - PathCacheKey, - cache_path, - get_initial_path, -) logger = logging.getLogger(__name__) @@ -169,7 +165,7 @@ def show_warning_dialog( def show_cached_directory_dialog( self, - cache_key: PathCacheKey, + cache_key: type[PathCacheKey], title: str = "Select Directory", fallback_path: Optional[Path] = None, allow_multiple: bool = False, diff --git a/openhcs/pyqt_gui/widgets/plate_manager.py b/openhcs/pyqt_gui/widgets/plate_manager.py index 806f17b094..f51e249d25 100644 --- a/openhcs/pyqt_gui/widgets/plate_manager.py +++ b/openhcs/pyqt_gui/widgets/plate_manager.py @@ -17,6 +17,7 @@ from objectstate import DataclassFieldAccess from objectstate.object_state import ObjectState, ObjectStateRegistry from PyQt6.QtCore import pyqtSignal +from pyqt_reactive.core.path_cache import PathCacheKey from pyqt_reactive.theming import ColorScheme from pyqt_reactive.widgets.editors.simple_code_editor import SimpleCodeEditorService from pyqt_reactive.widgets.shared.abstract_manager_widget import ( @@ -83,7 +84,6 @@ ) from openhcs.core.config import PipelineConfig from openhcs.constants.constants import OrchestratorState -from openhcs.core.path_cache import PathCacheKey from openhcs.core.selection import SelectedAllSelectionMode from openhcs.pyqt_gui.services.ui_bridge_contracts import ( UiOwnedStateSurfaceDeclaration, @@ -477,13 +477,17 @@ def results_orchestrator(self, scope_id: str | None): # --------------------------------------------------------------------------- +class PlateImport(PathCacheKey): + """The directory a dataset was last added from.""" + + class AddDatasetsPrompter(RequestPrompter): operation = AddDatasets def prompt(self, renderer: GuiRenderer) -> DatasetRootsRequest | None: manager = renderer.plate_manager selected = manager.service_adapter.show_cached_directory_dialog( - cache_key=PathCacheKey.PLATE_IMPORT, + cache_key=PlateImport, title="Select Dataset Directory", fallback_path=Path.home(), allow_multiple=True, diff --git a/openhcs/pyqt_gui/widgets/source_bindings_editor.py b/openhcs/pyqt_gui/widgets/source_bindings_editor.py index e5ae749afd..5a3c945c9a 100644 --- a/openhcs/pyqt_gui/widgets/source_bindings_editor.py +++ b/openhcs/pyqt_gui/widgets/source_bindings_editor.py @@ -3,56 +3,58 @@ from __future__ import annotations import re -from abc import ABC, abstractmethod -from dataclasses import MISSING, dataclass, field, fields, is_dataclass, replace -from enum import Enum -from functools import cache -from types import MappingProxyType, UnionType +from dataclasses import dataclass +from types import MappingProxyType from typing import ( TYPE_CHECKING, Annotated, Callable, - Generic, - Mapping, - TypeVar, - Union, cast, - get_args, - get_origin, - get_type_hints, ) -from metaclass_registry import AutoRegisterMeta -from PyQt6.QtCore import Qt, QTimer, pyqtSignal -from PyQt6.QtGui import QBrush, QColor +from objectstate import ( + DataclassFieldAccess, + DottedFieldPath, + ObjectStateSubfieldSemanticIndex, + StructuralValuePath, +) +from objectstate.lazy_factory import ( + LazyDataclass, + get_base_type_for_lazy, + replace_raw, + resolve_lazy_configurations_for_serialization, +) +from PyQt6.QtCore import Qt, pyqtSignal from PyQt6.QtWidgets import ( QAbstractItemView, - QCheckBox, - QComboBox, QDialog, QDialogButtonBox, QGroupBox, QHBoxLayout, - QHeaderView, QLabel, - QLineEdit, - QListWidget, - QListWidgetItem, QPushButton, QSizePolicy, - QStyle, QTableWidget, QTableWidgetItem, QVBoxLayout, QWidget, ) +from pyqt_reactive.forms.inline_dataclass_chrome import InlineDataclassChildChrome +from pyqt_reactive.forms.inline_dataclass_context import ( + InlineDataclassChildFieldIdentity, + InlineDataclassFormContext, +) +from pyqt_reactive.forms.layout_constants import CURRENT_LAYOUT +from pyqt_reactive.forms.widget_creation_config import ( + EnabledTitleWidgetMoveAuthority, +) from pyqt_reactive.protocols import ( + ChangeSignalEmitter, ChildFieldChromeRefreshable, ChildFieldIdentityProvider, ChildFieldNavigationTargetProvider, ChildFieldSemanticChromeRefreshable, ChildSubfieldNavigationTargetProvider, - ChangeSignalEmitter, InlineDataclassGroupBoxChromeProvider, InlineDataclassRootResettable, PyQtWidgetMeta, @@ -61,1397 +63,64 @@ ValueGettable, ValueSettable, ) -from pyqt_reactive.forms.layout_constants import CURRENT_LAYOUT -from pyqt_reactive.forms.inline_dataclass_chrome import InlineDataclassChildChrome -from pyqt_reactive.forms.inline_dataclass_context import ( - InlineDataclassChildFieldIdentity, - InlineDataclassFormContext, +from pyqt_reactive.widgets.editable_table import ( + INVALID_CELL_VALUE_ERRORS, + CellEditContext, + ChoiceCellEditor, + DataclassFieldColumns, + EditableRowT, + EditableTableController, + EditableTableLayout, + RowSuggestion, + RowSuggestionMap, ) -from pyqt_reactive.forms.widget_creation_config import ( - EnabledTitleWidgetMoveAuthority, +from pyqt_reactive.widgets.no_scroll_spinbox import NoneAwareCheckBox +from pyqt_reactive.widgets.shared.clickable_help_components import ( + InlineDataclassGroupBox, ) -from pyqt_reactive.forms.widget_strategies import PlaceholderConfig -from pyqt_reactive.widgets.shared.scoped_table_widget import ScopedTableWidget from pyqt_reactive.widgets.shared.scope_color_receiver import ScopeColorSchemeReceiver +from pyqt_reactive.widgets.shared.scoped_table_widget import ScopedTableWidget from pyqt_reactive.widgets.structural_table import ( EditableTableSemanticBinding, IsomorphicDataclassRowPathPolicy, StructuralDescendantMaskTarget, StructuralFlashTarget, StructuralMaskedContainerTarget, - StructuralTableCellTarget, StructuralWidgetTarget, ) -from pyqt_reactive.widgets.shared.clickable_help_components import ( - InlineDataclassGroupBox, -) -from pyqt_reactive.widgets.no_scroll_spinbox import NoScrollComboBox, NoneAwareCheckBox from python_introspect import ( - AnnotationChoices, Enableable, - SignatureAnalyzer, - declared_annotation_choices, + FieldLabel, is_enableable, ) -from openhcs.core.field_label import FieldLabel from openhcs.core.source_bindings import ( - ComponentSelector, EMPTY_SOURCE_BINDINGS, + ComponentSelector, MetadataExtractionRule, - MetadataSource, MetadataSelector, + MetadataSource, NamedSourceBinding, SourceBindingMatchDimension, SourceBindingMatchField, SourceBindingMatchMethod, SourceBindingMatchPlan, + SourceBindingsConfig, SourceFilterClause, SourceFilterMatchType, SourceFilterSubject, - SourceBindingsConfig, StepSourceBindingsConfig, ) from openhcs.core.source_bindings_preview import ( SourceBindingsPreview, SourceInventory, ) -from objectstate import ( - DataclassFieldAccess, - DottedFieldPath, - ObjectStateSubfieldSemanticIndex, - StructuralValuePath, -) -from objectstate.lazy_factory import ( - LazyDataclass, - get_base_type_for_lazy, - replace_raw, - resolve_lazy_configurations_for_serialization, -) if TYPE_CHECKING: from pyqt_reactive.forms.parameter_form_manager import ParameterFormManager from pyqt_reactive.forms.parameter_info_types import InlineDataclassWidgetInfo -EditableRowT = TypeVar("EditableRowT") SourceBindingsEditorRawValue = SourceBindingsConfig | LazyDataclass -INVALID_CELL_VALUE_ERRORS = (ValueError, TypeError, re.error) -"""Errors a row constructor raises for a value the user has not finished editing.""" - -# --- Dataclass-derived table columns and typed cell editors ----------------- -# Generic editable-table block owned by L4 (moves to pyqt-reactive): from here -# to ``EditableTableLayout`` inclusive. - - -@dataclass(frozen=True, slots=True) -class RowSuggestion: - """Prefilled field values offered for a new record row in a picker dialog.""" - - values: tuple[tuple[str, object], ...] - label: str - - @classmethod - def of(cls, row_type: type, **values: object) -> "RowSuggestion": - columns = DataclassFieldColumns.of(row_type) - return cls( - values=tuple(values.items()), - label=":".join( - columns.named(name).editor.display(value) - for name, value in values.items() - ), - ) - - -RowSuggestionMap = Mapping[type, tuple[RowSuggestion, ...]] - - -@dataclass(frozen=True, slots=True) -class CellEditContext: - """What a cell editor needs from its table besides the cell value.""" - - on_change: Callable[[], None] - suggestions: RowSuggestionMap = field(default_factory=lambda: MappingProxyType({})) - - -def cell_editor_key(name: str, cls: type) -> str: - return name - - -class CellEditor(ABC, metaclass=AutoRegisterMeta): - """Cell editor family keyed by the field annotation it edits. - - A cell holds a *raw* value (the widget's own state, e.g. item text); the - editor converts it to the field's typed value. No raw value is ever a - serialized encoding of several fields. - """ - - __registry_key__ = "editor_key" - __key_extractor__ = cell_editor_key - __skip_if_no_key__ = True - - def __init__(self, annotation: object) -> None: - self.annotation = annotation - - @classmethod - def for_annotation(cls, annotation: object) -> "CellEditor | None": - for editor_type in cls.__registry__.values(): - if editor_type.accepts(annotation): - return editor_type(annotation) - return None - - @classmethod - @abstractmethod - def accepts(cls, annotation: object) -> bool: ... - - @abstractmethod - def raw_for(self, value: object) -> object: - """Return the raw cell state that renders ``value``.""" - - @abstractmethod - def empty_raw(self) -> object: - """Raw state of a new cell whose field has no default.""" - - @abstractmethod - def value_from_raw(self, raw: object) -> object: - """Return the typed field value; raise ``ValueError`` while incomplete.""" - - @abstractmethod - def display(self, value: object) -> str: ... - - @abstractmethod - def create( - self, - table: QTableWidget, - row: int, - column: int, - raw: object, - context: CellEditContext, - ) -> None: ... - - @abstractmethod - def update(self, table: QTableWidget, row: int, column: int, raw: object) -> bool: - """Update an existing cell in place; return False when it must be rebuilt.""" - - @abstractmethod - def raw(self, table: QTableWidget, row: int, column: int) -> object: ... - - @staticmethod - def union_members(annotation: object) -> tuple[object, ...]: - if get_origin(annotation) in (Union, UnionType): - return get_args(annotation) - return (annotation,) - - -class ScalarTextCellEditor(CellEditor): - """Text item for ``str``/``int``/``float`` scalars, optionally ``None``.""" - - SCALAR_TYPES = (str, int, float, bool) - - @classmethod - def accepts(cls, annotation: object) -> bool: - if annotation is bool: - return False - members = tuple( - member - for member in cls.union_members(annotation) - if member is not type(None) - ) - return bool(members) and all(member in cls.SCALAR_TYPES for member in members) - - @property - def optional(self) -> bool: - return type(None) in self.union_members(self.annotation) - - @property - def scalar_type(self) -> type: - members = tuple( - member - for member in self.union_members(self.annotation) - if member is not type(None) - ) - return members[0] if len(members) == 1 else str - - def raw_for(self, value: object) -> str: - return "" if value is None else str(value) - - def empty_raw(self) -> str: - return "" - - def value_from_raw(self, raw: object) -> object: - text = str(raw).strip() - if not text and self.optional: - return None - return self.scalar_type(text) - - def display(self, value: object) -> str: - return self.raw_for(value) - - def create(self, table, row, column, raw, context) -> None: - table.setItem(row, column, EditableTableItem(str(raw))) - - def update(self, table, row, column, raw) -> bool: - item = table.item(row, column) - if not isinstance(item, EditableTableItem): - return False - item.set_logical_text(str(raw)) - return True - - def raw(self, table, row, column) -> str: - item = table.item(row, column) - if item is None: - return "" - return EditableTableController._editable_item(item).data( - EditableTableItem.LOGICAL_VALUE_ROLE - ) - - -class BooleanCellEditor(CellEditor): - """Check box for ``bool`` fields.""" - - @classmethod - def accepts(cls, annotation: object) -> bool: - return annotation is bool - - def raw_for(self, value: object) -> bool: - return bool(value) - - def empty_raw(self) -> bool: - return False - - def value_from_raw(self, raw: object) -> bool: - return bool(raw) - - def display(self, value: object) -> str: - return str(bool(value)) - - def create(self, table, row, column, raw, context) -> None: - checkbox = QCheckBox(table) - checkbox.setChecked(bool(raw)) - checkbox.toggled.connect(lambda _checked: context.on_change()) - table.setCellWidget(row, column, checkbox) - - def update(self, table, row, column, raw) -> bool: - checkbox = table.cellWidget(row, column) - if not isinstance(checkbox, QCheckBox): - return False - blocked = checkbox.blockSignals(True) - try: - checkbox.setChecked(bool(raw)) - finally: - checkbox.blockSignals(blocked) - return True - - def raw(self, table, row, column) -> bool: - checkbox = table.cellWidget(row, column) - return isinstance(checkbox, QCheckBox) and checkbox.isChecked() - - -class ChoiceCellEditor(CellEditor): - """Combo box whose choices are the field type's members. - - Declared ``AnnotationChoices`` supply their choices and labels; an ``Enum`` - type supplies its members; ``type[F]`` for an ``AutoRegisterMeta`` family - supplies the family registry, labelled by each member's registry key. - """ - - COMBO_LOGICAL_TEXT_ROLE = Qt.ItemDataRole.UserRole + 1 - - @classmethod - def accepts(cls, annotation: object) -> bool: - return ( - cls.declared_choices(annotation) is not None - or cls.family(annotation) is not None - or (isinstance(annotation, type) and issubclass(annotation, Enum)) - ) - - @staticmethod - def declared_choices(annotation: object) -> AnnotationChoices | None: - if get_origin(annotation) is not Annotated: - return None - return declared_annotation_choices(annotation) - - @staticmethod - def family(annotation: object) -> type | None: - if get_origin(annotation) is not type: - return None - (member_type,) = get_args(annotation) - return member_type if isinstance(member_type, AutoRegisterMeta) else None - - @property - def choices(self) -> tuple[object, ...]: - declared = self.declared_choices(self.annotation) - if declared is not None: - return declared.choices() - family = self.family(self.annotation) - if family is not None: - return tuple(family.__registry__.values()) - return tuple(cast(type[Enum], self.annotation)) - - def raw_for(self, value: object) -> object: - return value - - def empty_raw(self) -> object: - return self.choices[0] - - def value_from_raw(self, raw: object) -> object: - return raw - - def display(self, value: object) -> str: - declared = self.declared_choices(self.annotation) - if declared is not None: - return declared.label(value) - family = self.family(self.annotation) - if family is not None: - return str(getattr(value, family.__registry_key__)) - return str(cast(Enum, value).value) - - def create(self, table, row, column, raw, context) -> None: - combo = NoScrollComboBox(table) - for choice in self.choices: - text = self.display(choice) - combo.addItem(text, choice) - combo.setItemData(combo.count() - 1, text, self.COMBO_LOGICAL_TEXT_ROLE) - combo.setCurrentIndex(self.index_of(combo, raw)) - combo.currentIndexChanged.connect(lambda _: context.on_change()) - combo.activated.connect(lambda _: context.on_change()) - table.setCellWidget(row, column, combo) - - def update(self, table, row, column, raw) -> bool: - combo = table.cellWidget(row, column) - if not isinstance(combo, QComboBox): - return False - index = self.index_of(combo, raw) - if index < 0: - return False - blocked = combo.blockSignals(True) - try: - combo.setCurrentIndex(index) - finally: - combo.blockSignals(blocked) - return True - - def raw(self, table, row, column) -> object: - combo = table.cellWidget(row, column) - if not isinstance(combo, QComboBox): - raise TypeError("Choice cells are rendered as combo boxes.") - return combo.currentData() - - @staticmethod - def index_of(combo: QComboBox, value: object) -> int: - for index in range(combo.count()): - if combo.itemData(index) == value: - return index - return -1 - - -class RecordTupleCellEditor(CellEditor): - """Picker cell for ``tuple[Record, ...]`` with columns derived from ``Record``.""" - - @classmethod - def accepts(cls, annotation: object) -> bool: - if get_origin(annotation) is not tuple: - return False - arguments = get_args(annotation) - return ( - len(arguments) == 2 - and arguments[1] is Ellipsis - and isinstance(arguments[0], type) - and is_dataclass(arguments[0]) - ) - - @property - def element_type(self) -> type: - return get_args(self.annotation)[0] - - def raw_for(self, value: object) -> tuple[object, ...]: - return tuple(cast(tuple, value)) - - def empty_raw(self) -> tuple[object, ...]: - return () - - def value_from_raw(self, raw: object) -> tuple[object, ...]: - return tuple(cast(tuple, raw)) - - def display(self, value: object) -> str: - columns = DataclassFieldColumns.of(self.element_type) - return "; ".join( - ":".join( - text - for column in columns.columns - if (text := column.editor.display(column.value_of(element))) - ) - for element in cast(tuple, value) - ) - - def create(self, table, row, column, raw, context) -> None: - table.setCellWidget( - row, - column, - StructuredSelectorCellWidget( - editor=self, - value=self.value_from_raw(raw), - suggestions=context.suggestions.get(self.element_type, ()), - apply_changes=context.on_change, - parent=table, - ), - ) - - def update(self, table, row, column, raw) -> bool: - widget = table.cellWidget(row, column) - if not isinstance(widget, StructuredSelectorCellWidget): - return False - widget.set_value_silently(self.value_from_raw(raw)) - return True - - def raw(self, table, row, column) -> tuple[object, ...]: - widget = table.cellWidget(row, column) - if not isinstance(widget, StructuredSelectorCellWidget): - raise TypeError("Record-list cells are rendered as selector widgets.") - return widget.value() - - -@dataclass(frozen=True, slots=True) -class DataclassFieldColumn: - """One editable leaf field of a row dataclass, at ``path`` from the row.""" - - index: int - path: tuple[str, ...] - declaring_type: type - editor: CellEditor - label: str - tooltip: str | None - default: object = MISSING - - @property - def name(self) -> str: - return self.path[-1] - - def value_of(self, row: object) -> object: - value = row - for name in self.path: - value = getattr(value, name) - return value - - def replaced(self, row: object, value: object) -> object: - """Return ``row`` with this field replaced; every other field is kept.""" - - return self._replaced(row, self.path, value) - - @classmethod - def _replaced(cls, row: object, path: tuple[str, ...], value: object) -> object: - name, *rest = path - if rest: - value = cls._replaced(getattr(row, name), tuple(rest), value) - return replace(row, **{name: value}) - - -@dataclass(frozen=True, slots=True) -class DataclassFieldColumns: - """Columns derived from a row dataclass: one per field a cell editor accepts. - - Nested dataclass fields contribute their own fields. Fields no editor accepts - have no column; editing a row through ``replaced`` keeps them unchanged. - """ - - row_type: type - columns: tuple[DataclassFieldColumn, ...] - - @classmethod - @cache - def of(cls, row_type: type) -> "DataclassFieldColumns": - columns: list[DataclassFieldColumn] = [] - cls._collect(row_type, (), columns) - return cls(row_type=row_type, columns=tuple(columns)) - - @classmethod - def _collect( - cls, - row_type: type, - prefix: tuple[str, ...], - columns: list[DataclassFieldColumn], - ) -> None: - hints = get_type_hints(row_type, include_extras=True) - for row_field in fields(row_type): - annotation, metadata = cls._split_annotated(hints[row_field.name]) - path = (*prefix, row_field.name) - if isinstance(annotation, type) and is_dataclass(annotation): - cls._collect(annotation, path, columns) - continue - editor = CellEditor.for_annotation( - hints[row_field.name] - if any(isinstance(item, AnnotationChoices) for item in metadata) - else annotation - ) - - if editor is None: - continue - columns.append( - DataclassFieldColumn( - index=len(columns), - path=path, - declaring_type=row_type, - editor=editor, - label=next( - ( - item.text - for item in metadata - if isinstance(item, FieldLabel) - ), - row_field.name.replace("_", " ").title(), - ), - tooltip=SignatureAnalyzer.extract_field_documentation( - row_type, - row_field.name, - ), - default=row_field.default, - ) - ) - - @staticmethod - def _split_annotated(annotation: object) -> tuple[object, tuple[object, ...]]: - if get_origin(annotation) is Annotated: - base, *metadata = get_args(annotation) - return base, tuple(metadata) - return annotation, () - - def __len__(self) -> int: - return len(self.columns) - - def __iter__(self): - return iter(self.columns) - - def named(self, *path: str) -> DataclassFieldColumn: - for column in self.columns: - if column.path[-len(path) :] == path: - return column - raise KeyError(f"{self.row_type.__name__} has no column {'.'.join(path)}.") - - @property - def labels(self) -> tuple[str, ...]: - return tuple(column.label for column in self.columns) - - def apply_header_items(self, header_item: Callable[[int], QTableWidgetItem | None]): - for column in self.columns: - item = header_item(column.index) - if item is not None and column.tooltip is not None: - item.setToolTip(column.tooltip) - - @property - def is_isomorphic(self) -> bool: - return len(self.columns) == len(fields(self.row_type)) and all( - len(column.path) == 1 for column in self.columns - ) - - def construct(self, values: tuple[object, ...]) -> object: - """Build an isomorphic row from one typed value per field.""" - - return self.row_type( - **{column.name: value for column, value in zip(self.columns, values)} - ) - - def draft_raw(self, values: Mapping[str, object]) -> tuple[object, ...]: - """Raw cells for a new row: given values, then field defaults, then empty.""" - - return tuple( - column.editor.raw_for(values[column.name]) - if column.name in values - else ( - column.editor.raw_for(column.default) - if column.default is not MISSING - else column.editor.empty_raw() - ) - for column in self.columns - ) - - -class StructuredSelectorCellWidget(QWidget): - """Read-only summary of a record list with a typed picker dialog.""" - - def __init__( - self, - *, - editor: RecordTupleCellEditor, - value: tuple[object, ...], - suggestions: tuple[RowSuggestion, ...], - apply_changes: Callable[[], None], - parent: QWidget | None = None, - ) -> None: - super().__init__(parent) - self.editor = editor - self.suggestions = suggestions - self._value = value - self._apply_changes = apply_changes - self.semantic_marker_label = QLabel("", self) - self.semantic_marker_label.setVisible(False) - self.line_edit = QLineEdit(editor.display(value), self) - self.line_edit.setReadOnly(True) - picker_button = QPushButton("...", self) - picker_button.setFixedWidth(28) - picker_button.clicked.connect(self._open_picker) - - layout = QHBoxLayout(self) - layout.setContentsMargins(0, 0, 0, 0) - layout.setSpacing(2) - layout.addWidget(self.semantic_marker_label) - layout.addWidget(self.line_edit, 1) - layout.addWidget(picker_button) - - def value(self) -> tuple[object, ...]: - return self._value - - def set_value(self, value: tuple[object, ...]) -> None: - self.set_value_silently(value) - self._apply_changes() - - def set_value_silently(self, value: tuple[object, ...]) -> None: - self._value = tuple(value) - self.line_edit.setText(self.editor.display(self._value)) - - def set_semantic_markers(self, markers: tuple[str, ...]) -> None: - """Render ObjectState-owned markers without changing the edit value.""" - - self.semantic_marker_label.setText("".join(markers)) - self.semantic_marker_label.setVisible(bool(markers)) - - def create_dialog(self) -> "StructuredSelectorDialog": - return StructuredSelectorDialog( - element_type=self.editor.element_type, - suggestions=self.suggestions, - value=self._value, - parent=self, - ) - - def _open_picker(self) -> None: - dialog = self.create_dialog() - if dialog.exec() != QDialog.DialogCode.Accepted: - return - self.set_value(dialog.value()) - - -class StructuredSelectorDialog(QDialog): - """Typed editor for a record list; its columns derive from the record type.""" - - def __init__( - self, - *, - element_type: type, - suggestions: tuple[RowSuggestion, ...], - value: tuple[object, ...], - parent: QWidget | None = None, - ) -> None: - super().__init__(parent) - columns = DataclassFieldColumns.of(element_type) - self.setWindowTitle(f"Edit {element_type.__name__} list") - self.table = QTableWidget(0, len(columns), self) - self.table.setHorizontalHeaderLabels(columns.labels) - columns.apply_header_items(self.table.horizontalHeaderItem) - self.validation_label = QLabel(self) - self.controller: EditableTableController[object] = EditableTableController( - table=self.table, - columns=columns, - apply_changes=self._update_validation_hint, - ) - for element in value: - self.controller.append(element) - self.table.itemChanged.connect( - lambda _item: self.controller.request_apply_changes() - ) - self.suggestions = QListWidget(self) - for suggestion in suggestions: - item = QListWidgetItem(suggestion.label) - item.setData(Qt.ItemDataRole.UserRole, suggestion) - self.suggestions.addItem(item) - self.suggestions.itemDoubleClicked.connect( - lambda item: self._append_suggestion(item.data(Qt.ItemDataRole.UserRole)) - ) - add_button = QPushButton("Add selected", self) - add_button.clicked.connect(self._append_selected) - add_row_button = QPushButton("Add row", self) - add_row_button.clicked.connect(lambda: self.append_draft({})) - buttons = QDialogButtonBox( - QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel, - self, - ) - buttons.accepted.connect(self.accept) - buttons.rejected.connect(self.reject) - - layout = QVBoxLayout(self) - layout.addWidget(self.table) - layout.addWidget(self.validation_label) - layout.addWidget(add_row_button) - layout.addWidget(QLabel("Suggestions", self)) - layout.addWidget(self.suggestions) - layout.addWidget(add_button) - layout.addWidget(buttons) - self._update_validation_hint() - - def value(self) -> tuple[object, ...]: - return self.controller.rows() - - def append_draft(self, values: Mapping[str, object]) -> None: - self.controller.append_draft(values) - self._update_validation_hint() - - def _append_selected(self) -> None: - for item in self.suggestions.selectedItems(): - self._append_suggestion(item.data(Qt.ItemDataRole.UserRole)) - - def _append_suggestion(self, suggestion: RowSuggestion) -> None: - self.append_draft(dict(suggestion.values)) - - def _update_validation_hint(self) -> None: - invalid_rows = self.controller.incomplete_row_numbers() - if invalid_rows: - joined_rows = ", ".join(str(row) for row in invalid_rows) - self.validation_label.setText(f"Incomplete rows ignored: {joined_rows}") - return - self.validation_label.setText("All rows are structurally valid.") - - -RawRow = tuple[object, ...] - - -@dataclass(slots=True, weakref_slot=True) -class EditableTableProgrammaticUpdateGuard: - """Suppress table-driven change callbacks while the model updates the view.""" - - depth: int = 0 - pending_release: bool = False - programmatic_row_values: tuple[RawRow, ...] | None = None - - def __enter__(self) -> "EditableTableProgrammaticUpdateGuard": - self.depth += 1 - self.pending_release = False - self.programmatic_row_values = None - return self - - def __exit__(self, exc_type, exc_value, traceback) -> None: - if self.depth > 0: - self.depth -= 1 - self.pending_release = True - QTimer.singleShot(0, lambda: self._release_pending()) - - @property - def active(self) -> bool: - return self.depth > 0 - - def remember_rows(self, row_values: tuple[RawRow, ...]) -> None: - """Record the table rows produced by the current programmatic update.""" - - self.programmatic_row_values = row_values - - def suppress_pending_rows( - self, - row_values: tuple[RawRow, ...], - ) -> None: - """Suppress delayed callbacks that still reflect programmatic rows.""" - - self.pending_release = True - self.remember_rows(row_values) - QTimer.singleShot(0, lambda: self._release_pending()) - - def should_suppress_pending_rows( - self, - row_values: tuple[RawRow, ...], - ) -> bool: - """Whether a pending callback still reflects the programmatic rows.""" - - return self.pending_release and self.programmatic_row_values == row_values - - def release(self) -> None: - self.pending_release = False - self.programmatic_row_values = None - - def _release_pending(self) -> None: - if self.depth == 0: - self.release() - - -class EditableTableItem(QTableWidgetItem): - """QTableWidgetItem with separate logical/edit value and rendered chrome.""" - - LOGICAL_VALUE_ROLE = Qt.ItemDataRole.UserRole - RENDERED_TEXT_ROLE = Qt.ItemDataRole.UserRole + 1 - - def __init__(self, value: str) -> None: - super().__init__() - self._logical_value = value - self._rendered_text = value - super().setData(Qt.ItemDataRole.DisplayRole, value) - - def data(self, role): - if role == Qt.ItemDataRole.DisplayRole: - return self._rendered_text - if role == Qt.ItemDataRole.EditRole: - return self._logical_value - if role == self.LOGICAL_VALUE_ROLE: - return self._logical_value - if role == self.RENDERED_TEXT_ROLE: - return self._rendered_text - return super().data(role) - - def setData(self, role, value) -> None: - text = "" if value is None else str(value) - if role == Qt.ItemDataRole.EditRole: - self.set_logical_text(text) - return - if role == Qt.ItemDataRole.DisplayRole: - self.set_rendered_text(text) - return - if role == self.LOGICAL_VALUE_ROLE: - self._logical_value = text - super().setData(self.LOGICAL_VALUE_ROLE, text) - return - if role == self.RENDERED_TEXT_ROLE: - self.set_rendered_text(text) - return - super().setData(role, value) - - def setText(self, text: str) -> None: - self.set_logical_text(text) - - def set_logical_text(self, value: str) -> None: - self._logical_value = value - self._rendered_text = value - super().setData(self.LOGICAL_VALUE_ROLE, value) - super().setData(self.RENDERED_TEXT_ROLE, value) - super().setData(Qt.ItemDataRole.DisplayRole, value) - - def set_rendered_text(self, value: str) -> None: - self._rendered_text = value - super().setData(self.RENDERED_TEXT_ROLE, value) - super().setData(Qt.ItemDataRole.DisplayRole, value) - - -@dataclass(frozen=True) -class EditableTableController(Generic[EditableRowT]): - """Own editable Qt table mechanics for one isomorphic row dataclass. - - Every field of the row type has a column, so a row is constructed from its - typed cell values without losing anything. - """ - - LOGICAL_VALUE_ROLE = EditableTableItem.LOGICAL_VALUE_ROLE - RENDERED_TEXT_ROLE = EditableTableItem.RENDERED_TEXT_ROLE - COMBO_LOGICAL_TEXT_ROLE = ChoiceCellEditor.COMBO_LOGICAL_TEXT_ROLE - - table: QTableWidget - columns: DataclassFieldColumns - apply_changes: Callable[[], None] - suggestions: RowSuggestionMap = field(default_factory=lambda: MappingProxyType({})) - semantic_binding: EditableTableSemanticBinding | None = None - update_guard: EditableTableProgrammaticUpdateGuard = field( - default_factory=EditableTableProgrammaticUpdateGuard, - ) - - def __post_init__(self) -> None: - if not self.columns.is_isomorphic: - raise TypeError( - f"{self.columns.row_type.__name__} has fields without an editable " - "column; edit it through replace-based typed rows instead." - ) - - @property - def cell_context(self) -> CellEditContext: - return CellEditContext( - on_change=self.request_apply_changes, - suggestions=self.suggestions, - ) - - def append(self, row_model: EditableRowT) -> None: - self._append_raw( - tuple( - column.editor.raw_for(column.value_of(row_model)) - for column in self.columns - ) - ) - - def append_draft(self, values: Mapping[str, object]) -> None: - """Append a row prefilled with ``values`` that may not be complete yet.""" - - self._append_raw(self.columns.draft_raw(values)) - - def _append_raw(self, raw_row: RawRow) -> None: - table_signals_blocked = self.table.blockSignals(True) - try: - row_index = self.table.rowCount() - self.table.insertRow(row_index) - for column, raw in zip(self.columns, raw_row, strict=True): - self._set_cell(row_index, column, raw) - finally: - self.table.blockSignals(table_signals_blocked) - - def replace_all(self, row_models: tuple[EditableRowT, ...]) -> bool: - """Replace table contents; return whether table structure changed.""" - - with self.update_guard: - table_signals_blocked = self.table.blockSignals(True) - try: - if self.table.rowCount() == len(row_models): - for row_index, row_model in enumerate(row_models): - for column in self.columns: - raw = column.editor.raw_for(column.value_of(row_model)) - if not column.editor.update( - self.table, row_index, column.index, raw - ): - self._set_cell(row_index, column, raw) - return False - - self.table.setRowCount(0) - for row_model in row_models: - self.append(row_model) - return True - finally: - self.table.blockSignals(table_signals_blocked) - self.update_guard.remember_rows(self.row_values()) - - def request_apply_changes(self) -> None: - """Apply edits unless they are fallout from a programmatic table update.""" - - if self.update_guard.active: - return - self._sync_item_logical_values_from_edit_role() - if self.update_guard.pending_release: - if self.update_guard.should_suppress_pending_rows(self.row_values()): - return - self.update_guard.release() - self.apply_changes() - - def rows(self) -> tuple[EditableRowT, ...]: - """Return the complete rows; incomplete rows are reported separately.""" - - return tuple( - row - for row in (self._row_or_none(values) for values in self.row_values()) - if row is not None - ) - - def row_values(self) -> tuple[RawRow, ...]: - """Return the current raw cell state for every row.""" - - return tuple( - tuple( - column.editor.raw(self.table, row_index, column.index) - for column in self.columns - ) - for row_index in range(self.table.rowCount()) - ) - - def incomplete_row_numbers(self) -> tuple[int, ...]: - """One-based numbers of rows whose cells do not yet form a valid row.""" - - return tuple( - row_index + 1 - for row_index, values in enumerate(self.row_values()) - if self._row_or_none(values) is None - ) - - def has_incomplete_rows(self) -> bool: - return bool(self.incomplete_row_numbers()) - - def _row_or_none(self, raw_row: RawRow) -> EditableRowT | None: - try: - return cast( - EditableRowT, - self.columns.construct( - tuple( - column.editor.value_from_raw(raw) - for column, raw in zip(self.columns, raw_row, strict=True) - ) - ), - ) - except INVALID_CELL_VALUE_ERRORS: - return None - - def suppress_current_rows_until_idle(self) -> None: - """Suppress delayed callbacks for the currently rendered row values.""" - - self.update_guard.suppress_pending_rows(self.row_values()) - - def _sync_item_logical_values_from_edit_role(self) -> None: - """Update item backing values for a real table edit without re-emitting.""" - - table_signals_blocked = self.table.blockSignals(True) - try: - for row_index in range(self.table.rowCount()): - for column in self.columns: - if self.table.cellWidget(row_index, column.index) is not None: - continue - item = self.table.item(row_index, column.index) - if item is None: - continue - edit_value = item.data(Qt.ItemDataRole.EditRole) - if not isinstance(edit_value, str): - edit_value = item.text() - if item.data(self.LOGICAL_VALUE_ROLE) == edit_value: - continue - self._set_item_logical_text(item, edit_value) - finally: - self.table.blockSignals(table_signals_blocked) - - def remove_selected(self) -> bool: - selected_rows = {index.row() for index in self.table.selectedIndexes()} - if not selected_rows: - return False - table_signals_blocked = self.table.blockSignals(True) - try: - for row_index in sorted(selected_rows, reverse=True): - self.table.removeRow(row_index) - finally: - self.table.blockSignals(table_signals_blocked) - return True - - def _set_cell( - self, - row_index: int, - column: DataclassFieldColumn, - raw: object, - ) -> None: - column.editor.create( - self.table, - row_index, - column.index, - raw, - self.cell_context, - ) - self._apply_current_placeholder_text_style_to_cell(row_index, column) - - @classmethod - def _set_item_logical_text(cls, item: EditableTableItem, value: str) -> None: - cls._editable_item(item) - item.set_logical_text(value) - - @staticmethod - def _editable_item(item: QTableWidgetItem) -> EditableTableItem: - if not isinstance(item, EditableTableItem): - raise TypeError( - "Editable table text cells must be EditableTableItem instances " - f"for semantic chrome isolation, got {type(item).__name__}." - ) - return item - - def _apply_current_placeholder_text_style_to_cell( - self, - row_index: int, - column: DataclassFieldColumn, - ) -> None: - """Apply the table's current inherited-preview style to a newly built cell.""" - - active = self.table.property("placeholder_text_style_active") is True - widget = self.table.cellWidget(row_index, column.index) - if widget is not None: - self._apply_widget_placeholder_text_style(widget, active) - return - item = self.table.item(row_index, column.index) - if item is not None: - self._apply_item_placeholder_text_style(item, active) - - def apply_semantic_index( - self, - semantic_index: ObjectStateSubfieldSemanticIndex, - ) -> None: - """Apply ObjectState structural semantics to table cell chrome.""" - - from objectstate.time_travel_profile import TimeTravelProfiler - - with TimeTravelProfiler.phase( - "openhcs.editable_table.apply_semantic_index", - rows=self.table.rowCount(), - columns=self.table.columnCount(), - ): - with self.update_guard: - table_signals_blocked = self.table.blockSignals(True) - try: - for ( - row_index, - column_index, - ), relative_path in self.semantic_paths().items(): - semantic = semantic_index.leaf_for(relative_path) - if semantic is None: - continue - widget = self.table.cellWidget(row_index, column_index) - if widget is not None: - self._apply_widget_semantic(widget, semantic) - continue - item = self.table.item(row_index, column_index) - if item is not None: - self._apply_item_semantic(item, semantic) - finally: - self.table.blockSignals(table_signals_blocked) - self.update_guard.remember_rows(self.row_values()) - - def set_placeholder_text_style(self, active: bool) -> None: - """Apply inherited-preview text styling to every rendered table cell.""" - - with self.update_guard: - self.apply_placeholder_text_style_to_table(self.table, active) - self.update_guard.remember_rows(self.row_values()) - - @classmethod - def apply_placeholder_text_style_to_table( - cls, - table: QTableWidget, - active: bool, - ) -> None: - """Apply or clear placeholder text styling without dimming table controls.""" - - table_signals_blocked = table.blockSignals(True) - try: - table.setProperty("placeholder_text_style_active", active) - for row_index in range(table.rowCount()): - for column_index in range(table.columnCount()): - widget = table.cellWidget(row_index, column_index) - if widget is not None: - cls._apply_widget_placeholder_text_style(widget, active) - continue - item = table.item(row_index, column_index) - if item is not None: - cls._apply_item_placeholder_text_style(item, active) - finally: - table.blockSignals(table_signals_blocked) - - def semantic_paths(self) -> Mapping[tuple[int, int], StructuralValuePath]: - """Return structural paths for all currently rendered cells.""" - - if self.semantic_binding is None: - return MappingProxyType({}) - return MappingProxyType( - { - (row_index, column.index): self.semantic_binding.relative_path_for_cell( - row_index, - column.index, - ) - for row_index in range(self.table.rowCount()) - for column in self.columns - } - ) - - def cell_target_for_semantic_path( - self, - relative_path: StructuralValuePath, - ) -> StructuralTableCellTarget | None: - """Return a concrete target for a structural cell path.""" - - for (row_index, column_index), candidate in self.semantic_paths().items(): - if candidate != relative_path: - continue - return StructuralTableCellTarget( - table=self.table, - row_index=row_index, - column_index=column_index, - cell_widget=self.table.cellWidget(row_index, column_index), - ) - return None - - @staticmethod - def _semantic_tooltip(semantic) -> str: - reasons: list[str] = [semantic.display_path] - if semantic.dirty: - reasons.append("* differs from saved value") - if semantic.signature_diff: - reasons.append("_ differs from signature default") - if semantic.inherited_value: - reasons.append("inherited resolved value") - return "\n".join(reasons) - - @classmethod - def _apply_item_semantic(cls, item: QTableWidgetItem, semantic) -> None: - item = cls._editable_item(item) - logical_value = item.data(cls.LOGICAL_VALUE_ROLE) - if not isinstance(logical_value, str): - edit_value = item.data(Qt.ItemDataRole.EditRole) - logical_value = edit_value if isinstance(edit_value, str) else item.text() - item.setData(cls.LOGICAL_VALUE_ROLE, logical_value) - target_text = f"{''.join(semantic.semantic_markers)}{logical_value}" - target_tooltip = cls._semantic_tooltip(semantic) - font = item.font() - if ( - item.text() == target_text - and item.data(Qt.ItemDataRole.EditRole) == logical_value - and item.data(cls.RENDERED_TEXT_ROLE) == target_text - and font.underline() == semantic.signature_diff - and item.toolTip() == target_tooltip - ): - return - item.set_rendered_text(target_text) - font.setUnderline(semantic.signature_diff) - item.setFont(font) - item.setToolTip(target_tooltip) - - @classmethod - def _apply_widget_semantic(cls, widget: QWidget, semantic) -> None: - target_tooltip = cls._semantic_tooltip(semantic) - font = widget.font() - if isinstance(widget, QComboBox): - widget_marker_matches = cls._combo_current_marker_matches( - widget, - semantic.semantic_markers, - ) - elif isinstance(widget, StructuredSelectorCellWidget): - marker_text = "".join(semantic.semantic_markers) - widget_marker_matches = ( - widget.semantic_marker_label.text() == marker_text - and widget.semantic_marker_label.isVisible() == bool(marker_text) - ) - else: - widget_marker_matches = True - if ( - widget.property("objectstate_dirty") == semantic.dirty - and widget.property("objectstate_signature_diff") == semantic.signature_diff - and widget.property("objectstate_inherited") == semantic.inherited_value - and widget.toolTip() == target_tooltip - and font.underline() == semantic.signature_diff - and widget_marker_matches - ): - return - if isinstance(widget, QComboBox): - cls._apply_combo_semantic_markers(widget, semantic.semantic_markers) - elif isinstance(widget, StructuredSelectorCellWidget): - widget.set_semantic_markers(semantic.semantic_markers) - widget.setProperty("objectstate_dirty", semantic.dirty) - widget.setProperty("objectstate_signature_diff", semantic.signature_diff) - widget.setProperty("objectstate_inherited", semantic.inherited_value) - widget.setToolTip(target_tooltip) - font.setUnderline(semantic.signature_diff) - widget.setFont(font) - widget.style().unpolish(widget) - widget.style().polish(widget) - widget.update() - - @staticmethod - def _combo_current_marker_matches( - combo: QComboBox, - markers: tuple[str, ...], - ) -> bool: - rendered_index = combo.currentIndex() if markers else -1 - return ( - combo.property("objectstate_rendered_markers") == markers - and combo.property("objectstate_rendered_marker_index") == rendered_index - ) - - @classmethod - def _apply_combo_semantic_markers( - cls, - combo: QComboBox, - markers: tuple[str, ...], - ) -> None: - was_blocked = combo.blockSignals(True) - try: - cls._restore_combo_item_texts(combo) - if markers and combo.currentIndex() >= 0: - current_index = combo.currentIndex() - combo.setItemText( - current_index, - f"{''.join(markers)}{cls._combo_logical_text(combo, current_index)}", - ) - combo.setProperty("objectstate_rendered_marker_index", current_index) - else: - combo.setProperty("objectstate_rendered_marker_index", -1) - combo.setProperty("objectstate_rendered_markers", markers) - finally: - combo.blockSignals(was_blocked) - - @classmethod - def _restore_combo_item_texts(cls, combo: QComboBox) -> None: - for index in range(combo.count()): - combo.setItemText(index, cls._combo_logical_text(combo, index)) - - @classmethod - def _combo_logical_text(cls, combo: QComboBox, index: int) -> str: - logical_text = combo.itemData(index, cls.COMBO_LOGICAL_TEXT_ROLE) - if not isinstance(logical_text, str): - raise TypeError("Editable table choice cells require logical text data.") - return logical_text - - @classmethod - def _apply_item_placeholder_text_style( - cls, - item: QTableWidgetItem, - active: bool, - ) -> None: - font = item.font() - font.setItalic(active) - item.setFont(font) - item.setForeground( - QBrush(QColor(PlaceholderConfig.text_color_name())) if active else QBrush() - ) - - @classmethod - def _apply_widget_placeholder_text_style( - cls, - widget: QWidget, - active: bool, - ) -> None: - if isinstance(widget, StructuredSelectorCellWidget): - cls._apply_line_edit_placeholder_text_style(widget.line_edit, active) - return - if isinstance(widget, QLineEdit): - cls._apply_line_edit_placeholder_text_style(widget, active) - return - if isinstance(widget, QComboBox): - widget.setStyleSheet( - f"QComboBox {{ {PlaceholderConfig.text_style(include_opacity=False)} }}" - if active - else "" - ) - - @staticmethod - def _apply_line_edit_placeholder_text_style( - line_edit: QLineEdit, - active: bool, - ) -> None: - line_edit.setStyleSheet( - f"QLineEdit {{ {PlaceholderConfig.text_style(include_opacity=False)} }}" - if active - else "" - ) - - -class EditableTableLayout: - """Shared layout policy for compact source-binding tables.""" - - @staticmethod - def configure(table: QTableWidget) -> None: - table.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) - table.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded) - table.setSizePolicy( - QSizePolicy.Policy.Expanding, - QSizePolicy.Policy.Fixed, - ) - table.verticalHeader().setVisible(False) - table.verticalHeader().setDefaultSectionSize(22) - table.verticalHeader().setMinimumSectionSize(18) - table.horizontalHeader().setSectionResizeMode( - QHeaderView.ResizeMode.ResizeToContents - ) - table.verticalHeader().setSectionResizeMode( - QHeaderView.ResizeMode.ResizeToContents - ) - - @staticmethod - def fit_to_rows(table: QTableWidget) -> None: - table.ensurePolished() - table.resizeRowsToContents() - header = table.horizontalHeader() - header_height = max(header.height(), header.sizeHint().height()) - row_height = sum(table.rowHeight(row) for row in range(table.rowCount())) - if table.rowCount() == 0: - row_height = table.verticalHeader().defaultSectionSize() - scrollbar_height = table.horizontalScrollBar().sizeHint().height() - frame = table.frameWidth() * 2 - viewport_vertical_margin = 2 * table.style().pixelMetric( - QStyle.PixelMetric.PM_FocusFrameVMargin, - None, - table, - ) - table.viewport().setMinimumHeight(row_height) - table.setFixedHeight( - header_height - + row_height - + scrollbar_height - + frame - + viewport_vertical_margin - ) # --- Source-binding tables --------------------------------------------------- diff --git a/openhcs/utils/recache_function_registry.py b/openhcs/utils/recache_function_registry.py index 9134e0f1be..dfdce56552 100644 --- a/openhcs/utils/recache_function_registry.py +++ b/openhcs/utils/recache_function_registry.py @@ -106,12 +106,6 @@ def recache_function_registry(): except Exception as e: print(f" ❌ Error importing cache utilities: {e}") - # Step 0: Migrate legacy cache files to XDG locations - print("\n🔄 Migrating legacy cache files to XDG locations...") - from openhcs.core.xdg_paths import migrate_all_legacy_cache_files - - migrate_all_legacy_cache_files() - # Step 1: Clear function metadata caches for available libraries print("\n🧹 Clearing function metadata caches for available libraries...") print(" 🧹 Clearing canonical registry caches...") diff --git a/tests/unit/pyqt_gui/test_one_persistence_format.py b/tests/unit/pyqt_gui/test_one_persistence_format.py index 233d6f82e0..9eb2b3217d 100644 --- a/tests/unit/pyqt_gui/test_one_persistence_format.py +++ b/tests/unit/pyqt_gui/test_one_persistence_format.py @@ -10,7 +10,9 @@ FunctionPatternCodeDocumentService, ) -from openhcs.core.path_cache import PathCacheKey +from pyqt_reactive.core.path_cache import PathCacheKey + +import openhcs.pyqt_gui.widgets.plate_manager # noqa: F401 - declares its dialog context from openhcs.core.steps.function_step import FunctionStep from openhcs.processing.backends import cellprofiler as cellprofiler_backend from openhcs.processing.backends.lib_registry.registry_service import RegistryService @@ -179,4 +181,8 @@ def test_gui_offers_no_pickle_file_formats() -> None: if marker in path.read_text(encoding="utf-8") ] assert offenders == [] - assert [key.name for key in PathCacheKey] == ["PLATE_IMPORT"] + assert [ + key.__name__ + for key in PathCacheKey.__registry__.values() + if key.__module__.startswith("openhcs.") + ] == ["PlateImport"] diff --git a/tests/unit/pyqt_gui/test_source_bindings_editor.py b/tests/unit/pyqt_gui/test_source_bindings_editor.py index 02487d8114..daf30783bd 100644 --- a/tests/unit/pyqt_gui/test_source_bindings_editor.py +++ b/tests/unit/pyqt_gui/test_source_bindings_editor.py @@ -12,7 +12,6 @@ QPushButton, QScrollArea, QStyle, - QTableWidgetItem, QWidget, ) from python_introspect import is_enableable @@ -47,11 +46,11 @@ from openhcs.core.steps.function_step import FunctionStep from openhcs.core.source_bindings_preview import SourceInventory from openhcs.pyqt_gui.widgets.source_bindings_editor import ( - DataclassFieldColumns, - EditableTableLayout, - RowSuggestion, SourceBindingsEditorWidget, SourceSetPairingRow, +) +from pyqt_reactive.widgets.editable_table import ( + DataclassFieldColumns, StructuredSelectorCellWidget, StructuredSelectorDialog, ) @@ -191,35 +190,6 @@ def binding_cell_widget(table, binding_index: int, field: str): return table.cellWidget(*binding_cell_position(binding_index, field)) -def test_structured_selector_dialog_edits_typed_filter_rows() -> None: - QtApplicationHarness.app() - - existing = SourceFilterClause( - SourceFilterSubject.FILE, SourceFilterMatchType.CONTAINS, "DNA", any_group=1 - ) - dialog = StructuredSelectorDialog( - element_type=SourceFilterClause, - suggestions=( - RowSuggestion.of( - SourceFilterClause, - subject=SourceFilterSubject.DIRECTORY, - match_type=SourceFilterMatchType.IS_IMAGE, - ), - ), - value=(existing,), - ) - - assert dialog.table.columnCount() == len(DataclassFieldColumns.of(SourceFilterClause)) - assert dialog.value() == (existing,) - - dialog._append_suggestion(dialog.suggestions.item(0).data(Qt.ItemDataRole.UserRole)) - - assert dialog.value() == ( - existing, - SourceFilterClause(SourceFilterSubject.DIRECTORY, SourceFilterMatchType.IS_IMAGE), - ) - - def test_structured_selector_dialog_choices_come_from_field_type() -> None: QtApplicationHarness.app() @@ -239,21 +209,6 @@ def test_structured_selector_dialog_choices_come_from_field_type() -> None: assert component_widget.currentData() is Microscopy.Channel -def test_structured_selector_dialog_reports_incomplete_rows() -> None: - QtApplicationHarness.app() - - dialog = StructuredSelectorDialog( - element_type=MetadataSelector, - suggestions=(), - value=(), - ) - - dialog.append_draft({"field": "Well"}) - - assert "Incomplete rows ignored: 1" == dialog.validation_label.text() - assert dialog.value() == () - - def test_source_bindings_config_uses_inline_dataclass_widget_info() -> None: info = create_parameter_info( "source_bindings", @@ -2523,53 +2478,6 @@ def test_source_bindings_editor_tables_expand_without_vertical_scrollbars() -> N assert table.visualRect(final_index).bottom() <= table.viewport().rect().bottom() -def test_editable_table_layout_keeps_final_row_visible_with_or_without_scrollbar() -> ( - None -): - app = QtApplicationHarness.app() - table = ScopedTableWidget(2, 2) - table.setItem(0, 0, QTableWidgetItem("field")) - table.setItem(0, 1, QTableWidgetItem("short")) - table.setItem(1, 0, QTableWidgetItem("imported metadata tables")) - table.setItem(1, 1, QTableWidgetItem("a value wide enough to require scrolling")) - EditableTableLayout.configure(table) - EditableTableLayout.fit_to_rows(table) - table.resize(180, table.height()) - table.show() - for _ in range(4): - app.processEvents() - EditableTableLayout.fit_to_rows(table) - app.processEvents() - - expected_rows_height = sum(table.rowHeight(row) for row in range(table.rowCount())) - final_index = table.model().index(table.rowCount() - 1, 0) - assert table.horizontalScrollBar().isVisible() - assert table.viewport().height() >= expected_rows_height - assert table.visualRect(final_index).bottom() <= table.viewport().rect().bottom() - viewport_rect = QRect( - table.viewport().mapTo(table, table.viewport().rect().topLeft()), - table.viewport().size(), - ) - horizontal_bar_rect = QRect( - table.horizontalScrollBar().mapTo( - table, - table.horizontalScrollBar().rect().topLeft(), - ), - table.horizontalScrollBar().size(), - ) - assert not viewport_rect.intersects(horizontal_bar_rect) - - table.resize(1200, table.height()) - for _ in range(4): - app.processEvents() - EditableTableLayout.fit_to_rows(table) - app.processEvents() - - assert not table.horizontalScrollBar().isVisible() - assert table.viewport().height() >= expected_rows_height - assert table.visualRect(final_index).bottom() <= table.viewport().rect().bottom() - - def test_pipeline_sources_preview_fits_rows_and_bar_in_reflowing_config_body() -> None: app = QtApplicationHarness.app() widget = SourceBindingsEditorWidget.from_bindings(StepSourceBindingsConfig()) diff --git a/tests/unit/pyqt_gui/test_source_bindings_editor_guards.py b/tests/unit/pyqt_gui/test_source_bindings_editor_guards.py index a85ea75062..3d520da8a9 100644 --- a/tests/unit/pyqt_gui/test_source_bindings_editor_guards.py +++ b/tests/unit/pyqt_gui/test_source_bindings_editor_guards.py @@ -11,10 +11,7 @@ import openhcs.core.source_bindings_preview as preview_module import openhcs.pyqt_gui.widgets.source_bindings_editor as editor_module from openhcs.core.source_bindings import NamedSourceBinding -from openhcs.pyqt_gui.widgets.source_bindings_editor import ( - CellEditor, - DataclassFieldColumns, -) +from pyqt_reactive.widgets.editable_table import CellEditor, DataclassFieldColumns def module_tree(module) -> ast.Module: diff --git a/tests/unit/test_axis_family_guards.py b/tests/unit/test_axis_family_guards.py index 0ca8f4efd1..8d7335781b 100644 --- a/tests/unit/test_axis_family_guards.py +++ b/tests/unit/test_axis_family_guards.py @@ -316,7 +316,7 @@ def test_runtime_foreign_names_name_existing_modules() -> None: # Every (kernel module, identifier) that still speaks microscopy. The set may # only shrink: delete the entries a change removes, never add one. The ceiling # makes growing the pin itself a visible edit. -KERNEL_DOMAIN_REFERENCE_CEILING = 72 +KERNEL_DOMAIN_REFERENCE_CEILING = 70 KERNEL_DOMAIN_REFERENCES = frozenset( { ("openhcs/core/artifacts.py", "PLATE"), @@ -338,8 +338,6 @@ def test_runtime_foreign_names_name_existing_modules() -> None: ("openhcs/core/function_patterns.py", "PLATE"), ("openhcs/core/orchestrator/bundle_execution.py", "PLATE"), ("openhcs/core/orchestrator/worker_execution.py", "PLATE"), - ("openhcs/core/path_cache.py", "PLATE_IMPORT"), - ("openhcs/core/path_cache.py", "plate_import"), ("openhcs/core/pipeline/artifact_planning.py", "PLATE"), ("openhcs/core/pipeline/compilation_session.py", "PLATE"), ("openhcs/core/pipeline/compiler.py", "PLATE"), From e51c5d53f4a803241960cc13ab630d9dd12871f6 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Sun, 11 Oct 2026 03:30:49 -0400 Subject: [PATCH 03/17] U3: scope ids are built and read through objectstate.ScopeKey SCOPE_SEGMENT_SEPARATOR and the hand-written '::' joins, splits and prefix tests in the session, step scopes, pipeline scope identity, compiler, image browser, live measurements, plate viewer and MCP viewer renderer call ScopeKey. Window-route regexes derive the separator from ScopeKey.SEPARATOR. Co-Authored-By: Claude Opus 5.5 --- external/ObjectState | 2 +- external/pyqt-reactive | 2 +- openhcs/authoring/session/pipelines.py | 16 ++++++---------- openhcs/authoring/session/step_scopes.py | 16 ++++++---------- openhcs/core/dataset_sources/dataset_scopes.py | 7 ++----- openhcs/core/pipeline/compiler.py | 7 +++++-- openhcs/mcp/dev_client_renderers/viewer.py | 7 ++++--- openhcs/pyqt_gui/services/window_handlers.py | 3 ++- openhcs/pyqt_gui/widgets/image_browser.py | 6 +++--- openhcs/pyqt_gui/widgets/pipeline_editor.py | 3 ++- .../pyqt_gui/windows/live_measurements_window.py | 9 +++------ openhcs/pyqt_gui/windows/plate_viewer_window.py | 5 ++++- openhcs/ui/shared/plate_scope_identity.py | 14 ++++---------- 13 files changed, 43 insertions(+), 54 deletions(-) diff --git a/external/ObjectState b/external/ObjectState index c75a457182..a4cfcf883d 160000 --- a/external/ObjectState +++ b/external/ObjectState @@ -1 +1 @@ -Subproject commit c75a457182e6b158ce68c759cb1ef5c86c849afa +Subproject commit a4cfcf883d0e8474bfa3a1d1d9a634b748346c9d diff --git a/external/pyqt-reactive b/external/pyqt-reactive index 096934678b..0e5f5216f8 160000 --- a/external/pyqt-reactive +++ b/external/pyqt-reactive @@ -1 +1 @@ -Subproject commit 096934678b37126f9946f64296e7fd6cec654e16 +Subproject commit 0e5f5216f843d0fed8604b086eb68832427f2732 diff --git a/openhcs/authoring/session/pipelines.py b/openhcs/authoring/session/pipelines.py index fb10c0e5c6..397af7755e 100644 --- a/openhcs/authoring/session/pipelines.py +++ b/openhcs/authoring/session/pipelines.py @@ -2,6 +2,7 @@ from __future__ import annotations +from objectstate import ScopeKey from collections.abc import Callable, Mapping from dataclasses import dataclass from typing import Self @@ -13,10 +14,7 @@ DATASET_LIST_SCOPE_ID, dataset_scope_ids, ) -from openhcs.core.dataset_sources.dataset_scopes import ( - SCOPE_SEGMENT_SEPARATOR, - DatasetScope, -) +from openhcs.core.dataset_sources.dataset_scopes import DatasetScope from openhcs.ui.shared.plate_scope_identity import PipelineScopeIdentity from openhcs.authoring.session.step_scopes import FunctionStepScopeToken from pyqt_reactive.services.function_pattern_code_document import ( @@ -196,7 +194,7 @@ def commit_plate_state(cls, plate_path: str) -> None: for token in binding._flatten_function_tokens( step_state.metadata.get(FUNC_EDITOR_PATTERN_TOKENS_META_KEY) ): - function_scope_id = f"{step_scope_id}{SCOPE_SEGMENT_SEPARATOR}{token}" + function_scope_id = ScopeKey(step_scope_id).child(token) function_state = ObjectStateRegistry.get_by_scope(function_scope_id) if function_state is None: raise RuntimeError( @@ -264,9 +262,7 @@ def _synchronize_steps(self, steps: list[FunctionStep]) -> None: previous_tokens: list[str] = [] for scope_id in existing_step_scope_ids: state = ObjectStateRegistry.get_by_scope(scope_id) - token = FunctionStepScopeToken.from_segment( - scope_id.rsplit(SCOPE_SEGMENT_SEPARATOR, 1)[-1] - ) + token = FunctionStepScopeToken.from_segment(ScopeKey(scope_id).last) if state is None: raise ValueError( f"Pipeline editor declares missing ObjectState scope {scope_id!r}." @@ -409,7 +405,7 @@ def _collect_step_registration_states( pattern_tokens, ): func_obj, kwargs, token = entry.func, entry.kwargs, entry.token - func_scope_id = f"{scope_id}{SCOPE_SEGMENT_SEPARATOR}{token}" + func_scope_id = ScopeKey(scope_id).child(token) existing_func_state = ObjectStateRegistry.get_by_scope(func_scope_id) if existing_func_state is not None: existing_func_state = FunctionPatternCodeDocumentService.synchronize_existing_function_state( @@ -554,7 +550,7 @@ def _function_entry_from_child_state( if token is None: return func_item - child_scope_id = f"{parent_scope_id}::{token}" + child_scope_id = ScopeKey(parent_scope_id).child(token) child_state = ( function_states[child_scope_id] if function_states is not None diff --git a/openhcs/authoring/session/step_scopes.py b/openhcs/authoring/session/step_scopes.py index 839249d180..0c1dc9b3f9 100644 --- a/openhcs/authoring/session/step_scopes.py +++ b/openhcs/authoring/session/step_scopes.py @@ -5,7 +5,7 @@ import re from dataclasses import dataclass -from openhcs.core.dataset_sources.dataset_scopes import SCOPE_SEGMENT_SEPARATOR +from objectstate import ScopeKey from openhcs.core.steps.function_step import FunctionStep from pyqt_reactive.services.function_navigation import ( FUNCTION_FIELD_ROOT, @@ -16,7 +16,7 @@ FUNCTION_STEP_SCOPE_TOKEN_PREFIX = f"{FunctionStep.__name__.lower()}_" -_SCOPE_SEPARATOR_PATTERN = re.escape(SCOPE_SEGMENT_SEPARATOR) +_SCOPE_SEPARATOR_PATTERN = re.escape(ScopeKey.SEPARATOR) _FUNCTION_STEP_TOKEN_PATTERN = re.compile( rf"^{re.escape(FUNCTION_STEP_SCOPE_TOKEN_PREFIX)}(?P\d+)$" ) @@ -76,23 +76,19 @@ def window_field_path(self, field_path: str | None) -> str | None: @classmethod def parse(cls, scope_id: str) -> "StepEditorScope": - segments = tuple(scope_id.split(SCOPE_SEGMENT_SEPARATOR)) + segments = ScopeKey(scope_id).parts step_ref = cls._step_token_ref(segments) if step_ref is None: raise ValueError(f"Invalid step scope_id format: {scope_id}") step_token_index, step_token = step_ref - plate_scope = SCOPE_SEGMENT_SEPARATOR.join(segments[:step_token_index]) - step_scope_id = SCOPE_SEGMENT_SEPARATOR.join(segments[: step_token_index + 1]) child_segments = segments[step_token_index + 1 :] return cls( scope_id=scope_id, - plate_scope=plate_scope, - step_scope_id=step_scope_id, + plate_scope=ScopeKey.of(*segments[:step_token_index]), + step_scope_id=ScopeKey.of(*segments[: step_token_index + 1]), step_token=step_token, - child_scope_id=( - SCOPE_SEGMENT_SEPARATOR.join(child_segments) if child_segments else None - ), + child_scope_id=ScopeKey.of(*child_segments) if child_segments else None, ) @classmethod diff --git a/openhcs/core/dataset_sources/dataset_scopes.py b/openhcs/core/dataset_sources/dataset_scopes.py index b1d7dc8707..719cca4416 100644 --- a/openhcs/core/dataset_sources/dataset_scopes.py +++ b/openhcs/core/dataset_sources/dataset_scopes.py @@ -8,6 +8,7 @@ from __future__ import annotations +from objectstate import ScopeKey from abc import ABC, abstractmethod from dataclasses import dataclass from pathlib import Path @@ -57,9 +58,7 @@ def code_value(self) -> Path | str: def owns_object_state_scope(self, scope_id: str) -> bool: """Whether an ObjectState scope belongs to this dataset.""" - return scope_id == self.scope_id or scope_id.startswith( - f"{self.scope_id}{SCOPE_SEGMENT_SEPARATOR}" - ) + return ScopeKey(self.scope_id).contains(scope_id) def prepare_input_workspace(self) -> "InputWorkspacePreparationResult | None": return self.kind.prepare_input_workspace(self) @@ -195,5 +194,3 @@ def prepare_input_workspace( execution_dataset_root=scope.execution_root, ) - -SCOPE_SEGMENT_SEPARATOR = "::" diff --git a/openhcs/core/pipeline/compiler.py b/openhcs/core/pipeline/compiler.py index 67f7a5e04c..cc09e96f84 100644 --- a/openhcs/core/pipeline/compiler.py +++ b/openhcs/core/pipeline/compiler.py @@ -6,6 +6,7 @@ from __future__ import annotations +from objectstate import ScopeKey import logging from pathlib import Path from typing import ( @@ -123,7 +124,7 @@ def _compiler_step_scope_id( step_index: int, ) -> str: """Build a compiler scope id that preserves stable step tokens.""" - return f"{compilation_scope}::{_step_scope_token(step, step_index)}" + return ScopeKey(compilation_scope).child(_step_scope_token(step, step_index)) def _compiler_pipeline_scope_id( @@ -131,7 +132,9 @@ def _compiler_pipeline_scope_id( pipeline_definition: Sequence["AbstractStep"], ) -> str: """Build the compiler declaration scope for one submitted pipeline.""" - return f"{dataset_root}::pipeline::submission_{id(pipeline_definition):x}" + return ScopeKey.of( + str(dataset_root), "pipeline", f"submission_{id(pipeline_definition):x}" + ) MATERIALIZATION_PLAN_REQUIREMENTS = ( diff --git a/openhcs/mcp/dev_client_renderers/viewer.py b/openhcs/mcp/dev_client_renderers/viewer.py index 8c71b9fcf8..54255f24f0 100644 --- a/openhcs/mcp/dev_client_renderers/viewer.py +++ b/openhcs/mcp/dev_client_renderers/viewer.py @@ -2,6 +2,7 @@ from __future__ import annotations +from objectstate import ScopeKey import json from collections.abc import Mapping from pathlib import Path @@ -441,9 +442,9 @@ def _route_summary(cls, value: str | None) -> str: text = cls.text(value) if value is None or len(text) <= 80: return text - separator_index = text.rfind("::") - if separator_index >= 0: - return f"...{text[separator_index:]}" + route = ScopeKey(text) + if route.depth > 1: + return f"...{ScopeKey.SEPARATOR}{route.last}" return f"{text[:32]}...{text[-32:]}" @classmethod diff --git a/openhcs/pyqt_gui/services/window_handlers.py b/openhcs/pyqt_gui/services/window_handlers.py index 32c8c801f0..938dc0c7e7 100644 --- a/openhcs/pyqt_gui/services/window_handlers.py +++ b/openhcs/pyqt_gui/services/window_handlers.py @@ -4,6 +4,7 @@ Called during application initialization. """ +from objectstate import ScopeKey import logging import re from typing import TYPE_CHECKING, Optional @@ -30,7 +31,7 @@ logger = logging.getLogger(__name__) -ORCHESTRATOR_SCOPE_PATTERN = r"^/(?!.*::).*$" +ORCHESTRATOR_SCOPE_PATTERN = rf"^/(?!.*{re.escape(ScopeKey.SEPARATOR)}).*$" def pipeline_scope_window_id(scope_id: str) -> str: diff --git a/openhcs/pyqt_gui/widgets/image_browser.py b/openhcs/pyqt_gui/widgets/image_browser.py index c705ee166f..f6e248dfa3 100644 --- a/openhcs/pyqt_gui/widgets/image_browser.py +++ b/openhcs/pyqt_gui/widgets/image_browser.py @@ -5,6 +5,7 @@ view them in Napari with configurable display settings. """ +from objectstate import ScopeKey import logging import subprocess import time @@ -47,7 +48,6 @@ from openhcs.pyqt_gui.config import ProgressUIConfig from openhcs.pyqt_gui.services.ui_window_ids import OpenHCSUiWindowId from openhcs.runtime.zmq_config import OPENHCS_ZMQ_CONFIG, OpenHCSZMQConfig -from openhcs.core.dataset_sources.dataset_scopes import SCOPE_SEGMENT_SEPARATOR from openhcs.core.axes import AxisFamily from openhcs.pyqt_gui.widgets.shared.plate_view_widget import ( PlateViewWidget, @@ -793,8 +793,8 @@ def _create_state_for_orchestrator(self, orchestrator): state_parent_scope = self._state_scope_parent_id or str( orchestrator.dataset_root ) - self.scope_id = SCOPE_SEGMENT_SEPARATOR.join( - (state_parent_scope, OpenHCSUiWindowId.image_browser) + self.scope_id = ScopeKey(state_parent_scope).child( + OpenHCSUiWindowId.image_browser ) parent_state = ( ObjectStateRegistry.get_by_scope(str(orchestrator.dataset_root)) diff --git a/openhcs/pyqt_gui/widgets/pipeline_editor.py b/openhcs/pyqt_gui/widgets/pipeline_editor.py index 9d482ebc6b..5b49c47f8e 100644 --- a/openhcs/pyqt_gui/widgets/pipeline_editor.py +++ b/openhcs/pyqt_gui/widgets/pipeline_editor.py @@ -5,6 +5,7 @@ them on every pipeline event; every change goes through the session. """ +from objectstate import ScopeKey import copy import logging import os @@ -525,7 +526,7 @@ def selected_step_scope_ids(self) -> tuple[str, ...]: def get_item_insert_index(self, item: FunctionStep, scope_key: str) -> Optional[int]: del item - token = scope_key.rsplit("::", 1)[-1] + token = ScopeKey(scope_key).last parts = token.rsplit("_", 1) if len(parts) == 2 and parts[1].isdigit(): return min(int(parts[1]), len(self.displayed_steps)) diff --git a/openhcs/pyqt_gui/windows/live_measurements_window.py b/openhcs/pyqt_gui/windows/live_measurements_window.py index e55e36704f..d5b72e090d 100644 --- a/openhcs/pyqt_gui/windows/live_measurements_window.py +++ b/openhcs/pyqt_gui/windows/live_measurements_window.py @@ -2,6 +2,7 @@ from __future__ import annotations +from objectstate import ScopeKey from typing import Any from PyQt6.QtCore import Qt @@ -28,7 +29,6 @@ LiveMeasurementTable, LiveMeasurementTableEntry, ) -from openhcs.core.dataset_sources.dataset_scopes import SCOPE_SEGMENT_SEPARATOR from openhcs.pyqt_gui.config import ProgressUIConfig from openhcs.runtime.zmq_config import OpenHCSZMQConfig @@ -299,11 +299,8 @@ def _ensure_image_browser(self) -> None: color_scheme=self.color_scheme, zmq_config=self._zmq_config, progress_config=self._progress_config, - state_scope_parent_id=SCOPE_SEGMENT_SEPARATOR.join( - ( - str(self._orchestrator.dataset_root), - self.STATE_SCOPE_SEGMENT, - ) + state_scope_parent_id=ScopeKey(str(self._orchestrator.dataset_root)).child( + self.STATE_SCOPE_SEGMENT ), parent=self, ) diff --git a/openhcs/pyqt_gui/windows/plate_viewer_window.py b/openhcs/pyqt_gui/windows/plate_viewer_window.py index 8572bdb169..38749da1c8 100644 --- a/openhcs/pyqt_gui/windows/plate_viewer_window.py +++ b/openhcs/pyqt_gui/windows/plate_viewer_window.py @@ -4,6 +4,7 @@ Combines image browsing and metadata viewing in a single window with tabs. """ +from objectstate import ScopeKey import logging from PyQt6.QtCore import Qt @@ -66,7 +67,9 @@ def __init__( # scope_id for singleton behavior - one viewer per plate # Use ::plate_viewer suffix to avoid conflicts with ConfigWindow (which uses just plate_path) self.scope_id = ( - f"{orchestrator.dataset_root}::plate_viewer" if orchestrator else None + ScopeKey(str(orchestrator.dataset_root)).child("plate_viewer") + if orchestrator + else None ) # Store plate path for styling (without suffix) so border matches plate's ConfigWindow self._style_scope_id = str(orchestrator.dataset_root) if orchestrator else None diff --git a/openhcs/ui/shared/plate_scope_identity.py b/openhcs/ui/shared/plate_scope_identity.py index 1cef114f17..fd181bf9ba 100644 --- a/openhcs/ui/shared/plate_scope_identity.py +++ b/openhcs/ui/shared/plate_scope_identity.py @@ -5,11 +5,11 @@ import re from dataclasses import dataclass -from openhcs.core.dataset_sources.dataset_scopes import SCOPE_SEGMENT_SEPARATOR +from objectstate import ScopeKey PIPELINE_SCOPE_SEGMENT = "pipeline" PIPELINE_SCOPE_PATTERN = re.compile( - rf"^.+{re.escape(SCOPE_SEGMENT_SEPARATOR)}{re.escape(PIPELINE_SCOPE_SEGMENT)}$" + rf"^.+{re.escape(ScopeKey.SEPARATOR)}{re.escape(PIPELINE_SCOPE_SEGMENT)}$" ) @@ -25,9 +25,7 @@ def from_plate_scope(cls, plate_scope: str) -> "PipelineScopeIdentity": if not plate_scope: raise ValueError("Pipeline scope requires a non-empty plate scope.") return cls( - scope_id=( - f"{plate_scope}{SCOPE_SEGMENT_SEPARATOR}{PIPELINE_SCOPE_SEGMENT}" - ), + scope_id=ScopeKey(plate_scope).child(PIPELINE_SCOPE_SEGMENT), plate_scope=plate_scope, ) @@ -35,11 +33,7 @@ def from_plate_scope(cls, plate_scope: str) -> "PipelineScopeIdentity": def from_scope_id(cls, scope_id: str) -> "PipelineScopeIdentity": if not cls.matches(scope_id): raise ValueError(f"Invalid pipeline scope id: {scope_id!r}") - plate_scope, _pipeline_segment = scope_id.rsplit( - SCOPE_SEGMENT_SEPARATOR, - 1, - ) - return cls(scope_id=scope_id, plate_scope=plate_scope) + return cls(scope_id=scope_id, plate_scope=ScopeKey(scope_id).parent) @classmethod def matches(cls, scope_id: str) -> bool: From a9b74e6439c37fb741689f487d7f12b3b7573fb8 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Sun, 11 Oct 2026 03:34:58 -0400 Subject: [PATCH 04/17] U3: delete GlobalEventBus; step editors follow session pipeline events GlobalEventBus restated two facts the session already publishes: a dataset's pipeline changed (PipelineChanged/PipelineImported) and the global config changed (GlobalConfigChanged). The pipeline editor re-broadcast the first only for its current plate, and only DualEditorWindow listened. DualEditorWindow now subscribes to the session's events for its own plate (and follows its pipeline after time travel restores it); GlobalEventBus, GuiEventBusBroadcaster and get_event_bus are deleted. Co-Authored-By: Claude Opus 5.5 --- external/pyqt-reactive | 2 +- openhcs/pyqt_gui/services/service_adapter.py | 25 ---- openhcs/pyqt_gui/services/window_handlers.py | 1 + openhcs/pyqt_gui/widgets/pipeline_editor.py | 13 +- openhcs/pyqt_gui/widgets/plate_manager.py | 4 - .../services/gui_event_bus_broadcast.py | 28 ---- .../pyqt_gui/windows/dual_editor_window.py | 132 +++++++----------- .../test_dual_editor_window_context.py | 61 ++++++-- tests/unit/pyqt_gui/session_harness.py | 5 - .../pyqt_gui/test_pipeline_editor_widget.py | 19 +-- tests/unit/test_dead_ui_code_guards.py | 9 -- 11 files changed, 109 insertions(+), 190 deletions(-) delete mode 100644 openhcs/pyqt_gui/widgets/shared/services/gui_event_bus_broadcast.py diff --git a/external/pyqt-reactive b/external/pyqt-reactive index 0e5f5216f8..2e4e6a6bee 160000 --- a/external/pyqt-reactive +++ b/external/pyqt-reactive @@ -1 +1 @@ -Subproject commit 0e5f5216f843d0fed8604b086eb68832427f2732 +Subproject commit 2e4e6a6bee98c8772658ebd88512db06429f9e58 diff --git a/openhcs/pyqt_gui/services/service_adapter.py b/openhcs/pyqt_gui/services/service_adapter.py index dd2dfd1961..cde5929286 100644 --- a/openhcs/pyqt_gui/services/service_adapter.py +++ b/openhcs/pyqt_gui/services/service_adapter.py @@ -4,7 +4,6 @@ from pathlib import Path from typing import Optional -from PyQt6.QtCore import QObject, pyqtSignal from PyQt6.QtWidgets import QApplication, QFileDialog, QMessageBox, QWidget from pyqt_reactive.core.path_cache import PathCacheKey, cache_path, get_initial_path from pyqt_reactive.services.async_operation_executor import AsyncOperationExecutor @@ -15,19 +14,6 @@ logger = logging.getLogger(__name__) -class GlobalEventBus(QObject): - """Cross-window pipeline and configuration change signals.""" - - pipeline_changed = pyqtSignal(list) # list[FunctionStep] - config_changed = pyqtSignal(object) # config object - - def emit_pipeline_changed(self, pipeline_steps: list): - self.pipeline_changed.emit(pipeline_steps) - - def emit_config_changed(self, config): - self.config_changed.emit(config) - - class PyQtServiceAdapter: """Qt dialogs, async execution, theme and config access for GUI widgets.""" @@ -46,9 +32,6 @@ def __init__(self, main_window: QWidget): # Initialize theme manager for centralized color management self.theme_manager = ThemeManager() - # Initialize global event bus for cross-window communication - self.event_bus = GlobalEventBus() - # Apply dark theme globally to ensure consistent dialog styling self._apply_dark_theme() @@ -302,11 +285,3 @@ def get_file_manager(self): FileManager instance """ return self.main_window.file_manager - - def get_event_bus(self) -> GlobalEventBus: - """Get the global event bus for cross-window communication. - - Returns: - GlobalEventBus instance - """ - return self.event_bus diff --git a/openhcs/pyqt_gui/services/window_handlers.py b/openhcs/pyqt_gui/services/window_handlers.py index 938dc0c7e7..197028a723 100644 --- a/openhcs/pyqt_gui/services/window_handlers.py +++ b/openhcs/pyqt_gui/services/window_handlers.py @@ -251,6 +251,7 @@ def create_step_editor_window( ), parent=None, ) + window.connect_session_events(plate_manager.session) window.connect_artifact_signals( compiled_artifact_signal=( plate_manager.compiled_artifact_inspection_changed diff --git a/openhcs/pyqt_gui/widgets/pipeline_editor.py b/openhcs/pyqt_gui/widgets/pipeline_editor.py index 5b49c47f8e..0a0f7ca17f 100644 --- a/openhcs/pyqt_gui/widgets/pipeline_editor.py +++ b/openhcs/pyqt_gui/widgets/pipeline_editor.py @@ -100,16 +100,12 @@ from openhcs.pyqt_gui.widgets.shared.openhcs_manager_mixins import ( OpenHCSSingleRowActionManagerMixin, ) -from openhcs.pyqt_gui.widgets.shared.services.gui_event_bus_broadcast import ( - GuiEventBusBroadcaster, -) from openhcs.pyqt_gui.widgets.shared.services.pipeline_editor_workflows import ( PipelineEditorCodeWorkflow, PipelineEditorDebugWorkflow, PipelineEditorFunctionPresentation, ) from openhcs.pyqt_gui.windows.dual_editor_window import DualEditorWindow -from openhcs.ui.shared.plate_scope_identity import PipelineScopeIdentity from openhcs.core.axes import Axis, GroupingDeclaration logger = logging.getLogger(__name__) @@ -389,7 +385,6 @@ def _refresh_if_current(self, scope_id: str) -> None: self._load_current_steps() self.update_item_list() self.update_button_states() - GuiEventBusBroadcaster(self.event_bus).pipeline_changed(self.displayed_steps) # -- UI ------------------------------------------------------------------- @@ -606,13 +601,8 @@ def on_time_travel_complete(self, dirty_states, triggering_scope): del triggering_scope self._load_current_steps() self.update_item_list() + del dirty_states self.update_button_states() - if self.current_plate and any( - scope_id - == PipelineScopeIdentity.from_plate_scope(self.current_plate).scope_id - for scope_id, _state in (dirty_states or ()) - ): - GuiEventBusBroadcaster(self.event_bus).pipeline_changed(self.displayed_steps) # -- context for step editors --------------------------------------------- @@ -683,6 +673,7 @@ def open_step_editor( **({} if step_index is None else {"step_index": step_index}), ) editor.set_original_step_for_change_detection() + editor.connect_session_events(self.session) editor.connect_orchestrator_config_signal( plate_manager.orchestrator_config_changed ) diff --git a/openhcs/pyqt_gui/widgets/plate_manager.py b/openhcs/pyqt_gui/widgets/plate_manager.py index f51e249d25..aed317c282 100644 --- a/openhcs/pyqt_gui/widgets/plate_manager.py +++ b/openhcs/pyqt_gui/widgets/plate_manager.py @@ -98,9 +98,6 @@ from openhcs.pyqt_gui.widgets.shared.openhcs_manager_mixins import ( OpenHCSSingleRowActionManagerMixin, ) -from openhcs.pyqt_gui.widgets.shared.services.gui_event_bus_broadcast import ( - GuiEventBusBroadcaster, -) from openhcs.pyqt_gui.widgets.shared.services.plate_manager_workflows import ( PlateManagerCodeWorkflow, ) @@ -339,7 +336,6 @@ def _(self, event: LogsCleared) -> None: @on_session_event.register def _(self, event: GlobalConfigChanged) -> None: self.service_adapter.set_global_config(event.config) - GuiEventBusBroadcaster(self.event_bus).config_changed(event.config) # -- list rendering ------------------------------------------------------- diff --git a/openhcs/pyqt_gui/widgets/shared/services/gui_event_bus_broadcast.py b/openhcs/pyqt_gui/widgets/shared/services/gui_event_bus_broadcast.py deleted file mode 100644 index dbe1657c0c..0000000000 --- a/openhcs/pyqt_gui/widgets/shared/services/gui_event_bus_broadcast.py +++ /dev/null @@ -1,28 +0,0 @@ -"""OpenHCS GUI event-bus broadcast helpers.""" - -from __future__ import annotations - -import logging -from dataclasses import dataclass -from typing import Any - -logger = logging.getLogger(__name__) - - -@dataclass(frozen=True, slots=True) -class GuiEventBusBroadcaster: - """Broadcasts OpenHCS GUI state changes when an event bus is present.""" - - event_bus: Any - - def pipeline_changed(self, pipeline_steps: list) -> None: - if self.event_bus is None: - return - self.event_bus.emit_pipeline_changed(pipeline_steps) - logger.debug("Broadcasted pipeline_changed to event bus") - - def config_changed(self, config: Any) -> None: - if self.event_bus is None: - return - self.event_bus.emit_config_changed(config) - logger.debug("Broadcasted config_changed to event bus") diff --git a/openhcs/pyqt_gui/windows/dual_editor_window.py b/openhcs/pyqt_gui/windows/dual_editor_window.py index 7ab031c1c4..2bf022320c 100644 --- a/openhcs/pyqt_gui/windows/dual_editor_window.py +++ b/openhcs/pyqt_gui/windows/dual_editor_window.py @@ -7,10 +7,9 @@ import logging from collections.abc import Callable -from functools import cache +from functools import cache, singledispatchmethod + -from objectstate import is_global_config_instance -from objectstate.global_config import get_current_global_config from objectstate.object_state import ObjectStateRegistry from PyQt6.QtCore import QTimer, pyqtSignal from PyQt6.QtGui import QShowEvent @@ -45,7 +44,15 @@ from openhcs.authoring.session.compilation import CompiledDataset from openhcs.core.artifact_inspection import CompiledArtifactInspection -from openhcs.core.config import PipelineConfig +from openhcs.authoring.session.events import ( + GlobalConfigChanged, + PipelineChanged, + PipelineImported, + SessionEvent, +) +from openhcs.authoring.session.session import Session +from openhcs.pyqt_gui.session_rendering import QtSessionEventRelay +from openhcs.ui.shared.plate_scope_identity import PipelineScopeIdentity from openhcs.core.source_binding_context import SourceBindingContext from openhcs.core.source_bindings import SourceBindingsConfig from openhcs.core.steps.abstract import AbstractStep @@ -296,7 +303,8 @@ def __init__( self._save_button_base_style = "" self._function_pattern_controller = None self._time_travel_title_refresh_callback = None - self._event_bus = None + self._session = None + self._session_events = None self._orchestrator_config_signal = None self._compiled_artifact_signal = None self._runtime_artifact_signal = None @@ -322,7 +330,11 @@ def _connect_time_travel_title_refresh(self) -> None: if not self.scope_id: return - def refresh_after_time_travel(_dirty_states, _triggering_scope) -> None: + pipeline_scope = PipelineScopeIdentity.from_plate_scope(self.plate_scope).scope_id + + def refresh_after_time_travel(dirty_states, _triggering_scope) -> None: + if any(scope_id == pipeline_scope for scope_id, _state in dirty_states or ()): + QTimer.singleShot(0, lambda: self._follow_pipeline(self.plate_scope)) QTimer.singleShot(0, self._refresh_after_time_travel) ObjectStateRegistry.add_time_travel_complete_callback(refresh_after_time_travel) @@ -699,19 +711,10 @@ def _build_step_scope_id(self) -> str: return build_step_scope_id(self.plate_scope, self.editing_step) def _on_pipeline_changed(self, new_pipeline_steps: list): - """Handle pipeline_changed signal from global event bus. - - CRITICAL: This is connected to the global event bus in setup_connections(). - It receives updates from ANY window that modifies the pipeline: - - Pipeline editor code button - - Plate manager code button - - Pipeline editor UI - - Any future pipeline editing source + """Follow this editor's step through a changed pipeline. - This is the OpenHCS "set and forget" pattern - one handler receives ALL updates. - - Args: - new_pipeline_steps: Updated list of FunctionStep objects from the pipeline + Called for every pipeline change the session publishes for this plate and + after time travel restores the pipeline, whatever window made the change. """ # Find our step in the new pipeline by matching scope token. window_scope_id = self.scope_id @@ -775,62 +778,47 @@ def _on_pipeline_changed(self, new_pipeline_steps: list): # Detect changes (might have unsaved changes now) self.detect_changes() - def _on_config_changed(self, config): - """Handle config_changed signal from global event bus. + def connect_session_events(self, session: Session) -> None: + """Follow pipeline and global-config changes the session publishes.""" - CRITICAL: This is connected to the global event bus in setup_connections(). - It receives updates from ANY window that modifies configs: - - PlateManager code button (GlobalPipelineConfig, PipelineConfig) - - ConfigWindow code button (GlobalPipelineConfig, PipelineConfig, StepConfig) - - Any future config editing source + self._session = session + self._session_events = QtSessionEventRelay(session, parent=self) + # A bound method, not a closure over self: PyQt holds bound-method + # slots weakly, so the connection does not keep this window alive. + self._session_events.published.connect(self._on_session_record) - This is the OpenHCS "set and forget" pattern - one handler receives ALL updates. + def _on_session_record(self, record) -> None: + self.on_session_event(record.event) - Args: - config: Updated config object (GlobalPipelineConfig, PipelineConfig, or StepConfig) - """ - # Only care about global configs and PipelineConfig changes - # (StepConfig changes are handled by the step editor's own form manager) - is_global = is_global_config_instance(config) - is_pipeline = isinstance(config, PipelineConfig) - if not (is_global or is_pipeline): - return + @singledispatchmethod + def on_session_event(self, event: SessionEvent) -> None: + del event - # Only refresh if this is for our orchestrator - if not self.orchestrator: - return + @on_session_event.register + def _(self, event: PipelineChanged) -> None: + self._follow_pipeline(event.scope_id) - # Check if this config belongs to our orchestrator - if is_pipeline: - # Check if this is our orchestrator's pipeline config - if config is not self.orchestrator.pipeline_config: - return - elif is_global: - # Check if this is the current global config - current_global = get_current_global_config(type(config)) - if config is not current_global: - return + @on_session_event.register + def _(self, event: PipelineImported) -> None: + self._follow_pipeline(event.scope_id) - logger.debug(f"Step editor received config change: {type(config).__name__}") - - # Trigger cross-window refresh for all form managers - # This will update placeholders in the step editor to show new inherited values + @on_session_event.register + def _(self, event: GlobalConfigChanged) -> None: + del event + # Inherited placeholders in the step editor re-resolve against the new + # global config. ObjectStateRegistry.increment_token() - logger.debug("Triggered global cross-window refresh after config change") + + def _follow_pipeline(self, scope_id: str) -> None: + if self._session is None or scope_id != self.plate_scope: + return + self._on_pipeline_changed(self._session.pipeline_steps(scope_id)) def setup_connections(self): """Setup signal/slot connections.""" # Tab change tracking self._tab_body.current_changed.connect(self.on_tab_changed) - # CRITICAL: Connect to global event bus for cross-window updates - # This is the OpenHCS "set and forget" pattern - one connection handles ALL sources - event_bus = self._get_event_bus() - if event_bus: - event_bus.pipeline_changed.connect(self._on_pipeline_changed) - event_bus.config_changed.connect(self._on_config_changed) - self._event_bus = event_bus - logger.debug("Connected to global event bus for cross-window updates") def connect_orchestrator_config_signal(self, signal) -> None: """Subscribe this editor to orchestrator config updates.""" @@ -855,17 +843,10 @@ def connect_artifact_signals( def _cleanup_managed_resources(self) -> None: """Release editor-owned subscriptions and generic form resources.""" - event_bus = self._event_bus - if event_bus is not None: - try: - event_bus.pipeline_changed.disconnect(self._on_pipeline_changed) - except TypeError: - pass - try: - event_bus.config_changed.disconnect(self._on_config_changed) - except TypeError: - pass - self._event_bus = None + session_events = self._session_events + if session_events is not None: + session_events.close() + self._session_events = None orchestrator_signal = self._orchestrator_config_signal if orchestrator_signal is not None: @@ -999,13 +980,6 @@ def _find_main_window(self): return None return self.service_adapter.main_window - def _get_event_bus(self): - """Return the event bus from the editor service adapter.""" - if self.service_adapter is None: - logger.warning("Could not find service adapter for event bus") - return None - return self.service_adapter.get_event_bus() - # Old function pane methods removed - now using dedicated FunctionListEditorWidget def get_function_info(self) -> str: diff --git a/tests/pyqt_gui/test_dual_editor_window_context.py b/tests/pyqt_gui/test_dual_editor_window_context.py index a95569f32f..1d7b695642 100644 --- a/tests/pyqt_gui/test_dual_editor_window_context.py +++ b/tests/pyqt_gui/test_dual_editor_window_context.py @@ -1,13 +1,14 @@ +from openhcs.authoring.session.events import ( + GlobalConfigChanged, + PipelineChanged, + PipelineImported, +) from openhcs.pyqt_gui.windows.dual_editor_window import DualEditorWindow class DummyServiceAdapter: def __init__(self) -> None: self.main_window = object() - self.event_bus = object() - - def get_event_bus(self) -> object: - return self.event_bus def test_dual_editor_window_uses_explicit_service_adapter_context() -> None: @@ -15,7 +16,6 @@ def test_dual_editor_window_uses_explicit_service_adapter_context() -> None: window.service_adapter = DummyServiceAdapter() assert window._find_main_window() is window.service_adapter.main_window - assert window._get_event_bus() is window.service_adapter.event_bus class RecordingSignal: @@ -30,17 +30,19 @@ def disconnect(self, callback) -> None: self.disconnected.append(callback) -class RecordingEventBus: +class RecordingRelay: def __init__(self) -> None: - self.pipeline_changed = RecordingSignal() - self.config_changed = RecordingSignal() + self.closed = False + + def close(self) -> None: + self.closed = True def test_dual_editor_window_cleans_cross_window_subscriptions() -> None: window = DualEditorWindow.__new__(DualEditorWindow) - event_bus = RecordingEventBus() + relay = RecordingRelay() orchestrator_signal = RecordingSignal() - window._event_bus = event_bus + window._session_events = relay window._orchestrator_config_signal = orchestrator_signal window._compiled_artifact_signal = None window._runtime_artifact_signal = None @@ -50,10 +52,9 @@ def test_dual_editor_window_cleans_cross_window_subscriptions() -> None: window._cleanup_managed_resources() - assert event_bus.pipeline_changed.disconnected == [window._on_pipeline_changed] - assert event_bus.config_changed.disconnected == [window._on_config_changed] + assert relay.closed assert orchestrator_signal.disconnected == [window.on_orchestrator_config_changed] - assert window._event_bus is None + assert window._session_events is None assert window._orchestrator_config_signal is None @@ -65,3 +66,37 @@ def test_dual_editor_window_orchestrator_signal_connection_is_owned() -> None: assert signal.connected == [window.on_orchestrator_config_changed] assert window._orchestrator_config_signal is signal + + +class PipelineSession: + def __init__(self) -> None: + self.steps = {"/plate1": ["a", "b"], "/plate2": ["c"]} + + def pipeline_steps(self, scope_id: str) -> list: + return self.steps[scope_id] + + +def test_dual_editor_follows_its_own_plates_pipeline_changes(monkeypatch) -> None: + window = DualEditorWindow.__new__(DualEditorWindow) + window._session = PipelineSession() + window.plate_scope = "/plate1" + followed = [] + monkeypatch.setattr(window, "_on_pipeline_changed", followed.append) + + window.on_session_event(PipelineChanged(dataset_scope_id="/plate2")) + window.on_session_event(PipelineChanged(dataset_scope_id="/plate1")) + window.on_session_event(PipelineImported(dataset_scope_id="/plate1")) + + assert followed == [["a", "b"], ["a", "b"]] + + +def test_dual_editor_reresolves_placeholders_on_global_config_change(monkeypatch) -> None: + from objectstate.object_state import ObjectStateRegistry + + window = DualEditorWindow.__new__(DualEditorWindow) + tokens = [] + monkeypatch.setattr(ObjectStateRegistry, "increment_token", lambda: tokens.append(1)) + + window.on_session_event(GlobalConfigChanged(config=object())) + + assert tokens == [1] diff --git a/tests/unit/pyqt_gui/session_harness.py b/tests/unit/pyqt_gui/session_harness.py index a01c385592..04003ec140 100644 --- a/tests/unit/pyqt_gui/session_harness.py +++ b/tests/unit/pyqt_gui/session_harness.py @@ -17,7 +17,6 @@ from openhcs.authoring.session.datasets import write_dataset_scope_ids from openhcs.authoring.session.session import CallerThread, Session from openhcs.core.config import GlobalPipelineConfig -from openhcs.pyqt_gui.services.service_adapter import GlobalEventBus from openhcs.runtime.zmq_config import OpenHCSZMQConfig @@ -27,7 +26,6 @@ class GuiServiceStub: errors: list[str] = field(default_factory=list) directory_choices: list[str] = field(default_factory=list) - event_bus: GlobalEventBus = field(default_factory=GlobalEventBus) main_window: object | None = None global_config: object | None = None file_manager: object | None = None @@ -38,9 +36,6 @@ def get_file_manager(self): def get_current_color_scheme(self) -> ColorScheme: return ColorScheme() - def get_event_bus(self) -> GlobalEventBus: - return self.event_bus - def show_error_dialog(self, message: str) -> None: self.errors.append(message) diff --git a/tests/unit/pyqt_gui/test_pipeline_editor_widget.py b/tests/unit/pyqt_gui/test_pipeline_editor_widget.py index 75818291c8..043f30b911 100644 --- a/tests/unit/pyqt_gui/test_pipeline_editor_widget.py +++ b/tests/unit/pyqt_gui/test_pipeline_editor_widget.py @@ -1007,28 +1007,14 @@ def locally_declared_function(image): ] == [target_token, source_token] -def test_time_travel_broadcasts_only_pipeline_level_restores( - tmp_path, monkeypatch -) -> None: +def test_time_travel_reloads_displayed_steps(tmp_path) -> None: with editor_gui(tmp_path) as harness: harness.set_steps([FunctionStep(name="First"), FunctionStep(name="Second")]) - broadcasts = [] - monkeypatch.setattr( - harness.gui.services.event_bus, "emit_pipeline_changed", broadcasts.append - ) - - harness.editor.on_time_travel_complete( - [(f"{harness.scope_id}::functionstep_0", object())], None - ) - assert broadcasts == [] harness.editor.on_time_travel_complete( [(PipelineScopeIdentity.from_plate_scope(harness.scope_id).scope_id, object())], None, ) - assert [[step.name for step in steps] for steps in broadcasts] == [ - ["First", "Second"] - ] assert [step.name for step in harness.editor.displayed_steps] == [ "First", "Second", @@ -1087,6 +1073,9 @@ def set_original_step_for_change_detection(self) -> None: def connect_orchestrator_config_signal(self, signal) -> None: del signal + def connect_session_events(self, session) -> None: + del session + def connect_artifact_signals(self, **signals) -> None: del signals diff --git a/tests/unit/test_dead_ui_code_guards.py b/tests/unit/test_dead_ui_code_guards.py index b34da3475d..ac47088a6e 100644 --- a/tests/unit/test_dead_ui_code_guards.py +++ b/tests/unit/test_dead_ui_code_guards.py @@ -56,15 +56,6 @@ "register_theme_change_callback", } ), - "GlobalEventBus": frozenset( - { - "step_changed", - "emit_step_changed", - "register_window", - "unregister_window", - "_registered_windows", - } - ), } MAIN_WINDOW_SERVICE_ALIASES = frozenset( { From 273dd0e8cae57135d129107f76f473219e9d5c7f Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Sun, 11 Oct 2026 03:40:12 -0400 Subject: [PATCH 05/17] U3: dock panes and shortcut routing come from pyqt-reactive The main window builds its panes with pyqt-reactive's DockPane, persists the layout with DockLayoutStore under its existing key and version, and binds its menu and time-travel shortcuts through ShortcutLifecycle[ShortcutConfig]. OpenHCS keeps the window specs, the dock topology and MainWindowEmbeddedWidgets, a DockPanes subclass naming its panes by window id. The generic dock and shortcut tests moved to the library. Co-Authored-By: Claude Opus 5.5 --- external/pyqt-reactive | 2 +- openhcs/pyqt_gui/main.py | 30 +- .../services/main_window_workflows.py | 474 +----------------- .../pyqt_gui/services/ui_bridge_windows.py | 4 +- .../test_main_window_embedded_layout.py | 470 ----------------- .../pyqt_gui/test_plate_manager_widget.py | 4 +- tests/unit/pyqt_gui/test_ui_agent_bridge.py | 8 +- .../unit/pyqt_gui/test_ui_config_lifecycle.py | 85 ---- .../test_configuration_consumer_coverage.py | 31 +- 9 files changed, 42 insertions(+), 1066 deletions(-) delete mode 100644 tests/unit/pyqt_gui/test_main_window_embedded_layout.py diff --git a/external/pyqt-reactive b/external/pyqt-reactive index 2e4e6a6bee..f031bfd081 160000 --- a/external/pyqt-reactive +++ b/external/pyqt-reactive @@ -1 +1 @@ -Subproject commit 2e4e6a6bee98c8772658ebd88512db06429f9e58 +Subproject commit f031bfd08163e1e6371199c4ffda9726257ee3a1 diff --git a/openhcs/pyqt_gui/main.py b/openhcs/pyqt_gui/main.py index 1c69fa6a76..ca51ec1cf2 100644 --- a/openhcs/pyqt_gui/main.py +++ b/openhcs/pyqt_gui/main.py @@ -29,6 +29,7 @@ QWidget, ) from pyqt_reactive.animation import WindowFlashOverlay +from pyqt_reactive.services.application_shortcuts import ShortcutLifecycle from pyqt_reactive.services.window_manager import WindowManager from pyqt_reactive.services.zmq_server_scan_service import ( EndpointObservationSnapshot, @@ -38,6 +39,7 @@ StatusState, ) from pyqt_reactive.widgets.editors.simple_code_editor import QScintillaCodeEditorDialog +from pyqt_reactive.widgets.shared.dock_layout import DockLayoutStore, DockPane from pyqt_reactive.widgets.system_monitor import SystemMonitorWidget from zmqruntime import EndpointConnectionCancelledError @@ -76,7 +78,7 @@ OperationPresenter, QtSessionEventRelay, ) -from openhcs.pyqt_gui.config import PyQtGuiRuntimeContext, UIConfig +from openhcs.pyqt_gui.config import PyQtGuiRuntimeContext, ShortcutConfig, UIConfig from openhcs.desktop.restart import DesktopSessionRestart from openhcs.desktop.update import ( DesktopRestartSession, @@ -92,12 +94,9 @@ EmbeddedCodeDocumentRegistrationABC, ) from openhcs.pyqt_gui.services.main_window_workflows import ( - MainWindowDockLayoutStore, - MainWindowDockPane, MainWindowEmbeddedWidgets, MainWindowLifecycleWorkflow, MainWindowPipelineActions, - MainWindowShortcutLifecycle, MainWindowTimeTravelWorkflow, MainWindowUiBridgeLifecycle, build_main_window_specs, @@ -258,13 +257,16 @@ def __init__( ) self.embedded_widgets = MainWindowEmbeddedWidgets() - self.dock_layout_store = MainWindowDockLayoutStore.for_current_application() + self.dock_layout_store = DockLayoutStore.for_application( + state_key="main_window/dock_layout_state", + state_version=2, + ) self.floating_windows: dict[str, QWidget] = {} self.ui_bridge_lifecycle = MainWindowUiBridgeLifecycle() application = QApplication.instance() if application is None: raise RuntimeError("OpenHCSMainWindow requires an active QApplication.") - self.shortcut_lifecycle = MainWindowShortcutLifecycle(application) + self.shortcut_lifecycle = ShortcutLifecycle[ShortcutConfig](application) # Declarative window specs self.window_specs = self._get_window_specs() @@ -470,7 +472,7 @@ def setup_ui(self): ) self.system_monitor = self.service_adapter.create_system_monitor_widget() - system_monitor_pane = MainWindowDockPane.create( + system_monitor_pane = DockPane.create( main_window=self, window_id=SystemMonitorWindowIdentity.require_value(), title=SystemMonitorWindowIdentity.require_title(), @@ -500,7 +502,7 @@ def setup_ui(self): self.plate_manager_widget = self.service_adapter.create_plate_manager_widget( self.session ) - plate_manager_pane = MainWindowDockPane.create( + plate_manager_pane = DockPane.create( main_window=self, window_id=PlateManagerWidgetIdentity.require_value(), title=PlateManagerWidgetIdentity.require_title(), @@ -522,7 +524,7 @@ def setup_ui(self): self.session.bind_endpoint_observations( lambda: self.zmq_manager_widget.endpoint_snapshot ) - zmq_manager_pane = MainWindowDockPane.create( + zmq_manager_pane = DockPane.create( main_window=self, window_id=ZmqServerManagerWindowIdentity.require_value(), title=ZmqServerManagerWindowIdentity.require_title(), @@ -535,7 +537,7 @@ def setup_ui(self): self.pipeline_editor_widget = ( self.service_adapter.create_pipeline_editor_widget(self.session) ) - pipeline_editor_pane = MainWindowDockPane.create( + pipeline_editor_pane = DockPane.create( main_window=self, window_id=PipelineEditorWidgetIdentity.require_value(), title=PipelineEditorWidgetIdentity.require_title(), @@ -646,7 +648,7 @@ def show_default_windows(self): """Show plate manager by default.""" # Plate Manager and Pipeline Editor are now embedded in the main window # Just ensure they're visible (in case they were hidden) - self.embedded_widgets.show_defaults() + self.embedded_widgets.ensure_all_visible() # Log viewer is still a separate window (on-demand) @@ -1031,17 +1033,17 @@ def time_travel_forward(): def time_travel_to_head(): time_travel_workflow.to_head() - self.shortcut_lifecycle.bind_time_travel_command( + self.shortcut_lifecycle.bind_command( lambda config: config.time_travel_back, "Step back in history", time_travel_back, ) - self.shortcut_lifecycle.bind_time_travel_command( + self.shortcut_lifecycle.bind_command( lambda config: config.time_travel_forward, "Step forward in history", time_travel_forward, ) - self.shortcut_lifecycle.bind_time_travel_command( + self.shortcut_lifecycle.bind_command( lambda config: config.time_travel_to_head, "Return to history head", time_travel_to_head, diff --git a/openhcs/pyqt_gui/services/main_window_workflows.py b/openhcs/pyqt_gui/services/main_window_workflows.py index a3a0e2ea09..6bc63e2b3a 100644 --- a/openhcs/pyqt_gui/services/main_window_workflows.py +++ b/openhcs/pyqt_gui/services/main_window_workflows.py @@ -3,27 +3,20 @@ from __future__ import annotations import logging -from collections.abc import Mapping -from dataclasses import dataclass, field +from dataclasses import dataclass from pathlib import Path from typing import TYPE_CHECKING, Callable -from PyQt6.QtCore import QByteArray, QEvent, QObject, QSettings, QSize, Qt, QTimer -from PyQt6.QtGui import QAction, QKeySequence from PyQt6.QtWidgets import ( QApplication, QDialog, - QDockWidget, QFileDialog, - QMainWindow, QMessageBox, QProgressBar, - QStyle, - QToolButton, QWidget, ) from pyqt_reactive.services.window_manager import WindowManager -from pyqt_reactive.widgets.shared.manager_ui_scaffold import ManagerHeaderParts +from pyqt_reactive.widgets.shared.dock_layout import DockPanes from openhcs.agent.ui_bridge_identities import ( AboutOpenHCSWindowIdentity, @@ -47,7 +40,7 @@ ) if TYPE_CHECKING: - from openhcs.pyqt_gui.config import AgentUiBridgeConfig, ShortcutConfig + from openhcs.pyqt_gui.config import AgentUiBridgeConfig from openhcs.pyqt_gui.services.service_adapter import PyQtServiceAdapter from openhcs.pyqt_gui.services.ui_bridge_server import UiBridgeControlServer from openhcs.pyqt_gui.widgets.pipeline_editor import PipelineEditorWidget @@ -56,145 +49,6 @@ logger = logging.getLogger(__name__) -class QtShortcutSequenceAuthority: - """Convert Qt key events into configured shortcut strings.""" - - @classmethod - def from_event(cls, event) -> str | None: - if event.type() != QEvent.Type.KeyPress: - return None - sequence = QKeySequence(event.keyCombination()) - if sequence.isEmpty(): - return None - return sequence.toString(QKeySequence.SequenceFormat.PortableText) - - -class CodeEditorFocusAuthority: - """Decide whether global shortcuts should yield to a focused code editor.""" - - @staticmethod - def allows_global_time_travel() -> bool: - from PyQt6.Qsci import QsciScintilla - - widget = QApplication.focusWidget() - while widget is not None: - if isinstance(widget, QsciScintilla): - return False - widget = widget.parentWidget() - return True - - -class TimeTravelShortcutEventFilter(QObject): - """Qt event filter that routes configured shortcuts to time-travel actions.""" - - def __init__(self, actions: Mapping[str, Callable[[], None]]) -> None: - super().__init__() - self._actions = dict(actions) - - def replace_actions(self, actions: Mapping[str, Callable[[], None]]) -> None: - """Replace the complete configured key-to-command projection.""" - - self._actions = dict(actions) - - def eventFilter(self, obj, event): - del obj - sequence = QtShortcutSequenceAuthority.from_event(event) - if sequence is None: - return False - if sequence not in self._actions: - return False - if not CodeEditorFocusAuthority.allows_global_time_travel(): - return False - self._actions[sequence]() - return True - - -class MainWindowShortcutLifecycle: - """Own live key projection onto main-window actions and event routing.""" - - def __init__(self, application: QApplication) -> None: - self._application = application - self._menu_actions: list[tuple[Callable[["ShortcutConfig"], str], QAction]] = [] - self._time_travel_commands: list[ - tuple[ - Callable[["ShortcutConfig"], str], - str, - Callable[[], None], - ] - ] = [] - self._event_filter = TimeTravelShortcutEventFilter({}) - self._application.installEventFilter(self._event_filter) - - def bind_menu_action( - self, - key_from_config: Callable[["ShortcutConfig"], str], - action: QAction, - ) -> None: - """Bind one concrete action to a typed configuration projection.""" - - self._menu_actions.append((key_from_config, action)) - - def bind_time_travel_command( - self, - key_from_config: Callable[["ShortcutConfig"], str], - label: str, - command: Callable[[], None], - ) -> None: - """Bind one event-filter command to a typed config projection.""" - - self._time_travel_commands.append((key_from_config, label, command)) - - def apply(self, config: "ShortcutConfig") -> None: - """Validate and atomically project all configured key sequences.""" - - projected_menu = tuple( - ( - action, - self._validated_sequence( - action.text().replace("&", ""), - key_from_config(config), - ), - ) - for key_from_config, action in self._menu_actions - ) - projected_time_travel = tuple( - ( - command, - self._validated_sequence(label, key_from_config(config)), - ) - for key_from_config, label, command in self._time_travel_commands - ) - normalized = tuple( - sequence.toString(QKeySequence.SequenceFormat.PortableText) - for _consumer, sequence in (*projected_menu, *projected_time_travel) - ) - if len(set(normalized)) != len(normalized): - raise ValueError("Application keyboard shortcuts must be unique.") - - for action, sequence in projected_menu: - action.setShortcut(sequence) - self._event_filter.replace_actions( - { - sequence.toString(QKeySequence.SequenceFormat.PortableText): command - for command, sequence in projected_time_travel - } - ) - - def close(self) -> None: - self._application.removeEventFilter(self._event_filter) - self._menu_actions.clear() - self._time_travel_commands.clear() - - @staticmethod - def _validated_sequence(field_name: str, value: str) -> QKeySequence: - if not isinstance(value, str) or not value.strip(): - raise ValueError(f"Shortcut {field_name} must be a non-empty key sequence.") - sequence = QKeySequence(value) - if sequence.isEmpty(): - raise ValueError(f"Shortcut {field_name} is not a valid Qt key sequence.") - return sequence - - @dataclass(frozen=True, slots=True) class MainWindowSpecDefinition: """Declarative record for one WindowManager-managed window.""" @@ -266,276 +120,10 @@ def build_main_window_specs() -> dict[str, WindowSpec]: return {spec.window_id: spec for spec in specs} -@dataclass(slots=True, weakref_slot=True) -class MainWindowDockFloatController: - """Toggle one dock while preserving its complete pre-float workspace slot.""" - - main_window: QMainWindow - dock_widget: QDockWidget - docked_content_height: int | None = None - preferred_floating_size: QSize | None = None - _docked_state: QByteArray | None = None - _floating_size: QSize | None = field(init=False, repr=False) - _content_minimum_height: int = field(init=False, repr=False) - _content_maximum_height: int = field(init=False, repr=False) - _dock_transition_timer: QTimer = field(init=False, repr=False) - _top_level_sync_timer: QTimer = field(init=False, repr=False) - _pending_top_level_state: bool | None = field(default=None, init=False, repr=False) - _redock_pending: bool = field(default=False, init=False, repr=False) - - def __post_init__(self) -> None: - content = self.dock_widget.widget() - if content is None: - raise RuntimeError("Dock float controller requires a content widget") - if self.preferred_floating_size is not None and ( - self.preferred_floating_size.width() <= 0 - or self.preferred_floating_size.height() <= 0 - ): - raise ValueError("Preferred floating size must be positive") - self._floating_size = ( - QSize(self.preferred_floating_size) - if self.preferred_floating_size is not None - else None - ) - self._content_minimum_height = content.minimumHeight() - self._content_maximum_height = content.maximumHeight() - self._dock_transition_timer = QTimer(self.main_window) - self._dock_transition_timer.setSingleShot(True) - self._dock_transition_timer.setTimerType(Qt.TimerType.PreciseTimer) - self._dock_transition_timer.setInterval(1) - self._dock_transition_timer.timeout.connect(self._restore_docked_workspace) - self._top_level_sync_timer = QTimer(self.main_window) - self._top_level_sync_timer.setSingleShot(True) - self._top_level_sync_timer.setInterval(0) - self._top_level_sync_timer.timeout.connect(self._apply_pending_top_level_state) - self.sync_top_level(self.dock_widget.isFloating()) - - def sync_top_level(self, is_floating: bool) -> None: - """Apply embedded-only sizing while leaving floating panes resizable.""" - - if self.docked_content_height is None: - return - content = self.dock_widget.widget() - if content is None: - return - if is_floating: - content.setMinimumHeight(self._content_minimum_height) - content.setMaximumHeight(self._content_maximum_height) - return - if self._redock_pending: - return - content.setFixedHeight(self.docked_content_height) +class MainWindowEmbeddedWidgets(DockPanes): + """The OpenHCS main window's panes, addressed by their window ids.""" - def set_docked_content_height(self, height: int) -> None: - """Update the content owner's embedded height projection.""" - - if height <= 0: - raise ValueError("Docked content height must be positive") - if height == self.docked_content_height: - return - self.docked_content_height = height - self.sync_top_level(self.dock_widget.isFloating()) - - def schedule_top_level_sync(self, is_floating: bool) -> None: - """Apply dock constraints after Qt completes its native transition.""" - - self._pending_top_level_state = is_floating - self._top_level_sync_timer.start() - - def _apply_pending_top_level_state(self) -> None: - is_floating = self._pending_top_level_state - if is_floating is None: - return - self._pending_top_level_state = None - self.sync_top_level(is_floating) - - def toggle(self) -> None: - if not self.dock_widget.isFloating(): - self._docked_state = self.main_window.saveState() - self.dock_widget.setFloating(True) - self.dock_widget.show() - if self._floating_size is not None: - self.dock_widget.resize(self._floating_size) - QTimer.singleShot(0, self._reveal_after_layout) - return - - self._floating_size = self.dock_widget.size() - self._redock_pending = True - self.dock_widget.setFloating(False) - if self._docked_state is None: - self._redock_pending = False - self.sync_top_level(False) - self._reveal_after_layout() - return - - # A resized floating QDockWidget completes its native dock transition - # on the next event-loop turn. Restore the one authoritative workspace - # snapshot after that transition so the first click recovers the prior - # slot geometry instead of retaining the floating window dimensions. - # Qt emits topLevelChanged(False) before its dock layout has consumed - # a resized floating window. The first timed event-loop turn is the - # earliest boundary at which restoreState can recover the saved slot. - self._dock_transition_timer.start() - - def _restore_docked_workspace(self) -> None: - # Reapply embedded-only constraints before asking Qt to consume the - # saved layout. A resized floating pane otherwise contributes its - # top-level width to restoreState on slower event loops, leaving a - # fixed-height dock partially redocked at the floating geometry. - self._redock_pending = False - self.sync_top_level(False) - if self._docked_state is not None: - if not self.main_window.restoreState(self._docked_state): - self.dock_widget.setFloating(False) - QTimer.singleShot(0, self._reveal_after_layout) - - def _reveal_after_layout(self) -> None: - self.dock_widget.show() - self.dock_widget.raise_() - - -@dataclass(frozen=True, slots=True, weakref_slot=True) -class MainWindowDockPane: - """One logical embedded pane and its native Qt geometry owner.""" - - window_id: str - title: str - widget: QWidget - dock_widget: QDockWidget - float_controller: MainWindowDockFloatController | None = None - float_button: QToolButton | None = None - - @classmethod - def create( - cls, - *, - main_window: QMainWindow, - window_id: str, - title: str, - widget: QWidget, - manager_header: ManagerHeaderParts | None = None, - docked_content_height: int | None = None, - preferred_floating_size: QSize | None = None, - ) -> "MainWindowDockPane": - dock_widget = QDockWidget(title, main_window) - dock_widget.setObjectName(window_id) - dock_widget.setAllowedAreas(Qt.DockWidgetArea.AllDockWidgetAreas) - dock_widget.setFeatures( - QDockWidget.DockWidgetFeature.DockWidgetMovable - | QDockWidget.DockWidgetFeature.DockWidgetFloatable - ) - dock_widget.toggleViewAction().setVisible(False) - dock_widget.setWidget(widget) - float_button = None - float_controller = None - if manager_header is not None: - content_layout = widget.layout() - if content_layout is None: - raise RuntimeError( - f"Dock pane {window_id!r} has no content layout for its manager header" - ) - content_layout.removeWidget(manager_header.header) - manager_header.present_as_dock_title() - float_button = cls._title_button( - dock_widget=dock_widget, - object_name=f"{window_id}_dock_float_button", - tooltip="Float pane", - ) - float_controller = MainWindowDockFloatController( - main_window=main_window, - dock_widget=dock_widget, - docked_content_height=docked_content_height, - preferred_floating_size=preferred_floating_size, - ) - - def sync_float_button(is_floating: bool) -> None: - standard_icon = ( - QStyle.StandardPixmap.SP_TitleBarNormalButton - if is_floating - else QStyle.StandardPixmap.SP_TitleBarMaxButton - ) - float_button.setIcon(dock_widget.style().standardIcon(standard_icon)) - float_button.setToolTip("Dock pane" if is_floating else "Float pane") - - float_button.clicked.connect(float_controller.toggle) - dock_widget.topLevelChanged.connect(sync_float_button) - dock_widget.topLevelChanged.connect( - float_controller.schedule_top_level_sync - ) - sync_float_button(dock_widget.isFloating()) - manager_header.title_layout.add_right_widget(float_button) - dock_widget.setTitleBarWidget(manager_header.header) - return cls( - window_id=window_id, - title=title, - widget=widget, - dock_widget=dock_widget, - float_controller=float_controller, - float_button=float_button, - ) - - @staticmethod - def _title_button( - *, - dock_widget: QDockWidget, - object_name: str, - tooltip: str, - ) -> QToolButton: - button = QToolButton(dock_widget) - button.setObjectName(object_name) - button.setAutoRaise(True) - button.setIconSize(QSize(12, 12)) - button.setFixedSize(18, 18) - button.setToolTip(tooltip) - button.setAccessibleName(tooltip) - button.setFocusPolicy(Qt.FocusPolicy.NoFocus) - return button - - def set_docked_content_height(self, height: int) -> None: - """Delegate a content-owned embedded height to the dock controller.""" - - if self.float_controller is None: - raise RuntimeError(f"Dock pane {self.window_id!r} has no float controller") - self.float_controller.set_docked_content_height(height) - - def show(self) -> None: - """Reveal and focus this pane without changing its dock geometry.""" - - self.dock_widget.show() - self.dock_widget.raise_() - self.widget.setFocus(Qt.FocusReason.OtherFocusReason) - - -@dataclass(slots=True) -class MainWindowEmbeddedWidgets: - """Authoritative runtime graph for the main-window dock panes.""" - - _panes: dict[str, MainWindowDockPane] = field(default_factory=dict) - - def configure_host(self, main_window: QMainWindow) -> None: - """Configure stable native docking without reentrant drag animation.""" - - main_window.setDockNestingEnabled(True) - main_window.setDockOptions( - QMainWindow.DockOption.AllowNestedDocks - | QMainWindow.DockOption.AllowTabbedDocks - ) - - def register(self, pane: MainWindowDockPane) -> None: - if pane.window_id in self._panes: - raise ValueError(f"Duplicate main-window pane id: {pane.window_id!r}") - self._panes[pane.window_id] = pane - - def panes(self) -> tuple[MainWindowDockPane, ...]: - return tuple(self._panes.values()) - - def require_pane(self, window_id: str) -> MainWindowDockPane: - try: - return self._panes[window_id] - except KeyError as exc: - raise RuntimeError( - f"Main-window pane {window_id!r} has not been initialized" - ) from exc + __slots__ = () def require_plate_manager(self) -> QWidget: return self.require_pane(OpenHCSUiWindowId.plate_manager).widget @@ -549,15 +137,6 @@ def require_zmq_manager(self) -> QWidget: def require_system_monitor(self) -> QWidget: return self.require_pane(OpenHCSUiWindowId.system_monitor).widget - def show_defaults(self) -> None: - self.ensure_all_visible() - - def ensure_all_visible(self) -> None: - """Keep every permanent workspace pane either docked or floating.""" - - for pane in self.panes(): - pane.dock_widget.show() - def show_plate_manager(self) -> None: self.require_pane(OpenHCSUiWindowId.plate_manager).show() @@ -571,47 +150,6 @@ def show_system_monitor(self) -> None: self.require_pane(OpenHCSUiWindowId.system_monitor).show() -@dataclass(slots=True) -class MainWindowDockLayoutStore: - """Persist opaque native-Qt dock geometry outside scientific edit state.""" - - settings: QSettings - - STATE_KEY = "main_window/dock_layout_state" - STATE_VERSION = 2 - - @classmethod - def for_current_application(cls) -> "MainWindowDockLayoutStore": - return cls(settings=QSettings()) - - def restore(self, main_window: QMainWindow) -> bool: - stored_state = self.settings.value(self.STATE_KEY) - if stored_state is None: - return False - - default_state = main_window.saveState(self.STATE_VERSION) - if not isinstance(stored_state, QByteArray): - self._discard_invalid_state() - return False - if main_window.restoreState(stored_state, self.STATE_VERSION): - return True - - main_window.restoreState(default_state, self.STATE_VERSION) - self._discard_invalid_state() - return False - - def save(self, main_window: QMainWindow) -> None: - self.settings.setValue( - self.STATE_KEY, - main_window.saveState(self.STATE_VERSION), - ) - self.settings.sync() - - def _discard_invalid_state(self) -> None: - self.settings.remove(self.STATE_KEY) - self.settings.sync() - - @dataclass(frozen=True, slots=True) class MainWindowPipelineActions: """File-menu actions for the embedded pipeline editor.""" diff --git a/openhcs/pyqt_gui/services/ui_bridge_windows.py b/openhcs/pyqt_gui/services/ui_bridge_windows.py index 5b4c0b55cd..a86e1de91c 100644 --- a/openhcs/pyqt_gui/services/ui_bridge_windows.py +++ b/openhcs/pyqt_gui/services/ui_bridge_windows.py @@ -134,7 +134,7 @@ if TYPE_CHECKING: from openhcs.pyqt_gui.main import OpenHCSMainWindow - from openhcs.pyqt_gui.services.main_window_workflows import MainWindowDockPane + from pyqt_reactive.widgets.shared.dock_layout import DockPane WindowRouteCollection: TypeAlias = ( @@ -541,7 +541,7 @@ def focus(self) -> UiWindowSummary: class EmbeddedWindowRoute: """Focusable embedded widget route owned by the main window layout.""" - pane: "MainWindowDockPane" + pane: "DockPane" @property def identity(self) -> UiWindowIdentity: diff --git a/tests/unit/pyqt_gui/test_main_window_embedded_layout.py b/tests/unit/pyqt_gui/test_main_window_embedded_layout.py deleted file mode 100644 index aa56e59774..0000000000 --- a/tests/unit/pyqt_gui/test_main_window_embedded_layout.py +++ /dev/null @@ -1,470 +0,0 @@ -"""Stable contracts for the native main-window docking workspace.""" - -from __future__ import annotations - -import inspect - -import pytest -from PyQt6.QtCore import QByteArray, QObject, QSize, QSettings, Qt, pyqtSignal -from PyQt6.QtTest import QTest -from PyQt6.QtWidgets import QDockWidget, QMainWindow, QVBoxLayout, QWidget -from pyqt_reactive.theming import ColorScheme -from pyqt_reactive.widgets.shared.manager_ui_scaffold import create_manager_header - -from openhcs.pyqt_gui.services.main_window_workflows import ( - MainWindowDockLayoutStore, - MainWindowDockPane, - MainWindowEmbeddedWidgets, -) -from openhcs.pyqt_gui.services.ui_window_ids import OpenHCSUiWindowId - -PANE_ROWS = ( - (OpenHCSUiWindowId.system_monitor, "System Monitor"), - (OpenHCSUiWindowId.plate_manager, "Plate Manager"), - (OpenHCSUiWindowId.zmq_server_manager, "ZMQ Server Manager"), - (OpenHCSUiWindowId.pipeline_editor, "Pipeline Editor"), -) -TEST_DOCKED_CONTENT_HEIGHT = 90 -TEST_ZMQ_FLOATING_SIZE = QSize(900, 600) - - -class DockedHeightEmitter(QObject): - changed = pyqtSignal(int) - - -def _workspace(qapp) -> tuple[QMainWindow, MainWindowEmbeddedWidgets]: - main_window = QMainWindow() - embedded = MainWindowEmbeddedWidgets() - embedded.configure_host(main_window) - - panes = [] - for window_id, title in PANE_ROWS: - content = QWidget() - content_layout = QVBoxLayout(content) - manager_header = create_manager_header( - title=title, - color_scheme=ColorScheme(), - ) - content_layout.addWidget(manager_header.header) - content_layout.addWidget(QWidget()) - pane = MainWindowDockPane.create( - main_window=main_window, - window_id=window_id, - title=title, - widget=content, - manager_header=manager_header, - docked_content_height=( - TEST_DOCKED_CONTENT_HEIGHT - if window_id == OpenHCSUiWindowId.system_monitor - else None - ), - preferred_floating_size=( - TEST_ZMQ_FLOATING_SIZE - if window_id == OpenHCSUiWindowId.zmq_server_manager - else None - ), - ) - embedded.register(pane) - panes.append(pane) - - system_monitor, plate_manager, zmq_manager, pipeline_editor = panes - main_window.addDockWidget( - Qt.DockWidgetArea.TopDockWidgetArea, - system_monitor.dock_widget, - ) - main_window.splitDockWidget( - system_monitor.dock_widget, - plate_manager.dock_widget, - Qt.Orientation.Vertical, - ) - main_window.splitDockWidget( - plate_manager.dock_widget, - pipeline_editor.dock_widget, - Qt.Orientation.Horizontal, - ) - main_window.splitDockWidget( - plate_manager.dock_widget, - zmq_manager.dock_widget, - Qt.Orientation.Vertical, - ) - main_window.resize(1000, 700) - main_window.show() - qapp.processEvents() - return main_window, embedded - - -@pytest.mark.parametrize( - "window_id", - tuple(window_id for window_id, _title in PANE_ROWS), -) -def test_showing_dock_pane_preserves_user_geometry(qapp, window_id: str) -> None: - """Focus/show routes must not replace the user's pane proportions.""" - - main_window, embedded = _workspace(qapp) - pane_geometries_before = { - pane.window_id: pane.dock_widget.geometry() for pane in embedded.panes() - } - pane_areas_before = { - pane.window_id: main_window.dockWidgetArea(pane.dock_widget) - for pane in embedded.panes() - } - - embedded.require_pane(window_id).show() - qapp.processEvents() - - assert { - pane.window_id: pane.dock_widget.geometry() for pane in embedded.panes() - } == pane_geometries_before - assert { - pane.window_id: main_window.dockWidgetArea(pane.dock_widget) - for pane in embedded.panes() - } == pane_areas_before - main_window.close() - - -def test_dock_panes_expose_native_float_move_and_all_drop_areas(qapp) -> None: - main_window, embedded = _workspace(qapp) - required_features = ( - QDockWidget.DockWidgetFeature.DockWidgetMovable - | QDockWidget.DockWidgetFeature.DockWidgetFloatable - ) - - for pane in embedded.panes(): - assert pane.dock_widget.objectName() == pane.window_id - assert pane.dock_widget.features() == required_features - assert pane.dock_widget.allowedAreas() == Qt.DockWidgetArea.AllDockWidgetAreas - assert not pane.dock_widget.toggleViewAction().isVisible() - assert pane.float_button is not None - - pipeline_pane = embedded.require_pane(OpenHCSUiWindowId.pipeline_editor) - pipeline_pane.dock_widget.setFloating(True) - qapp.processEvents() - assert pipeline_pane.dock_widget.isFloating() - - pipeline_pane.show() - qapp.processEvents() - assert pipeline_pane.dock_widget.isFloating() - - main_window.close() - - -def test_workspace_keeps_nested_snap_docking_without_reentrant_animation( - qapp, -) -> None: - main_window, _embedded = _workspace(qapp) - - assert main_window.isDockNestingEnabled() - assert main_window.dockOptions() == ( - QMainWindow.DockOption.AllowNestedDocks - | QMainWindow.DockOption.AllowTabbedDocks - ) - main_window.close() - - -def test_float_button_reflows_then_restores_exact_workspace_geometry( - qapp, qtbot -) -> None: - main_window, embedded = _workspace(qapp) - pipeline = embedded.require_pane(OpenHCSUiWindowId.pipeline_editor) - plate = embedded.require_pane(OpenHCSUiWindowId.plate_manager) - docked_geometries = { - pane.window_id: pane.dock_widget.geometry() for pane in embedded.panes() - } - docked_plate_width = plate.dock_widget.width() - - pipeline.float_button.click() - qapp.processEvents() - qapp.processEvents() - - assert pipeline.dock_widget.isFloating() - assert plate.dock_widget.width() > docked_plate_width - - pipeline.dock_widget.resize(720, 520) - qapp.processEvents() - assert pipeline.dock_widget.size().width() == 720 - - pipeline.float_button.click() - qtbot.waitUntil( - lambda: { - pane.window_id: pane.dock_widget.geometry() for pane in embedded.panes() - } - == docked_geometries, - timeout=1000, - ) - - assert not pipeline.dock_widget.isFloating() - assert pipeline.dock_widget.isVisible() - assert { - pane.window_id: pane.dock_widget.geometry() for pane in embedded.panes() - } == docked_geometries - main_window.close() - - -def test_zmq_float_button_uses_large_size_and_remembers_user_resize(qapp) -> None: - main_window, embedded = _workspace(qapp) - zmq = embedded.require_pane(OpenHCSUiWindowId.zmq_server_manager) - - zmq.float_button.click() - qapp.processEvents() - qapp.processEvents() - - assert zmq.dock_widget.isFloating() - assert zmq.dock_widget.size() == TEST_ZMQ_FLOATING_SIZE - - user_size = QSize(840, 540) - zmq.dock_widget.resize(user_size) - qapp.processEvents() - zmq.float_button.click() - QTest.qWait(25) - assert not zmq.dock_widget.isFloating() - - zmq.float_button.click() - qapp.processEvents() - qapp.processEvents() - assert zmq.dock_widget.isFloating() - assert zmq.dock_widget.size() == user_size - main_window.close() - - -def test_resized_system_monitor_redocks_without_corrupting_workspace( - qapp, qtbot -) -> None: - main_window, embedded = _workspace(qapp) - monitor = embedded.require_pane(OpenHCSUiWindowId.system_monitor) - docked_geometries = { - pane.window_id: pane.dock_widget.geometry() for pane in embedded.panes() - } - - monitor.float_button.click() - qapp.processEvents() - monitor.dock_widget.resize(900, 500) - qapp.processEvents() - assert monitor.dock_widget.isFloating() - - monitor.float_button.click() - qtbot.waitUntil( - lambda: { - pane.window_id: pane.dock_widget.geometry() for pane in embedded.panes() - } - == docked_geometries, - timeout=1000, - ) - - assert not monitor.dock_widget.isFloating() - assert monitor.widget.minimumHeight() == TEST_DOCKED_CONTENT_HEIGHT - assert monitor.widget.maximumHeight() == TEST_DOCKED_CONTENT_HEIGHT - assert { - pane.window_id: pane.dock_widget.geometry() for pane in embedded.panes() - } == docked_geometries - main_window.close() - - -def test_manager_header_becomes_single_dock_title_row_with_owned_controls(qapp) -> None: - main_window = QMainWindow() - content = QWidget() - content_layout = QVBoxLayout(content) - manager_header = create_manager_header( - title="System Monitor", - color_scheme=ColorScheme(), - ) - content_layout.addWidget(manager_header.header) - content_layout.addWidget(QWidget()) - - pane = MainWindowDockPane.create( - main_window=main_window, - window_id=OpenHCSUiWindowId.system_monitor, - title="System Monitor", - widget=content, - manager_header=manager_header, - ) - main_window.addDockWidget(Qt.DockWidgetArea.TopDockWidgetArea, pane.dock_widget) - main_window.show() - qapp.processEvents() - - assert pane.dock_widget.titleBarWidget() is manager_header.header - assert content_layout.indexOf(manager_header.header) == -1 - assert not manager_header.title_layout._staged_layout._row2_widget.isVisible() - assert manager_header.header.sizeHint().height() <= 32 - assert pane.float_button is not None - assert not ( - pane.dock_widget.features() & QDockWidget.DockWidgetFeature.DockWidgetClosable - ) - assert not pane.dock_widget.toggleViewAction().isVisible() - - pane.float_button.click() - qapp.processEvents() - assert pane.dock_widget.isFloating() - assert pane.float_button.toolTip() == "Dock pane" - - pane.float_button.click() - QTest.qWait(25) - assert not pane.dock_widget.isFloating() - assert pane.dock_widget.isVisible() - assert pane.float_button.toolTip() == "Float pane" - main_window.close() - - -def test_embedded_height_is_fixed_but_floating_pane_remains_resizable(qapp) -> None: - main_window = QMainWindow() - main_window.resize(800, 600) - content = QWidget() - content_layout = QVBoxLayout(content) - manager_header = create_manager_header( - title="System Monitor", - color_scheme=ColorScheme(), - ) - content_layout.addWidget(manager_header.header) - content_layout.addWidget(QWidget()) - original_minimum = content.minimumHeight() - original_maximum = content.maximumHeight() - - pane = MainWindowDockPane.create( - main_window=main_window, - window_id=OpenHCSUiWindowId.system_monitor, - title="System Monitor", - widget=content, - manager_header=manager_header, - docked_content_height=TEST_DOCKED_CONTENT_HEIGHT, - ) - lower = QDockWidget("Lower", main_window) - lower.setObjectName("lower") - lower.setWidget(QWidget()) - main_window.addDockWidget(Qt.DockWidgetArea.TopDockWidgetArea, pane.dock_widget) - main_window.addDockWidget(Qt.DockWidgetArea.BottomDockWidgetArea, lower) - main_window.show() - qapp.processEvents() - - assert content.minimumHeight() == TEST_DOCKED_CONTENT_HEIGHT - assert content.maximumHeight() == TEST_DOCKED_CONTENT_HEIGHT - - height_emitter = DockedHeightEmitter() - height_emitter.changed.connect(pane.set_docked_content_height) - height_emitter.changed.emit(100) - qapp.processEvents() - assert content.minimumHeight() == 100 - assert content.maximumHeight() == 100 - - pane.dock_widget.topLevelChanged.emit(True) - assert content.minimumHeight() == 100 - assert content.maximumHeight() == 100 - qapp.processEvents() - assert content.minimumHeight() == original_minimum - assert content.maximumHeight() == original_maximum - - pane.dock_widget.topLevelChanged.emit(False) - assert content.minimumHeight() == original_minimum - assert content.maximumHeight() == original_maximum - qapp.processEvents() - assert content.minimumHeight() == 100 - assert content.maximumHeight() == 100 - docked_height = pane.dock_widget.height() - main_window.resizeDocks( - [pane.dock_widget, lower], - [400, 100], - Qt.Orientation.Vertical, - ) - qapp.processEvents() - assert pane.dock_widget.height() == docked_height - - pane.float_button.click() - qapp.processEvents() - assert pane.dock_widget.isFloating() - assert content.minimumHeight() == original_minimum - assert content.maximumHeight() == original_maximum - - pane.set_docked_content_height(110) - assert content.minimumHeight() == original_minimum - assert content.maximumHeight() == original_maximum - - pane.float_button.click() - QTest.qWait(25) - assert not pane.dock_widget.isFloating() - assert content.minimumHeight() == 110 - assert content.maximumHeight() == 110 - main_window.close() - - -def test_dock_layout_round_trips_tabs_and_floating_while_panes_remain_visible( - qapp, - tmp_path, -) -> None: - settings = QSettings( - str(tmp_path / "dock-layout.ini"), - QSettings.Format.IniFormat, - ) - store = MainWindowDockLayoutStore(settings) - source_window, source_embedded = _workspace(qapp) - source_plate = source_embedded.require_pane(OpenHCSUiWindowId.plate_manager) - source_pipeline = source_embedded.require_pane(OpenHCSUiWindowId.pipeline_editor) - source_zmq = source_embedded.require_pane(OpenHCSUiWindowId.zmq_server_manager) - source_system = source_embedded.require_pane(OpenHCSUiWindowId.system_monitor) - - source_window.tabifyDockWidget( - source_plate.dock_widget, - source_pipeline.dock_widget, - ) - source_zmq.dock_widget.setFloating(True) - source_system.dock_widget.hide() - qapp.processEvents() - store.save(source_window) - - restored_store = MainWindowDockLayoutStore( - QSettings( - str(tmp_path / "dock-layout.ini"), - QSettings.Format.IniFormat, - ) - ) - restored_window, restored_embedded = _workspace(qapp) - assert restored_store.restore(restored_window) - qapp.processEvents() - - restored_plate = restored_embedded.require_pane(OpenHCSUiWindowId.plate_manager) - restored_pipeline = restored_embedded.require_pane( - OpenHCSUiWindowId.pipeline_editor - ) - restored_zmq = restored_embedded.require_pane(OpenHCSUiWindowId.zmq_server_manager) - restored_system = restored_embedded.require_pane(OpenHCSUiWindowId.system_monitor) - assert restored_pipeline.dock_widget in restored_window.tabifiedDockWidgets( - restored_plate.dock_widget - ) - assert restored_zmq.dock_widget.isFloating() - restored_embedded.ensure_all_visible() - assert restored_system.dock_widget.isVisible() - - source_window.close() - restored_window.close() - - -@pytest.mark.parametrize( - "invalid_state", - ("not Qt state", QByteArray(b"not Qt state")), -) -def test_invalid_dock_layout_is_discarded_without_changing_default( - qapp, - tmp_path, - invalid_state, -) -> None: - settings = QSettings( - str(tmp_path / "invalid-dock-layout.ini"), - QSettings.Format.IniFormat, - ) - settings.setValue(MainWindowDockLayoutStore.STATE_KEY, invalid_state) - store = MainWindowDockLayoutStore(settings) - main_window, embedded = _workspace(qapp) - default_areas = { - pane.window_id: main_window.dockWidgetArea(pane.dock_widget) - for pane in embedded.panes() - } - - assert not store.restore(main_window) - assert not settings.contains(MainWindowDockLayoutStore.STATE_KEY) - assert { - pane.window_id: main_window.dockWidgetArea(pane.dock_widget) - for pane in embedded.panes() - } == default_areas - main_window.close() - - -def test_dock_layout_store_does_not_depend_on_object_state() -> None: - source = inspect.getsource(MainWindowDockLayoutStore) - assert "ObjectState" not in source diff --git a/tests/unit/pyqt_gui/test_plate_manager_widget.py b/tests/unit/pyqt_gui/test_plate_manager_widget.py index d6fd7d8429..3eb20015f8 100644 --- a/tests/unit/pyqt_gui/test_plate_manager_widget.py +++ b/tests/unit/pyqt_gui/test_plate_manager_widget.py @@ -2,6 +2,7 @@ from __future__ import annotations +from pyqt_reactive.widgets.shared.dock_layout import DockPane import ast import asyncio import threading @@ -98,7 +99,6 @@ percentile_normalize, ) from openhcs.pyqt_gui.services.main_window_workflows import ( - MainWindowDockPane, MainWindowEmbeddedWidgets, MainWindowPipelineActions, ) @@ -625,7 +625,7 @@ def test_embedded_manager_navigation_selects_the_exact_live_row(tmp_path) -> Non main_window.embedded_widgets = MainWindowEmbeddedWidgets() main_window.window_specs = {} main_window.embedded_widgets.register( - MainWindowDockPane.create( + DockPane.create( main_window=main_window, window_id=OpenHCSUiWindowId.plate_manager, title="Plate Manager", diff --git a/tests/unit/pyqt_gui/test_ui_agent_bridge.py b/tests/unit/pyqt_gui/test_ui_agent_bridge.py index 885f55da94..d0fa2a8452 100644 --- a/tests/unit/pyqt_gui/test_ui_agent_bridge.py +++ b/tests/unit/pyqt_gui/test_ui_agent_bridge.py @@ -1,5 +1,6 @@ from __future__ import annotations +from pyqt_reactive.widgets.shared.dock_layout import DockPane from contextlib import contextmanager from dataclasses import dataclass from pathlib import Path @@ -163,7 +164,6 @@ EmbeddedCodeDocumentRegistrationABC, ) from openhcs.pyqt_gui.services.main_window_workflows import ( - MainWindowDockPane, MainWindowEmbeddedWidgets, ) from openhcs.pyqt_gui.services.reactor_providers import OpenHCSCodegenProvider @@ -490,7 +490,7 @@ def __init__(self, *, pipeline_editor: QWidget | None = None) -> None: dock_widget.setObjectName(window_id) dock_widget.setWidget(widget) self.register( - MainWindowDockPane( + DockPane( window_id=window_id, title=title, widget=widget, @@ -989,7 +989,7 @@ def test_floating_embedded_pane_is_not_duplicated_as_qt_top_level() -> None: main_window = QMainWindow() main_window.session = BridgeSessions.get() embedded_widgets = MainWindowEmbeddedWidgets() - pipeline_pane = MainWindowDockPane.create( + pipeline_pane = DockPane.create( main_window=main_window, window_id=OpenHCSUiWindowId.pipeline_editor, title="Pipeline Editor", @@ -1050,7 +1050,7 @@ def test_embedded_registration_does_not_hide_main_window_from_bridge( embedded_widgets = MainWindowEmbeddedWidgets() embedded_scope_id = "test_main_window_embedded_scope" embedded_widget = QWidget() - embedded_pane = MainWindowDockPane.create( + embedded_pane = DockPane.create( main_window=main_window, window_id=embedded_scope_id, title="Embedded", diff --git a/tests/unit/pyqt_gui/test_ui_config_lifecycle.py b/tests/unit/pyqt_gui/test_ui_config_lifecycle.py index 846d9319bd..36e182df47 100644 --- a/tests/unit/pyqt_gui/test_ui_config_lifecycle.py +++ b/tests/unit/pyqt_gui/test_ui_config_lifecycle.py @@ -5,8 +5,6 @@ from pathlib import Path import pytest -from PyQt6.QtCore import QEvent, Qt -from PyQt6.QtGui import QAction, QKeyEvent from openhcs.core.config_cache import ConfigCacheSpec from openhcs.pyqt_gui import config as config_module @@ -23,9 +21,6 @@ load_cached_ui_execution_endpoint_sync, save_ui_config_sync, ) -from openhcs.pyqt_gui.services.main_window_workflows import ( - MainWindowShortcutLifecycle, -) from openhcs.pyqt_gui.widgets.plate_manager import PlateManagerWidget from tests.unit.pyqt_gui.session_harness import ( release_widgets, @@ -171,86 +166,6 @@ def test_shortcut_config_rejects_noncanonical_qt_sequences() -> None: ShortcutConfig(show_plate_manager="NoSuchKey") -def test_shortcut_lifecycle_rebinds_live_qactions(qapp) -> None: - action = QAction("Plate Manager") - lifecycle = MainWindowShortcutLifecycle(qapp) - lifecycle.bind_menu_action( - lambda config: config.show_plate_manager, - action, - ) - - try: - lifecycle.apply( - replace( - ShortcutConfig(), - show_plate_manager="Ctrl+1", - ) - ) - assert action.shortcut().toString() == "Ctrl+1" - - lifecycle.apply( - replace( - ShortcutConfig(), - show_plate_manager="Alt+2", - ) - ) - assert action.shortcut().toString() == "Alt+2" - finally: - lifecycle.close() - - -def test_shortcut_lifecycle_rejects_duplicates_before_rebinding(qapp) -> None: - first = QAction("First") - second = QAction("Second") - lifecycle = MainWindowShortcutLifecycle(qapp) - lifecycle.bind_menu_action(lambda config: config.show_plate_manager, first) - lifecycle.bind_menu_action(lambda config: config.show_pipeline_editor, second) - lifecycle.apply(ShortcutConfig()) - original = (first.shortcut().toString(), second.shortcut().toString()) - - try: - with pytest.raises(ValueError, match="must be unique"): - lifecycle.apply( - replace( - ShortcutConfig(), - show_plate_manager="Ctrl+1", - show_pipeline_editor="Ctrl+1", - ) - ) - - assert (first.shortcut().toString(), second.shortcut().toString()) == original - finally: - lifecycle.close() - - -def test_time_travel_shortcut_accepts_any_configured_qt_key(qapp) -> None: - calls: list[str] = [] - lifecycle = MainWindowShortcutLifecycle(qapp) - lifecycle.bind_time_travel_command( - lambda config: config.time_travel_back, - "Back", - lambda: calls.append("back"), - ) - - try: - lifecycle.apply( - replace( - ShortcutConfig(), - time_travel_back="Ctrl+B", - ) - ) - event = QKeyEvent( - QEvent.Type.KeyPress, - Qt.Key.Key_B, - Qt.KeyboardModifier.ControlModifier, - ) - - assert lifecycle._event_filter.eventFilter(None, event) is True - assert calls == ["back"] - finally: - lifecycle.close() - - def test_ui_config_reaches_the_session_progress_interval_and_transport() -> None: qt_app() with caller_session() as session: diff --git a/tests/unit/test_configuration_consumer_coverage.py b/tests/unit/test_configuration_consumer_coverage.py index da343dde88..4575214b63 100644 --- a/tests/unit/test_configuration_consumer_coverage.py +++ b/tests/unit/test_configuration_consumer_coverage.py @@ -13,9 +13,7 @@ from openhcs.core.config import AxisRoleModeDisplayConfig, GlobalPipelineConfig from openhcs.pyqt_gui.config import ShortcutConfig, get_default_ui_config from openhcs.pyqt_gui.main import OpenHCSMainWindow -from openhcs.pyqt_gui.services.main_window_workflows import ( - MainWindowShortcutLifecycle, -) +from pyqt_reactive.services.application_shortcuts import ShortcutLifecycle @dataclass(frozen=True, slots=True) @@ -870,21 +868,13 @@ def _field_owners_for_receiver( def _shortcut_projection_fields() -> frozenset[_ConsumedField]: """Resolve shortcut lambdas through their exact typed lifecycle owner.""" - binding_methods: set[str] = set() - for method_name, method in inspect.getmembers( - MainWindowShortcutLifecycle, - predicate=inspect.isfunction, - ): - try: - hints = get_type_hints( - method, - localns={"ShortcutConfig": ShortcutConfig}, - ) - except (NameError, TypeError): - continue - callable_signature = get_args(hints.get("key_from_config")) - if callable_signature == ([ShortcutConfig], str): - binding_methods.add(method_name) + binding_methods = { + method_name + for method_name, method in inspect.getmembers( + ShortcutLifecycle, predicate=inspect.isfunction + ) + if "key_from_config" in inspect.signature(method).parameters + } assert binding_methods main_window_tree = ast.parse(inspect.getsource(OpenHCSMainWindow)) @@ -898,8 +888,9 @@ def _shortcut_projection_fields() -> frozenset[_ConsumedField]: for target in node.targets ) and isinstance(node.value, ast.Call) - and isinstance(node.value.func, ast.Name) - and node.value.func.id == MainWindowShortcutLifecycle.__name__ + and isinstance(node.value.func, ast.Subscript) + and ast.unparse(node.value.func) + == f"{ShortcutLifecycle.__name__}[{ShortcutConfig.__name__}]" for node in ast.walk(main_window_tree) ) assert owns_lifecycle From e79c2302eaf588fe881ac7c72589b5c5c4b8f719 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Sun, 11 Oct 2026 03:46:00 -0400 Subject: [PATCH 06/17] U3: time travel controls and navigation come from pyqt-reactive The status-bar time-travel widget, the snapshot browser and the restore commands are pyqt-reactive's; OpenHCS supplies its mutation guard and hides the global and dataset-list scopes from history. The post-restore navigation (main.py's four methods and services/time_travel_navigation.py) is TimeTravelNavigator; OpenHCS supplies which scopes are a step's function children (step_function_scope, through StepEditorScope rather than a second 'functionstep_' parser) and which dual-editor tab to show. Co-Authored-By: Claude Opus 5.5 --- external/ObjectState | 2 +- external/pyqt-reactive | 2 +- openhcs/pyqt_gui/main.py | 221 +---------- .../services/main_window_workflows.py | 57 --- .../services/time_travel_navigation.py | 182 --------- .../widgets/shared/time_travel_widget.py | 373 ------------------ .../pyqt_gui/windows/dual_editor_window.py | 33 ++ .../windows/snapshot_browser_window.py | 268 ------------- tests/unit/pyqt_gui/test_main_status_bar.py | 5 +- .../test_main_window_time_travel_workflow.py | 20 - .../pyqt_gui/test_snapshot_browser_window.py | 39 -- tests/unit/test_time_travel_navigation.py | 146 ++----- 12 files changed, 86 insertions(+), 1262 deletions(-) delete mode 100644 openhcs/pyqt_gui/services/time_travel_navigation.py delete mode 100644 openhcs/pyqt_gui/widgets/shared/time_travel_widget.py delete mode 100644 openhcs/pyqt_gui/windows/snapshot_browser_window.py delete mode 100644 tests/unit/pyqt_gui/test_main_window_time_travel_workflow.py delete mode 100644 tests/unit/pyqt_gui/test_snapshot_browser_window.py diff --git a/external/ObjectState b/external/ObjectState index a4cfcf883d..fcf8546cdd 160000 --- a/external/ObjectState +++ b/external/ObjectState @@ -1 +1 @@ -Subproject commit a4cfcf883d0e8474bfa3a1d1d9a634b748346c9d +Subproject commit fcf8546cddb73373df4721a5f78cedf0bc354099 diff --git a/external/pyqt-reactive b/external/pyqt-reactive index f031bfd081..6ead575343 160000 --- a/external/pyqt-reactive +++ b/external/pyqt-reactive @@ -1 +1 @@ -Subproject commit f031bfd08163e1e6371199c4ffda9726257ee3a1 +Subproject commit 6ead5753438adc2a10a453725c7765efce5e06fb diff --git a/openhcs/pyqt_gui/main.py b/openhcs/pyqt_gui/main.py index ca51ec1cf2..c8d2869a78 100644 --- a/openhcs/pyqt_gui/main.py +++ b/openhcs/pyqt_gui/main.py @@ -30,6 +30,7 @@ ) from pyqt_reactive.animation import WindowFlashOverlay from pyqt_reactive.services.application_shortcuts import ShortcutLifecycle +from pyqt_reactive.services.time_travel_navigation import TimeTravelNavigator from pyqt_reactive.services.window_manager import WindowManager from pyqt_reactive.services.zmq_server_scan_service import ( EndpointObservationSnapshot, @@ -97,26 +98,18 @@ MainWindowEmbeddedWidgets, MainWindowLifecycleWorkflow, MainWindowPipelineActions, - MainWindowTimeTravelWorkflow, MainWindowUiBridgeLifecycle, build_main_window_specs, ) from openhcs.pyqt_gui.services.service_adapter import PyQtServiceAdapter -from openhcs.pyqt_gui.services.time_travel_navigation import ( - TimeTravelNavigationTarget, - TimeTravelSourceScope, - TimeTravelWindowRequest, - make_field_path_target, - make_function_token_target, - parse_function_scope_ref, - resolve_fallback_field_path, - should_include_time_travel_scope, - should_replace_navigation_target, -) from openhcs.pyqt_gui.services.ui_bridge_contracts import ( UiOwnedStateSurfaceDeclaration, ) from openhcs.pyqt_gui.services.ui_window_ids import OpenHCSUiWindowId +from openhcs.pyqt_gui.windows.dual_editor_window import ( + select_step_editor_tab_after_time_travel, + step_function_scope, +) from openhcs.desktop.zmq_version_restart import ( ZMQVersionRestartDialogPresenter, ZMQVersionRestartWorkflow, @@ -863,7 +856,9 @@ def setup_status_bar(self): # Add time-travel controls to the ordinary left lane. Transient # QStatusBar messages hide ordinary widgets, so application status is # rendered by a permanent right-lane label below instead. - from openhcs.pyqt_gui.widgets.shared.time_travel_widget import TimeTravelWidget + from pyqt_reactive.widgets.time_travel import TimeTravelCommands, TimeTravelWidget + + from openhcs.authoring.session.datasets import DATASET_LIST_SCOPE_ID color_scheme = self.service_adapter.get_current_color_scheme() self.bottom_control_panel = QWidget(self) @@ -871,13 +866,14 @@ def setup_status_bar(self): bottom_control_layout.setContentsMargins(0, 0, 0, 0) bottom_control_layout.setSpacing(1) - time_travel_workflow = MainWindowTimeTravelWorkflow( - refresh_time_travel_widget=lambda: self.time_travel_widget.refresh(), + time_travel_commands = TimeTravelCommands( + refresh=lambda: self.time_travel_widget.refresh(), before_restore=self.session.require_definition_mutation_allowed, + hidden_scopes=frozenset({"", DATASET_LIST_SCOPE_ID}), ) self.time_travel_widget = TimeTravelWidget( color_scheme=color_scheme, - time_travel_workflow=time_travel_workflow, + commands=time_travel_commands, ) bottom_control_layout.addWidget(self.time_travel_widget) @@ -947,9 +943,13 @@ def setup_connections(self): def _connect_object_state_lifecycle(self) -> None: """Own the registry subscriptions rendered by this main window.""" + self.time_travel_navigator = TimeTravelNavigator( + function_scope=step_function_scope, + window_shown=select_step_editor_tab_after_time_travel, + ) self._time_travel_complete_subscription = ( ObjectStateRegistry.add_time_travel_complete_callback( - self._on_time_travel_complete + self.time_travel_navigator.on_time_travel_complete ) ) self.destroyed.connect(self._time_travel_complete_subscription.release) @@ -1021,17 +1021,17 @@ def _setup_global_shortcuts(self): Uses event filter to intercept Ctrl+Z/Y BEFORE input widgets get them, so time-travel always takes priority over widget-level undo/redo. """ - time_travel_workflow = self.time_travel_widget.time_travel_workflow + time_travel_commands = self.time_travel_widget.commands # Time travel functions def time_travel_back(): - time_travel_workflow.back() + time_travel_commands.back() def time_travel_forward(): - time_travel_workflow.forward() + time_travel_commands.forward() def time_travel_to_head(): - time_travel_workflow.to_head() + time_travel_commands.to_head() self.shortcut_lifecycle.bind_command( lambda config: config.time_travel_back, @@ -1104,185 +1104,6 @@ def _on_object_state_unregistered(self, scope_id: str, state: "ObjectState"): f"⏱️ TIME_TRAVEL: Auto-closed window for unregistered state: {scope_id}" ) - def _on_time_travel_complete(self, dirty_states, triggering_scope: str | None): - """Handle time-travel completion by reopening windows for dirty ObjectStates. - - When time-travel restores state with unsaved changes, this callback - reopens the appropriate editor windows so the user can see/save the changes. - - Opens windows for the triggering mutation scope when available. Older - dirty states may still exist in the full registry snapshot, but undoing - one edit should not reopen unrelated editors. - - Args: - dirty_states: List of (scope_id, ObjectState) tuples with unsaved changes - triggering_scope: Scope that triggered the snapshot (for logging only) - """ - logger.debug( - f"⏱️ TIME_TRAVEL_CALLBACK: triggering_scope={triggering_scope!r} dirty_count={len(dirty_states)}" - ) - - from objectstate.time_travel_profile import TimeTravelProfiler - - with TimeTravelProfiler.phase( - "openhcs.main.build_time_travel_window_requests", - dirty_states=len(dirty_states), - ): - pending = self._build_time_travel_window_requests( - dirty_states, - triggering_scope, - ) - if not pending: - return - - self._defer_time_travel_navigation( - lambda: self._execute_time_travel_window_requests( - pending, - triggering_scope, - ) - ) - - def _defer_time_travel_navigation(self, callback) -> None: - """Run time-travel navigation after queued restore refreshes settle.""" - from PyQt6.QtCore import QTimer - - QTimer.singleShot(0, lambda: QTimer.singleShot(0, callback)) - - def _execute_time_travel_window_requests( - self, - pending: dict[str, TimeTravelWindowRequest], - triggering_scope: str | None, - ) -> None: - """Open/focus the windows targeted by a completed time-travel restore.""" - from objectstate.time_travel_profile import TimeTravelProfiler - from pyqt_reactive.services.scope_window_navigation import ( - ScopeWindowNavigationService, - ) - from pyqt_reactive.services.window_navigation import WindowNavigationRequest - - with TimeTravelProfiler.phase( - "openhcs.main.execute_time_travel_window_requests", - requests=len(pending), - ): - for request in pending.values(): - field_path = ( - request.target.to_field_path() - if request.target is not None - else None - ) - with TimeTravelProfiler.phase( - "openhcs.main.navigate_window", - scope=request.scope_id, - field_path=field_path, - ): - result = ScopeWindowNavigationService.navigate( - WindowNavigationRequest( - scope_id=request.scope_id, - object_state=request.object_state, - field_path=field_path, - create_if_missing=triggering_scope is not None, - ) - ) - if result.created: - logger.info( - f"⏱️ TIME_TRAVEL: Opened window for changed state: {request.scope_id}" - ) - if result.window is not None: - with TimeTravelProfiler.phase( - "openhcs.main.select_time_travel_tab", - scope=request.scope_id, - ): - self._select_tab_for_time_travel( - request.scope_id, request.target - ) - - def _build_time_travel_window_requests( - self, - dirty_states, - triggering_scope: str | None, - ) -> dict[str, TimeTravelWindowRequest]: - """Project changed ObjectStates into canonical window reopen requests.""" - from objectstate import ObjectStateRegistry - - pending: dict[str, TimeTravelWindowRequest] = {} - - for entry in dirty_states: - if not isinstance(entry, (tuple, list)) or len(entry) != 2: - continue - scope_id, state = entry - if not isinstance(scope_id, str) or not isinstance(state, ObjectState): - continue - if not should_include_time_travel_scope( - TimeTravelSourceScope( - changed_scope_id=scope_id, - triggering_scope=triggering_scope, - ) - ): - continue - - use_scope_id = scope_id - use_state = state - target: TimeTravelNavigationTarget | None = None - - function_scope = parse_function_scope_ref(scope_id) - if function_scope is not None: - use_scope_id = function_scope.step_scope_id - parent_state = ObjectStateRegistry.get_by_scope(use_scope_id) - if isinstance(parent_state, ObjectState): - use_state = parent_state - target = make_function_token_target(function_scope.function_token) - - if target is None: - field_path = resolve_fallback_field_path( - use_state.last_changed_field, - use_state.dirty_fields, - ) - logger.debug( - f"⏱️ TIME_TRAVEL_NAV: scope={use_scope_id} last_changed_field={field_path}" - ) - if field_path: - target = make_field_path_target(field_path) - - request = TimeTravelWindowRequest( - scope_id=use_scope_id, - object_state=use_state, - target=target, - ) - existing = pending.get(use_scope_id) - if existing is None: - pending[use_scope_id] = request - elif should_replace_navigation_target(existing.target, target): - pending[use_scope_id] = request - - return pending - - def _select_tab_for_time_travel( - self, scope_id: str, target: TimeTravelNavigationTarget | None - ) -> None: - """Select appropriate tab in step editor after time-travel. - - If 'func' parameter was modified, switch to Function Pattern tab. - Otherwise, stay on Step Settings tab. - """ - from pyqt_reactive.services.window_manager import WindowManager - - from openhcs.pyqt_gui.windows.dual_editor_window import DualEditorWindow - - window = WindowManager.get_window(scope_id) - if not isinstance(window, DualEditorWindow): - return - - if window.tab_widget is None: - return - - is_function_scope = parse_function_scope_ref(scope_id) is not None - if is_function_scope or (target is not None and target.is_function_target): - window.tab_widget.setCurrentIndex(1) - logger.debug("[TAB_SELECT] Time-travel: Function Pattern tab") - else: - window.tab_widget.setCurrentIndex(0) - logger.debug("[TAB_SELECT] Time-travel: Step Settings tab") - def show_synthetic_plate_generator(self): """Show synthetic plate generator window.""" from openhcs.pyqt_gui.windows.synthetic_plate_generator_window import ( diff --git a/openhcs/pyqt_gui/services/main_window_workflows.py b/openhcs/pyqt_gui/services/main_window_workflows.py index 6bc63e2b3a..2f2e3afcd0 100644 --- a/openhcs/pyqt_gui/services/main_window_workflows.py +++ b/openhcs/pyqt_gui/services/main_window_workflows.py @@ -386,60 +386,3 @@ def close(self) -> None: return self.server.stop() self.server = None - - -@dataclass(frozen=True, slots=True) -class MainWindowTimeTravelWorkflow: - """Time-travel shortcut actions and widget refresh.""" - - refresh_time_travel_widget: Callable[[], None] - before_restore: Callable[[], None] - - def back(self) -> bool: - from objectstate.object_state import ObjectStateRegistry - - return self._run(ObjectStateRegistry.time_travel_back) - - def forward(self) -> bool: - from objectstate.object_state import ObjectStateRegistry - - return self._run(ObjectStateRegistry.time_travel_forward) - - def to_head(self) -> bool: - from objectstate.object_state import ObjectStateRegistry - - return self._run(ObjectStateRegistry.time_travel_to_head) - - def to_index(self, index: int) -> bool: - from objectstate.object_state import ObjectStateRegistry - - return self._run(lambda: ObjectStateRegistry.time_travel_to(index)) - - def switch_branch(self, branch: str) -> bool: - from objectstate.object_state import ObjectStateRegistry - - return self._run(lambda: ObjectStateRegistry.switch_branch(branch)) - - def delete_branch(self, branch: str) -> bool: - from objectstate.object_state import ObjectStateRegistry - - def delete() -> bool: - if ObjectStateRegistry.get_current_branch() == branch: - ObjectStateRegistry.switch_branch("main") - return ObjectStateRegistry.delete_branch(branch) - - return self._run(delete) - - def _run(self, operation: Callable[[], object]) -> bool: - """Authorize and execute one ObjectState restore operation.""" - - try: - self.before_restore() - result = operation() - except Exception as exc: - logger.warning("Time-travel mutation rejected: %s", exc) - return False - self.refresh_time_travel_widget() - if isinstance(result, bool): - return result - return True diff --git a/openhcs/pyqt_gui/services/time_travel_navigation.py b/openhcs/pyqt_gui/services/time_travel_navigation.py deleted file mode 100644 index 067bf0f85f..0000000000 --- a/openhcs/pyqt_gui/services/time_travel_navigation.py +++ /dev/null @@ -1,182 +0,0 @@ -"""Typed helpers for OpenHCS time-travel navigation decisions.""" - -from __future__ import annotations - -from dataclasses import dataclass -from typing import Iterable, Literal, Optional - -from pyqt_reactive.services.function_navigation import ( - FUNCTION_FIELD_ROOT, - build_function_token_field_path, - is_function_field_path, -) - -from objectstate.object_state import ObjectState - -FUNCTION_STEP_SCOPE_PREFIX = "functionstep_" -FIELD_PATH_TARGET_KIND: Literal["field_path"] = "field_path" -FUNCTION_TOKEN_TARGET_KIND: Literal["function_token"] = "function_token" - - -@dataclass(frozen=True) -class FunctionScopeRef: - """Typed parse result for function ObjectState scopes.""" - - step_scope_id: str - function_token: str - - -@dataclass(frozen=True) -class TimeTravelNavigationTarget: - """Typed time-travel navigation target.""" - - kind: Literal["field_path", "function_token"] - value: str - - @property - def is_function_target(self) -> bool: - return self.kind == FUNCTION_TOKEN_TARGET_KIND or is_function_field_path( - self.value - ) - - def to_field_path(self) -> str: - if self.kind == FUNCTION_TOKEN_TARGET_KIND: - return build_function_token_field_path(self.value) - return self.value - - -@dataclass(frozen=True) -class TimeTravelWindowRequest: - """Window request derived from one or more time-travel state changes.""" - - scope_id: str - object_state: ObjectState - target: TimeTravelNavigationTarget | None - - def replace_target( - self, - target: TimeTravelNavigationTarget | None, - ) -> "TimeTravelWindowRequest": - return TimeTravelWindowRequest( - scope_id=self.scope_id, - object_state=self.object_state, - target=target, - ) - - -@dataclass(frozen=True) -class TimeTravelSourceScope: - """Changed scope plus the semantic scope that triggered time-travel.""" - - changed_scope_id: str - triggering_scope: str | None - - -def parse_function_scope_ref(scope_id: str) -> FunctionScopeRef | None: - """Parse function ObjectState scope into typed reference.""" - parts = scope_id.rsplit("::", 2) - if len(parts) < 3: - return None - parent_scope, step_token, function_token = parts - if not step_token.startswith(FUNCTION_STEP_SCOPE_PREFIX): - return None - if not function_token: - return None - step_scope_id = f"{parent_scope}::{step_token}" - return FunctionScopeRef( - step_scope_id=step_scope_id, - function_token=function_token, - ) - - -def make_function_token_target(function_token: str) -> TimeTravelNavigationTarget: - """Create typed function-token navigation target.""" - return TimeTravelNavigationTarget( - kind=FUNCTION_TOKEN_TARGET_KIND, - value=function_token, - ) - - -def make_field_path_target(field_path: str) -> TimeTravelNavigationTarget: - """Create typed plain field-path navigation target.""" - return TimeTravelNavigationTarget( - kind=FIELD_PATH_TARGET_KIND, - value=field_path, - ) - - -def resolve_fallback_field_path( - last_changed_field: str | None, - dirty_fields: Iterable[str], -) -> Optional[str]: - """Resolve best-effort field path when no explicit function scope mapping exists.""" - if last_changed_field: - return last_changed_field - - candidates = [field for field in dirty_fields if isinstance(field, str)] - if not candidates: - return None - sorted_fields = sorted(candidates, key=DirtyFieldSortKeyAuthority.sort_key) - return sorted_fields[0] - - -def should_replace_navigation_target( - existing_target: TimeTravelNavigationTarget | None, - candidate_target: TimeTravelNavigationTarget | None, -) -> bool: - """Return True when candidate should replace existing target.""" - if existing_target is None: - return candidate_target is not None - if candidate_target is None: - return False - if existing_target.is_function_target and not candidate_target.is_function_target: - return True - return False - - -def should_include_time_travel_scope(scope: TimeTravelSourceScope) -> bool: - """Return whether a changed scope belongs to the active time-travel edit. - - ObjectState snapshots capture the whole registry, so old dirty states can - still be present when the user undoes one edit. UI navigation should follow - the semantic mutation owner when history provides one instead of reopening - every dirty ObjectState in the registry. - """ - if scope.triggering_scope is None: - return True - - triggering_function_scope = parse_function_scope_ref(scope.triggering_scope) - if triggering_function_scope is not None: - if scope.changed_scope_id == scope.triggering_scope: - return True - if scope.changed_scope_id.startswith(f"{scope.triggering_scope}::"): - return True - return scope.changed_scope_id == triggering_function_scope.step_scope_id - - if scope.changed_scope_id == scope.triggering_scope: - return True - - if parse_function_scope_ref( - scope.changed_scope_id - ) is not None and scope.changed_scope_id.startswith(f"{scope.triggering_scope}::"): - return False - - return False - - -class DirtyFieldSortKeyAuthority: - """Classify dirty fields for deterministic time-travel navigation.""" - - FUNCTION_ROOT_RANK = 0 - FIELD_PATH_RANK = 1 - - @classmethod - def sort_key(cls, field: str) -> tuple[int, int, str]: - function_rank = cls.function_field_rank(field) - return (function_rank, -field.count("."), field) - - @classmethod - def function_field_rank(cls, field: str) -> int: - if field == FUNCTION_FIELD_ROOT: - return cls.FUNCTION_ROOT_RANK - return cls.FIELD_PATH_RANK diff --git a/openhcs/pyqt_gui/widgets/shared/time_travel_widget.py b/openhcs/pyqt_gui/widgets/shared/time_travel_widget.py deleted file mode 100644 index a2505a5aa7..0000000000 --- a/openhcs/pyqt_gui/widgets/shared/time_travel_widget.py +++ /dev/null @@ -1,373 +0,0 @@ -""" -Time-Travel Debugging Widget for ObjectStateRegistry. - -Provides a compact slider UI for scrubbing through the ENTIRE system history. -All ObjectStates across all scopes are captured together for coherent time-travel. -""" - -import logging -from typing import TYPE_CHECKING, Any - -from objectstate.object_state import ObjectStateRegistry -from PyQt6.QtCore import QPoint, Qt, pyqtSignal -from PyQt6.QtGui import QFont -from PyQt6.QtWidgets import ( - QComboBox, - QHBoxLayout, - QLabel, - QPushButton, - QSizePolicy, - QSlider, - QToolTip, - QWidget, -) -from pyqt_reactive.theming import ColorScheme -from pyqt_reactive.widgets import ThemedCheckBox - -if TYPE_CHECKING: - from openhcs.pyqt_gui.services.main_window_workflows import ( - MainWindowTimeTravelWorkflow, - ) - -logger = logging.getLogger(__name__) - - -class TimeTravelWidget(QWidget): - """Compact time-travel debugging widget for ObjectStateRegistry. - - Tracks the ENTIRE system state (all ObjectStates) at each point in time. - When you scrub, ALL config windows update simultaneously. - - Features: - - Slider to scrub through global history - - Back/Forward buttons - - Label showing current snapshot info - - Tooltip showing num states captured - """ - - # OpenHCS-specific filter: hide system-only scopes from history - _HIDDEN_SCOPES = frozenset({"", "__plates__"}) - - # Emitted when time-travel position changes - position_changed = pyqtSignal(int) # (index) - - def __init__( - self, - color_scheme: ColorScheme | None = None, - show_browse_button: bool = True, - *, - time_travel_workflow: "MainWindowTimeTravelWorkflow", - parent=None, - ): - super().__init__(parent) - self.color_scheme = color_scheme or ColorScheme() - self._show_browse_button = show_browse_button - self.time_travel_workflow = time_travel_workflow - self._setup_ui() - - # Subscribe to history changes (event-based, no polling) - self._history_subscription = ObjectStateRegistry.add_history_changed_callback( - self._update_ui - ) - self.destroyed.connect(self._history_subscription.release) - - def _create_icon_button(self, text: str, tooltip: str) -> QPushButton: - """Create a compact icon button with consistent styling.""" - btn = QPushButton(text) - btn.setToolTip(tooltip) - btn.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed) - # Let the button size to its content - btn.setMinimumWidth(0) - btn.adjustSize() - return btn - - def _setup_ui(self): - """Build the compact UI.""" - layout = QHBoxLayout(self) - layout.setContentsMargins(4, 0, 4, 0) - layout.setSpacing(4) - - # Time-travel icon/label - self.icon_label = QLabel("⏱️") - self.icon_label.setToolTip("Time-Travel Debugging (All States)") - layout.addWidget(self.icon_label) - - # Back button - self.back_btn = self._create_icon_button("◀", "Step back in history") - self.back_btn.clicked.connect(self._on_back) - layout.addWidget(self.back_btn) - - # Slider - self.slider = QSlider(Qt.Orientation.Horizontal) - self.slider.setMinimum(0) - self.slider.setMaximum(0) - self.slider.setFixedWidth(150) - self.slider.setToolTip("Scrub through system state history") - self.slider.valueChanged.connect(self._on_slider_changed) - self.slider.sliderMoved.connect(self._show_tooltip_at_position) - layout.addWidget(self.slider) - - # Forward button - self.forward_btn = self._create_icon_button("▶", "Step forward in history") - self.forward_btn.clicked.connect(self._on_forward) - layout.addWidget(self.forward_btn) - - # Head button (return to present) - self.head_btn = self._create_icon_button( - "⏭", "Return to present (latest state)" - ) - self.head_btn.clicked.connect(self._on_head) - layout.addWidget(self.head_btn) - - # Branch dropdown (auto-hides when only one branch) - self.branch_combo = QComboBox() - self.branch_combo.setSizeAdjustPolicy( - QComboBox.SizeAdjustPolicy.AdjustToContents - ) - self.branch_combo.setToolTip("Switch branch") - self.branch_combo.currentTextChanged.connect(self._on_branch_changed) - layout.addWidget(self.branch_combo) - - # Skip unsaved checkbox - self.skip_unsaved_cb = ThemedCheckBox("Saves only") - self.skip_unsaved_cb.setToolTip( - "Skip unsaved changes, jump only between save points" - ) - self.skip_unsaved_cb.setChecked(False) - layout.addWidget(self.skip_unsaved_cb) - - # Browse button - opens full snapshot browser window (optional) - if self._show_browse_button: - self.browse_btn = self._create_icon_button("⏳", "Open Snapshot Browser") - self.browse_btn.clicked.connect(self._on_browse) - layout.addWidget(self.browse_btn) - - # Status label: right-most widget, text left-aligned - self.status_label = QLabel("No history") - self.status_label.setMinimumWidth(180) - self.status_label.setAlignment( - Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter - ) - self.status_label.setSizePolicy( - QSizePolicy.Policy.Expanding, - QSizePolicy.Policy.Preferred, - ) - font = QFont() - font.setPointSize(9) - self.status_label.setFont(font) - layout.addWidget(self.status_label) - - # Apply the canonical control styles to both the timeline buttons and - # its branch selector. The application stylesheet deliberately avoids - # form geometry, so local composite controls opt into their owned input - # style explicitly. - self.setStyleSheet( - self.color_scheme.styles.generate_button_style() - + self.color_scheme.styles.generate_combobox_style() - ) - - self._update_ui() - - def _update_ui(self): - """Update slider and labels from registry history. - - History: [oldest, ..., newest] - index 0 = oldest, len-1 = head. - Slider: 0 = oldest, max = head. Direct mapping, no inversion needed. - """ - history = ObjectStateRegistry.get_history_info( - filter_fn=lambda scope_id: scope_id not in self._HIDDEN_SCOPES - ) - - if not history: - self.slider.setMaximum(0) - self.slider.setEnabled(False) - self.back_btn.setEnabled(False) - self.forward_btn.setEnabled(False) - self.head_btn.setEnabled(False) - self.status_label.setText("No history yet") - return - - self.slider.setEnabled(True) - self.slider.setMaximum(len(history) - 1) - - # Find current position within the filtered list. - # NOTE: history entries include a global "index" which is not guaranteed to be - # contiguous after filtering; using it as a list index will crash. - current_pos = next( - (i for i, h in enumerate(history) if h["is_current"]), len(history) - 1 - ) - self.slider.blockSignals(True) - self.slider.setValue(current_pos) - self.slider.blockSignals(False) - - # Update status - current = history[current_pos] - is_traveling = ObjectStateRegistry.is_time_traveling() - - status = f"{current['timestamp']} | {current['label'][:30]}" - if is_traveling: - status += f" ({current_pos + 1}/{len(history)})" - else: - status += " (HEAD)" - - self.status_label.setText(status) - - # Enable/disable buttons (back = toward 0, forward = toward len-1) - self.back_btn.setEnabled(current_pos > 0) - self.forward_btn.setEnabled(current_pos < len(history) - 1) - self.head_btn.setEnabled(is_traveling) - - # Visual indicator when time-traveling - if is_traveling: - self.icon_label.setText("⏪") - self.icon_label.setStyleSheet("color: orange;") - else: - self.icon_label.setText("⏱️") - self.icon_label.setStyleSheet("") - - # Update branch dropdown - branches = ObjectStateRegistry.list_branches() - self.branch_combo.setVisible(len(branches) >= 1) - if branches: - self.branch_combo.blockSignals(True) - self.branch_combo.clear() - for b in branches: - self.branch_combo.addItem(b["name"]) - if b["is_current"]: - self.branch_combo.setCurrentText(b["name"]) - self.branch_combo.blockSignals(False) - - def _on_branch_changed(self, name: str): - """Handle branch dropdown selection change.""" - if ( - name - and name != ObjectStateRegistry.get_current_branch() - and not self.time_travel_workflow.switch_branch(name) - ): - self._update_ui() - - def _on_slider_changed(self, value: int): - """Handle slider value change. Direct mapping: slider value = history index.""" - history = ObjectStateRegistry.get_history_info( - filter_fn=lambda scope_id: scope_id not in self._HIDDEN_SCOPES - ) - if value < 0 or value >= len(history): - return - - if not self.time_travel_workflow.to_index(history[value]["index"]): - self._update_ui() - return - self._update_ui() - self.position_changed.emit(value) - - def _is_save_snapshot(self, label: str) -> bool: - """Check if a snapshot label represents a save operation.""" - return label.startswith(("save", "init")) - - def _find_next_save_index( - self, history: list[dict[str, Any]], current_pos: int, direction: int - ) -> int: - """Find next save snapshot in given direction. - - Args: - history: Filtered history list. - current_pos: Current position in the filtered list (0 = oldest, len-1 = head) - direction: -1 for back (toward older), +1 for forward (toward newer) - - Returns: - Index of next save snapshot, or current if none found - """ - if not history: - return current_pos - - search_pos = current_pos + direction - while 0 <= search_pos < len(history): - if self._is_save_snapshot(history[search_pos]["label"]): - return search_pos - search_pos += direction - - # No save found - stay at current or go to boundary - if direction > 0: - return len(history) - 1 # Go to head - return current_pos - - def _on_back(self): - """Step back in history (toward older = lower index).""" - if self.skip_unsaved_cb.isChecked(): - history = ObjectStateRegistry.get_history_info( - filter_fn=lambda scope_id: scope_id not in self._HIDDEN_SCOPES - ) - if history: - current_pos = next( - (i for i, h in enumerate(history) if h["is_current"]), - len(history) - 1, - ) - target_pos = self._find_next_save_index(history, current_pos, -1) - if target_pos != current_pos: - self.time_travel_workflow.to_index(history[target_pos]["index"]) - else: - self.time_travel_workflow.back() - self._update_ui() - - def _on_forward(self): - """Step forward in history (toward newer = higher index).""" - if self.skip_unsaved_cb.isChecked(): - history = ObjectStateRegistry.get_history_info( - filter_fn=lambda scope_id: scope_id not in self._HIDDEN_SCOPES - ) - if history: - current_pos = next( - (i for i, h in enumerate(history) if h["is_current"]), - len(history) - 1, - ) - target_pos = self._find_next_save_index(history, current_pos, +1) - if target_pos != current_pos: - self.time_travel_workflow.to_index(history[target_pos]["index"]) - else: - self.time_travel_workflow.forward() - self._update_ui() - - def _on_head(self): - """Return to present.""" - self.time_travel_workflow.to_head() - self._update_ui() - - def _on_browse(self): - """Open the full Snapshot Browser window.""" - from openhcs.pyqt_gui.windows.snapshot_browser_window import ( - SnapshotBrowserWindow, - ) - - color_scheme = None - parent = self.window() - if parent and hasattr(parent, "color_scheme"): - color_scheme = parent.color_scheme - - self._snapshot_browser = SnapshotBrowserWindow( - color_scheme=color_scheme, - time_travel_workflow=self.time_travel_workflow, - parent=self, - ) - self._snapshot_browser.show() - - def _show_tooltip_at_position(self, value: int): - """Show tooltip with snapshot details. Slider value = history index.""" - history = ObjectStateRegistry.get_history_info( - filter_fn=lambda scope_id: scope_id not in self._HIDDEN_SCOPES - ) - if value < 0 or value >= len(history): - return - - h = history[value] - tooltip = f"{h['timestamp']}
{h['label']}
States captured: {h['num_states']}" - pos = self.slider.mapToGlobal(QPoint(self.slider.width() // 2, -30)) - QToolTip.showText(pos, tooltip) - - def refresh(self): - """Refresh UI from registry (call after external changes).""" - self._update_ui() - - def closeEvent(self, event): - """Unsubscribe from callbacks on close.""" - self._history_subscription.release() - super().closeEvent(event) diff --git a/openhcs/pyqt_gui/windows/dual_editor_window.py b/openhcs/pyqt_gui/windows/dual_editor_window.py index 2bf022320c..29d8f38e8c 100644 --- a/openhcs/pyqt_gui/windows/dual_editor_window.py +++ b/openhcs/pyqt_gui/windows/dual_editor_window.py @@ -10,6 +10,7 @@ from functools import cache, singledispatchmethod +from objectstate import ScopeKey from objectstate.object_state import ObjectStateRegistry from PyQt6.QtCore import QTimer, pyqtSignal from PyQt6.QtGui import QShowEvent @@ -23,7 +24,9 @@ from pyqt_reactive.forms.layout_constants import CURRENT_LAYOUT from pyqt_reactive.services.function_navigation import is_function_field_path from pyqt_reactive.services.scope_token_service import ScopeTokenService +from pyqt_reactive.services.time_travel_navigation import FunctionScopeRef, NavigationTarget from pyqt_reactive.services.window_code_document import WindowCodeDocumentDriver +from pyqt_reactive.services.window_manager import WindowManager from pyqt_reactive.services.window_navigation import ( FieldWindowNavigationDriver, NavigationWaitReason, @@ -1274,3 +1277,33 @@ def before_managed_close(self) -> None: # No need to override _get_form_managers() - BaseFormDialog automatically # discovers all ParameterFormManager instances recursively in the widget tree + + +def step_function_scope(scope_id: str) -> FunctionScopeRef | None: + """A function child scope of a step editor, owned by the step's scope.""" + + try: + scope = StepEditorScope.parse(scope_id) + except ValueError: + return None + if scope.child_scope_id is None or ScopeKey(scope.child_scope_id).depth != 1: + return None + return FunctionScopeRef( + owner_scope_id=scope.step_scope_id, + function_token=scope.child_scope_id, + ) + + +def select_step_editor_tab_after_time_travel( + scope_id: str, + target: NavigationTarget | None, +) -> None: + """Show the function pattern tab when time travel targets a function.""" + + window = WindowManager.get_window(scope_id) + if not isinstance(window, DualEditorWindow) or window.tab_widget is None: + return + if target is not None and target.is_function_target: + DualEditorTab.FUNCTION_PATTERN.select(window.tab_widget) + else: + DualEditorTab.STEP_SETTINGS.select(window.tab_widget) diff --git a/openhcs/pyqt_gui/windows/snapshot_browser_window.py b/openhcs/pyqt_gui/windows/snapshot_browser_window.py deleted file mode 100644 index 24b231a243..0000000000 --- a/openhcs/pyqt_gui/windows/snapshot_browser_window.py +++ /dev/null @@ -1,268 +0,0 @@ -""" -Snapshot Browser Window - Time-travel visualization using AbstractTableBrowser. - -Displays all history snapshots in a table view with search/filter support. -Double-click to time-travel to any snapshot. -""" - -from dataclasses import dataclass -from typing import Dict, List, Optional - -from PyQt6.QtWidgets import ( - QMainWindow, - QVBoxLayout, - QWidget, - QToolBar, - QFrame, - QComboBox, - QLabel, -) -from PyQt6.QtCore import Qt -from PyQt6.QtGui import QAction - -from objectstate.object_state import ObjectStateRegistry -from pyqt_reactive.widgets.shared.abstract_table_browser import ( - AbstractTableBrowser, - ColumnDef, - TableSelectionMode, -) -from pyqt_reactive.theming import ColorScheme -from openhcs.pyqt_gui.widgets.shared.time_travel_widget import TimeTravelWidget -from openhcs.pyqt_gui.services.main_window_workflows import ( - MainWindowTimeTravelWorkflow, -) - - -@dataclass -class SnapshotInfo: - """Snapshot data for display in table.""" - - index: int - timestamp: str - timestamp_raw: float - label: str - num_states: int - is_current: bool - - -class SnapshotTableBrowser(AbstractTableBrowser[SnapshotInfo]): - """Table browser for time-travel snapshots.""" - - def __init__( - self, - *args, - time_travel_workflow: MainWindowTimeTravelWorkflow, - **kwargs, - ) -> None: - self._time_travel_workflow = time_travel_workflow - super().__init__(*args, **kwargs) - - def get_columns(self) -> List[ColumnDef]: - return [ - ColumnDef("Index", "index", width=60), - ColumnDef("Time", "timestamp", width=100), - ColumnDef("Label", "label", width=250), - ColumnDef("States", "num_states", width=60), - ] - - def extract_row_data(self, item: SnapshotInfo) -> List[str]: - prefix = "→ " if item.is_current else "" - return [ - f"{prefix}{item.index}", - item.timestamp, - item.label, - str(item.num_states), - ] - - def get_searchable_text(self, item: SnapshotInfo) -> str: - return f"{item.label} {item.timestamp}" - - def get_search_placeholder(self) -> str: - return "Search snapshots..." - - def on_item_double_clicked(self, key: str, item: SnapshotInfo): - """Time-travel to the selected snapshot by index.""" - if not self._time_travel_workflow.to_index(item.index): - return - self._refresh_from_registry() - - def _refresh_from_registry(self): - """Refresh table from ObjectStateRegistry history.""" - # OpenHCS-specific filter: hide system-only scopes - HIDDEN_SCOPES = {"", "__plates__"} - history = ObjectStateRegistry.get_history_info( - filter_fn=lambda scope_id: scope_id not in HIDDEN_SCOPES - ) - items: Dict[str, SnapshotInfo] = {} - - for h in history: - key = str(h["index"]) - items[key] = SnapshotInfo( - index=h["index"], - timestamp=h["timestamp"], - timestamp_raw=0.0, # Not exposed by get_history_info - label=h["label"], - num_states=h["num_states"], - is_current=h["is_current"], - ) - - self.set_items(items) - - -class SnapshotBrowserWindow(QMainWindow): - """Main window for browsing time-travel snapshots.""" - - def __init__( - self, - color_scheme: Optional[ColorScheme] = None, - *, - time_travel_workflow: MainWindowTimeTravelWorkflow, - parent=None, - ): - super().__init__(parent) - self.setWindowTitle("Snapshot Browser - Time Travel") - self.resize(700, 500) - - # Add Dialog hint so tiling WMs don't fullscreen this window - # QMainWindow defaults to Window type which tiling WMs tile/fullscreen - self.setWindowFlags(self.windowFlags() | Qt.WindowType.Dialog) - - self.color_scheme = color_scheme or ColorScheme() - self.time_travel_workflow = time_travel_workflow - - # Central widget - central = QWidget() - self.setCentralWidget(central) - layout = QVBoxLayout(central) - layout.setContentsMargins(0, 0, 0, 0) - layout.setSpacing(0) - - # Toolbar - self._setup_toolbar() - - # Snapshot table browser (main content) - self.browser = SnapshotTableBrowser( - color_scheme=self.color_scheme, - selection_mode=TableSelectionMode.SINGLE, - time_travel_workflow=self.time_travel_workflow, - parent=self, - ) - layout.addWidget(self.browser, 1) # stretch factor 1 - - # Separator - separator = QFrame() - separator.setFrameShape(QFrame.Shape.HLine) - separator.setFrameShadow(QFrame.Shadow.Sunken) - layout.addWidget(separator) - - # Timeline widget at bottom (stays in sync with all other timeline widgets) - # No browse button - we're already in the browser window - self.timeline_widget = TimeTravelWidget( - color_scheme=self.color_scheme, - show_browse_button=False, - time_travel_workflow=self.time_travel_workflow, - parent=self, - ) - layout.addWidget(self.timeline_widget) - - # Connect timeline widget position changes to refresh table - self.timeline_widget.position_changed.connect( - self._on_timeline_position_changed - ) - - # Subscribe to history changes to refresh table (event-based, no polling) - ObjectStateRegistry.add_history_changed_callback(self._on_refresh) - - # Initial load - self.browser._refresh_from_registry() - - def _setup_toolbar(self): - """Create toolbar with actions.""" - toolbar = QToolBar("Snapshot Actions") - self.addToolBar(toolbar) - - # Jump to HEAD action - head_action = QAction("⏭ HEAD", self) - head_action.setToolTip("Return to present (latest state)") - head_action.triggered.connect(self._on_jump_to_head) - toolbar.addAction(head_action) - - # Refresh action - refresh_action = QAction("🔄 Refresh", self) - refresh_action.setToolTip("Refresh snapshot list") - refresh_action.triggered.connect(self._on_refresh) - toolbar.addAction(refresh_action) - - # Separator before branch section - toolbar.addSeparator() - - # Branch dropdown - toolbar.addWidget(QLabel("Branch:")) - self.branch_combo = QComboBox() - self.branch_combo.setToolTip("Switch branch") - self.branch_combo.setStyleSheet( - self.color_scheme.styles.generate_combobox_style() - ) - self.branch_combo.currentTextChanged.connect(self._on_branch_changed) - toolbar.addWidget(self.branch_combo) - - # Delete branch action - self.delete_branch_action = QAction("🗑️", self) - self.delete_branch_action.setToolTip( - "Delete current branch (cannot delete 'main')" - ) - self.delete_branch_action.triggered.connect(self._on_delete_branch) - toolbar.addAction(self.delete_branch_action) - - # Initial branch UI state - self._update_branch_ui() - - def _update_branch_ui(self): - """Update branch dropdown and delete button state.""" - branches = ObjectStateRegistry.list_branches() - self.branch_combo.blockSignals(True) - self.branch_combo.clear() - for b in branches: - self.branch_combo.addItem(b["name"]) - if b["is_current"]: - self.branch_combo.setCurrentText(b["name"]) - self.branch_combo.blockSignals(False) - - # Can't delete main - current = ObjectStateRegistry.get_current_branch() - self.delete_branch_action.setEnabled(current != "main") - - def _on_branch_changed(self, name: str): - """Handle branch dropdown selection change.""" - if name and name != ObjectStateRegistry.get_current_branch(): - if not self.time_travel_workflow.switch_branch(name): - self._update_branch_ui() - return - self._update_branch_ui() - - def _on_delete_branch(self): - """Delete current branch and switch to main.""" - current = ObjectStateRegistry.get_current_branch() - if current != "main": - if not self.time_travel_workflow.delete_branch(current): - return - - def _on_jump_to_head(self): - """Return to present state.""" - if not self.time_travel_workflow.to_head(): - return - self.browser._refresh_from_registry() - - def _on_refresh(self): - """Refresh the snapshot list and branch UI.""" - self.browser._refresh_from_registry() - self._update_branch_ui() - - def _on_timeline_position_changed(self, index: int): - """Handle timeline widget position change - refresh table.""" - self.browser._refresh_from_registry() - - def closeEvent(self, event): - """Unsubscribe from callbacks on close.""" - ObjectStateRegistry.remove_history_changed_callback(self._on_refresh) - super().closeEvent(event) diff --git a/tests/unit/pyqt_gui/test_main_status_bar.py b/tests/unit/pyqt_gui/test_main_status_bar.py index ab8b22056d..f65e455c30 100644 --- a/tests/unit/pyqt_gui/test_main_status_bar.py +++ b/tests/unit/pyqt_gui/test_main_status_bar.py @@ -20,9 +20,6 @@ class _MainWindowStatusBarHarness(QMainWindow): status_message = pyqtSignal(str) - def _on_time_travel_complete(self, _dirty_states, _triggering_scope) -> None: - pass - def _on_object_state_unregistered(self, _scope_id, _state) -> None: pass @@ -182,7 +179,7 @@ def build_window() -> _MainWindowLifecycleHarness: assert [ callback.__self__ for callback in ObjectStateRegistry._on_time_travel_complete_callbacks - ] == [second] + ] == [second.time_travel_navigator] assert [ callback.__self__ for callback in ObjectStateRegistry._on_unregister_callbacks ] == [second] diff --git a/tests/unit/pyqt_gui/test_main_window_time_travel_workflow.py b/tests/unit/pyqt_gui/test_main_window_time_travel_workflow.py deleted file mode 100644 index dfbac882a4..0000000000 --- a/tests/unit/pyqt_gui/test_main_window_time_travel_workflow.py +++ /dev/null @@ -1,20 +0,0 @@ -from __future__ import annotations - -from openhcs.pyqt_gui.services.main_window_workflows import ( - MainWindowTimeTravelWorkflow, -) - - -def test_time_travel_workflow_authorizes_before_registry_operation() -> None: - operations: list[str] = [] - refreshes: list[bool] = [] - workflow = MainWindowTimeTravelWorkflow( - refresh_time_travel_widget=lambda: refreshes.append(True), - before_restore=lambda: (_ for _ in ()).throw(RuntimeError("mutation rejected")), - ) - - result = workflow._run(lambda: operations.append("mutated")) - - assert not result - assert operations == [] - assert refreshes == [] diff --git a/tests/unit/pyqt_gui/test_snapshot_browser_window.py b/tests/unit/pyqt_gui/test_snapshot_browser_window.py deleted file mode 100644 index 1bafae854d..0000000000 --- a/tests/unit/pyqt_gui/test_snapshot_browser_window.py +++ /dev/null @@ -1,39 +0,0 @@ -from PyQt6.QtWidgets import QApplication - - -def test_snapshot_browser_window_constructs_table_browser_with_selection_mode( - monkeypatch, - tmp_path, -) -> None: - monkeypatch.setenv("QT_QPA_PLATFORM", "offscreen") - monkeypatch.setenv("XDG_CACHE_HOME", str(tmp_path / "cache")) - monkeypatch.setenv("XDG_DATA_HOME", str(tmp_path / "data")) - monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / "config")) - monkeypatch.setenv("MPLCONFIGDIR", str(tmp_path / "mpl")) - - from openhcs.pyqt_gui.windows.snapshot_browser_window import ( - SnapshotBrowserWindow, - ) - from openhcs.pyqt_gui.services.main_window_workflows import ( - MainWindowTimeTravelWorkflow, - ) - from pyqt_reactive.widgets.shared.abstract_table_browser import ( - TableSelectionMode, - ) - - app = QApplication.instance() or QApplication([]) - window = SnapshotBrowserWindow( - time_travel_workflow=MainWindowTimeTravelWorkflow( - refresh_time_travel_widget=lambda: None, - before_restore=lambda: None, - ) - ) - - try: - assert window.browser._selection_mode is TableSelectionMode.SINGLE - assert window.browser.window() is window - assert "QComboBox::drop-down" not in window.branch_combo.styleSheet() - assert "QComboBox::drop-down" not in window.timeline_widget.styleSheet() - finally: - window.close() - app.processEvents() diff --git a/tests/unit/test_time_travel_navigation.py b/tests/unit/test_time_travel_navigation.py index 540772d58d..6adf3de9b5 100644 --- a/tests/unit/test_time_travel_navigation.py +++ b/tests/unit/test_time_travel_navigation.py @@ -1,13 +1,6 @@ -from openhcs.pyqt_gui.services.time_travel_navigation import ( - TimeTravelSourceScope, - make_field_path_target, - make_function_token_target, - should_replace_navigation_target, - should_include_time_travel_scope, -) +from pyqt_reactive.services.time_travel_navigation import TimeTravelNavigator from objectstate.object_state import ObjectState, ObjectStateRegistry from openhcs.core.steps.function_step import FunctionStep -from openhcs.pyqt_gui.main import OpenHCSMainWindow from openhcs.pyqt_gui.ui_tab_identities import DualEditorTab from pyqt_reactive.services.function_navigation import build_function_token_field_path from pyqt_reactive.services.window_navigation import ( @@ -17,7 +10,12 @@ WindowNavigationDriver, WindowNavigationResult, ) -from openhcs.pyqt_gui.windows.dual_editor_window import DualEditorWindowNavigationDriver +from openhcs.pyqt_gui.windows.dual_editor_window import ( + DualEditorWindowNavigationDriver, + step_function_scope, +) + +NAVIGATOR = TimeTravelNavigator(function_scope=step_function_scope) class RuntimeCallable: @@ -60,64 +58,29 @@ def _reset_registry() -> None: def test_time_travel_navigation_filters_unrelated_dirty_scopes() -> None: triggering_scope = "/tmp/plate::functionstep_5" - assert should_include_time_travel_scope( - TimeTravelSourceScope( - changed_scope_id=triggering_scope, - triggering_scope=triggering_scope, - ) - ) - assert not should_include_time_travel_scope( - TimeTravelSourceScope( - changed_scope_id=f"{triggering_scope}::cellprofilerruntimecallable_0", - triggering_scope=triggering_scope, - ) - ) - assert not should_include_time_travel_scope( - TimeTravelSourceScope( - changed_scope_id="/tmp/plate::functionstep_9", - triggering_scope=triggering_scope, - ) - ) + assert NAVIGATOR.includes(triggering_scope, triggering_scope) + assert not NAVIGATOR.includes(f"{triggering_scope}::cellprofilerruntimecallable_0", triggering_scope) + assert not NAVIGATOR.includes("/tmp/plate::functionstep_9", triggering_scope) def test_time_travel_navigation_keeps_legacy_global_refresh_when_scope_unknown() -> ( None ): - assert should_include_time_travel_scope( - TimeTravelSourceScope( - changed_scope_id="/tmp/plate::functionstep_9", - triggering_scope=None, - ) - ) + assert NAVIGATOR.includes("/tmp/plate::functionstep_9", None) def test_time_travel_navigation_includes_parent_step_for_function_trigger() -> None: step_scope = "/tmp/plate::functionstep_5" function_scope = f"{step_scope}::cellprofilerruntimecallable_0" - assert should_include_time_travel_scope( - TimeTravelSourceScope( - changed_scope_id=function_scope, - triggering_scope=function_scope, - ) - ) - assert should_include_time_travel_scope( - TimeTravelSourceScope( - changed_scope_id=step_scope, - triggering_scope=function_scope, - ) - ) + assert NAVIGATOR.includes(function_scope, function_scope) + assert NAVIGATOR.includes(step_scope, function_scope) def test_time_travel_plate_scope_does_not_open_step_descendants() -> None: plate_scope = "/tmp/plate" - assert not should_include_time_travel_scope( - TimeTravelSourceScope( - changed_scope_id=f"{plate_scope}::functionstep_5", - triggering_scope=plate_scope, - ) - ) + assert not NAVIGATOR.includes(f"{plate_scope}::functionstep_5", plate_scope) def test_time_travel_unknown_trigger_does_not_create_missing_windows( @@ -154,16 +117,12 @@ def fake_navigate(request): ) scheduled: list[object] = [] monkeypatch.setattr( - OpenHCSMainWindow, - "_defer_time_travel_navigation", - lambda _self, callback: scheduled.append(callback), + TimeTravelNavigator, + "defer", + staticmethod(lambda callback: scheduled.append(callback)), ) - OpenHCSMainWindow._on_time_travel_complete( - OpenHCSMainWindow.__new__(OpenHCSMainWindow), - [(step_scope, step_state)], - None, - ) + NAVIGATOR.on_time_travel_complete([(step_scope, step_state)], None) assert requests == [] assert len(scheduled) == 1 @@ -186,9 +145,9 @@ def test_time_travel_navigation_runs_after_later_refresh_callbacks(monkeypatch) scheduled: list[object] = [] monkeypatch.setattr( - OpenHCSMainWindow, - "_defer_time_travel_navigation", - lambda _self, callback: scheduled.append(callback), + TimeTravelNavigator, + "defer", + staticmethod(lambda callback: scheduled.append(callback)), ) class TabWidget: @@ -225,18 +184,11 @@ def fake_navigate(request): staticmethod(fake_navigate), ) - monkeypatch.setattr( - OpenHCSMainWindow, - "_select_tab_for_time_travel", - lambda _self, scope_id, target: window.tab_widget.setCurrentIndex(0), - ) - - main_window = OpenHCSMainWindow.__new__(OpenHCSMainWindow) - OpenHCSMainWindow._on_time_travel_complete( - main_window, - [(step_scope, step_state)], - step_scope, + navigator = TimeTravelNavigator( + function_scope=step_function_scope, + window_shown=lambda scope_id, target: window.tab_widget.setCurrentIndex(0), ) + navigator.on_time_travel_complete([(step_scope, step_state)], step_scope) assert navigate_requests == [] window.tab_widget.setCurrentIndex(1) @@ -248,32 +200,6 @@ def fake_navigate(request): assert window.tab_widget.indices == [1, 0] -def test_time_travel_navigation_defer_runs_after_same_wave_refresh(monkeypatch) -> None: - callbacks: list[object] = [] - - def fake_single_shot(delay, callback) -> None: - assert delay == 0 - callbacks.append(callback) - - from PyQt6.QtCore import QTimer - - monkeypatch.setattr(QTimer, "singleShot", staticmethod(fake_single_shot)) - - order: list[str] = [] - main_window = OpenHCSMainWindow.__new__(OpenHCSMainWindow) - OpenHCSMainWindow._defer_time_travel_navigation( - main_window, - lambda: order.append("navigate"), - ) - QTimer.singleShot(0, lambda: order.append("refresh")) - - while callbacks: - callback = callbacks.pop(0) - callback() - - assert order == ["refresh", "navigate"] - - def test_time_travel_function_scope_reopens_parent_step_with_parent_state() -> None: _reset_registry() step_scope = "/tmp/plate::functionstep_5" @@ -291,8 +217,7 @@ def test_time_travel_function_scope_reopens_parent_step_with_parent_state() -> N ObjectStateRegistry.register(step_state, _skip_snapshot=True) ObjectStateRegistry.register(function_state, _skip_snapshot=True) - requests = OpenHCSMainWindow._build_time_travel_window_requests( - OpenHCSMainWindow.__new__(OpenHCSMainWindow), + requests = NAVIGATOR.window_requests( [(function_scope, function_state)], function_scope, ) @@ -317,8 +242,7 @@ def test_time_travel_step_source_bindings_child_targets_step_settings() -> None: ObjectStateRegistry.register(step_state, _skip_snapshot=True) step_state._last_changed_field = field_path - requests = OpenHCSMainWindow._build_time_travel_window_requests( - OpenHCSMainWindow.__new__(OpenHCSMainWindow), + requests = NAVIGATOR.window_requests( [(step_scope, step_state)], step_scope, ) @@ -441,14 +365,6 @@ def __init__(self) -> None: window.close() -def test_time_travel_step_field_target_replaces_stale_function_target() -> None: - field_target = make_field_path_target("source_bindings.source_filters") - function_target = make_function_token_target("function_0") - - assert should_replace_navigation_target(function_target, field_target) - assert not should_replace_navigation_target(field_target, function_target) - - def test_time_travel_unknown_trigger_prefers_source_bindings_field_over_function_scope() -> ( None ): @@ -470,15 +386,11 @@ def test_time_travel_unknown_trigger_prefers_source_bindings_field_over_function ObjectStateRegistry.register(function_state, _skip_snapshot=True) step_state._last_changed_field = field_path - main_window = OpenHCSMainWindow.__new__(OpenHCSMainWindow) - - function_first = OpenHCSMainWindow._build_time_travel_window_requests( - main_window, + function_first = NAVIGATOR.window_requests( [(function_scope, function_state), (step_scope, step_state)], None, ) - step_first = OpenHCSMainWindow._build_time_travel_window_requests( - main_window, + step_first = NAVIGATOR.window_requests( [(step_scope, step_state), (function_scope, function_state)], None, ) From 0fc25e95e9cd9f5d554f8e1718d7280e70741028 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Sun, 11 Oct 2026 03:49:33 -0400 Subject: [PATCH 07/17] U3: the image browser's plate view is pyqt-reactive's grid selector The plate view is a GridSelectorWidget addressed by the family's GridAddressed axis itself (it already declares the default grid, cell positions and row labels). Deleted with plate_view_widget.py: the plate-output subdirectory selector, whose selection nothing read, and the partition-filter sync, whose filter widget was never assigned. Co-Authored-By: Claude Opus 5.5 --- external/pyqt-reactive | 2 +- openhcs/pyqt_gui/widgets/image_browser.py | 112 +- .../widgets/shared/plate_view_widget.py | 1278 ----------------- .../test_image_browser_column_presentation.py | 2 +- .../pyqt_gui/test_plate_view_grid_model.py | 76 +- tests/unit/test_grid_addressed_axis.py | 2 +- 6 files changed, 46 insertions(+), 1426 deletions(-) delete mode 100644 openhcs/pyqt_gui/widgets/shared/plate_view_widget.py diff --git a/external/pyqt-reactive b/external/pyqt-reactive index 6ead575343..062fa62522 160000 --- a/external/pyqt-reactive +++ b/external/pyqt-reactive @@ -1 +1 @@ -Subproject commit 6ead5753438adc2a10a453725c7765efce5e06fb +Subproject commit 062fa62522ef19c65a0dc6ab2aef8b287c985d83 diff --git a/openhcs/pyqt_gui/widgets/image_browser.py b/openhcs/pyqt_gui/widgets/image_browser.py index f6e248dfa3..89f840a577 100644 --- a/openhcs/pyqt_gui/widgets/image_browser.py +++ b/openhcs/pyqt_gui/widgets/image_browser.py @@ -5,6 +5,8 @@ view them in Napari with configurable display settings. """ +from openhcs.core.axes import Axis, AxisFamily, GridAddressed +from pyqt_reactive.widgets.grid_selector import GridSelectorWidget from objectstate import ScopeKey import logging import subprocess @@ -12,7 +14,7 @@ from collections.abc import Iterator, Mapping from dataclasses import dataclass, field, make_dataclass from pathlib import Path -from typing import Callable, Dict, List, Optional, Set +from typing import Callable, Dict, Optional, Set from objectstate.object_state import ObjectState, ObjectStateRegistry from polystore.base import storage_registry @@ -48,16 +50,29 @@ from openhcs.pyqt_gui.config import ProgressUIConfig from openhcs.pyqt_gui.services.ui_window_ids import OpenHCSUiWindowId from openhcs.runtime.zmq_config import OPENHCS_ZMQ_CONFIG, OpenHCSZMQConfig -from openhcs.core.axes import AxisFamily -from openhcs.pyqt_gui.widgets.shared.plate_view_widget import ( - PlateViewWidget, - active_grid_axis, - require_grid_axis, -) logger = logging.getLogger(__name__) +def active_grid_axis() -> type[Axis] | None: + """The active family's grid-addressed axis, if it declares one.""" + + grid_axes = AxisFamily.active().with_role(GridAddressed) + return grid_axes[0] if grid_axes else None + + +def require_grid_axis() -> type[Axis]: + """The grid axis the plate view lays out; a plate view needs one.""" + + grid_axis = active_grid_axis() + if grid_axis is None: + raise TypeError( + f"Axis family {AxisFamily.active().__qualname__} declares no " + "GridAddressed axis; it has no plate grid." + ) + return grid_axis + + def _streaming_config_field_names() -> tuple[str, ...]: """Registered streaming-config field names used by image-browser controls.""" @@ -599,10 +614,15 @@ def init_ui(self): # Plate view (initially hidden; built only for a family with a grid axis) if active_grid_axis() is not None: - self.plate_view_widget = PlateViewWidget( - color_scheme=self.color_scheme, parent=self + grid_axis = require_grid_axis() + self.plate_view_widget = GridSelectorWidget( + addressing=grid_axis, + title="Plate View", + cell_noun=f"{grid_axis.label.lower()}s", + color_scheme=self.color_scheme, + parent=self, ) - self.plate_view_widget.wells_selected.connect(self._on_wells_selected) + self.plate_view_widget.cells_selected.connect(self._on_wells_selected) self.plate_view_widget.detach_requested.connect(self._detach_plate_view) self.plate_view_widget.setVisible(False) self.middle_splitter.addWidget(self.plate_view_widget) @@ -1486,7 +1506,7 @@ def _on_table_filter_selection_changed( ): return self.selected_wells = set() - self.plate_view_widget.select_wells( + self.plate_view_widget.select_cells( self.metadata_display_resolver.raw_values( well_key, set(selected_values), @@ -1525,78 +1545,10 @@ def _update_plate_view(self): ) # Update plate view with well IDs, dimensions, and coordinate mapping - self.plate_view_widget.set_available_wells( + self.plate_view_widget.set_available_cells( well_ids, plate_dimensions, coord_to_well ) - # Handle subdirectory selection - current_folder = self._get_current_folder() - subdirs = self._detect_plate_subdirs(current_folder) - self.plate_view_widget.set_subdirectories(subdirs) - - def _get_current_folder(self) -> Optional[str]: - """Get currently selected folder path from tree.""" - selected_items = self.folder_tree.selectedItems() - if selected_items: - folder_path = selected_items[0].data(0, Qt.ItemDataRole.UserRole) - return folder_path - return None - - def _detect_plate_subdirs(self, current_folder: Optional[str]) -> List[str]: - """ - Detect plate output subdirectories. - - Logic: - - If at plate root (no folder selected or root selected), look for subdirs with well images - - If in a subdir, return empty list (already in a plate output) - - Returns list of subdirectory names (not full paths). - """ - if not self.orchestrator: - return [] - - plate_path = self.orchestrator.dataset_root - - # If no folder selected or root selected, we're at plate root - if current_folder is None: - base_path = plate_path - else: - # Check if current folder is plate root - if str(Path(current_folder)) == str(plate_path): - base_path = plate_path - else: - # Already in a subdirectory, no subdirs to show - return [] - - # Find immediate subdirectories that contain well files - subdirs_with_wells = set() - - for filename, item in self.file_items.items(): - file_path = Path(filename) - - # Check if file is in a subdirectory of base_path - try: - relative = file_path.relative_to(base_path) - parts = relative.parts - - # If file is in a subdirectory (not directly in base_path) - if len(parts) > 1: - subdir_name = parts[0] - - # Check if this file has well metadata - try: - self._extract_well_id(item.metadata) - # Has well metadata, add subdir - subdirs_with_wells.add(subdir_name) - except (KeyError, ValueError): - # No well metadata, skip - pass - except ValueError: - # File not relative to base_path, skip - pass - - return sorted(list(subdirs_with_wells)) - # ========== Plate View Helper Methods ========== def _extract_well_id(self, metadata: dict) -> str: diff --git a/openhcs/pyqt_gui/widgets/shared/plate_view_widget.py b/openhcs/pyqt_gui/widgets/shared/plate_view_widget.py deleted file mode 100644 index 7b41267edd..0000000000 --- a/openhcs/pyqt_gui/widgets/shared/plate_view_widget.py +++ /dev/null @@ -1,1278 +0,0 @@ -""" -Plate View Widget - Visual grid representation of plate wells. - -Displays a clickable grid of the active axis family's GridAddressed values -(plate wells for microscopy) with visual states for empty/has-images/selected. Supports multi-select and subdirectory selection. -""" - -import logging -from collections.abc import Callable -from dataclasses import dataclass -from enum import Enum -from typing import Set, List, Optional, Tuple -from PyQt6.QtWidgets import ( - QWidget, - QVBoxLayout, - QHBoxLayout, - QGridLayout, - QPushButton, - QLabel, - QFrame, - QButtonGroup, - QSizePolicy, -) -from PyQt6.QtCore import Qt, pyqtSignal, QEvent, QRect -from pyqt_reactive.theming import ColorScheme -from openhcs.core.axes import Axis, AxisFamily, GridAddressed - -logger = logging.getLogger(__name__) - - -class PlateSelectionEventTarget(Enum): - """Event source handled by plate selection routing.""" - - BUTTON = "button" - GRID = "grid" - - -class PlateSubdirectoryMode(Enum): - """Display mode for the plate-output subdirectory selector.""" - - NONE = "none" - SINGLE = "single" - MULTIPLE = "multiple" - - @classmethod - def from_count(cls, count: int) -> "PlateSubdirectoryMode": - if count == 0: - return cls.NONE - if count == 1: - return cls.SINGLE - return cls.MULTIPLE - - -class WellButtonState(Enum): - """Visual state for a well button.""" - - EMPTY = "empty" - HAS_IMAGES = "has_images" - SELECTED = "selected" - - -@dataclass(frozen=True, slots=True) -class WellButtonStyleColors: - """Color attribute names used to render a well button state.""" - - background: str - text: str - border: str | None = None - hover_background: str | None = None - - -WELL_BUTTON_STYLE_COLORS: dict[WellButtonState, WellButtonStyleColors] = { - WellButtonState.EMPTY: WellButtonStyleColors( - background="button_disabled_bg", - text="button_disabled_text", - ), - WellButtonState.HAS_IMAGES: WellButtonStyleColors( - background="button_normal_bg", - text="button_text", - hover_background="button_hover_bg", - ), - WellButtonState.SELECTED: WellButtonStyleColors( - background="selection_bg", - text="selection_text", - border="border_color", - ), -} - - -@dataclass(frozen=True, slots=True) -class PlateGridBounds: - """Tight bounding rectangle for wells in plate coordinates.""" - - min_row: int - max_row: int - min_col: int - max_col: int - - @property - def rows(self) -> list[int]: - return list(range(self.min_row, self.max_row + 1)) - - @property - def cols(self) -> list[int]: - return list(range(self.min_col, self.max_col + 1)) - - @property - def dimensions(self) -> tuple[int, int]: - return (self.max_row - self.min_row + 1, self.max_col - self.min_col + 1) - - -def active_grid_axis() -> type[Axis] | None: - """The active family's grid-addressed axis, if it declares one.""" - - grid_axes = AxisFamily.active().with_role(GridAddressed) - return grid_axes[0] if grid_axes else None - - -def grid_value_noun() -> str: - """Plural noun for the grid axis's values in status text (``wells``).""" - - return f"{require_grid_axis().label.lower()}s" - - -def require_grid_axis() -> type[Axis]: - """The grid axis a plate view lays out; a plate view needs one.""" - - grid_axis = active_grid_axis() - if grid_axis is None: - raise TypeError( - f"Axis family {AxisFamily.active().__qualname__} declares no " - "GridAddressed axis; it has no plate grid." - ) - return grid_axis - - -@dataclass(frozen=True, slots=True) -class PlateGridModel: - """Pure plate-coordinate model backing the Qt grid facade.""" - - wells_with_images: frozenset[str] - coord_to_well: dict[tuple[int, int], str] - well_to_coord: dict[str, tuple[int, int]] - plate_dimensions: tuple[int, int] - row_offset: int - col_offset: int - bounds: PlateGridBounds | None - - @classmethod - def empty(cls) -> "PlateGridModel": - return cls( - wells_with_images=frozenset(), - coord_to_well={}, - well_to_coord={}, - plate_dimensions=require_grid_axis().default_grid, - row_offset=0, - col_offset=0, - bounds=None, - ) - - @classmethod - def from_wells( - cls, - well_ids: Set[str], - plate_dimensions: Optional[Tuple[int, int]] = None, - coord_to_well: Optional[dict] = None, - ) -> "PlateGridModel": - coordinates = ( - dict(coord_to_well) - if coord_to_well is not None - else cls._coordinates_from_standard_well_ids(well_ids) - ) - reverse_coordinates = {well_id: coord for coord, well_id in coordinates.items()} - - if not well_ids: - return cls.empty() - - bounds = cls._detect_bounds(well_ids, coordinates) - dimensions = ( - plate_dimensions - if plate_dimensions is not None - else ( - bounds.dimensions - if bounds is not None - else require_grid_axis().default_grid - ) - ) - - return cls( - wells_with_images=frozenset(well_ids), - coord_to_well=coordinates, - well_to_coord=reverse_coordinates, - plate_dimensions=dimensions, - row_offset=(bounds.min_row - 1) if bounds is not None else 0, - col_offset=(bounds.min_col - 1) if bounds is not None else 0, - bounds=bounds, - ) - - @classmethod - def _coordinates_from_standard_well_ids( - cls, well_ids: Set[str] - ) -> dict[tuple[int, int], str]: - grid_axis = require_grid_axis() - coordinates: dict[tuple[int, int], str] = {} - for well_id in well_ids: - coord = grid_axis.grid_index(well_id) - if coord is not None: - coordinates[coord] = well_id - return coordinates - - @classmethod - def _detect_bounds( - cls, well_ids: Set[str], coord_to_well: dict[tuple[int, int], str] - ) -> PlateGridBounds | None: - occupied = [ - coord for coord, well_id in coord_to_well.items() if well_id in well_ids - ] - if not occupied: - return None - - rows = [row for row, _ in occupied] - cols = [col for _, col in occupied] - return PlateGridBounds( - min_row=min(rows), - max_row=max(rows), - min_col=min(cols), - max_col=max(cols), - ) - - @property - def is_empty(self) -> bool: - return not self.wells_with_images - - @property - def actual_rows(self) -> list[int]: - return self.bounds.rows if self.bounds is not None else [] - - @property - def actual_cols(self) -> list[int]: - return self.bounds.cols if self.bounds is not None else [] - - def well_at(self, row: int, col: int) -> str | None: - return self.coord_to_well.get((row, col)) - - def wells_on_axis(self, *, axis_index: int, axis_value: int) -> list[str]: - return sorted( - well_id - for well_id, coord in self.well_to_coord.items() - if coord[axis_index] == axis_value and well_id in self.wells_with_images - ) - - -class PlateSubdirectoryButtonRegistry: - """Own the Qt button registry for the plate-output selector.""" - - def __init__( - self, - button_group: QButtonGroup, - layout: QHBoxLayout, - color_scheme: ColorScheme, - ) -> None: - self.button_group = button_group - self.layout = layout - self.color_scheme = color_scheme - self.buttons: dict[str, QPushButton] = {} - - def clear(self) -> None: - for button in self.buttons.values(): - self.button_group.removeButton(button) - button.deleteLater() - self.buttons.clear() - - def populate( - self, - subdirs: List[str], - on_selected: Callable[[str], None], - ) -> str: - self.clear() - for subdir in subdirs: - button = QPushButton(subdir) - button.setCheckable(True) - button.setStyleSheet(self.color_scheme.styles.generate_button_style()) - button.clicked.connect(lambda checked, s=subdir: on_selected(s)) - - self.button_group.addButton(button) - self.layout.insertWidget(self.layout.count() - 1, button) - self.buttons[subdir] = button - - first_subdir = subdirs[0] - self.buttons[first_subdir].setChecked(True) - return first_subdir - - -class PlateSubdirectoryController: - """Own plate-output subdirectory selector state and visibility.""" - - MODE_HANDLERS: dict[ - PlateSubdirectoryMode, - Callable[["PlateSubdirectoryController", List[str]], None], - ] = { - PlateSubdirectoryMode.NONE: lambda controller, subdirs: ( - controller._set_no_subdirectories() - ), - PlateSubdirectoryMode.SINGLE: lambda controller, subdirs: ( - controller._set_single_subdirectory(subdirs[0]) - ), - PlateSubdirectoryMode.MULTIPLE: lambda controller, subdirs: ( - controller._set_multiple_subdirectories(subdirs) - ), - } - - def __init__(self, view: "PlateViewWidget"): - self.view = view - - def set_subdirectories(self, subdirs: List[str]) -> None: - self.view.subdirs = subdirs - self.view.subdir_button_registry.clear() - - mode = PlateSubdirectoryMode.from_count(len(subdirs)) - self.MODE_HANDLERS[mode](self, subdirs) - - def _set_no_subdirectories(self) -> None: - self.view.subdir_frame.setVisible(False) - self.view.active_subdir = None - - def _set_single_subdirectory(self, subdir: str) -> None: - self.view.subdir_frame.setVisible(False) - self.view.active_subdir = subdir - - def _set_multiple_subdirectories(self, subdirs: List[str]) -> None: - self.view.subdir_frame.setVisible(True) - - def select_subdirectory(subdir: str) -> None: - self.view.active_subdir = subdir - - self.view.active_subdir = self.view.subdir_button_registry.populate( - subdirs, - select_subdirectory, - ) - - -class PlateWellButtonRegistry: - """Own well-id to Qt button lookup for the plate grid.""" - - def __init__(self): - self._buttons: dict[str, QPushButton] = {} - - def clear(self) -> None: - for button in self._buttons.values(): - button.deleteLater() - self._buttons.clear() - - def register(self, well_id: str, button: QPushButton) -> None: - self._buttons[well_id] = button - - def contains(self, well_id: str) -> bool: - return well_id in self._buttons - - def button(self, well_id: str) -> QPushButton: - return self._buttons[well_id] - - def items(self): - return self._buttons.items() - - -class PlateSelectionController: - """Own well selection mutation, status projection, and filter sync.""" - - def __init__(self, view: "PlateViewWidget"): - self.view = view - - def clear_selection( - self, - emit_signal: bool = True, - sync_to_filter: bool = True, - ) -> None: - logger.info( - "[CLEAR] clear_selection called, had %s wells, emit_signal=%s, sync_to_filter=%s", - len(self.view.selected_wells), - emit_signal, - sync_to_filter, - ) - for well_id in list(self.view.selected_wells): - if self.view.well_button_registry.contains(well_id): - btn = self.view.well_button_registry.button(well_id) - btn.setChecked(False) - btn.setStyleSheet( - self.view._get_well_button_style(WellButtonState.HAS_IMAGES) - ) - - self.view.selected_wells.clear() - self.update_status() - - if sync_to_filter: - self.sync_to_partition_filter() - - if emit_signal: - self.view.wells_selected.emit(set()) - - def select_wells(self, well_ids: Set[str], emit_signal: bool = True) -> None: - self.clear_selection(emit_signal=False, sync_to_filter=False) - - for well_id in well_ids: - if ( - self.view.well_button_registry.contains(well_id) - and well_id in self.view.wells_with_images - ): - self.toggle_well_selection(well_id, True) - - self.update_status() - if emit_signal: - self.sync_to_partition_filter() - self.view.wells_selected.emit(self.view.selected_wells.copy()) - - def publish_selection_change(self) -> None: - self.update_status() - self.sync_to_partition_filter() - self.view.wells_selected.emit(self.view.selected_wells.copy()) - - def toggle_well_selection(self, well_id: str, select: bool) -> None: - if ( - not self.view.well_button_registry.contains(well_id) - or well_id not in self.view.wells_with_images - ): - return - - btn = self.view.well_button_registry.button(well_id) - - if select and well_id not in self.view.selected_wells: - self.view.selected_wells.add(well_id) - btn.setChecked(True) - btn.setStyleSheet( - self.view._get_well_button_style(WellButtonState.SELECTED) - ) - elif not select and well_id in self.view.selected_wells: - self.view.selected_wells.discard(well_id) - btn.setChecked(False) - btn.setStyleSheet( - self.view._get_well_button_style(WellButtonState.HAS_IMAGES) - ) - - def update_rectangle_selection(self, rect: QRect) -> None: - wells_in_rect = set() - for well_id, btn in self.view.well_button_registry.items(): - if well_id not in self.view.wells_with_images: - continue - btn_rect = QRect(btn.pos(), btn.size()) - if rect.intersects(btn_rect): - wells_in_rect.add(well_id) - - for well_id in self.view.wells_with_images: - if well_id not in wells_in_rect: - should_be_selected = well_id in self.view.pre_drag_selection - self.toggle_well_selection(well_id, should_be_selected) - else: - self.toggle_well_selection(well_id, True) - - self.publish_selection_change() - - def toggle_row_selection(self, row_index: int) -> None: - self.toggle_axis_selection(axis_index=0, axis_value=row_index) - - def toggle_column_selection(self, col_index: int) -> None: - self.toggle_axis_selection(axis_index=1, axis_value=col_index) - - def toggle_axis_selection(self, *, axis_index: int, axis_value: int) -> None: - wells_in_axis = self.view.grid_model.wells_on_axis( - axis_index=axis_index, - axis_value=axis_value, - ) - - if not wells_in_axis: - return - - all_selected = all( - well_id in self.view.selected_wells for well_id in wells_in_axis - ) - select = not all_selected - - for well_id in wells_in_axis: - self.toggle_well_selection(well_id, select) - - self.publish_selection_change() - - def invert_selection(self) -> None: - all_wells = self.view.wells_with_images.copy() - new_selection = all_wells - self.view.selected_wells - - for well_id in self.view.selected_wells.copy(): - self.toggle_well_selection(well_id, False) - - for well_id in new_selection: - self.toggle_well_selection(well_id, True) - - self.publish_selection_change() - - def sync_to_partition_filter(self) -> None: - if not self.view.partition_filter_widget: - return - - if self.view.selected_wells: - self.view.partition_filter_widget.set_selected_values( - self.view.selected_wells, - block_signals=True, - ) - else: - self.view.partition_filter_widget.select_all(block_signals=True) - - def sync_from_partition_filter(self) -> None: - if not self.view.partition_filter_widget: - return - - selected_in_filter = self.view.partition_filter_widget.get_selected_values() - self.select_wells(selected_in_filter, emit_signal=False) - - def update_status(self) -> None: - total_wells = len(self.view.wells_with_images) - selected_count = len(self.view.selected_wells) - - if selected_count > 0: - self.view.status_label.setText( - f"{total_wells} {grid_value_noun()} have images | {selected_count} selected" - ) - else: - self.view.status_label.setText( - f"{total_wells} {grid_value_noun()} have images" - ) - - -@dataclass(frozen=True, slots=True) -class PlateSelectionEventRoute: - """One event route into plate selection handling.""" - - target: PlateSelectionEventTarget - event_type: QEvent.Type - handler: Callable[["PlateSelectionEventController", object, object], bool | None] - - -class SquareButton(QPushButton): - """QPushButton that fills its grid cell.""" - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - # Use Expanding policy so buttons fill available space uniformly - self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) - - -class PlateSelectionInteractionLifecycle: - """Own begin/update/finish transitions for plate selection gestures.""" - - def __init__(self, view: "PlateViewWidget"): - self.view = view - - def is_left_rectangle_drag(self, event) -> bool: - return ( - self.view.is_rect_selecting and event.buttons() & Qt.MouseButton.LeftButton - ) - - def begin_rectangle_selection(self, start_pos) -> None: - self.view.is_rect_selecting = True - self.view.rect_start_pos = start_pos - self.view.rect_current_pos = start_pos - self.view.pre_drag_selection = self.view.selected_wells.copy() - self.view.grid_widget.grabMouse() - self.show_rectangle(QRect(start_pos, start_pos).normalized()) - - def begin_well_drag(self, well_id: str) -> None: - self.view.is_dragging = True - self.view.drag_start_well = well_id - self.view.drag_current_well = well_id - self.view.drag_affected_wells = set() - self.view.drag_moved = False - self.view.drag_selection_mode = ( - "deselect" if well_id in self.view.selected_wells else "select" - ) - self.view.selection_controller.toggle_well_selection( - well_id, self.view.drag_selection_mode == "select" - ) - self.view.drag_affected_wells.add(well_id) - self.view.selection_controller.publish_selection_change() - - def update_rectangle(self, current_pos) -> None: - self.view.rect_current_pos = current_pos - rect = QRect(self.view.rect_start_pos, current_pos).normalized() - self.show_rectangle(rect) - self.view.selection_controller.update_rectangle_selection(rect) - - def show_rectangle(self, rect: QRect) -> None: - self.view.selection_rect_widget.setGeometry(rect) - self.view.selection_rect_widget.raise_() - self.view.selection_rect_widget.show() - - def finish_interaction(self) -> None: - self.view.grid_widget.releaseMouse() - if self.view.is_dragging: - self.finish_well_drag() - if self.view.is_rect_selecting: - self.finish_rectangle_selection() - - def finish_well_drag(self) -> None: - self.view.is_dragging = False - self.view.drag_start_well = None - self.view.drag_current_well = None - self.view.drag_selection_mode = None - self.view.drag_affected_wells.clear() - self.view.drag_moved = False - - def finish_rectangle_selection(self) -> None: - self.view.grid_widget.releaseMouse() - self.view.is_rect_selecting = False - self.view.rect_start_pos = None - self.view.rect_current_pos = None - self.view.selection_rect_widget.hide() - - -class AspectRatioContainer(QWidget): - """Container that maintains aspect ratio for its child widget. - - This widget acts as a wrapper that sizes its child to maintain a specific - aspect ratio while centering it within the available space. - """ - - # Minimum cell size in pixels (wells won't shrink smaller than this) - MIN_CELL_SIZE = 8 - - def __init__(self, child_widget: QWidget, *args, **kwargs): - super().__init__(*args, **kwargs) - self.aspect_ratio = 1.0 # width / height ratio - self.num_cols = 1 - self.num_rows = 1 - self.child_widget = child_widget - self.child_widget.setParent(self) - # Use Expanding to fill available space - self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) - - def set_aspect_ratio(self, num_cols: int, num_rows: int): - """Set the aspect ratio based on grid dimensions.""" - self.num_cols = num_cols - self.num_rows = num_rows - if num_rows > 0: - self.aspect_ratio = num_cols / num_rows - # Set minimum size on container so layout respects it - min_width = num_cols * self.MIN_CELL_SIZE - min_height = num_rows * self.MIN_CELL_SIZE - self.setMinimumSize(min_width, min_height) - self._update_child_geometry() - - def resizeEvent(self, event): - """Resize child to maintain aspect ratio, centered in available space.""" - super().resizeEvent(event) - self._update_child_geometry() - - def _update_child_geometry(self): - """Calculate and set child geometry to maintain aspect ratio.""" - if self.aspect_ratio <= 0: - return - - available_w = self.width() - available_h = self.height() - - if available_w <= 0 or available_h <= 0: - return - - # Calculate minimum size based on cell count - min_width = self.num_cols * self.MIN_CELL_SIZE - min_height = self.num_rows * self.MIN_CELL_SIZE - - # Calculate the largest size that fits while maintaining aspect ratio - height_for_width = int(available_w / self.aspect_ratio) - width_for_height = int(available_h * self.aspect_ratio) - - if height_for_width <= available_h: - # Width-constrained: use full width - child_w = available_w - child_h = height_for_width - else: - # Height-constrained: use full height - child_w = width_for_height - child_h = available_h - - # Enforce minimum size (cells won't shrink below MIN_CELL_SIZE) - child_w = max(child_w, min_width) - child_h = max(child_h, min_height) - - # Center the child widget (may overflow if below minimum) - x = (available_w - child_w) // 2 - y = (available_h - child_h) // 2 - - self.child_widget.setGeometry(x, y, child_w, child_h) - - -class PlateSelectionEventController: - """Own mouse-event semantics for plate drag and rectangle selection.""" - - EVENT_ROUTES: tuple[PlateSelectionEventRoute, ...] = ( - PlateSelectionEventRoute( - PlateSelectionEventTarget.BUTTON, - QEvent.Type.MouseButtonPress, - lambda controller, obj, event: controller._handle_button_press(obj, event), - ), - PlateSelectionEventRoute( - PlateSelectionEventTarget.BUTTON, - QEvent.Type.MouseMove, - lambda controller, obj, event: controller._handle_button_move(obj, event), - ), - PlateSelectionEventRoute( - PlateSelectionEventTarget.BUTTON, - QEvent.Type.MouseButtonRelease, - lambda controller, obj, event: controller._handle_button_release(event), - ), - PlateSelectionEventRoute( - PlateSelectionEventTarget.GRID, - QEvent.Type.MouseButtonPress, - lambda controller, obj, event: controller._handle_grid_press(event), - ), - PlateSelectionEventRoute( - PlateSelectionEventTarget.GRID, - QEvent.Type.MouseMove, - lambda controller, obj, event: controller._handle_grid_move(event), - ), - PlateSelectionEventRoute( - PlateSelectionEventTarget.GRID, - QEvent.Type.MouseButtonRelease, - lambda controller, obj, event: controller._handle_grid_release(event), - ), - ) - EVENT_ROUTE_BY_KEY = { - (route.target, route.event_type): route for route in EVENT_ROUTES - } - - def __init__(self, view: "PlateViewWidget"): - self.view = view - self.lifecycle = PlateSelectionInteractionLifecycle(view) - - def handle(self, obj, event) -> bool | None: - """Return handled state, or None when the widget should use Qt default.""" - if isinstance(obj, QPushButton): - target = PlateSelectionEventTarget.BUTTON - elif obj == self.view.grid_widget: - target = PlateSelectionEventTarget.GRID - else: - return None - - route = self.EVENT_ROUTE_BY_KEY.get((target, event.type())) - if route is None: - return None - return route.handler(self, obj, event) - - def _handle_button_press(self, button: QPushButton, event) -> bool | None: - if event.button() != Qt.MouseButton.LeftButton: - return None - - well_id = button.property("well_id") - self.lifecycle.begin_rectangle_selection( - button.mapTo(self.view.grid_widget, event.pos()) - ) - - if well_id and well_id in self.view.wells_with_images: - self.lifecycle.begin_well_drag(well_id) - - event.accept() - return True - - def _handle_button_move(self, button: QPushButton, event) -> bool | None: - if not self.lifecycle.is_left_rectangle_drag(event): - return None - - self.lifecycle.update_rectangle( - button.mapTo(self.view.grid_widget, event.pos()) - ) - if self.view.is_dragging: - self.view.drag_moved = True - - event.accept() - return True - - def _handle_button_release(self, event) -> bool | None: - if event.button() != Qt.MouseButton.LeftButton: - return None - if not (self.view.is_rect_selecting or self.view.is_dragging): - return None - - self.lifecycle.finish_interaction() - event.accept() - return True - - def _handle_grid_press(self, event) -> bool | None: - if event.button() != Qt.MouseButton.LeftButton: - return None - if isinstance(self.view.grid_widget.childAt(event.pos()), QPushButton): - return None - - self.lifecycle.begin_rectangle_selection(event.pos()) - event.accept() - return True - - def _handle_grid_move(self, event) -> bool | None: - if not self.lifecycle.is_left_rectangle_drag(event): - return None - - self.lifecycle.update_rectangle(event.pos()) - event.accept() - return True - - def _handle_grid_release(self, event) -> bool | None: - if ( - event.button() != Qt.MouseButton.LeftButton - or not self.view.is_rect_selecting - ): - return None - - self.lifecycle.finish_rectangle_selection() - event.accept() - return True - - -class PlateViewWidget(QWidget): - """ - Visual plate grid widget with clickable wells. - - Features: - - Auto-detects plate dimensions from well IDs - - Clickable well buttons with visual states (empty/has-images/selected) - - Multi-select support (Ctrl+Click, Shift+Click) - - Subdirectory selector for multiple plate outputs - - Clear selection button - - Detachable to external window - - Signals: - wells_selected: Emitted when well selection changes (set of well IDs) - detach_requested: Emitted when user clicks detach button - """ - - wells_selected = pyqtSignal(set) - detach_requested = pyqtSignal() - - def __init__(self, color_scheme: Optional[ColorScheme] = None, parent=None): - super().__init__(parent) - - self.color_scheme = color_scheme or ColorScheme() - - # State - self.well_button_registry = PlateWellButtonRegistry() - self.wells_with_images = set() # Set of well IDs that have images - self.selected_wells = set() # Currently selected wells - self.grid_model = PlateGridModel.empty() - self.plate_dimensions = self.grid_model.plate_dimensions - self.row_offset = 0 # Offset for tight bounding box (first row index - 1) - self.col_offset = 0 # Offset for tight bounding box (first col index - 1) - self.subdirs = [] # List of subdirectory names - self.active_subdir = None # Currently selected subdirectory - self.coord_to_well = {} # (row_index, col_index) -> well_id mapping - self.well_to_coord = {} # well_id -> (row_index, col_index) reverse mapping - - # Drag selection state - self.is_dragging = False - self.drag_start_well = None - self.drag_current_well = None - self.drag_selection_mode = None # 'select' or 'deselect' - self.drag_affected_wells = set() # Wells affected by current drag operation - self.pre_drag_selection = set() # Selection state before drag started - self.drag_moved = False # Track if mouse actually moved during drag - - # Rectangle selection state (for dragging in empty space) - self.is_rect_selecting = False - self.rect_start_pos = None - self.rect_current_pos = None - self.selection_rect_widget = None # Visual rectangle overlay - - # Column filter sync - self.partition_filter_widget = ( - None # Reference to ColumnFilterWidget for 'well' column - ) - self.selection_controller = PlateSelectionController(self) - self.subdirectory_controller = PlateSubdirectoryController(self) - - # UI components - self.subdir_buttons = {} # subdir_name -> QPushButton - self.subdir_button_group = None - self.subdir_button_registry = None - self.well_grid_layout = None - self.status_label = None - self.selection_event_controller = PlateSelectionEventController(self) - - self._setup_ui() - - def _setup_ui(self): - """Setup the UI layout.""" - layout = QVBoxLayout(self) - layout.setContentsMargins(5, 5, 5, 5) - layout.setSpacing(5) - - # Header with title, detach button, and clear button - header_layout = QHBoxLayout() - title_label = QLabel("Plate View") - title_label.setStyleSheet( - f"font-weight: bold; color: {self.color_scheme.to_hex(self.color_scheme.text_accent)};" - ) - header_layout.addWidget(title_label) - - header_layout.addStretch() - - detach_btn = QPushButton("↗") - detach_btn.setToolTip("Detach to separate window") - detach_btn.setFixedWidth(30) - detach_btn.setStyleSheet(self.color_scheme.styles.generate_button_style()) - detach_btn.clicked.connect(lambda: self.detach_requested.emit()) - header_layout.addWidget(detach_btn) - - clear_btn = QPushButton("Clear Selection") - clear_btn.setStyleSheet(self.color_scheme.styles.generate_button_style()) - # Use lambda to avoid clicked signal's bool arg being passed to clear_selection - clear_btn.clicked.connect(lambda: self.clear_selection()) - header_layout.addWidget(clear_btn) - - layout.addLayout(header_layout) - - # Subdirectory selector (initially hidden) - self.subdir_frame = QFrame() - self.subdir_layout = QHBoxLayout(self.subdir_frame) - self.subdir_layout.setContentsMargins(0, 0, 0, 0) - self.subdir_layout.setSpacing(5) - - subdir_label = QLabel("Plate Output:") - self.subdir_layout.addWidget(subdir_label) - - self.subdir_button_group = QButtonGroup(self) - self.subdir_button_group.setExclusive(True) - self.subdir_button_registry = PlateSubdirectoryButtonRegistry( - self.subdir_button_group, - self.subdir_layout, - self.color_scheme, - ) - self.subdir_buttons = self.subdir_button_registry.buttons - - self.subdir_layout.addStretch() - self.subdir_frame.setVisible(False) - layout.addWidget(self.subdir_frame) - - # Well grid container (background color shows through as grid lines) - grid_container = QFrame() - # Use panel_bg color for grid lines (shows between 1px spacing) - grid_container.setStyleSheet( - f"background-color: {self.color_scheme.to_hex(self.color_scheme.panel_bg)}; border-radius: 3px;" - ) - grid_layout_wrapper = QVBoxLayout(grid_container) - grid_layout_wrapper.setContentsMargins(10, 10, 10, 10) - - # Inner grid widget that holds the actual grid layout - inner_grid_widget = QWidget() - inner_grid_widget.setMouseTracking(True) - self.well_grid_layout = QGridLayout(inner_grid_widget) - self.well_grid_layout.setSpacing(2) # Thin grid lines (2px for visibility) - self.well_grid_layout.setContentsMargins(0, 0, 0, 0) - - # Wrap in AspectRatioContainer to maintain square cells - aspect_container = AspectRatioContainer(inner_grid_widget) - self.grid_widget = inner_grid_widget # Store reference to inner widget - self.aspect_container = aspect_container # Store reference to container - - # Install event filter on inner grid widget for rectangle selection - inner_grid_widget.installEventFilter(self) - - # Create selection rectangle overlay (initially hidden) - self.selection_rect_widget = QLabel(inner_grid_widget) - self.selection_rect_widget.setStyleSheet(f""" - background-color: {self.color_scheme.to_hex(self.color_scheme.selection_bg)}40; - border: 2px solid {self.color_scheme.to_hex(self.color_scheme.selection_bg)}; - """) - self.selection_rect_widget.hide() - self.selection_rect_widget.setAttribute( - Qt.WidgetAttribute.WA_TransparentForMouseEvents - ) - self.selection_rect_widget.raise_() # Ensure it's on top - - # Add aspect container to wrapper (it will expand and center the grid) - grid_layout_wrapper.addWidget(aspect_container, 1) # stretch factor 1 to expand - - layout.addWidget(grid_container, 1) # Stretch to fill - - # Status label - self.status_label = QLabel(f"No {grid_value_noun()}") - self.status_label.setStyleSheet( - f"color: {self.color_scheme.to_hex(self.color_scheme.text_secondary)};" - ) - layout.addWidget(self.status_label) - - def set_subdirectories(self, subdirs: List[str]): - """ - Set available subdirectories for plate outputs. - - Args: - subdirs: List of subdirectory names - """ - self.subdirectory_controller.set_subdirectories(subdirs) - - def set_available_wells( - self, - well_ids: Set[str], - plate_dimensions: Optional[Tuple[int, int]] = None, - coord_to_well: Optional[dict] = None, - ): - """ - Update which wells have images and rebuild grid. - - Args: - well_ids: Set of well IDs that have images - plate_dimensions: Optional (rows, cols) tuple. If None, auto-detects from well IDs. - coord_to_well: Optional mapping from (row_index, col_index) to well_id. - Required for non-standard well ID formats (e.g., Opera Phenix R01C01). - """ - self.grid_model = PlateGridModel.from_wells( - well_ids, - plate_dimensions=plate_dimensions, - coord_to_well=coord_to_well, - ) - self.wells_with_images = set(self.grid_model.wells_with_images) - self.coord_to_well = self.grid_model.coord_to_well - self.well_to_coord = self.grid_model.well_to_coord - self.plate_dimensions = self.grid_model.plate_dimensions - self.row_offset = self.grid_model.row_offset - self.col_offset = self.grid_model.col_offset - - if self.grid_model.is_empty: - self._clear_grid() - self.status_label.setText(f"No {grid_value_noun()}") - return - - # Rebuild grid - self._build_grid() - - # Update status - self.selection_controller.update_status() - - def _clear_grid(self): - """Clear the well grid.""" - self.well_button_registry.clear() - - # Clear layout - while self.well_grid_layout.count(): - item = self.well_grid_layout.takeAt(0) - if item.widget(): - item.widget().deleteLater() - - def _build_grid(self): - """Build the well grid based on current dimensions.""" - self._clear_grid() - - actual_rows = self.grid_model.actual_rows - actual_cols = self.grid_model.actual_cols - - if not actual_rows or not actual_cols: - return - - all_rows = actual_rows - all_cols = actual_cols - - # Calculate minimum size based on label width - # Find longest column number for width calculation - max_col_label = str(all_cols[-1]) - from PyQt6.QtGui import QFontMetrics - from PyQt6.QtGui import QFont - - font = QFont() - font.setPointSize(10) - fm = QFontMetrics(font) - min_col_width = max( - fm.horizontalAdvance(max_col_label) + 8, 15 - ) # +8 for padding, min 15px - min_row_height = max(fm.height() + 4, 15) # +4 for padding, min 15px - - # Header minimum sizes (can be smaller than well size) - min_header_width = max(min_col_width, 8) - min_header_height = max(min_row_height, 8) - - # Well buttons use MIN_CELL_SIZE directly - allow shrinking independently of headers - min_well_size = AspectRatioContainer.MIN_CELL_SIZE - - # Top-left corner: Invert selection button - invert_btn = QPushButton("⇄") - invert_btn.setFlat(True) - invert_btn.setCursor(Qt.CursorShape.PointingHandCursor) - invert_btn.setMinimumSize(18, 18) - invert_btn.setToolTip("Invert Selection") - invert_btn.setStyleSheet(f""" - QPushButton {{ - color: {self.color_scheme.to_hex(self.color_scheme.text_secondary)}; - font-size: 12px; - border: none; - background: transparent; - }} - QPushButton:hover {{ - color: {self.color_scheme.to_hex(self.color_scheme.text_primary)}; - background-color: {self.color_scheme.to_hex(self.color_scheme.panel_bg)}; - }} - """) - invert_btn.clicked.connect(lambda: self.selection_controller.invert_selection()) - self.well_grid_layout.addWidget(invert_btn, 0, 0) - - # Add column headers - for all columns in bounding rectangle - for grid_col, actual_col in enumerate(all_cols, start=1): - header = QPushButton(str(actual_col)) - header.setFlat(True) - header.setCursor(Qt.CursorShape.PointingHandCursor) - header.setMinimumSize(min_header_width, 18) - header.setMaximumHeight(18) - header.setSizePolicy(QSizePolicy.Policy.Ignored, QSizePolicy.Policy.Fixed) - header.setStyleSheet(f""" - QPushButton {{ - color: {self.color_scheme.to_hex(self.color_scheme.text_secondary)}; - font-size: 10px; - border: none; - background: transparent; - }} - QPushButton:hover {{ - color: {self.color_scheme.to_hex(self.color_scheme.text_primary)}; - background-color: {self.color_scheme.to_hex(self.color_scheme.panel_bg)}; - }} - """) - header.clicked.connect( - lambda checked, c=actual_col: ( - self.selection_controller.toggle_column_selection(c) - ) - ) - self.well_grid_layout.addWidget(header, 0, grid_col) - - # Add row headers and well buttons - for all rows in bounding rectangle - for grid_row, actual_row in enumerate(all_rows, start=1): - # Row header (A, B, C, ...) - row_letter = require_grid_axis().row_label(actual_row) - header = QPushButton(row_letter) - header.setFlat(True) - header.setCursor(Qt.CursorShape.PointingHandCursor) - header.setMinimumSize(18, min_header_height) - header.setMaximumWidth(18) - header.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Ignored) - header.setStyleSheet(f""" - QPushButton {{ - color: {self.color_scheme.to_hex(self.color_scheme.text_secondary)}; - font-size: 10px; - border: none; - background: transparent; - }} - QPushButton:hover {{ - color: {self.color_scheme.to_hex(self.color_scheme.text_primary)}; - background-color: {self.color_scheme.to_hex(self.color_scheme.panel_bg)}; - }} - """) - header.clicked.connect( - lambda checked, r=actual_row: ( - self.selection_controller.toggle_row_selection(r) - ) - ) - self.well_grid_layout.addWidget(header, grid_row, 0) - - # Well buttons - for all columns in bounding rectangle - for grid_col, actual_col in enumerate(all_cols, start=1): - well_id = self.grid_model.well_at(actual_row, actual_col) - - btn = SquareButton() # Use SquareButton to maintain 1:1 aspect ratio - btn.setMinimumSize(min_well_size, min_well_size) - btn.setCheckable(True) - - if well_id and well_id in self.wells_with_images: - # Well exists and has images - btn.setEnabled(True) - btn.setStyleSheet( - self._get_well_button_style(WellButtonState.HAS_IMAGES) - ) - btn.clicked.connect( - lambda checked, wid=well_id: self._on_well_clicked(wid, checked) - ) - btn.setProperty("well_id", well_id) - btn.installEventFilter(self) - self.well_button_registry.register(well_id, btn) - else: - # Empty position in bounding rectangle - btn.setEnabled(False) - btn.setStyleSheet( - self._get_well_button_style(WellButtonState.EMPTY) - ) - btn.setProperty("well_id", None) - # Install event filter for rectangle selection - btn.installEventFilter(self) - - self.well_grid_layout.addWidget(btn, grid_row, grid_col) - - # Set uniform column and row stretches so all cells get equal space - # This ensures wells expand uniformly and stay aligned - for grid_col in range(1, len(all_cols) + 1): - self.well_grid_layout.setColumnStretch(grid_col, 1) - for grid_row in range(1, len(all_rows) + 1): - self.well_grid_layout.setRowStretch(grid_row, 1) - - # Set aspect ratio on container to maintain square wells - # Add 1 to account for header row/column - self.aspect_container.set_aspect_ratio(len(all_cols) + 1, len(all_rows) + 1) - - def _get_well_button_style(self, state: WellButtonState) -> str: - """Generate style for well button based on state.""" - cs = self.color_scheme - colors = WELL_BUTTON_STYLE_COLORS[state] - border = ( - f"2px solid {cs.to_hex(getattr(cs, colors.border))}" - if colors.border - else "none" - ) - hover = ( - f""" - QPushButton:hover {{ - background-color: {cs.to_hex(getattr(cs, colors.hover_background))}; - }} - """ - if colors.hover_background - else "" - ) - - return f""" - QPushButton {{ - background-color: {cs.to_hex(getattr(cs, colors.background))}; - color: {cs.to_hex(getattr(cs, colors.text))}; - border: {border}; - border-radius: 3px; - }} - {hover} - """ - - def _on_well_clicked(self, well_id: str, checked: bool): - """Handle well button click (only for non-drag clicks).""" - # Skip if this was part of a drag operation - if self.is_dragging: - return - - if checked: - self.selection_controller.toggle_well_selection(well_id, True) - else: - self.selection_controller.toggle_well_selection(well_id, False) - - self.selection_controller.publish_selection_change() - - def clear_selection(self, emit_signal: bool = True, sync_to_filter: bool = True): - """ - Clear all selected wells. - - Args: - emit_signal: Whether to emit wells_selected signal (default True) - sync_to_filter: Whether to sync to well filter (default True) - """ - self.selection_controller.clear_selection( - emit_signal=emit_signal, - sync_to_filter=sync_to_filter, - ) - - def select_wells(self, well_ids: Set[str], emit_signal: bool = True): - """ - Programmatically select wells. - - Args: - well_ids: Set of well IDs to select - emit_signal: Whether to emit wells_selected signal (default True) - """ - self.selection_controller.select_wells(well_ids, emit_signal=emit_signal) - - def eventFilter(self, obj, event): - """Handle mouse events on buttons for drag selection and rectangle selection in empty space.""" - handled = self.selection_event_controller.handle(obj, event) - if handled is not None: - return handled - - return super().eventFilter(obj, event) - - def sync_to_partition_filter(self): - """Sync current plate view selection to well filter checkboxes.""" - self.selection_controller.sync_to_partition_filter() - - def sync_from_partition_filter(self): - """Sync well filter checkbox selection to plate view.""" - self.selection_controller.sync_from_partition_filter() diff --git a/tests/unit/pyqt_gui/test_image_browser_column_presentation.py b/tests/unit/pyqt_gui/test_image_browser_column_presentation.py index e9844d3e09..52a658d147 100644 --- a/tests/unit/pyqt_gui/test_image_browser_column_presentation.py +++ b/tests/unit/pyqt_gui/test_image_browser_column_presentation.py @@ -17,7 +17,7 @@ class _PlateSelectionRecorder: def __init__(self) -> None: self.selected: set[str] = set() - def select_wells(self, well_ids: set[str], *, emit_signal: bool) -> None: + def select_cells(self, well_ids: set[str], *, emit_signal: bool) -> None: assert not emit_signal self.selected = well_ids diff --git a/tests/unit/pyqt_gui/test_plate_view_grid_model.py b/tests/unit/pyqt_gui/test_plate_view_grid_model.py index dd7effd9c5..43ca7c5992 100644 --- a/tests/unit/pyqt_gui/test_plate_view_grid_model.py +++ b/tests/unit/pyqt_gui/test_plate_view_grid_model.py @@ -1,69 +1,15 @@ -from PyQt6.QtWidgets import QApplication +from pyqt_reactive.widgets.grid_selector import GridModel -from openhcs.pyqt_gui.widgets.shared.plate_view_widget import ( - PlateGridModel, - PlateViewWidget, -) +from openhcs.domains.microscopy.axes import Microscopy -def test_plate_grid_model_parses_standard_well_coordinates() -> None: - model = PlateGridModel.from_wells({"B02", "C04", "AA10"}) +def test_well_axis_places_standard_well_ids_on_the_plate_grid() -> None: + model = GridModel.of(Microscopy.Well, {"B02", "C04", "AA10"}) - assert model.coord_to_well[(2, 2)] == "B02" - assert model.coord_to_well[(3, 4)] == "C04" - assert model.coord_to_well[(27, 10)] == "AA10" - assert model.well_to_coord["AA10"] == (27, 10) - assert model.plate_dimensions == (26, 9) - assert model.row_offset == 1 - assert model.col_offset == 1 - - -def test_plate_grid_model_uses_supplied_nonstandard_coordinates() -> None: - model = PlateGridModel.from_wells( - {"R02C03", "R04C05"}, - coord_to_well={(2, 3): "R02C03", (4, 5): "R04C05"}, - ) - - assert model.actual_rows == [2, 3, 4] - assert model.actual_cols == [3, 4, 5] - assert model.well_at(4, 5) == "R04C05" - assert model.well_at(3, 4) is None - assert model.plate_dimensions == (3, 3) - - -def test_plate_grid_model_preserves_explicit_plate_dimensions() -> None: - model = PlateGridModel.from_wells( - {"A01", "B02"}, - plate_dimensions=(8, 12), - ) - - assert model.plate_dimensions == (8, 12) - assert model.actual_rows == [1, 2] - assert model.actual_cols == [1, 2] - - -def test_plate_grid_model_projects_axis_membership() -> None: - model = PlateGridModel.from_wells({"A01", "A02", "B01"}) - - assert model.wells_on_axis(axis_index=0, axis_value=1) == ["A01", "A02"] - assert model.wells_on_axis(axis_index=1, axis_value=1) == ["A01", "B01"] - - -def test_plate_selection_controller_mutates_widget_selection() -> None: - app = QApplication.instance() or QApplication([]) - widget = PlateViewWidget() - widget.set_available_wells( - {"A01", "A02"}, - plate_dimensions=(1, 2), - coord_to_well={(1, 1): "A01", (1, 2): "A02"}, - ) - - widget.select_wells({"A01"}, emit_signal=False) - assert widget.selected_wells == {"A01"} - - widget.selection_controller.invert_selection() - assert widget.selected_wells == {"A02"} - - widget.clear_selection(emit_signal=False) - assert widget.selected_wells == set() - assert app is not None + assert model.cell_at_position[(2, 2)] == "B02" + assert model.cell_at_position[(3, 4)] == "C04" + assert model.cell_at_position[(27, 10)] == "AA10" + assert model.position_of_cell["AA10"] == (27, 10) + assert model.dimensions == (26, 9) + assert GridModel.of(Microscopy.Well, set()).dimensions == Microscopy.Well.default_grid + assert Microscopy.Well.row_label(27) == "AA" diff --git a/tests/unit/test_grid_addressed_axis.py b/tests/unit/test_grid_addressed_axis.py index 678d20f49b..5a47b35d26 100644 --- a/tests/unit/test_grid_addressed_axis.py +++ b/tests/unit/test_grid_addressed_axis.py @@ -15,7 +15,7 @@ ) from openhcs.core.component_filters import ComponentFilters from openhcs.domains.microscopy.axes import Microscopy -from openhcs.pyqt_gui.widgets.shared.plate_view_widget import active_grid_axis +from openhcs.pyqt_gui.widgets.image_browser import active_grid_axis class _Survey(AxisFamily): From b0fdc4c55bb28f3415a1856ffca8c69eabc51d32 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Sun, 11 Oct 2026 03:57:34 -0400 Subject: [PATCH 08/17] U3: config windows come from pyqt-reactive OpenHCSConfigWindow is pyqt-reactive's ConfigWindow with the OpenHCS configuration document (an adapter over ConfigDocumentAuthority) and the global-config window id. Deleted: config_edit_session.py, config_window_code_document.py, ui/shared/code_editor_form_updater.py, the three readings of OPENHCS_USE_EXTERNAL_EDITOR (the variable name is now the form config's external_editor_env_var) and LazyDataclassFieldEmissionState.retain_only_authored_paths (now objectstate.retain_only_authored_paths). The image browser's grid selector addresses wells through GridAxisAddressing over the GridAddressed axis. Co-Authored-By: Claude Opus 5.5 --- external/ObjectState | 2 +- external/pyqt-reactive | 2 +- openhcs/authoring/session/dataset_document.py | 6 +- .../services/config_window_code_document.py | 85 -- .../services/function_step_code_document.py | 4 +- .../pyqt_gui/services/reactor_providers.py | 1 + openhcs/pyqt_gui/services/window_handlers.py | 16 +- openhcs/pyqt_gui/widgets/image_browser.py | 21 +- openhcs/pyqt_gui/widgets/pipeline_editor.py | 16 +- openhcs/pyqt_gui/widgets/plate_manager.py | 18 +- .../pyqt_gui/widgets/step_parameter_editor.py | 12 +- .../pyqt_gui/windows/config_edit_session.py | 168 ---- openhcs/pyqt_gui/windows/config_window.py | 842 +----------------- openhcs/serialization/pycodify_formatters.py | 52 -- openhcs/ui/shared/code_editor_form_updater.py | 269 ------ .../test_end_to_end_workflow_foundation.py | 6 +- .../pyqt_gui/test_code_editor_form_updater.py | 141 --- .../test_config_window_state_resolution.py | 36 +- .../test_config_window_transaction.py | 31 +- .../pyqt_gui/test_pipeline_editor_widget.py | 2 +- .../pyqt_gui/test_plate_view_grid_model.py | 7 +- tests/unit/pyqt_gui/test_ui_agent_bridge.py | 7 +- 22 files changed, 113 insertions(+), 1631 deletions(-) delete mode 100644 openhcs/pyqt_gui/services/config_window_code_document.py delete mode 100644 openhcs/pyqt_gui/windows/config_edit_session.py delete mode 100644 openhcs/ui/shared/code_editor_form_updater.py delete mode 100644 tests/unit/pyqt_gui/test_code_editor_form_updater.py diff --git a/external/ObjectState b/external/ObjectState index fcf8546cdd..6a84d43963 160000 --- a/external/ObjectState +++ b/external/ObjectState @@ -1 +1 @@ -Subproject commit fcf8546cddb73373df4721a5f78cedf0bc354099 +Subproject commit 6a84d43963784eaf4d3d4bb24e0374146a3007fa diff --git a/external/pyqt-reactive b/external/pyqt-reactive index 062fa62522..bb26812257 160000 --- a/external/pyqt-reactive +++ b/external/pyqt-reactive @@ -1 +1 @@ -Subproject commit 062fa62522ef19c65a0dc6ab2aef8b287c985d83 +Subproject commit bb26812257f3968da906f9cfda9a8ceec0ba973e diff --git a/openhcs/authoring/session/dataset_document.py b/openhcs/authoring/session/dataset_document.py index e1de6c4d07..600f4aa03a 100644 --- a/openhcs/authoring/session/dataset_document.py +++ b/openhcs/authoring/session/dataset_document.py @@ -17,7 +17,7 @@ from openhcs.core.config import GlobalPipelineConfig, PipelineConfig from openhcs.core.selection import SelectedAllSelectionMode, SelectedScopeIdsCarrier -from openhcs.serialization.pycodify_formatters import LazyDataclassFieldEmissionState +from objectstate import retain_only_authored_paths from openhcs.ui.shared.plate_manager_code_document import ( PlateManagerCodeDocumentAuthority, PlateManagerOrchestratorCodePayload, @@ -118,9 +118,7 @@ def authored_pipeline_config(scope_id: str) -> PipelineConfig: config = state.to_object(update_delegate=False) if not isinstance(config, PipelineConfig): raise TypeError("A dataset ObjectState must rebuild a PipelineConfig.") - return LazyDataclassFieldEmissionState.retain_only_authored_paths( - config, state.signature_diff_fields - ) + return retain_only_authored_paths(config, state.signature_diff_fields) def render_dataset_document( diff --git a/openhcs/pyqt_gui/services/config_window_code_document.py b/openhcs/pyqt_gui/services/config_window_code_document.py deleted file mode 100644 index b7fed8685b..0000000000 --- a/openhcs/pyqt_gui/services/config_window_code_document.py +++ /dev/null @@ -1,85 +0,0 @@ -"""Configuration code-document driver for managed PyQt windows.""" - -from __future__ import annotations - -import os -from collections.abc import Callable -from typing import ClassVar, Generic, TypeVar - -from pyqt_reactive.services.window_code_document import ( - PYTHON_MIME_TYPE, - WindowCodeDocument, - WindowCodeDocumentDriver, -) - -from openhcs.core.config_document import ConfigDocumentAuthority -from openhcs.ui.shared.code_editor_form_updater import CodeEditorFormUpdater - -ConfigT = TypeVar("ConfigT") - - -class ExternalCodeEditorPreference: - """Environment-backed policy for opening code documents externally.""" - - env_var: ClassVar[str] = "OPENHCS_USE_EXTERNAL_EDITOR" - enabled_values: ClassVar[frozenset[str]] = frozenset(("1", "true", "yes")) - - @classmethod - def use_external_editor(cls) -> bool: - if cls.env_var not in os.environ: - return False - return os.environ[cls.env_var].lower() in cls.enabled_values - - -class ConfigCodeDocumentDriver(WindowCodeDocumentDriver, Generic[ConfigT]): - """Render and apply configuration source through its nominal authority.""" - - def __init__( - self, - *, - title: str, - config_type: type[ConfigT], - current_config: Callable[[], ConfigT], - apply_config: Callable[[ConfigT], None], - before_read: Callable[[], None] | None = None, - before_apply: Callable[[], None] | None = None, - ) -> None: - self._title = title - self._config_type = config_type - self._current_config = current_config - self._apply_config = apply_config - self._before_read = before_read - self._before_apply = before_apply - - def read_document(self, clean: bool = True) -> WindowCodeDocument: - if self._before_read is not None: - self._before_read() - source = ConfigDocumentAuthority.render( - self._current_config(), - expected_config_type=self._config_type, - clean_mode=clean, - ) - return WindowCodeDocument( - title=self._title, - source=source, - mime_type=PYTHON_MIME_TYPE, - declaration_type=self._config_type, - ) - - def validate_source(self, source: str) -> None: - self._config_from_source(source) - - def apply_source(self, source: str) -> None: - if self._before_apply is not None: - self._before_apply() - self._apply_config(self._config_from_source(source)) - - def _config_from_source(self, source: str) -> ConfigT: - with CodeEditorFormUpdater.patch_lazy_constructors(): - return ConfigDocumentAuthority.from_source( - source, - expected_config_type=self._config_type, - ) - - -__all__ = ("ConfigCodeDocumentDriver", "ExternalCodeEditorPreference") diff --git a/openhcs/pyqt_gui/services/function_step_code_document.py b/openhcs/pyqt_gui/services/function_step_code_document.py index 3cfdcb616f..42c6197a1a 100644 --- a/openhcs/pyqt_gui/services/function_step_code_document.py +++ b/openhcs/pyqt_gui/services/function_step_code_document.py @@ -12,7 +12,7 @@ from openhcs.core.function_step_document import FunctionStepDocumentCodec from openhcs.core.steps.function_step import FunctionStep -from openhcs.ui.shared.code_editor_form_updater import CodeEditorFormUpdater +from objectstate import patch_lazy_constructors class FunctionStepCodeDocumentDriver(WindowCodeDocumentDriver): @@ -57,5 +57,5 @@ def apply_source(self, source: str) -> None: @staticmethod def _step_from_source(source: str) -> FunctionStep: - with CodeEditorFormUpdater.patch_lazy_constructors(): + with patch_lazy_constructors(): return FunctionStepDocumentCodec.from_source(source).step diff --git a/openhcs/pyqt_gui/services/reactor_providers.py b/openhcs/pyqt_gui/services/reactor_providers.py index adc685e206..aa55b034dd 100644 --- a/openhcs/pyqt_gui/services/reactor_providers.py +++ b/openhcs/pyqt_gui/services/reactor_providers.py @@ -55,6 +55,7 @@ class OpenHCSFormGenConfig(FormGenConfig): performance_logger_name: str = "openhcs.performance" performance_log_filename: str = "performance.log" path_cache_file: str | None = None + external_editor_env_var: str = "OPENHCS_USE_EXTERNAL_EDITOR" class OpenHCSCodegenProvider(CodegenProviderABC): diff --git a/openhcs/pyqt_gui/services/window_handlers.py b/openhcs/pyqt_gui/services/window_handlers.py index 197028a723..09491d9c48 100644 --- a/openhcs/pyqt_gui/services/window_handlers.py +++ b/openhcs/pyqt_gui/services/window_handlers.py @@ -59,12 +59,13 @@ def create_global_config_window( from openhcs.core.config import GlobalPipelineConfig from openhcs.core.config_cache import save_global_config_sync from openhcs.pyqt_gui.config import UIConfig, save_ui_config_sync - from openhcs.pyqt_gui.windows.config_window import ( + from pyqt_reactive.windows.config_window import ( ConfigSaveParticipant, - ConfigWindow, ConfigWindowTabSpec, ) + from openhcs.pyqt_gui.windows.config_window import OpenHCSConfigWindow + plate_manager = self._plate_manager() if plate_manager is None: raise RuntimeError( @@ -107,7 +108,7 @@ def handle_ui_save(new_config: UIConfig) -> None: main_window.set_ui_config(new_config) logger.info("UI config saved via window") - window = ConfigWindow( + window = OpenHCSConfigWindow( tabs=( ConfigWindowTabSpec( state=global_state, @@ -155,10 +156,9 @@ def create_plate_config_window( from objectstate import ObjectStateRegistry from openhcs.core.config import PipelineConfig - from openhcs.pyqt_gui.windows.config_window import ( - ConfigWindow, - ConfigWindowTabSpec, - ) + from pyqt_reactive.windows.config_window import ConfigWindowTabSpec + + from openhcs.pyqt_gui.windows.config_window import OpenHCSConfigWindow plate_manager = self._plate_manager() if plate_manager is None: @@ -187,7 +187,7 @@ def create_plate_config_window( ) return None - window = ConfigWindow( + window = OpenHCSConfigWindow( tabs=( ConfigWindowTabSpec( state=state, diff --git a/openhcs/pyqt_gui/widgets/image_browser.py b/openhcs/pyqt_gui/widgets/image_browser.py index 89f840a577..6feb1765c6 100644 --- a/openhcs/pyqt_gui/widgets/image_browser.py +++ b/openhcs/pyqt_gui/widgets/image_browser.py @@ -6,7 +6,7 @@ """ from openhcs.core.axes import Axis, AxisFamily, GridAddressed -from pyqt_reactive.widgets.grid_selector import GridSelectorWidget +from pyqt_reactive.widgets.grid_selector import GridAddressing, GridSelectorWidget from objectstate import ScopeKey import logging import subprocess @@ -61,6 +61,23 @@ def active_grid_axis() -> type[Axis] | None: return grid_axes[0] if grid_axes else None +@dataclass(frozen=True, slots=True) +class GridAxisAddressing(GridAddressing): + """Lays a GridAddressed axis's values out on the grid selector.""" + + axis: type[Axis] + + @property + def default_grid(self) -> tuple[int, int]: + return self.axis.default_grid + + def grid_index(self, value: object) -> tuple[int, int] | None: + return self.axis.grid_index(value) + + def row_label(self, row: int) -> str: + return self.axis.row_label(row) + + def require_grid_axis() -> type[Axis]: """The grid axis the plate view lays out; a plate view needs one.""" @@ -616,7 +633,7 @@ def init_ui(self): if active_grid_axis() is not None: grid_axis = require_grid_axis() self.plate_view_widget = GridSelectorWidget( - addressing=grid_axis, + addressing=GridAxisAddressing(grid_axis), title="Plate View", cell_noun=f"{grid_axis.label.lower()}s", color_scheme=self.color_scheme, diff --git a/openhcs/pyqt_gui/widgets/pipeline_editor.py b/openhcs/pyqt_gui/widgets/pipeline_editor.py index 0a0f7ca17f..3d0d89c923 100644 --- a/openhcs/pyqt_gui/widgets/pipeline_editor.py +++ b/openhcs/pyqt_gui/widgets/pipeline_editor.py @@ -5,10 +5,10 @@ them on every pipeline event; every change goes through the session. """ +from pyqt_reactive.services.window_code_document import use_external_code_editor from objectstate import ScopeKey import copy import logging -import os from dataclasses import dataclass from functools import singledispatchmethod from typing import Any, Callable, Optional, Tuple @@ -112,7 +112,6 @@ StepFunctionDeclaration = FunctionSpec | dict[str, FunctionSpec] | None -PIPELINE_EDITOR_EXTERNAL_EDITOR_ENV = "OPENHCS_USE_EXTERNAL_EDITOR" SHOW_PIPELINE_DEBUG_TOOLBAR = False @@ -132,17 +131,6 @@ def code_document_driver_for_window(cls, window): return window.code_document_driver() -def pipeline_editor_external_editor_enabled() -> bool: - """Return the explicit environment policy for launching pipeline code edits.""" - if PIPELINE_EDITOR_EXTERNAL_EDITOR_ENV not in os.environ: - return False - return os.environ[PIPELINE_EDITOR_EXTERNAL_EDITOR_ENV].lower() in ( - "1", - "true", - "yes", - ) - - @dataclass(frozen=True, slots=True) class StepFunctionTooltipSection: """Tooltip section for a step's function declaration.""" @@ -801,7 +789,7 @@ def present(self, renderer: GuiRenderer, request) -> None: initial_content=editor_widget.code_document_source(clean=True), title=editor_widget.code_document_title(), callback=editor_widget._handle_edited_code, - use_external=pipeline_editor_external_editor_enabled(), + use_external=use_external_code_editor(), declaration_type=PipelineDocument, code_data={"clean_mode": True}, ) diff --git a/openhcs/pyqt_gui/widgets/plate_manager.py b/openhcs/pyqt_gui/widgets/plate_manager.py index aed317c282..426fa41c09 100644 --- a/openhcs/pyqt_gui/widgets/plate_manager.py +++ b/openhcs/pyqt_gui/widgets/plate_manager.py @@ -7,8 +7,8 @@ from __future__ import annotations +from pyqt_reactive.services.window_code_document import use_external_code_editor import logging -import os from dataclasses import fields, replace from functools import singledispatchmethod from pathlib import Path @@ -101,7 +101,7 @@ from openhcs.pyqt_gui.widgets.shared.services.plate_manager_workflows import ( PlateManagerCodeWorkflow, ) -from openhcs.pyqt_gui.windows.config_window import ConfigWindow +from openhcs.pyqt_gui.windows.config_window import OpenHCSConfigWindow from openhcs.pyqt_gui.windows.live_measurements_window import LiveMeasurementsWindow from openhcs.pyqt_gui.windows.plate_viewer_window import PlateViewerWindow @@ -111,14 +111,6 @@ logger = logging.getLogger(__name__) -def external_editor_enabled() -> bool: - """Return the explicit environment policy for launching the code editor.""" - variable_name = "OPENHCS_USE_EXTERNAL_EDITOR" - if variable_name not in os.environ: - return False - return os.environ[variable_name].lower() in ("1", "true", "yes") - - class PlateManagerWidget( SessionOperationButtons, OpenHCSSingleRowActionManagerMixin, @@ -441,7 +433,7 @@ def show_dataset_code(self, selection_mode: SelectedAllSelectionMode) -> None: callback=lambda edited: self._action_controller.apply_edited_code( operations, edited ), - use_external=external_editor_enabled(), + use_external=use_external_code_editor(), declaration_type=type(document.payload), code_data={"clean_mode": document.clean_mode}, ) @@ -527,7 +519,7 @@ def open_config_window( on_save, mutation_scope_ids: tuple[str, ...], ) -> None: - from openhcs.pyqt_gui.windows.config_window import ( + from pyqt_reactive.windows.config_window import ( ConfigSaveParticipant, ConfigWindowTabSpec, ) @@ -536,7 +528,7 @@ def require_mutation_allowed() -> None: for scope_id in mutation_scope_ids: manager.session.require_definition_mutation_allowed(scope_id) - window = ConfigWindow( + window = OpenHCSConfigWindow( tabs=( ConfigWindowTabSpec( state=state, diff --git a/openhcs/pyqt_gui/widgets/step_parameter_editor.py b/openhcs/pyqt_gui/widgets/step_parameter_editor.py index c9956c5efe..154d98c540 100644 --- a/openhcs/pyqt_gui/widgets/step_parameter_editor.py +++ b/openhcs/pyqt_gui/widgets/step_parameter_editor.py @@ -48,19 +48,19 @@ ) from pyqt_reactive.widgets.shared.scrollable_form_mixin import ScrollableFormMixin from pyqt_reactive.services.parameter_ops_service import ParameterOpsService -from pyqt_reactive.services.window_code_document import WindowCodeDocumentDriver +from pyqt_reactive.services.code_editor_form_updater import CodeEditorFormUpdater +from pyqt_reactive.services.window_code_document import ( + WindowCodeDocumentDriver, + use_external_code_editor, +) from pyqt_reactive.widgets.editors.simple_code_editor import SimpleCodeEditorService from pyqt_reactive.theming import ColorScheme, WidgetTheme from pyqt_reactive.forms.layout_constants import CURRENT_LAYOUT from openhcs.pyqt_gui.services.function_step_code_document import ( FunctionStepCodeDocumentDriver, ) -from openhcs.pyqt_gui.services.config_window_code_document import ( - ExternalCodeEditorPreference, -) from pyqt_reactive.forms.parameter_type_utils import ParameterTypeUtils -from openhcs.ui.shared.code_editor_form_updater import CodeEditorFormUpdater from objectstate.object_state import ObjectState, ObjectStateRegistry logger = logging.getLogger(__name__) @@ -506,7 +506,7 @@ def view_step_code(self): initial_content=document.source, title=document.title, callback=self._handle_edited_step_code, - use_external=ExternalCodeEditorPreference.use_external_editor(), + use_external=use_external_code_editor(), declaration_type=document.declaration_type, code_data={"clean_mode": True}, ) diff --git a/openhcs/pyqt_gui/windows/config_edit_session.py b/openhcs/pyqt_gui/windows/config_edit_session.py deleted file mode 100644 index 21c79189d4..0000000000 --- a/openhcs/pyqt_gui/windows/config_edit_session.py +++ /dev/null @@ -1,168 +0,0 @@ -"""Nominal edit-session state for ConfigWindow.""" - -from __future__ import annotations - -import logging -from dataclasses import dataclass, field -from typing import Generic, TypeVar, cast - -from objectstate import ( - ObjectState, - ObjectStateEditSession, - get_live_global_config, - get_saved_global_config, - is_global_config_type, - set_live_global_config, - set_saved_global_config, -) -from objectstate.lazy_factory import LazyDataclass - -from openhcs.serialization.pycodify_formatters import ( - LazyDataclassFieldEmissionState, -) - -logger = logging.getLogger(__name__) - - -ConfigT = TypeVar("ConfigT") - - -@dataclass(frozen=True, slots=True) -class GlobalConfigContextCheckpoint(Generic[ConfigT]): - """Exact external and edit-session context at one transaction boundary.""" - - saved: ConfigT | None - live: ConfigT | None - original_live: ConfigT | None - dirty: bool - - -@dataclass(slots=True) -class ConfigEditSession(Generic[ConfigT]): - """Own non-visual config edit state for ConfigWindow.""" - - state: ObjectState - original_config: ConfigT - global_context_dirty: bool = False - saving: bool = False - _original_live_global_config: ConfigT | None = field(init=False, repr=False) - _object_session: ObjectStateEditSession[ConfigT] = field(init=False, repr=False) - - def __post_init__(self) -> None: - self._original_live_global_config = None - if self.is_global_config: - self._original_live_global_config = get_live_global_config( - self.config_class - ) - self._object_session = ObjectStateEditSession( - state_provider=lambda: self.state, - fallback_object=self.original_config, - expected_type=self.config_class, - ) - - @property - def config_class(self) -> type[ConfigT]: - return type(self.original_config) - - @property - def is_global_config(self) -> bool: - return is_global_config_type(self.config_class) - - def to_object(self) -> ConfigT: - """Reconstruct the current config object from ObjectState storage.""" - return self._object_session.to_object() - - def to_code_document_object(self) -> ConfigT: - """Reconstruct config while retaining only authored lazy field paths. - - Flattened ``ObjectState`` storage reconstructs every nested lazy - dataclass so raw inheritance markers remain intact. Clean code mode - must not mistake those reconstructed containers for user-authored - overrides, so project the object through ObjectState's authoritative - signature-diff paths before serialization. - """ - - config = self.to_object() - if not isinstance(config, LazyDataclass): - return config - return cast( - ConfigT, - LazyDataclassFieldEmissionState.retain_only_authored_paths( - config, - self.state.signature_diff_fields, - ), - ) - - def begin_save_callback(self, window_id: int) -> None: - self.saving = True - logger.info("SAVE_CONFIG: Set saving=True before callback (id=%s)", window_id) - - def end_save_callback(self, window_id: int) -> None: - self.saving = False - logger.info("SAVE_CONFIG: Reset saving=False (id=%s)", window_id) - - def mark_global_field_changed(self) -> None: - """Track unsaved global-context changes unless a save is in progress.""" - if self.saving: - return - self.global_context_dirty = True - - def apply_code_edit_context(self, new_config: ConfigT) -> None: - """Publish an unsaved code edit to the live UI context only.""" - if not self.is_global_config: - return - set_live_global_config(self.config_class, new_config) - self.global_context_dirty = True - logger.debug( - "Updated LIVE thread-local %s context", - self.config_class.__name__, - ) - - def capture_global_context(self) -> GlobalConfigContextCheckpoint[ConfigT] | None: - """Capture saved and live stores independently before a fallible save.""" - - if not self.is_global_config: - return None - return GlobalConfigContextCheckpoint( - saved=get_saved_global_config(self.config_class), - live=get_live_global_config(self.config_class), - original_live=self._original_live_global_config, - dirty=self.global_context_dirty, - ) - - def restore_global_context( - self, - checkpoint: GlobalConfigContextCheckpoint[ConfigT] | None, - ) -> None: - """Restore the exact saved/live split captured before a save.""" - - if not self.is_global_config or checkpoint is None: - return - set_saved_global_config(self.config_class, checkpoint.saved) - set_live_global_config(self.config_class, checkpoint.live) - self._original_live_global_config = checkpoint.original_live - self.global_context_dirty = checkpoint.dirty - - def publish_saved_global_config(self, new_config: ConfigT) -> None: - """Publish a saved global config to the saved/live thread-local stores.""" - if not self.is_global_config: - return - set_saved_global_config(self.config_class, new_config) - set_live_global_config(self.config_class, new_config) - logger.debug( - "Updated SAVED and LIVE thread-local %s on SAVE", - self.config_class.__name__, - ) - self._original_live_global_config = new_config - self.global_context_dirty = False - - def restore_global_context_if_dirty(self) -> bool: - """Restore only the live context that preceded this edit session.""" - if not self.is_global_config or not self.global_context_dirty: - return False - set_live_global_config( - self.config_class, - self._original_live_global_config, - ) - self.global_context_dirty = False - return True diff --git a/openhcs/pyqt_gui/windows/config_window.py b/openhcs/pyqt_gui/windows/config_window.py index 8887b717dc..7c13b6f588 100644 --- a/openhcs/pyqt_gui/windows/config_window.py +++ b/openhcs/pyqt_gui/windows/config_window.py @@ -1,844 +1,38 @@ -"""ObjectState-backed configuration editor windows.""" +"""OpenHCS's configuration window: its document format and window ids.""" from __future__ import annotations -import dataclasses -import logging -from dataclasses import dataclass -from functools import partial -from typing import Callable +from typing import ClassVar -from PyQt6.QtCore import pyqtSignal -from PyQt6.QtWidgets import ( - QHBoxLayout, - QLabel, - QMessageBox, - QPushButton, - QTreeWidget, - QTreeWidgetItem, - QVBoxLayout, - QWidget, -) +from pyqt_reactive.windows.config_window import ConfigDocumentFormat, ConfigWindow -from objectstate import ( - ObjectState, - ObjectStateRegistry, - ObjectStateTransactionCheckpoint, -) -from pyqt_reactive.forms.layout_constants import CURRENT_LAYOUT -from pyqt_reactive.forms.parameter_form_manager import ( - FormManagerConfig, - ParameterFormManager, -) -from pyqt_reactive.services.parameter_ops_service import ParameterOpsService -from pyqt_reactive.services.window_code_document import ( - WindowCodeDocument, - WindowCodeDocumentDriver, -) -from pyqt_reactive.services.window_navigation import ( - ObjectStateNavigationTargetAuthority, - RegisteredWindowNavigationRequest, - WindowNavigationDriver, -) -from pyqt_reactive.theming import ColorScheme, WidgetTheme -from pyqt_reactive.widgets.editors.simple_code_editor import SimpleCodeEditorService -from pyqt_reactive.widgets.shared import ( - ActionTabMaterialization, - ActionTabSpec, - ActionTabbedWindowBody, - BaseFormDialog, - DirtyWindowPresentation, - FormWindowActionHeader, - HeaderAction, - HeaderActionGroup, - ManagedStateRestorePolicy, - ManagedWindowActionCapabilities, - create_scrollable_form_body, -) -from pyqt_reactive.widgets.shared.clickable_help_components import ( - HelpButton, - HelpContext, -) -from pyqt_reactive.widgets.shared.config_hierarchy_tree import ( - ConfigHierarchyTreeHelper, -) -from pyqt_reactive.widgets.shared.form_window_action_header import ( - HeaderActionGroupRole, -) -from pyqt_reactive.widgets.shared.scrollable_form_mixin import ( - ScrollableFormMixin, - ScrollableFormWindowNavigationDriver, -) - -from openhcs.pyqt_gui.services.config_window_code_document import ( - ConfigCodeDocumentDriver, - ExternalCodeEditorPreference, -) +from openhcs.core.config_document import ConfigDocumentAuthority from openhcs.pyqt_gui.services.ui_window_ids import OpenHCSUiWindowId -from openhcs.pyqt_gui.windows.config_edit_session import ConfigEditSession -from openhcs.ui.shared.code_editor_form_updater import CodeEditorFormUpdater - -logger = logging.getLogger(__name__) - - -@dataclass(frozen=True, slots=True) -class ConfigSaveParticipant: - """Reversible external effect participating in a ConfigWindow save.""" - - apply: Callable[[object], None] - rollback: Callable[[object], None] - - -@dataclass(frozen=True, slots=True) -class ConfigWindowTabSpec: - """One caller-owned config state and its persistence boundary.""" - - state: ObjectState - save_participant: ConfigSaveParticipant | None = None - before_mutation: Callable[[], None] | None = None - - @property - def label(self) -> str: - return type(self.state.saved_object).__name__ - - -@dataclass(slots=True) -class _ConfigEditorTabView: - form_manager: ParameterFormManager - tree_helper: ConfigHierarchyTreeHelper - tree_widget: QTreeWidget - content: QWidget - actions: QWidget - scroll_area: QWidget - code_document_driver: ConfigCodeDocumentDriver - - -@dataclass(slots=True) -class _ConfigEditorTab: - spec: ConfigWindowTabSpec - state: ObjectState - session: ConfigEditSession - restore_policy: ManagedStateRestorePolicy - state_changed_callback: Callable[[set[str]], None] - help_button: HelpButton | None - view: _ConfigEditorTabView | None = None - - def require_view(self) -> _ConfigEditorTabView: - if self.view is None: - raise RuntimeError(f"Config tab {self.spec.label} is not materialized.") - return self.view - - @property - def form_manager(self) -> ParameterFormManager: - return self.require_view().form_manager - - @property - def tree_helper(self) -> ConfigHierarchyTreeHelper: - return self.require_view().tree_helper - - @property - def tree_widget(self) -> QTreeWidget: - return self.require_view().tree_widget - - @property - def content(self) -> QWidget: - return self.require_view().content - - @property - def actions(self) -> QWidget: - return self.require_view().actions - - @property - def scroll_area(self) -> QWidget: - return self.require_view().scroll_area - - @property - def code_document_driver(self) -> ConfigCodeDocumentDriver: - return self.require_view().code_document_driver - - -class _ActiveConfigCodeDocumentDriver(WindowCodeDocumentDriver): - """Expose the selected config tab through the managed-window code API.""" - - def __init__(self, active_driver: Callable[[], WindowCodeDocumentDriver]) -> None: - self._active_driver = active_driver - - def read_document(self, clean: bool = True) -> WindowCodeDocument: - return self._active_driver().read_document(clean=clean) - - def validate_source(self, source: str) -> None: - self._active_driver().validate_source(source) - - def apply_source(self, source: str) -> None: - self._active_driver().apply_source(source) - - -class ConfigWindowNavigationDriver(ScrollableFormWindowNavigationDriver): - """Navigate fields through the config tab whose ObjectState owns them.""" - - _owner: ConfigWindow - - def __init__(self, owner: "ConfigWindow") -> None: - super().__init__(owner) - - def accepts_field_path( - self, - request: RegisteredWindowNavigationRequest, - ) -> bool: - return self._tab_index(request) is not None - - def prepare(self, request: RegisteredWindowNavigationRequest) -> None: - tab_index = self._tab_index(request) - if tab_index is not None: - self._owner._tab_body.set_current_index(tab_index) - - def _tab_index( - self, - request: RegisteredWindowNavigationRequest, - ) -> int | None: - field_path = request.field_path - if field_path is None: - return None - - scoped_indices = tuple( - index - for index, tab in enumerate(self._owner._tabs) - if tab.state.scope_id == request.requested_scope_id - ) - candidate_indices = scoped_indices or tuple(range(len(self._owner._tabs))) - matching_indices = tuple( - index - for index in candidate_indices - if ObjectStateNavigationTargetAuthority.owns_field_path( - self._owner._tabs[index].state, - field_path, - ) - ) - if len(matching_indices) != 1: - return None - return matching_indices[0] - - -class ConfigWindow(ScrollableFormMixin, BaseFormDialog): - """Edit one or more authoritative configuration objects in tabs.""" - - config_saved = pyqtSignal(object) - config_cancelled = pyqtSignal() - - def __init__( - self, - tabs: tuple[ConfigWindowTabSpec, ...], - color_scheme: ColorScheme | None = None, - parent=None, - scope_id: str | None = None, - title_text: str | None = None, - ) -> None: - if not tabs: - raise ValueError("ConfigWindow requires at least one config tab.") - super().__init__(parent) - - self.scope_id = scope_id - self.theme = WidgetTheme.from_optional(color_scheme) - self._scope_accent_color = None - self._header_label: QLabel | None = None - self._save_button: QPushButton | None = None - self._default_size_applied = False - self._base_window_title = title_text or f"Config {tabs[0].label}" - self._action_header: FormWindowActionHeader | None = None - self._tabs = [self._create_tab_model(spec) for spec in tabs] - self._tab_body = ActionTabbedWindowBody( - color_scheme=self.theme.scheme, - parent=self, - ) - self._tab_body.tab_materialization_failed.connect( - self._on_tab_materialization_failed - ) - - for tab in self._tabs: - self._tab_body.add_tab( - ActionTabSpec( - label=tab.spec.label, - materialization_factory=partial(self._materialize_tab, tab), - ) - ) - - self.state = self.active_tab.state - self.state_restore_policy = self.active_tab.restore_policy - self._sync_tab_action_visibility() - self._code_document_driver = _ActiveConfigCodeDocumentDriver( - lambda: self.active_tab.code_document_driver - ) - - self.setup_ui() - self._tab_body.current_changed.connect(self._on_tab_changed) - self.connect_change_detection() - self.detect_changes() - logger.debug("Config window initialized with %d tab(s)", len(self._tabs)) - - @property - def active_tab(self) -> _ConfigEditorTab: - return self._tabs[max(self._tab_body.current_index(), 0)] - - @property - def form_manager(self) -> ParameterFormManager: - return self.active_tab.form_manager - - @property - def tree_widget(self) -> QTreeWidget: - return self.active_tab.tree_widget - - @property - def scroll_area(self): - return self.active_tab.scroll_area - - def _create_tab_model(self, spec: ConfigWindowTabSpec) -> _ConfigEditorTab: - state = spec.state - config = state.saved_object - config_type = type(config) - session = ConfigEditSession( - state=state, - original_config=config, - ) - restore_policy = ManagedStateRestorePolicy( - propagate_descendants=not state.has_delegate - ) - - def state_changed(_fields: set[str]) -> None: - self.detect_changes() - - state.on_state_changed(state_changed) - return _ConfigEditorTab( - spec=spec, - state=state, - session=session, - restore_policy=restore_policy, - state_changed_callback=state_changed, - help_button=self._build_help_button(config_type), - ) - def _materialize_tab( - self, - tab: _ConfigEditorTab, - ) -> ActionTabMaterialization: - state = tab.state - spec = tab.spec - session = tab.session - config_type = type(state.saved_object) - form_config = FormManagerConfig( - parent=None, - scope_id=self.scope_id, - color_scheme=self.theme.scheme, - scope_accent_color=self._scope_accent_color, - before_mutation=spec.before_mutation, - ) - form_config.field_id = "" - form_manager = ParameterFormManager(state=state, config=form_config) - form_manager.form_build_failed.connect(partial(self._on_form_build_failed, tab)) - if session.is_global_config: - form_manager.parameter_changed.connect( - lambda _name, _value, current_session=session: ( - current_session.mark_global_field_changed() - ) - ) - - tree_helper = ConfigHierarchyTreeHelper() - tree_widget = tree_helper.create_tree_from_root_dataclass( - root_dataclass=config_type, - form_manager=form_manager, - state=state, - strip_config_suffix=True, - on_item_double_clicked=partial( - self._on_tree_item_double_clicked, - tree_helper, - form_manager, - ), - ) - body_parts = create_scrollable_form_body( - form_widget=form_manager, - tree_widget=tree_widget, - tree_initial_size=300, - form_initial_size=700, - parent=self, - ) - code_document_driver = ConfigCodeDocumentDriver( - title=f"View/Edit {config_type.__name__}", - config_type=config_type, - current_config=session.to_code_document_object, - apply_config=partial( - self._apply_config_from_code_document, - session, - form_manager, - ), - before_read=partial( - ParameterOpsService().refresh_with_live_context, - form_manager, - ), - before_apply=spec.before_mutation, - ) - actions = self._build_tab_actions( - form_manager, - code_document_driver, - ) - tab.view = _ConfigEditorTabView( - form_manager=form_manager, - tree_helper=tree_helper, - tree_widget=tree_widget, - content=body_parts.body_widget, - actions=actions, - scroll_area=body_parts.scroll_area, - code_document_driver=code_document_driver, - ) - return ActionTabMaterialization( - content=body_parts.body_widget, - actions=actions, - ) +class OpenHCSConfigDocument(ConfigDocumentFormat): + """The OpenHCS configuration document (``ConfigDocumentAuthority``).""" - def _on_form_build_failed( - self, - tab: _ConfigEditorTab, - error: Exception, - ) -> None: - logger.error( - "Failed to construct config tab %s: %s", - tab.spec.label, - error, + def render(self, config: object, *, expected_config_type: type, clean_mode: bool) -> str: + return ConfigDocumentAuthority.render( + config, + expected_config_type=expected_config_type, + clean_mode=clean_mode, ) - QMessageBox.critical( - self, - "Configuration Form Error", - f"Failed to construct {tab.spec.label}:\n{error}", - ) - self.reject() - - def _on_tab_materialization_failed( - self, - index: int, - error: Exception, - ) -> None: - tab = self._tabs[index] - logger.error( - "Failed to materialize config tab %s: %s", - tab.spec.label, - error, - ) - QMessageBox.critical( - self, - "Configuration Form Error", - f"Failed to construct {tab.spec.label}:\n{error}", - ) - self.reject() - - def _build_tab_actions( - self, - form_manager: ParameterFormManager, - code_document_driver: ConfigCodeDocumentDriver, - ) -> QWidget: - actions = QWidget(self) - layout = QHBoxLayout(actions) - layout.setContentsMargins(0, 0, 0, 0) - layout.setSpacing(4) - button_styles = self.theme.scheme.styles.generate_config_button_styles() - - reset_button = QPushButton("Reset") - reset_button.clicked.connect(form_manager.reset_all_parameters) - reset_button.setStyleSheet(button_styles["compact"]) - layout.addWidget(reset_button) - - code_button = QPushButton("View Code") - code_button.clicked.connect(partial(self._view_code, code_document_driver)) - code_button.setStyleSheet(button_styles["compact"]) - layout.addWidget(code_button) - for index in range(layout.count()): - widget = layout.itemAt(index).widget() - if widget is not None: - widget.setFixedHeight(CURRENT_LAYOUT.button_height) - return actions - - def _build_help_button(self, config_type: type[object]) -> HelpButton | None: - if not dataclasses.is_dataclass(config_type): - return None - help_button = HelpButton( - help_context=HelpContext( - help_target=config_type, - color_scheme=self.theme.scheme, - scope_accent_color=self._scope_accent_color, - ), - text="Help", + def from_source(self, source: str, *, expected_config_type: type) -> object: + return ConfigDocumentAuthority.from_source( + source, + expected_config_type=expected_config_type, ) - help_button.setStyleSheet( - self.theme.scheme.styles.generate_config_button_styles()["compact"] - ) - help_button.setFixedHeight(CURRENT_LAYOUT.button_height) - return help_button - def form_managers(self) -> tuple[ParameterFormManager, ...]: - return tuple( - tab.view.form_manager for tab in self._tabs if tab.view is not None - ) - def window_code_document_driver(self) -> WindowCodeDocumentDriver: - return self._code_document_driver +class OpenHCSConfigWindow(ConfigWindow): + """Config windows over OpenHCS configuration documents.""" - def window_navigation_driver(self) -> WindowNavigationDriver: - return ConfigWindowNavigationDriver(self) + document_format: ClassVar[ConfigDocumentFormat] = OpenHCSConfigDocument() def window_manager_scope_id(self) -> str | None: if self.scope_id is None: return None return OpenHCSUiWindowId.agent_window_id_for_manager_scope(self.scope_id) - - def managed_window_action_capabilities( - self, - ) -> ManagedWindowActionCapabilities: - return ManagedWindowActionCapabilities( - save_and_close=True, - save_without_close=True, - discard_and_close=True, - ) - - def agent_save_managed_window(self, *, close_window: bool) -> None: - self.save_config(close_window=close_window) - - def dirty_window_widgets(self) -> tuple[QLabel, QPushButton] | None: - if self._header_label is None or self._save_button is None: - return None - return self._header_label, self._save_button - - def dirty_window_presentation(self) -> DirtyWindowPresentation: - is_dirty = any(tab.state.is_raw_dirty for tab in self._tabs) - has_signature_diff = any( - bool(tab.state.signature_diff_fields) for tab in self._tabs - ) - return DirtyWindowPresentation( - window_title=self._base_window_title, - header_text=self._base_window_title, - save_label="Save", - is_dirty=is_dirty, - has_signature_diff=has_signature_diff, - mark_save_label_dirty=False, - ) - - def detect_changes(self) -> None: - has_changes = any(tab.state.is_raw_dirty for tab in self._tabs) - if has_changes != self.dirty_state.has_changes: - self.dirty_state.has_changes = has_changes - self.changes_detected.emit(has_changes) - else: - self.apply_dirty_window_presentation() - - def setup_ui(self) -> None: - self.setWindowTitle(self._base_window_title) - self.setModal(False) - if self.size().isEmpty(): - self.resize(650, 650) - - layout = QVBoxLayout(self) - layout.setContentsMargins(4, 4, 4, 4) - layout.setSpacing(4) - button_styles = self.theme.scheme.styles.generate_config_button_styles() - - cancel_button = QPushButton("Cancel") - cancel_button.clicked.connect(self.reject) - cancel_button.setStyleSheet(button_styles["compact"]) - - self._save_button = QPushButton("Save") - self._save_button.setEnabled(False) - self.setup_save_button(self._save_button, self.save_config) - self._save_button.setStyleSheet(button_styles["compact"]) - - for button in (cancel_button, self._save_button): - button.setFixedHeight(CURRENT_LAYOUT.button_height) - - active_tab_actions = self._tab_body.release_active_actions_widget() - - header = FormWindowActionHeader( - title_text=self._base_window_title, - title_color=self.theme.scheme.to_hex(self.theme.scheme.text_accent), - action_groups=( - HeaderActionGroup( - "group_title_companion", - tuple( - HeaderAction(f"tab_{index}_help", tab.help_button) - for index, tab in enumerate(self._tabs) - if tab.help_button is not None - ), - role=HeaderActionGroupRole.TITLE_COMPANION, - ), - HeaderActionGroup( - "group_auxiliary", - (HeaderAction("active_tab_actions", active_tab_actions),), - role=HeaderActionGroupRole.AUXILIARY, - ), - HeaderActionGroup( - "group_commit", - ( - HeaderAction("cancel", cancel_button), - HeaderAction("save", self._save_button), - ), - role=HeaderActionGroupRole.COMMIT, - ), - ), - parent=self, - ) - self._action_header = header - self._header_label = header.header_label - self._sync_tab_action_visibility() - layout.addWidget(header) - layout.addWidget(self._tab_body, 1) - - self.setStyleSheet( - self.theme.scheme.styles.generate_config_window_style() - + "\n" - + self.theme.scheme.styles.generate_tree_widget_style() - ) - if self.scope_id is not None: - self.init_scope_border() - - def _on_tab_changed(self, _index: int) -> None: - self._sync_tab_action_visibility() - self.state = self.active_tab.state - self.state_restore_policy = self.active_tab.restore_policy - self.apply_scope_accent_styling() - self.detect_changes() - - def _sync_tab_action_visibility(self) -> None: - active_index = max(self._tab_body.current_index(), 0) - for index, tab in enumerate(self._tabs): - if tab.help_button is not None: - tab.help_button.setVisible(index == active_index) - if self._action_header is not None: - self._action_header.refresh_layout() - - def _on_tree_item_double_clicked( - self, - tree_helper: ConfigHierarchyTreeHelper, - form_manager: ParameterFormManager, - item: QTreeWidgetItem, - column: int, - ) -> None: - del column - tree_helper.activate_item( - item, - scroll_to_section=self._scroll_to_section, - field_for_class=partial( - tree_helper.field_for_class_in_dataclass_instance, - form_manager.object_instance, - ), - ) - - def reset_to_defaults(self) -> None: - self.active_tab.form_manager.reset_all_parameters() - - def save_config(self, *, close_window: bool = True) -> None: - candidates: tuple[tuple[_ConfigEditorTab, object], ...] = () - previous_objects: dict[int, object] = {} - attempted_participants: list[_ConfigEditorTab] = [] - committed: tuple[tuple[_ConfigEditorTab, object], ...] = () - state_checkpoints: list[ - tuple[ObjectState, ObjectStateTransactionCheckpoint] - ] = [] - global_context_checkpoints: dict[int, object] = {} - transaction_started = False - try: - # Widget validation and authorization precede even reconstruction - # cache changes, so a rejected save has no transaction side effects. - for tab in self._tabs: - if tab.view is not None: - tab.form_manager.validate_current_values() - self.require_managed_state_mutation_allowed() - - states = list(ObjectStateRegistry.get_all()) - for tab in self._tabs: - if all(state is not tab.state for state in states): - states.append(tab.state) - state_checkpoints = [ - (state, ObjectStateTransactionCheckpoint.capture(state)) - for state in states - ] - previous_objects = {id(tab): tab.state.saved_object for tab in self._tabs} - global_context_checkpoints = { - id(tab): checkpoint - for tab in self._tabs - if (checkpoint := tab.session.capture_global_context()) is not None - } - transaction_started = True - - with ObjectStateRegistry.atomic_success( - "save configuration", - self.scope_id, - ): - candidates = tuple((tab, tab.session.to_object()) for tab in self._tabs) - - for tab, _config in candidates: - tab.state.mark_saved() - - committed = tuple( - (tab, tab.state.saved_object) for tab, _ in candidates - ) - for tab, config in committed: - tab.session.publish_saved_global_config(config) - - # External effects run only after every ObjectState and global - # context exposes the committed object. Include the current - # participant before invocation so its own partial apply is - # compensated if it raises. - for tab, config in committed: - participant = tab.spec.save_participant - if participant is None: - continue - attempted_participants.append(tab) - tab.session.begin_save_callback(id(self)) - try: - participant.apply(config) - finally: - tab.session.end_save_callback(id(self)) - - except Exception as error: - rollback_errors: list[Exception] = [] - if transaction_started: - # Compensate external effects first. ObjectState restoration - # emits coherent change notifications, so those observers must - # see the pre-transaction external and global world. - for tab in reversed(attempted_participants): - participant = tab.spec.save_participant - if participant is None: - continue - tab.session.begin_save_callback(id(self)) - try: - participant.rollback(previous_objects[id(tab)]) - except Exception as rollback_error: - rollback_errors.append(rollback_error) - finally: - tab.session.end_save_callback(id(self)) - for tab in self._tabs: - try: - tab.session.restore_global_context( - global_context_checkpoints.get(id(tab)) - ) - except Exception as rollback_error: - rollback_errors.append(rollback_error) - for state, checkpoint in state_checkpoints: - try: - checkpoint.restore(state) - except Exception as rollback_error: - rollback_errors.append(rollback_error) - ObjectStateRegistry.increment_token(notify=True) - if rollback_errors: - error.add_note( - "Configuration rollback also reported: " - + "; ".join( - f"{type(item).__name__}: {item}" for item in rollback_errors - ) - ) - logger.error("Failed to save configuration: %s", error, exc_info=True) - QMessageBox.critical( - self, - "Save Error", - f"Failed to save configuration:\n{error}", - ) - return - - # The transaction and its history snapshot are committed. UI - # notifications and window finalization must never drive compensation - # against that durable boundary. - for _tab, config in committed: - try: - self.config_saved.emit(config) - except Exception: - logger.exception("Post-commit config_saved observer failed") - try: - ObjectStateRegistry.increment_token(notify=True) - except Exception: - logger.exception("Post-commit ObjectState notification failed") - try: - if close_window: - self.accept_committed_state() - else: - self.detect_changes() - except Exception: - logger.exception("Post-commit config window finalization failed") - - def _view_code( - self, - code_document_driver: ConfigCodeDocumentDriver | None = None, - ) -> None: - driver = code_document_driver or self.active_tab.code_document_driver - try: - document = driver.read_document() - SimpleCodeEditorService(self).edit_code( - initial_content=document.source, - title=document.title, - callback=driver.apply_source, - use_external=ExternalCodeEditorPreference.use_external_editor(), - declaration_type=document.declaration_type, - code_data={"clean_mode": True}, - ) - except Exception as error: - logger.error("Failed to view config code: %s", error, exc_info=True) - QMessageBox.critical( - self, - "View Code Error", - f"Failed to view code:\n{error}", - ) - - @staticmethod - def _apply_config_from_code_document( - session: ConfigEditSession, - form_manager: ParameterFormManager, - new_config: object, - ) -> None: - session.apply_code_edit_context(new_config) - CodeEditorFormUpdater.update_form_from_instance(form_manager, new_config) - - def require_managed_state_mutation_allowed(self) -> None: - for tab in self._tabs: - if tab.spec.before_mutation is not None: - tab.spec.before_mutation() - - def restore_managed_state(self) -> None: - for tab in self._tabs: - tab.restore_policy.restore(tab.state) - tab.session.restore_global_context_if_dirty() - - def before_managed_reject(self) -> None: - self.config_cancelled.emit() - - def after_managed_reject(self) -> None: - ObjectStateRegistry.increment_token() - - def showEvent(self, event) -> None: - super().showEvent(event) - if not self._default_size_applied: - self.resize(650, 650) - self._default_size_applied = True - self.setProperty("_fixed_default_size", True) - - def apply_scope_accent_styling(self) -> None: - super().apply_scope_accent_styling() - accent_color = self.get_scope_accent_color() - if not accent_color: - return - if self._save_button is not None: - self._save_button.setStyleSheet( - self.theme.scheme.styles.generate_scope_accent_button_style( - accent_color - ) - ) - if self._header_label is not None: - self._header_label.setStyleSheet(f"color: {accent_color.name()};") - tree_style = self.get_scope_tree_selection_stylesheet() - for tab in self._tabs: - if tree_style and tab.view is not None: - tab.tree_widget.setStyleSheet( - f"{tab.tree_widget.styleSheet()}\n{tree_style}" - ) - if tab.help_button is not None: - tab.help_button.set_scope_accent_color(accent_color) - - def before_managed_close(self) -> None: - for tab in self._tabs: - tab.state.off_state_changed(tab.state_changed_callback) - if tab.view is not None: - tab.tree_helper.cleanup_subscriptions() diff --git a/openhcs/serialization/pycodify_formatters.py b/openhcs/serialization/pycodify_formatters.py index e9a9d51d1f..e131093065 100644 --- a/openhcs/serialization/pycodify_formatters.py +++ b/openhcs/serialization/pycodify_formatters.py @@ -7,7 +7,6 @@ from pathlib import Path from objectstate import semantic_values_equal -from objectstate.field_access import DataclassFieldAccess, DottedFieldPath from objectstate.lazy_factory import LazyDataclass from pycodify import FormatContext, SourceFormatter, SourceFragment, to_source from pyqt_reactive.pattern_metadata import PatternScopeToken @@ -364,57 +363,6 @@ def raw_field_values_match(left: LazyDataclass, right: LazyDataclass) -> bool: return False return True - @classmethod - def retain_only_authored_paths( - cls, - value: LazyDataclass, - authored_field_paths: frozenset[str] | set[str], - ) -> LazyDataclass: - """Restore constructor intent after a flattened ObjectState rebuild. - - ObjectState reconstructs lazy dataclasses from every flattened field so - their constructors can preserve raw ``None`` inheritance markers. That - necessarily marks every constructor keyword as explicit. Clean source - generation instead needs the raw-vs-signature paths already owned by - ObjectState, so apply those paths to the existing lazy metadata without - changing field values. - """ - - paths = tuple(DottedFieldPath(path) for path in authored_field_paths) - return cls._copy_with_only_authored_paths( - value, - prefix=DottedFieldPath(""), - authored_field_paths=paths, - ) - - @classmethod - def _copy_with_only_authored_paths( - cls, - value: LazyDataclass, - *, - prefix: DottedFieldPath, - authored_field_paths: tuple[DottedFieldPath, ...], - ) -> LazyDataclass: - field_values = {} - for field in fields(value): - field_value = DataclassFieldAccess.raw_value(value, field.name) - if isinstance(field_value, LazyDataclass): - field_value = cls._copy_with_only_authored_paths( - field_value, - prefix=prefix.child(field.name), - authored_field_paths=authored_field_paths, - ) - field_values[field.name] = field_value - - projected = type(value)(**field_values) - explicit_field_names = { - field.name - for field in fields(projected) - if prefix.child(field.name).contains_any(authored_field_paths) - } - object.__setattr__(projected, "_explicitly_set_fields", explicit_field_names) - return projected - @dataclass(frozen=True) class LazyDataclassSerializedField: diff --git a/openhcs/ui/shared/code_editor_form_updater.py b/openhcs/ui/shared/code_editor_form_updater.py deleted file mode 100644 index 9204d4799b..0000000000 --- a/openhcs/ui/shared/code_editor_form_updater.py +++ /dev/null @@ -1,269 +0,0 @@ -"""Shared utility for updating forms from code editor changes. - -This module provides utilities for parsing edited code and updating form -managers with only the explicitly set fields, preserving ``None`` values for -unspecified fields. -""" - -from __future__ import annotations - -import logging -from contextlib import contextmanager -from dataclasses import fields, is_dataclass -from typing import Any, get_args, get_origin - -from objectstate import semantic_values_equal - -logger = logging.getLogger(__name__) - - -class CodeEditorFormUpdater: - """Utility for updating forms from code editor changes. - - The code editor always produces a *concrete* instance (dataclass or - regular object). For lazy configs, we rely on the pattern that raw - attribute access (``object.__getattribute__``) returns ``None`` for - inherited / unset fields. The form layer then interprets ``None`` as - "show placeholder", while concrete values override placeholders. - """ - - @staticmethod - def update_form_from_instance( - form_manager, - new_instance: Any, - broadcast_callback=None, - ) -> None: - """Update a form manager with values from a new instance. - - Args: - form_manager: ParameterFormManager instance. - new_instance: New object/dataclass instance with updated values. - broadcast_callback: Optional callback to broadcast the new - instance (e.g., to an event bus). - """ - - is_instance_dataclass = is_dataclass(new_instance) - if is_instance_dataclass: - instance_fields = [field.name for field in fields(new_instance)] - else: - # Fallback: drive off whatever the form currently knows about. - instance_fields = list(form_manager.parameters.keys()) - - for field_name in instance_fields: - if field_name not in form_manager.parameters: - logger.debug( - "CodeEditorFormUpdater: field %s missing from form_manager %s", - field_name, - getattr(form_manager, "field_id", ""), - ) - continue - - if is_instance_dataclass: - new_value = CodeEditorFormUpdater._get_raw_field_value( - new_instance, field_name - ) - else: - new_value = getattr(new_instance, field_name, None) - - if is_dataclass(new_value) and not isinstance(new_value, type): - CodeEditorFormUpdater._update_nested_dataclass( - form_manager, field_name, new_value - ) - else: - logger.debug( - "CodeEditorFormUpdater: updating %s to %r", field_name, new_value - ) - CodeEditorFormUpdater._update_parameter_if_changed( - form_manager, - field_name, - new_value, - ) - - # NOTE: - # Cross-window propagation and placeholder refresh are handled by - # ParameterFormManager.update_parameter together with the - # FieldChangeDispatcher / LiveContextService stack. Callers usually - # do NOT need to trigger a global refresh after using this helper; - # only special flows (e.g., global-config snapshot restore/cancel) - # should call ParameterFormManager.trigger_global_cross_window_refresh(). - - if broadcast_callback: - broadcast_callback(new_instance) - - # ------------------------------------------------------------------ - # Nested dataclass handling - # ------------------------------------------------------------------ - - @staticmethod - def _update_nested_dataclass( - form_manager, - field_name: str, - new_value: Any, - ) -> None: - """Recursively update a nested dataclass field and all its children. - - ``new_value`` is a concrete dataclass instance coming from the code - editor. We walk its fields and drive the *nested* manager entirely via - ``update_parameter`` so that FieldChangeDispatcher and - ValueCollectionService can do the right thing (parent reconstruction, - _user_set_fields tracking, cross-window propagation, etc.). - """ - - nested_manager = form_manager.nested_managers.get(field_name) - if not nested_manager: - # No dedicated nested manager – treat as a regular field on the - # parent. This is still routed through FieldChangeDispatcher. - CodeEditorFormUpdater._update_parameter_if_changed( - form_manager, - field_name, - new_value, - ) - return - - for field in fields(new_value): - nested_field_value = CodeEditorFormUpdater._get_raw_field_value( - new_value, field.name - ) - if field.name not in nested_manager.parameters: - continue - - if is_dataclass(nested_field_value) and not isinstance( - nested_field_value, type - ): - CodeEditorFormUpdater._update_nested_dataclass_in_manager( - nested_manager, field.name, nested_field_value - ) - else: - CodeEditorFormUpdater._update_parameter_if_changed( - nested_manager, - field.name, - nested_field_value, - ) - - @staticmethod - def _update_nested_dataclass_in_manager( - manager, - field_name: str, - new_value: Any, - ) -> None: - """Helper to update nested dataclass within a specific manager.""" - - nested_manager = manager.nested_managers.get(field_name) - if not nested_manager: - CodeEditorFormUpdater._update_parameter_if_changed( - manager, - field_name, - new_value, - ) - return - - for field in fields(new_value): - nested_field_value = CodeEditorFormUpdater._get_raw_field_value( - new_value, field.name - ) - if field.name not in nested_manager.parameters: - continue - - if is_dataclass(nested_field_value) and not isinstance( - nested_field_value, type - ): - CodeEditorFormUpdater._update_nested_dataclass_in_manager( - nested_manager, field.name, nested_field_value - ) - else: - CodeEditorFormUpdater._update_parameter_if_changed( - nested_manager, - field.name, - nested_field_value, - ) - - @staticmethod - def _update_parameter_if_changed( - form_manager, - field_name: str, - new_value: Any, - ) -> bool: - """Dispatch one code-mode field only when its raw value changed. - - ``ObjectState`` already rejects identical assignments before cache - invalidation, but ``ParameterFormManager.update_parameter`` performs - widget synchronization and emits root/cross-window signals before the - caller can benefit from that no-op. Code documents reconstruct a whole - object, so filtering against the manager's raw, path-scoped parameters - avoids turning a one-field edit into one dispatch per dataclass field. - - Returns: - ``True`` when the field was sent through the normal form dispatcher. - """ - - current_parameters = form_manager.parameters - if field_name not in current_parameters: - return False - if semantic_values_equal( - current_parameters[field_name], - new_value, - ): - logger.debug( - "CodeEditorFormUpdater: skipping unchanged field %s", - field_name, - ) - return False - - form_manager.update_parameter(field_name, new_value) - return True - - # ------------------------------------------------------------------ - # Lazy-constructor patching and helpers - # ------------------------------------------------------------------ - - @staticmethod - @contextmanager - def patch_lazy_constructors(): - """Patch lazy dataclass constructors during ``exec``. - - Ensures exec()-created instances only set explicitly provided kwargs, - allowing unspecified fields to remain ``None``. - """ - - from objectstate import patch_lazy_constructors - - with patch_lazy_constructors(): - yield - - @staticmethod - def _get_raw_field_value(obj: Any, field_name: str): - """Fetch field without triggering lazy ``__getattr__`` logic.""" - - try: - return object.__getattribute__(obj, field_name) - except AttributeError: - return getattr(obj, field_name, None) - - @staticmethod - def _is_dataclass(field_type: Any) -> bool: - """Check if a field type represents (or wraps) a dataclass.""" - - origin = get_origin(field_type) - if origin is not None: - return any( - CodeEditorFormUpdater._is_dataclass(arg) - for arg in get_args(field_type) - if arg is not type(None) - ) - try: - return is_dataclass(field_type) - except TypeError: - return False - - @staticmethod - def _get_dataclass_field_value(instance: Any, field_obj) -> Any: - """Get a field value from a dataclass, preserving raw ``None``. - - For nested dataclasses we want to avoid triggering any lazy resolution - logic, so we go through ``_get_raw_field_value``. For primitives and - non-dataclass fields we fall back to normal ``getattr``. - """ - - if CodeEditorFormUpdater._is_dataclass(field_obj.type): - return CodeEditorFormUpdater._get_raw_field_value(instance, field_obj.name) - return getattr(instance, field_obj.name, None) diff --git a/tests/pyqt_gui/integration/test_end_to_end_workflow_foundation.py b/tests/pyqt_gui/integration/test_end_to_end_workflow_foundation.py index 6007bc0d21..454a2e3aef 100644 --- a/tests/pyqt_gui/integration/test_end_to_end_workflow_foundation.py +++ b/tests/pyqt_gui/integration/test_end_to_end_workflow_foundation.py @@ -48,7 +48,7 @@ from openhcs.pyqt_gui.config import PyQtGuiRuntimeContext, get_default_ui_config from openhcs.pyqt_gui.main import OpenHCSMainWindow from openhcs.pyqt_gui.widgets.plate_manager import PlateManagerWidget -from openhcs.pyqt_gui.windows.config_window import ConfigWindow +from openhcs.pyqt_gui.windows.config_window import OpenHCSConfigWindow from openhcs.microscopes.imagexpress import ImageXpressHandler # ============================================================================ @@ -675,11 +675,11 @@ def _apply_orchestrator_config(context: WorkflowContext) -> WorkflowContext: return context.with_updates(orchestrator=orchestrator) -def _find_config_window(scope_id: str) -> Optional[ConfigWindow]: +def _find_config_window(scope_id: str) -> Optional[OpenHCSConfigWindow]: """Find the visible configuration window owned by one ObjectState scope.""" for widget in QApplication.topLevelWidgets(): if ( - isinstance(widget, ConfigWindow) + isinstance(widget, OpenHCSConfigWindow) and widget.scope_id == scope_id and widget.isVisible() ): diff --git a/tests/unit/pyqt_gui/test_code_editor_form_updater.py b/tests/unit/pyqt_gui/test_code_editor_form_updater.py deleted file mode 100644 index 5a03009629..0000000000 --- a/tests/unit/pyqt_gui/test_code_editor_form_updater.py +++ /dev/null @@ -1,141 +0,0 @@ -"""Change-filtering tests for code-document form synchronization.""" - -from __future__ import annotations - -from dataclasses import dataclass - -from openhcs.core.steps.function_step import FunctionStep -from openhcs.ui.shared.code_editor_form_updater import CodeEditorFormUpdater - - -@dataclass(frozen=True) -class _NestedConfig: - enabled: bool | None = None - label: str | None = None - - -@dataclass(frozen=True) -class _RootConfig: - name: str | None = None - nested: _NestedConfig | None = None - - -class _RecordingFormManager: - def __init__( - self, - parameters: dict[str, object], - *, - nested_managers: dict[str, "_RecordingFormManager"] | None = None, - ) -> None: - self.parameters = parameters - self.nested_managers = nested_managers or {} - self.updated: list[tuple[str, object]] = [] - - def update_parameter(self, field_name: str, value: object) -> None: - self.updated.append((field_name, value)) - self.parameters[field_name] = value - - -def test_update_form_from_instance_dispatches_only_changed_nested_leaf() -> None: - nested_manager = _RecordingFormManager( - { - "enabled": None, - "label": "same", - } - ) - root_manager = _RecordingFormManager( - { - "name": "same", - "nested": _NestedConfig(enabled=None, label="same"), - }, - nested_managers={"nested": nested_manager}, - ) - - CodeEditorFormUpdater.update_form_from_instance( - root_manager, - _RootConfig( - name="same", - nested=_NestedConfig(enabled=False, label="same"), - ), - ) - - assert root_manager.updated == [] - assert nested_manager.updated == [("enabled", False)] - - -def test_update_form_from_instance_skips_fully_unchanged_document() -> None: - nested_manager = _RecordingFormManager( - { - "enabled": None, - "label": "same", - } - ) - root_manager = _RecordingFormManager( - { - "name": "same", - "nested": _NestedConfig(enabled=None, label="same"), - }, - nested_managers={"nested": nested_manager}, - ) - - CodeEditorFormUpdater.update_form_from_instance( - root_manager, - _RootConfig( - name="same", - nested=_NestedConfig(enabled=None, label="same"), - ), - ) - - assert root_manager.updated == [] - assert nested_manager.updated == [] - - -def test_update_form_from_instance_preserves_explicit_reset_to_none() -> None: - root_manager = _RecordingFormManager( - { - "name": "override", - "nested": None, - } - ) - - CodeEditorFormUpdater.update_form_from_instance( - root_manager, - _RootConfig(name=None, nested=None), - ) - - assert root_manager.updated == [("name", None)] - - -def test_update_form_from_instance_updates_unexpanded_dataclass_once() -> None: - original_nested = _NestedConfig(enabled=None, label="same") - replacement_nested = _NestedConfig(enabled=True, label="same") - root_manager = _RecordingFormManager( - { - "name": "same", - "nested": original_nested, - } - ) - - CodeEditorFormUpdater.update_form_from_instance( - root_manager, - _RootConfig(name="same", nested=replacement_nested), - ) - - assert root_manager.updated == [("nested", replacement_nested)] - - -def test_update_form_from_non_dataclass_step_dispatches_only_changed_field() -> None: - root_manager = _RecordingFormManager( - { - "name": "Original", - "description": None, - "enabled": True, - } - ) - - CodeEditorFormUpdater.update_form_from_instance( - root_manager, - FunctionStep(name="Renamed"), - ) - - assert root_manager.updated == [("name", "Renamed")] diff --git a/tests/unit/pyqt_gui/test_config_window_state_resolution.py b/tests/unit/pyqt_gui/test_config_window_state_resolution.py index d841054fea..6764a99311 100644 --- a/tests/unit/pyqt_gui/test_config_window_state_resolution.py +++ b/tests/unit/pyqt_gui/test_config_window_state_resolution.py @@ -34,11 +34,13 @@ OpenHCSWindowCreationAuthority, register_openhcs_window_handlers, ) -from openhcs.pyqt_gui.windows.config_window import ( +from pyqt_reactive.windows.config_window import ( + ConfigEditSession, ConfigSaveParticipant, - ConfigWindow, ConfigWindowTabSpec, ) + +from openhcs.pyqt_gui.windows.config_window import OpenHCSConfigWindow from openhcs.runtime.zmq_config import OpenHCSZMQConfig from openhcs.interop.cellprofiler.dataset_scope import CellProfilerPipelineScope from openhcs.domains.microscopy.axes import Microscopy @@ -218,7 +220,7 @@ def test_two_tab_config_window_saves_both_authoritative_objects(qapp) -> None: ObjectStateRegistry.register(pipeline_state, _skip_snapshot=True) ObjectStateRegistry.register(ui_state, _skip_snapshot=True) saved: list[object] = [] - window = ConfigWindow( + window = OpenHCSConfigWindow( tabs=( ConfigWindowTabSpec( state=pipeline_state, @@ -269,7 +271,7 @@ def test_config_window_page_count_and_visual_scope_own_presentation(qapp) -> Non "auto_add_output_dataset", } plate_state = ObjectState(PipelineConfig(), scope_id="/tmp/plate") - plate_window = ConfigWindow( + plate_window = OpenHCSConfigWindow( tabs=(ConfigWindowTabSpec(state=plate_state),), scope_id=plate_state.scope_id, ) @@ -301,7 +303,7 @@ def test_config_window_page_count_and_visual_scope_own_presentation(qapp) -> Non get_default_ui_config(), scope_id=UIConfig.object_state_scope_id(), ) - global_window = ConfigWindow( + global_window = OpenHCSConfigWindow( tabs=( ConfigWindowTabSpec(state=global_state), ConfigWindowTabSpec(state=ui_state), @@ -360,7 +362,7 @@ def test_pipeline_config_header_owns_live_tab_actions_and_commit_groups( from PyQt6.QtWidgets import QPushButton state = ObjectState(PipelineConfig(), scope_id="/tmp/plate") - window = ConfigWindow( + window = OpenHCSConfigWindow( tabs=(ConfigWindowTabSpec(state=state),), scope_id=state.scope_id, ) @@ -424,7 +426,7 @@ def test_pipeline_config_help_uses_shared_rich_document_renderer(qapp) -> None: HelpWindowManager._help_window = None state = ObjectState(PipelineConfig(), scope_id="/tmp/help-renderer-plate") - window = ConfigWindow( + window = OpenHCSConfigWindow( tabs=(ConfigWindowTabSpec(state=state),), scope_id=state.scope_id, ) @@ -461,7 +463,7 @@ def test_config_window_header_switches_title_help_and_auxiliary_actions_by_tab( get_default_ui_config(), scope_id=UIConfig.object_state_scope_id(), ) - window = ConfigWindow( + window = OpenHCSConfigWindow( tabs=( ConfigWindowTabSpec(state=global_state), ConfigWindowTabSpec(state=ui_state), @@ -512,7 +514,7 @@ def test_pipeline_config_code_document_emits_only_authored_override(qapp) -> Non PipelineConfig(), scope_id="/tmp/plate", ) - window = ConfigWindow( + window = OpenHCSConfigWindow( tabs=(ConfigWindowTabSpec(state=state),), scope_id=state.scope_id, ) @@ -555,7 +557,7 @@ def test_ui_config_save_commits_state_before_live_notifications( from openhcs.pyqt_gui.config import PyQtGuiRuntimeContext from openhcs.pyqt_gui.main import OpenHCSMainWindow - from openhcs.pyqt_gui.windows import config_window + from pyqt_reactive.windows import config_window def unexpected_save_error(_parent, _title, message): pytest.fail(message) @@ -642,7 +644,7 @@ def emit(self, config) -> None: ObjectStateRegistry.connect_listener( lambda: registry_observations.append(state.saved_object) ) - window = ConfigWindow( + window = OpenHCSConfigWindow( tabs=( ConfigWindowTabSpec( state=state, @@ -724,7 +726,7 @@ def test_config_window_save_button_starts_disabled_and_styles_disabled_state( """Config windows expose managed dirty state through visible save styling.""" state = ObjectState(GlobalPipelineConfig(), scope_id="") ObjectStateRegistry.register(state, _skip_snapshot=True) - window = ConfigWindow( + window = OpenHCSConfigWindow( tabs=( ConfigWindowTabSpec( state=state, @@ -809,7 +811,7 @@ def test_pipeline_streaming_enableable_placeholders_dim_after_build_and_toggle( def test_global_config_window_registers_with_stable_window_id() -> None: - window = ConfigWindow.__new__(ConfigWindow) + window = OpenHCSConfigWindow.__new__(OpenHCSConfigWindow) window.scope_id = "" assert window.window_manager_scope_id() == OpenHCSUiWindowId.global_config @@ -874,7 +876,7 @@ def save_global_config(config: GlobalPipelineConfig) -> bool: from openhcs.core import config_cache from openhcs.pyqt_gui.windows import config_window - monkeypatch.setattr(config_window, "ConfigWindow", FakeConfigWindow) + monkeypatch.setattr(config_window, "OpenHCSConfigWindow", FakeConfigWindow) monkeypatch.setattr( config_cache, "save_global_config_sync", @@ -951,7 +953,7 @@ def __init__(self, *, tabs, scope_id) -> None: from openhcs.pyqt_gui.windows import config_window - monkeypatch.setattr(config_window, "ConfigWindow", FakeConfigWindow) + monkeypatch.setattr(config_window, "OpenHCSConfigWindow", FakeConfigWindow) monkeypatch.setattr(window_authority, "_show_window", lambda window: None) window = window_authority.create_plate_config_window( @@ -1024,7 +1026,7 @@ def test_ui_config_scope_navigation_selects_owning_config_tab(qapp, qtbot) -> No get_default_ui_config(), scope_id=UIConfig.object_state_scope_id(), ) - window = ConfigWindow( + window = OpenHCSConfigWindow( tabs=( ConfigWindowTabSpec(state=global_state), ConfigWindowTabSpec(state=ui_state), @@ -1078,7 +1080,7 @@ class SecondConfig: first_state = ObjectState(FirstConfig(), scope_id="first_config") second_state = ObjectState(SecondConfig(), scope_id="second_config") - window = ConfigWindow( + window = OpenHCSConfigWindow( tabs=( ConfigWindowTabSpec(state=first_state), ConfigWindowTabSpec(state=second_state), diff --git a/tests/unit/pyqt_gui/test_config_window_transaction.py b/tests/unit/pyqt_gui/test_config_window_transaction.py index cb8cdcff9f..c4725c4a0f 100644 --- a/tests/unit/pyqt_gui/test_config_window_transaction.py +++ b/tests/unit/pyqt_gui/test_config_window_transaction.py @@ -14,13 +14,14 @@ set_saved_global_config, ) from openhcs.core.config import GlobalPipelineConfig -from openhcs.pyqt_gui.windows.config_edit_session import ConfigEditSession -from openhcs.pyqt_gui.windows.config_window import ( +from pyqt_reactive.windows.config_window import ( + ConfigEditSession, ConfigSaveParticipant, - ConfigWindow, ConfigWindowTabSpec, ) +from openhcs.pyqt_gui.windows.config_window import OpenHCSConfigWindow + @dataclass(frozen=True) class _FirstConfig: @@ -50,7 +51,7 @@ def suppress_save_dialog(monkeypatch): return messages -def _dispose_window(window: ConfigWindow, qapp) -> None: +def _dispose_window(window: OpenHCSConfigWindow, qapp) -> None: window.before_managed_close() window.deleteLater() qapp.processEvents() @@ -88,7 +89,7 @@ def test_save_guard_rejection_has_no_transaction_side_effects( def reject_mutation() -> None: raise RuntimeError("mutation rejected") - window = ConfigWindow( + window = OpenHCSConfigWindow( tabs=( ConfigWindowTabSpec( state=state, @@ -135,7 +136,7 @@ def test_failure_after_first_mark_saved_restores_every_state_exactly( second_state = ObjectState(second_saved, scope_id="second") ObjectStateRegistry.register(first_state, _skip_snapshot=True) ObjectStateRegistry.register(second_state, _skip_snapshot=True) - window = ConfigWindow( + window = OpenHCSConfigWindow( tabs=( ConfigWindowTabSpec(state=first_state), ConfigWindowTabSpec(state=second_state), @@ -191,7 +192,7 @@ def rollback(value: object) -> None: rollback_values.append(value) external_config["current"] = value - window = ConfigWindow( + window = OpenHCSConfigWindow( tabs=( ConfigWindowTabSpec( state=state, @@ -266,7 +267,7 @@ def test_failed_global_context_publication_restores_session_and_both_stores( set_live_global_config(GlobalPipelineConfig, prior_live) state = ObjectState(saved, scope_id="") ObjectStateRegistry.register(state, _skip_snapshot=True) - window = ConfigWindow( + window = OpenHCSConfigWindow( tabs=(ConfigWindowTabSpec(state=state),), scope_id="", ) @@ -324,7 +325,7 @@ def fail_second(value: object) -> None: effects.append(("apply_second", value)) raise RuntimeError("second participant failed") - window = ConfigWindow( + window = OpenHCSConfigWindow( tabs=( ConfigWindowTabSpec( state=first_state, @@ -376,7 +377,7 @@ def test_postcommit_window_failure_does_not_roll_back_commit( ObjectStateRegistry.register(state, _skip_snapshot=True) applied: list[object] = [] rolled_back: list[object] = [] - window = ConfigWindow( + window = OpenHCSConfigWindow( tabs=( ConfigWindowTabSpec( state=state, @@ -420,15 +421,15 @@ def test_inactive_tab_factory_failure_is_visible_and_rejects_window( "critical", lambda _parent, _title, message: messages.append(message), ) - original_materialize = ConfigWindow._materialize_tab + original_materialize = OpenHCSConfigWindow._materialize_tab def materialize_or_fail(self, tab): if tab.spec.label == "_SecondConfig": raise RuntimeError("inactive tab build failed") return original_materialize(self, tab) - monkeypatch.setattr(ConfigWindow, "_materialize_tab", materialize_or_fail) - window = ConfigWindow( + monkeypatch.setattr(OpenHCSConfigWindow, "_materialize_tab", materialize_or_fail) + window = OpenHCSConfigWindow( tabs=( ConfigWindowTabSpec(state=first_state), ConfigWindowTabSpec(state=second_state), @@ -463,7 +464,7 @@ def test_progressive_form_build_failure_is_visible_and_rejects_window( "critical", lambda _parent, _title, message: messages.append(message), ) - window = ConfigWindow( + window = OpenHCSConfigWindow( tabs=(ConfigWindowTabSpec(state=state),), scope_id="progressive", ) @@ -490,7 +491,7 @@ def test_closing_progressive_config_cancels_deferred_form_work( "critical", lambda _parent, _title, message: messages.append(message), ) - window = ConfigWindow( + window = OpenHCSConfigWindow( tabs=(ConfigWindowTabSpec(state=state),), scope_id="progressive-close", ) diff --git a/tests/unit/pyqt_gui/test_pipeline_editor_widget.py b/tests/unit/pyqt_gui/test_pipeline_editor_widget.py index 043f30b911..a3f9525cec 100644 --- a/tests/unit/pyqt_gui/test_pipeline_editor_widget.py +++ b/tests/unit/pyqt_gui/test_pipeline_editor_widget.py @@ -60,7 +60,7 @@ stack_percentile_normalize, ) from openhcs.pyqt_gui.windows.dual_editor_window import DualEditorWindow -from openhcs.ui.shared.code_editor_form_updater import CodeEditorFormUpdater +from pyqt_reactive.services.code_editor_form_updater import CodeEditorFormUpdater from openhcs.ui.shared.plate_scope_identity import PipelineScopeIdentity from tests.unit.pyqt_gui.session_harness import add_datasets, qt_app, session_gui diff --git a/tests/unit/pyqt_gui/test_plate_view_grid_model.py b/tests/unit/pyqt_gui/test_plate_view_grid_model.py index 43ca7c5992..981250563e 100644 --- a/tests/unit/pyqt_gui/test_plate_view_grid_model.py +++ b/tests/unit/pyqt_gui/test_plate_view_grid_model.py @@ -1,15 +1,18 @@ from pyqt_reactive.widgets.grid_selector import GridModel from openhcs.domains.microscopy.axes import Microscopy +from openhcs.pyqt_gui.widgets.image_browser import GridAxisAddressing + +WELLS = GridAxisAddressing(Microscopy.Well) def test_well_axis_places_standard_well_ids_on_the_plate_grid() -> None: - model = GridModel.of(Microscopy.Well, {"B02", "C04", "AA10"}) + model = GridModel.of(WELLS, {"B02", "C04", "AA10"}) assert model.cell_at_position[(2, 2)] == "B02" assert model.cell_at_position[(3, 4)] == "C04" assert model.cell_at_position[(27, 10)] == "AA10" assert model.position_of_cell["AA10"] == (27, 10) assert model.dimensions == (26, 9) - assert GridModel.of(Microscopy.Well, set()).dimensions == Microscopy.Well.default_grid + assert GridModel.of(WELLS, set()).dimensions == Microscopy.Well.default_grid assert Microscopy.Well.row_label(27) == "AA" diff --git a/tests/unit/pyqt_gui/test_ui_agent_bridge.py b/tests/unit/pyqt_gui/test_ui_agent_bridge.py index d0fa2a8452..731c7b797b 100644 --- a/tests/unit/pyqt_gui/test_ui_agent_bridge.py +++ b/tests/unit/pyqt_gui/test_ui_agent_bridge.py @@ -157,9 +157,7 @@ ) from openhcs.core.steps.function_step import FunctionStep from openhcs.pyqt_gui.config import AgentUiBridgeConfig -from openhcs.pyqt_gui.services.config_window_code_document import ( - ConfigCodeDocumentDriver, -) +from pyqt_reactive.windows.config_window import ConfigCodeDocumentDriver from openhcs.pyqt_gui.services.embedded_code_documents import ( EmbeddedCodeDocumentRegistrationABC, ) @@ -302,6 +300,7 @@ def replacement_function(image, threshold: int = 2): def test_config_code_document_honors_clean_flag() -> None: driver = ConfigCodeDocumentDriver( + document_format=ConfigDocumentAuthority, title="View/Edit GlobalPipelineConfig", config_type=GlobalPipelineConfig, current_config=lambda: GlobalPipelineConfig(num_workers=2), @@ -320,6 +319,7 @@ def test_pycodified_config_document_delegates_to_config_authority() -> None: replacement = GlobalPipelineConfig(num_workers=3) applied: list[GlobalPipelineConfig] = [] driver = ConfigCodeDocumentDriver( + document_format=ConfigDocumentAuthority, title="View/Edit GlobalPipelineConfig", config_type=GlobalPipelineConfig, current_config=lambda: original, @@ -347,6 +347,7 @@ def test_pycodified_config_document_delegates_to_config_authority() -> None: def test_pycodified_config_document_authorizes_before_apply() -> None: applied: list[GlobalPipelineConfig] = [] driver = ConfigCodeDocumentDriver( + document_format=ConfigDocumentAuthority, title="View/Edit GlobalPipelineConfig", config_type=GlobalPipelineConfig, current_config=GlobalPipelineConfig, From cbfd68708964841639f161c0558a3a1da1417af6 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Sun, 11 Oct 2026 04:03:36 -0400 Subject: [PATCH 09/17] U3: UI bridge field semantics come from objectstate.inspection; guards ObjectStateFieldSemanticProjection inherits objectstate's FieldInspection (value, edit state, provenance, types) and adds only the bridge DTO projection; the provenance effect classes and the duplicated type/field-name helpers are deleted. The '*'/'_' marker rule, written four times, is objectstate.inspection's field_markers/state_markers. tests/unit/pyqt_gui/test_generic_qt_machinery_guards.py keeps the moved modules and names out of OpenHCS, the '::' scope grammar inside ScopeKey, the legacy XDG migration deleted, the library families free of enums, the external-editor switch named once and the markers in objectstate. Co-Authored-By: Claude Opus 5.5 --- external/ObjectState | 2 +- external/pyqt-reactive | 2 +- .../services/ui_bridge_object_state.py | 217 ++---------------- .../pyqt_gui/services/ui_bridge_windows.py | 29 +-- tests/unit/agent/test_u1_g8_guards.py | 8 +- .../test_generic_qt_machinery_guards.py | 176 ++++++++++++++ 6 files changed, 210 insertions(+), 224 deletions(-) create mode 100644 tests/unit/pyqt_gui/test_generic_qt_machinery_guards.py diff --git a/external/ObjectState b/external/ObjectState index 6a84d43963..af5b4f6186 160000 --- a/external/ObjectState +++ b/external/ObjectState @@ -1 +1 @@ -Subproject commit 6a84d43963784eaf4d3d4bb24e0374146a3007fa +Subproject commit af5b4f6186089618671c8254dfaeb227f17d39b6 diff --git a/external/pyqt-reactive b/external/pyqt-reactive index bb26812257..ff704d2fb4 160000 --- a/external/pyqt-reactive +++ b/external/pyqt-reactive @@ -1 +1 @@ -Subproject commit bb26812257f3968da906f9cfda9a8ceec0ba973e +Subproject commit ff704d2fb4464afb96108ce2c2152ba9a38f3ab6 diff --git a/openhcs/pyqt_gui/services/ui_bridge_object_state.py b/openhcs/pyqt_gui/services/ui_bridge_object_state.py index 74221355d2..b5f318eafd 100644 --- a/openhcs/pyqt_gui/services/ui_bridge_object_state.py +++ b/openhcs/pyqt_gui/services/ui_bridge_object_state.py @@ -55,6 +55,7 @@ ObjectStateFieldFilterDeclaration, ) from objectstate import ObjectState, ObjectStateRegistry +from objectstate.inspection import FieldInspection, type_name, type_qualname from openhcs.pyqt_gui.services.ui_bridge_contracts import ( APPLY_TIME_TRAVEL_OPT_IN_GUARD, CONFIRMATION_REQUIRED_GUARD, @@ -179,157 +180,8 @@ def provider_identity(self, title: str) -> UiCodeDocumentProviderIdentity: @dataclass(frozen=True, slots=True) -class ObjectStateFieldProvenanceEffect: - """Typed effect for projecting optional ObjectState provenance.""" - - @classmethod - def from_state( - cls, - state: ObjectState, - field_path: str, - ) -> "ObjectStateFieldProvenanceEffect": - match state.get_provenance(field_path): - case (source_scope_id, source_type) if source_type is not None: - return ResolvedObjectStateFieldProvenance( - source_scope_id=source_scope_id, - source_type=source_type, - field_name=ObjectStateScopeProjectionService.field_name(field_path), - ) - case _: - return MissingObjectStateFieldProvenance() - - def to_dto(self) -> UiObjectStateFieldProvenance | None: - return None - - -@dataclass(frozen=True, slots=True) -class MissingObjectStateFieldProvenance(ObjectStateFieldProvenanceEffect): - """No inherited ObjectState provenance is available for this field.""" - - -@dataclass(frozen=True, slots=True) -class ResolvedObjectStateFieldProvenance(ObjectStateFieldProvenanceEffect): - """Validated ObjectState provenance source for one field address.""" - - source_scope_id: str | None - source_type: type - field_name: str - - def to_dto(self) -> UiObjectStateFieldProvenance | None: - return UiObjectStateFieldProvenance( - source_scope_id=agent_object_state_scope_id(self.source_scope_id), - source_type=ObjectStateScopeProjectionService.type_qualname( - self.source_type - ), - source_field_path=self.source_field_path(), - ) - - def source_field_path(self) -> str | None: - source_state = ObjectStateRegistry.get_by_scope(self.source_scope_id) - if source_state is None: - return None - return source_state.project_ui_visible_field_path( - self.source_type, - self.field_name, - ) - - -@dataclass(frozen=True, slots=True) -class ObjectStateFieldSemanticProjection: - """Shared ObjectState field semantics used by scope and widget projections.""" - - state: ObjectState - field_path: str - raw_value: object - resolved_value: object - resolved_value_available: bool - provenance: UiObjectStateFieldProvenance | None - - @classmethod - def from_state( - cls, - state: ObjectState, - field_path: str, - ) -> "ObjectStateFieldSemanticProjection": - raw_value = state.parameters[field_path] - resolved_value = None - resolved_value_available = False - try: - resolved_value = state.get_resolved_value(field_path) - resolved_value_available = True - except Exception: - logger.debug( - "Failed to resolve ObjectState field %s on scope %r.", - field_path, - state.scope_id, - exc_info=True, - ) - return cls( - state=state, - field_path=field_path, - raw_value=raw_value, - resolved_value=resolved_value, - resolved_value_available=resolved_value_available, - provenance=ObjectStateFieldProvenanceEffect.from_state( - state, - field_path, - ).to_dto(), - ) - - @property - def dirty(self) -> bool: - return self.field_path in self.state.dirty_fields - - @property - def signature_diff(self) -> bool: - return self.field_path in self.state.signature_diff_fields - - @property - def last_changed(self) -> bool: - return self.field_path == self.state.last_changed_field - - @property - def raw_value_is_none(self) -> bool: - return self.raw_value is None - - @property - def resolved_value_is_none(self) -> bool: - return self.resolved_value_available and self.resolved_value is None - - @property - def inherited_value(self) -> bool: - return self.raw_value is None and ( - self.resolved_value_available and self.resolved_value is not None - ) - - @property - def semantic_markers(self) -> tuple[str, ...]: - markers = [] - if self.dirty: - markers.append("*") - if self.signature_diff: - markers.append("_") - return tuple(markers) - - @property - def field_name(self) -> str: - return self.field_path.rsplit(".", 1)[-1] - - @property - def container_path(self) -> str: - if "." not in self.field_path: - return "" - return self.field_path.rsplit(".", 1)[0] - - @property - def raw_value_type(self) -> str: - return self.type_name(self.raw_value) - - @property - def resolved_value_type(self) -> str | None: - if not self.resolved_value_available: - return None - return self.type_name(self.resolved_value) +class ObjectStateFieldSemanticProjection(FieldInspection): + """One inspected ObjectState field projected onto the bridge's DTOs.""" @property def raw_json_value(self) -> JsonValue: @@ -381,7 +233,7 @@ def to_semantic_carrier( raw_value_is_none=self.raw_value_is_none, resolved_value_is_none=self.resolved_value_is_none, inherited_value=self.inherited_value, - provenance=self.provenance, + provenance=self.provenance_dto, ) def to_field_summary( @@ -406,25 +258,21 @@ def to_field_summary( ), field_name=self.field_name, container_path=self.container_path, - object_state_path_type=self.object_state_path_type, + object_state_path_type=self.declaring_type, raw_value_type=self.raw_value_type, resolved_value_type=self.resolved_value_type, parameter_description=parameter_description, ) @property - def object_state_path_type(self) -> str: - return self.type_qualname(self.state.type_for_path(self.field_path)) - - @staticmethod - def type_name(value) -> str: - if value is None: - return "None" - return type(value).__name__ - - @staticmethod - def type_qualname(value_type: type | Callable[..., object]) -> str: - return f"{value_type.__module__}.{value_type.__qualname__}" + def provenance_dto(self) -> UiObjectStateFieldProvenance | None: + if self.provenance is None: + return None + return UiObjectStateFieldProvenance( + source_scope_id=agent_object_state_scope_id(self.provenance.source_scope_id), + source_type=type_qualname(self.provenance.source_type), + source_field_path=self.provenance.source_field_path, + ) @staticmethod def json_value(value) -> JsonValue: @@ -442,7 +290,7 @@ def json_value(value) -> JsonValue: def value_preview(cls, value) -> UiObjectStateValuePreview: text, truncated = cls.preview_text(value) return UiObjectStateValuePreview( - type_name=cls.type_name(value), + type_name=type_name(value), is_none=value is None, text=text, truncated=truncated, @@ -626,7 +474,7 @@ def _field_summary( include_values: bool, include_description: bool, ) -> UiObjectStateFieldSummary: - return ObjectStateFieldSemanticProjection.from_state( + return ObjectStateFieldSemanticProjection.of( state, field_path, ).to_field_summary( @@ -634,35 +482,6 @@ def _field_summary( include_description=include_description, ) - @staticmethod - def _resolved_type_name(state: ObjectState, field_path: str) -> str | None: - try: - return ObjectStateScopeProjectionService.type_name( - state.get_resolved_value(field_path) - ) - except Exception: - return None - - @staticmethod - def type_name(value) -> str: - if value is None: - return "None" - return type(value).__name__ - - @staticmethod - def type_qualname(value_type: type) -> str: - return f"{value_type.__module__}.{value_type.__qualname__}" - - @staticmethod - def field_name(field_path: str) -> str: - return field_path.rsplit(".", 1)[-1] - - @staticmethod - def container_path(field_path: str) -> str: - if "." not in field_path: - return "" - return field_path.rsplit(".", 1)[0] - def object_state_for_agent_scope_id(scope_id: str) -> ObjectState | None: """The ObjectState an agent-facing scope or window id names.""" @@ -747,7 +566,7 @@ def _field_summary( state: ObjectState, request: UiObjectStateFieldMutationRequest, ) -> UiObjectStateFieldSummary: - return ObjectStateFieldSemanticProjection.from_state( + return ObjectStateFieldSemanticProjection.of( state, request.field_path, ).to_field_summary(include_values=request.include_field_values) @@ -758,9 +577,7 @@ def _converted_value( request: UiObjectStateFieldMutationRequest, ) -> object: help_target = state.type_for_path(request.field_path) - parameter_name = ObjectStateScopeProjectionService.field_name( - request.field_path - ) + parameter_name = request.field_path.rsplit(".", 1)[-1] parameter_info = UnifiedParameterAnalyzer.analyze(help_target).get( parameter_name ) diff --git a/openhcs/pyqt_gui/services/ui_bridge_windows.py b/openhcs/pyqt_gui/services/ui_bridge_windows.py index a86e1de91c..350a34cc5d 100644 --- a/openhcs/pyqt_gui/services/ui_bridge_windows.py +++ b/openhcs/pyqt_gui/services/ui_bridge_windows.py @@ -2,6 +2,7 @@ from __future__ import annotations +from objectstate.inspection import field_markers, state_markers from abc import ABC, abstractmethod from collections.abc import Callable from dataclasses import dataclass, replace @@ -199,14 +200,7 @@ def _object_state_scope_id(state: ObjectState | None) -> str | None: @staticmethod def _semantic_markers(state: ObjectState | None) -> tuple[str, ...]: - if state is None: - return () - markers = [] - if state.dirty_fields: - markers.append("*") - if state.signature_diff_fields: - markers.append("_") - return tuple(markers) + return () if state is None else state_markers(state) @staticmethod def _managed_action_ids( @@ -259,12 +253,10 @@ def _semantic_markers( dirty_field_count: int, signature_diff_field_count: int, ) -> tuple[str, ...]: - markers = [] - if dirty_field_count: - markers.append("*") - if signature_diff_field_count: - markers.append("_") - return tuple(markers) + return field_markers( + dirty=bool(dirty_field_count), + signature_diff=bool(signature_diff_field_count), + ) @dataclass(frozen=True, slots=True) @@ -1871,7 +1863,7 @@ def _add_field_widget( return records[_WidgetGeometryKey.from_qwidget(widget)] = _WidgetFieldSemanticRecord( action_role=action_role, - projection=ObjectStateFieldSemanticProjection.from_state( + projection=ObjectStateFieldSemanticProjection.of( state, field_path, ), @@ -1936,12 +1928,7 @@ def signature_diff(self) -> bool: @property def semantic_markers(self) -> tuple[str, ...]: - markers = [] - if self.dirty_fields: - markers.append("*") - if self.signature_diff_fields: - markers.append("_") - return tuple(markers) + return field_markers(dirty=self.dirty, signature_diff=self.signature_diff) def to_action_carrier(self) -> UiWidgetActionSemanticCarrier: return UiWidgetActionSemanticCarrier( diff --git a/tests/unit/agent/test_u1_g8_guards.py b/tests/unit/agent/test_u1_g8_guards.py index daafdb09f2..df6221f7f2 100644 --- a/tests/unit/agent/test_u1_g8_guards.py +++ b/tests/unit/agent/test_u1_g8_guards.py @@ -76,6 +76,12 @@ def test_grid_placement_is_declared_only_by_grid_addressed_axes() -> None: base.id if isinstance(base, ast.Name) else getattr(base, "attr", "") for base in node.bases } - if declares and node.name != "GridAddressed" and "GridAddressed" not in bases: + # A pyqt-reactive GridAddressing adapter forwards to a GridAddressed + # axis; it lays the axis out and declares no placement of its own. + if ( + declares + and node.name != "GridAddressed" + and not bases & {"GridAddressed", "GridAddressing"} + ): offenders.append(f"{path.relative_to(ROOT)}:{node.name}") assert offenders == [] diff --git a/tests/unit/pyqt_gui/test_generic_qt_machinery_guards.py b/tests/unit/pyqt_gui/test_generic_qt_machinery_guards.py new file mode 100644 index 0000000000..7839ea6355 --- /dev/null +++ b/tests/unit/pyqt_gui/test_generic_qt_machinery_guards.py @@ -0,0 +1,176 @@ +"""Guards keeping generic Qt and ObjectState machinery in the libraries (U3 + L4).""" + +from __future__ import annotations + +import ast +from enum import Enum +from pathlib import Path + +import pytest + +REPO = Path(__file__).resolve().parents[3] +PRODUCT = REPO / "openhcs" +PYQT_REACTIVE = REPO / "external" / "pyqt-reactive" / "src" / "pyqt_reactive" +OBJECTSTATE = REPO / "external" / "ObjectState" / "src" / "objectstate" + +MOVED_MODULES = ( + "core/path_cache.py", + "core/field_label.py", + "pyqt_gui/widgets/shared/time_travel_widget.py", + "pyqt_gui/windows/snapshot_browser_window.py", + "pyqt_gui/services/time_travel_navigation.py", + "pyqt_gui/windows/config_edit_session.py", + "pyqt_gui/services/config_window_code_document.py", + "ui/shared/code_editor_form_updater.py", + "pyqt_gui/widgets/shared/services/gui_event_bus_broadcast.py", + "pyqt_gui/widgets/shared/plate_view_widget.py", +) + +LIBRARY_NAMES = frozenset( + { + "GlobalEventBus", + "GuiEventBusBroadcaster", + "get_event_bus", + "UnifiedPathCache", + "FieldLabel", + "CellEditor", + "DataclassFieldColumns", + "EditableTableController", + "EditableTableLayout", + "StructuredSelectorDialog", + "MainWindowDockFloatController", + "MainWindowDockPane", + "MainWindowDockLayoutStore", + "MainWindowShortcutLifecycle", + "TimeTravelShortcutEventFilter", + "MainWindowTimeTravelWorkflow", + "TimeTravelWidget", + "SnapshotBrowserWindow", + "AspectRatioContainer", + "PlateGridModel", + "PlateViewWidget", + "ConfigEditSession", + "ConfigCodeDocumentDriver", + "CodeEditorFormUpdater", + "ExternalCodeEditorPreference", + "ObjectStateFieldProvenanceEffect", + } +) + +SCOPE_SEPARATOR_EXEMPTIONS = frozenset( + { + ("openhcs/core/orchestrator/orchestrator.py", "local::"), + ("openhcs/mcp/http_auth.py", "::1"), + ( + "openhcs/agent/services/config_reference_service.py", + "::(?:\\s+(?P\\S+))?\\s*$", + ), + } +) + + +def _python_files(root: Path): + return sorted(path for path in root.rglob("*.py") if "__pycache__" not in path.parts) + + +def _tree(path: Path) -> ast.Module: + return ast.parse(path.read_text(encoding="utf-8"), filename=str(path)) + + +def _docstring_nodes(tree: ast.Module) -> set[int]: + return { + id(node.body[0].value) + for node in ast.walk(tree) + if isinstance(node, (ast.Module, ast.ClassDef, ast.FunctionDef, ast.AsyncFunctionDef)) + and node.body + and isinstance(node.body[0], ast.Expr) + and isinstance(node.body[0].value, ast.Constant) + } + + +def test_moved_modules_are_gone() -> None: + assert [path for path in MOVED_MODULES if (PRODUCT / path).exists()] == [] + + +def test_openhcs_defines_no_library_machinery() -> None: + found = sorted( + f"{path.relative_to(REPO)}:{node.lineno} {node.name}" + for path in _python_files(PRODUCT) + for node in ast.walk(_tree(path)) + if isinstance(node, (ast.ClassDef, ast.FunctionDef, ast.AsyncFunctionDef)) + and node.name in LIBRARY_NAMES + ) + assert found == [] + + +@pytest.mark.parametrize("root", (PRODUCT, PYQT_REACTIVE, OBJECTSTATE), ids=lambda p: p.name) +def test_scope_ids_are_built_through_scope_key(root: Path) -> None: + """No hand-written '::' scope grammar outside objectstate.scope_key.""" + + offenders = [] + for path in _python_files(root): + if path == OBJECTSTATE / "scope_key.py": + continue + tree = _tree(path) + docstrings = _docstring_nodes(tree) + relative = str(path.relative_to(REPO)) + for node in ast.walk(tree): + if isinstance(node, ast.Name) and node.id == "SCOPE_SEGMENT_SEPARATOR": + offenders.append(f"{relative}:{node.lineno} SCOPE_SEGMENT_SEPARATOR") + if ( + isinstance(node, ast.Constant) + and isinstance(node.value, str) + and id(node) not in docstrings + and (node.value.startswith("::") or node.value.endswith("::")) + and (relative, node.value) not in SCOPE_SEPARATOR_EXEMPTIONS + ): + offenders.append(f"{relative}:{node.lineno} {node.value!r}") + assert offenders == [] + + +def test_xdg_paths_carry_no_legacy_migration() -> None: + names = { + node.name + for node in ast.walk(_tree(PRODUCT / "core" / "xdg_paths.py")) + if isinstance(node, (ast.FunctionDef, ast.ClassDef)) + } + assert {name for name in names if "legacy" in name or name.startswith("migrate")} == set() + + +def test_library_families_are_classes_not_enums() -> None: + import pyqt_reactive.core.path_cache as path_cache + import pyqt_reactive.services.time_travel_navigation as time_travel_navigation + import pyqt_reactive.widgets.enhanced_path_widget as enhanced_path_widget + import pyqt_reactive.widgets.grid_selector as grid_selector + + enums = [ + f"{module.__name__}.{name}" + for module in (path_cache, enhanced_path_widget, grid_selector, time_travel_navigation) + for name, value in vars(module).items() + if isinstance(value, type) and issubclass(value, Enum) and value.__module__ == module.__name__ + ] + assert enums == [] + + +def test_external_editor_switch_is_named_once() -> None: + readers = sorted( + str(path.relative_to(REPO)) + for path in (*_python_files(PRODUCT), *_python_files(PYQT_REACTIVE)) + if "OPENHCS_USE_EXTERNAL_EDITOR" in path.read_text(encoding="utf-8") + ) + assert readers == ["openhcs/pyqt_gui/services/reactor_providers.py"] + + +def test_field_markers_come_from_objectstate_inspection() -> None: + offenders = sorted( + f"{path.relative_to(REPO)}:{node.lineno}" + for path in _python_files(PRODUCT / "pyqt_gui") + for node in ast.walk(_tree(path)) + if isinstance(node, ast.Call) + and isinstance(node.func, ast.Attribute) + and node.func.attr == "append" + and node.args + and isinstance(node.args[0], ast.Constant) + and node.args[0].value in {"*", "_"} + ) + assert offenders == [] From bcf23f01e4d3bceff9015c6b9b530b6f25e2c5e7 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Sun, 11 Oct 2026 04:04:54 -0400 Subject: [PATCH 10/17] U3: point at the library branch heads (ObjectState 1.4.0, pyqt-reactive 0.5.0, python-introspect 0.2.6) Co-Authored-By: Claude Opus 5.5 --- external/ObjectState | 2 +- external/pyqt-reactive | 2 +- pyproject.toml | 6 +++--- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/external/ObjectState b/external/ObjectState index af5b4f6186..9e32ef3619 160000 --- a/external/ObjectState +++ b/external/ObjectState @@ -1 +1 @@ -Subproject commit af5b4f6186089618671c8254dfaeb227f17d39b6 +Subproject commit 9e32ef3619aef50f817c77252a8ad7cc01035307 diff --git a/external/pyqt-reactive b/external/pyqt-reactive index ff704d2fb4..193dcbec2c 160000 --- a/external/pyqt-reactive +++ b/external/pyqt-reactive @@ -1 +1 @@ -Subproject commit ff704d2fb4464afb96108ce2c2152ba9a38f3ab6 +Subproject commit 193dcbec2c9b6284d8653662be33aabe01946045 diff --git a/pyproject.toml b/pyproject.toml index 3139640895..ba13a91608 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -101,12 +101,12 @@ dependencies = [ # External dependencies (PyPI versions for production builds) "zmqruntime>=0.6,<0.7", "pycodify>=0.1.3,<0.2", - "objectstate>=1.3.1,<2", - "python-introspect>=0.2.5,<0.3", + "objectstate>=1.4,<2", + "python-introspect>=0.2.6,<0.3", "metaclass-registry>=0.3.0,<0.4", "arraybridge>=0.4.1,<0.5", "polystore>=0.6,<0.7", - "pyqt-reactive>=0.4,<0.5", + "pyqt-reactive>=0.5,<0.6", ] [project.optional-dependencies] From 238d5dedd53682c03cad4db8651899ac7900b7cd Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Sun, 11 Oct 2026 04:08:21 -0400 Subject: [PATCH 11/17] U3: step-editor time-travel hooks ask the step scope and navigation driver step_function_scope asks StepEditorScope.is_function_scope and the tab choice goes through DualEditorWindowNavigationDriver.select_tab_for_field_path, which already owns the field-path-to-tab rule, instead of probing their fields. Co-Authored-By: Claude Opus 5.5 --- openhcs/pyqt_gui/windows/dual_editor_window.py | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/openhcs/pyqt_gui/windows/dual_editor_window.py b/openhcs/pyqt_gui/windows/dual_editor_window.py index 29d8f38e8c..3beaf6f336 100644 --- a/openhcs/pyqt_gui/windows/dual_editor_window.py +++ b/openhcs/pyqt_gui/windows/dual_editor_window.py @@ -1286,7 +1286,7 @@ def step_function_scope(scope_id: str) -> FunctionScopeRef | None: scope = StepEditorScope.parse(scope_id) except ValueError: return None - if scope.child_scope_id is None or ScopeKey(scope.child_scope_id).depth != 1: + if not scope.is_function_scope or ScopeKey(scope.child_scope_id).depth != 1: return None return FunctionScopeRef( owner_scope_id=scope.step_scope_id, @@ -1298,12 +1298,10 @@ def select_step_editor_tab_after_time_travel( scope_id: str, target: NavigationTarget | None, ) -> None: - """Show the function pattern tab when time travel targets a function.""" + """Show the tab that holds the field or function time travel focused.""" window = WindowManager.get_window(scope_id) - if not isinstance(window, DualEditorWindow) or window.tab_widget is None: - return - if target is not None and target.is_function_target: - DualEditorTab.FUNCTION_PATTERN.select(window.tab_widget) - else: - DualEditorTab.STEP_SETTINGS.select(window.tab_widget) + if isinstance(window, DualEditorWindow) and target is not None: + DualEditorWindowNavigationDriver(window).select_tab_for_field_path( + target.to_field_path() + ) From 3cf4b3395a0f14b0e2849ac3f1b279b50110c9a3 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Sun, 11 Oct 2026 04:09:53 -0400 Subject: [PATCH 12/17] U3: the step editor follows a restored pipeline only once a session is connected Co-Authored-By: Claude Opus 5.5 --- external/pyqt-reactive | 2 +- openhcs/pyqt_gui/main.py | 2 +- .../pyqt_gui/windows/dual_editor_window.py | 24 ++++++++++++------- .../test_dual_editor_window_context.py | 7 +++++- tests/unit/test_time_travel_navigation.py | 2 +- 5 files changed, 24 insertions(+), 13 deletions(-) diff --git a/external/pyqt-reactive b/external/pyqt-reactive index 193dcbec2c..86d9ccd08a 160000 --- a/external/pyqt-reactive +++ b/external/pyqt-reactive @@ -1 +1 @@ -Subproject commit 193dcbec2c9b6284d8653662be33aabe01946045 +Subproject commit 86d9ccd08a0e5bea5960cd92faed0eeab8a08481 diff --git a/openhcs/pyqt_gui/main.py b/openhcs/pyqt_gui/main.py index c8d2869a78..0e88fc96f2 100644 --- a/openhcs/pyqt_gui/main.py +++ b/openhcs/pyqt_gui/main.py @@ -945,7 +945,7 @@ def _connect_object_state_lifecycle(self) -> None: self.time_travel_navigator = TimeTravelNavigator( function_scope=step_function_scope, - window_shown=select_step_editor_tab_after_time_travel, + target_shown=select_step_editor_tab_after_time_travel, ) self._time_travel_complete_subscription = ( ObjectStateRegistry.add_time_travel_complete_callback( diff --git a/openhcs/pyqt_gui/windows/dual_editor_window.py b/openhcs/pyqt_gui/windows/dual_editor_window.py index 3beaf6f336..e907e4efe6 100644 --- a/openhcs/pyqt_gui/windows/dual_editor_window.py +++ b/openhcs/pyqt_gui/windows/dual_editor_window.py @@ -306,7 +306,6 @@ def __init__( self._save_button_base_style = "" self._function_pattern_controller = None self._time_travel_title_refresh_callback = None - self._session = None self._session_events = None self._orchestrator_config_signal = None self._compiled_artifact_signal = None @@ -333,11 +332,7 @@ def _connect_time_travel_title_refresh(self) -> None: if not self.scope_id: return - pipeline_scope = PipelineScopeIdentity.from_plate_scope(self.plate_scope).scope_id - - def refresh_after_time_travel(dirty_states, _triggering_scope) -> None: - if any(scope_id == pipeline_scope for scope_id, _state in dirty_states or ()): - QTimer.singleShot(0, lambda: self._follow_pipeline(self.plate_scope)) + def refresh_after_time_travel(_dirty_states, _triggering_scope) -> None: QTimer.singleShot(0, self._refresh_after_time_travel) ObjectStateRegistry.add_time_travel_complete_callback(refresh_after_time_travel) @@ -789,6 +784,16 @@ def connect_session_events(self, session: Session) -> None: # A bound method, not a closure over self: PyQt holds bound-method # slots weakly, so the connection does not keep this window alive. self._session_events.published.connect(self._on_session_record) + pipeline_scope = PipelineScopeIdentity.from_plate_scope(self.plate_scope).scope_id + + def follow_restored_pipeline(dirty_states, _triggering_scope) -> None: + if any(scope_id == pipeline_scope for scope_id, _state in dirty_states): + QTimer.singleShot(0, lambda: self._follow_pipeline(self.plate_scope)) + + self._pipeline_restore_subscription = ( + ObjectStateRegistry.add_time_travel_complete_callback(follow_restored_pipeline) + ) + self.destroyed.connect(self._pipeline_restore_subscription.release) def _on_session_record(self, record) -> None: self.on_session_event(record.event) @@ -813,7 +818,7 @@ def _(self, event: GlobalConfigChanged) -> None: ObjectStateRegistry.increment_token() def _follow_pipeline(self, scope_id: str) -> None: - if self._session is None or scope_id != self.plate_scope: + if scope_id != self.plate_scope: return self._on_pipeline_changed(self._session.pipeline_steps(scope_id)) @@ -849,6 +854,7 @@ def _cleanup_managed_resources(self) -> None: session_events = self._session_events if session_events is not None: session_events.close() + self._pipeline_restore_subscription.release() self._session_events = None orchestrator_signal = self._orchestrator_config_signal @@ -1296,12 +1302,12 @@ def step_function_scope(scope_id: str) -> FunctionScopeRef | None: def select_step_editor_tab_after_time_travel( scope_id: str, - target: NavigationTarget | None, + target: NavigationTarget, ) -> None: """Show the tab that holds the field or function time travel focused.""" window = WindowManager.get_window(scope_id) - if isinstance(window, DualEditorWindow) and target is not None: + if isinstance(window, DualEditorWindow): DualEditorWindowNavigationDriver(window).select_tab_for_field_path( target.to_field_path() ) diff --git a/tests/pyqt_gui/test_dual_editor_window_context.py b/tests/pyqt_gui/test_dual_editor_window_context.py index 1d7b695642..399739f987 100644 --- a/tests/pyqt_gui/test_dual_editor_window_context.py +++ b/tests/pyqt_gui/test_dual_editor_window_context.py @@ -37,12 +37,17 @@ def __init__(self) -> None: def close(self) -> None: self.closed = True + def release(self) -> None: + self.close() + def test_dual_editor_window_cleans_cross_window_subscriptions() -> None: window = DualEditorWindow.__new__(DualEditorWindow) relay = RecordingRelay() + restore_subscription = RecordingRelay() orchestrator_signal = RecordingSignal() window._session_events = relay + window._pipeline_restore_subscription = restore_subscription window._orchestrator_config_signal = orchestrator_signal window._compiled_artifact_signal = None window._runtime_artifact_signal = None @@ -52,7 +57,7 @@ def test_dual_editor_window_cleans_cross_window_subscriptions() -> None: window._cleanup_managed_resources() - assert relay.closed + assert relay.closed and restore_subscription.closed assert orchestrator_signal.disconnected == [window.on_orchestrator_config_changed] assert window._session_events is None assert window._orchestrator_config_signal is None diff --git a/tests/unit/test_time_travel_navigation.py b/tests/unit/test_time_travel_navigation.py index 6adf3de9b5..f50d36f49f 100644 --- a/tests/unit/test_time_travel_navigation.py +++ b/tests/unit/test_time_travel_navigation.py @@ -186,7 +186,7 @@ def fake_navigate(request): navigator = TimeTravelNavigator( function_scope=step_function_scope, - window_shown=lambda scope_id, target: window.tab_widget.setCurrentIndex(0), + target_shown=lambda scope_id, target: window.tab_widget.setCurrentIndex(0), ) navigator.on_time_travel_complete([(step_scope, step_state)], step_scope) From 29f78c125099defe496340f2af7b77920e18eb3e Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Sun, 11 Oct 2026 04:10:52 -0400 Subject: [PATCH 13/17] U3: step_function_scope states the function-child case positively Co-Authored-By: Claude Opus 5.5 --- openhcs/pyqt_gui/windows/dual_editor_window.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/openhcs/pyqt_gui/windows/dual_editor_window.py b/openhcs/pyqt_gui/windows/dual_editor_window.py index e907e4efe6..3e28510053 100644 --- a/openhcs/pyqt_gui/windows/dual_editor_window.py +++ b/openhcs/pyqt_gui/windows/dual_editor_window.py @@ -1292,12 +1292,12 @@ def step_function_scope(scope_id: str) -> FunctionScopeRef | None: scope = StepEditorScope.parse(scope_id) except ValueError: return None - if not scope.is_function_scope or ScopeKey(scope.child_scope_id).depth != 1: - return None - return FunctionScopeRef( - owner_scope_id=scope.step_scope_id, - function_token=scope.child_scope_id, - ) + if scope.is_function_scope and ScopeKey(scope.child_scope_id).depth == 1: + return FunctionScopeRef( + owner_scope_id=scope.step_scope_id, + function_token=scope.child_scope_id, + ) + return None def select_step_editor_tab_after_time_travel( From ac5095b40a8fa11a20f21fcde8d846af11ab0cf9 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Sun, 11 Oct 2026 04:15:59 -0400 Subject: [PATCH 14/17] U3+L4: receipt records what was built, the corrections and the U4 handoff Co-Authored-By: Claude Opus 5.5 --- .../consolidation-20261009/04-ARCHITECTURE.md | 2 +- .../U3-generic-qt-machinery.md | 59 +++++++++++-------- 2 files changed, 34 insertions(+), 27 deletions(-) diff --git a/docs/refactor/consolidation-20261009/04-ARCHITECTURE.md b/docs/refactor/consolidation-20261009/04-ARCHITECTURE.md index b9e3eb91dc..c0b51fa86d 100644 --- a/docs/refactor/consolidation-20261009/04-ARCHITECTURE.md +++ b/docs/refactor/consolidation-20261009/04-ARCHITECTURE.md @@ -230,7 +230,7 @@ class Session: | U1 | Session, `SessionOperation` and `SessionView` families in `openhcs/authoring/session`. Plate-manager and pipeline-editor semantics move out of widgets; one implementation each of dataset add, compile, run and streaming, shared by the GUI and headless MCP; state in ObjectState. Takes over G8 (container manager, component filters, grid role, importer and scope families). | ~10.4k moved, ~2k collapsed | | U2 | UI-bridge contract family derives its gateways (about 130 forwarded answers deleted); action providers inherit one base | ~2.1k | | U3 | Generic Qt machinery moves to pyqt-reactive (bridge server and window introspection, time travel, config windows, grid selector, dock and shortcut layout) and to ObjectState (`inspection`, typed `ScopeKey` instead of `::` strings, non-persisted view fields, change subscription replacing `GlobalEventBus`). Merges with L4. | ~8–9k after deduplication | -| U4 | A toolkit-neutral library holding pyqt-reactive's Qt-free tier (about 11k) and the generic bridge DTO and contract machinery (about 4.5k), so MCP DTOs no longer need PyQt6. Second consumer: the MCP server, including hosted HTTP. | new library | +| U4 | A toolkit-neutral library holding pyqt-reactive's Qt-free tier (about 11k) and the generic bridge DTO and contract machinery (about 4.5k), so MCP DTOs no longer need PyQt6. Second consumer: the MCP server, including hosted HTTP. Takes over from U3 the UI-bridge server and window introspection, which move once their DTOs have (U3-generic-qt-machinery.md, Reassigned). | new library | | U5 | Dead and misplaced code: the copy of pyqt-reactive's pattern data manager, dead dialogs and re-exports, unused workflow-surface ABCs, the unused `GlobalEventBus` paths, dead adapter methods, the stale `__all__` roster. The desktop updater (2.4k) moves beside `desktop_installation.py`. `history_migration` (a rule-2 converter in `openhcs/`) is deleted, because desktop histories are runtime state. | ~1.2k deleted | Order: U5 now (disjoint from step 3; F1 owns the pickle pattern files). U3 and U4 run as library lockstep releases. U1 and U2 follow G6, since they share the viewer streaming request. diff --git a/docs/refactor/consolidation-20261009/U3-generic-qt-machinery.md b/docs/refactor/consolidation-20261009/U3-generic-qt-machinery.md index 19e643271d..55c1b9bc9b 100644 --- a/docs/refactor/consolidation-20261009/U3-generic-qt-machinery.md +++ b/docs/refactor/consolidation-20261009/U3-generic-qt-machinery.md @@ -1,7 +1,7 @@ # U3 + L4: Generic Qt machinery leaves OpenHCS **Head audited:** `openhcs` `main` at `84e7a2904` (#1193); `pyqt-reactive` `main` at `269e896` (0.4.0); `ObjectState` `main` at `c75a457` (1.3.1); `python-introspect` `main` at `4bb7d13` (0.2.5). **Rules:** [00-RULES.md](00-RULES.md). **Step 3** (U3 merges with L4, [04-ARCHITECTURE.md](04-ARCHITECTURE.md#the-ui-one-session-interface-thin-renderers-surfaces-u1-to-u5)). -**Shared abstractions** ([02-SHARED-ABSTRACTIONS.md](02-SHARED-ABSTRACTIONS.md)). *Builds:* `objectstate.ScopeKey`, `objectstate.inspection`, view fields; pyqt-reactive's editable table, grid selector, time-travel widgets, config window, dock and shortcut layout, open `PathCacheKey` and `PathNameRole` families. *Uses:* `AutoRegisterMeta`, `IsomorphicDataclassRowPathPolicy`, `ObjectStateRegistry` subscriptions. +**Shared abstractions** ([02-SHARED-ABSTRACTIONS.md](02-SHARED-ABSTRACTIONS.md)). *Builds:* `objectstate.ScopeKey`, `objectstate.inspection`; pyqt-reactive's editable table, grid selector, time-travel widgets, config window, dock and shortcut layout, open `PathCacheKey` and `PathNameRole` families. *Uses:* `AutoRegisterMeta`, `IsomorphicDataclassRowPathPolicy`, `ObjectStateRegistry` subscriptions. Nothing in this surface had landed before this file was written; there is no closure note. @@ -30,51 +30,58 @@ Re-measured at `84e7a2904`: ## Target - **python-introspect 0.2.6:** `FieldLabel` and `declared_field_label` beside `AnnotationChoices`. `openhcs/core/field_label.py` is deleted. -- **ObjectState:** - - `ScopeKey(str)` owns the scope grammar: `ScopeKey.of(*parts)`, `child(token)`, `parent`, `root`, `ancestors()`, `contains(other)`, `depth`, `last`, `parts`, and `SEPARATOR` (declared once). The registry normalises every scope id it stores or is asked for to a `ScopeKey`; every `::` operation in the three code bases calls it. It is a `str` subclass, so history documents and dict keys keep their encoding. - - `objectstate.inspection`: `FieldInspection` (raw and resolved value, dirty, signature diff, inherited, provenance, value and declared type names, the `*`/`_` markers) and `ScopeInspection` (field listing, history header). The UI bridge maps them to its DTOs; no rule is written twice. - - View fields: `Annotated[T, ViewField()]` on an ObjectState dataclass field keeps it out of snapshots, dirty detection and saved values. The image browser's selected wells and the dataset-list selection are the first consumers. +- **ObjectState 1.4.0:** + - `ScopeKey(str)` owns the scope grammar: `ScopeKey.of(*parts)`, `child`, `parent`, `root`, `ancestors()`, `contains`, `strictly_contains`, `depth`, `last`, `parts`, and `SEPARATOR` (declared once). Registry keys are `ScopeKey`s; every `::` operation in the three code bases calls it (`get_root_from_scope_key` and `is_scope_affected`, which nothing called, are deleted). It is a `str` subclass, so history documents and dict keys keep their encoding. + - `objectstate.inspection`: `FieldInspection` (raw and resolved value, edit state, inheritance, provenance, type names) and `field_markers`/`state_markers`, the one owner of the `*`/`_` rule. The bridge's `ObjectStateFieldSemanticProjection` inherits `FieldInspection` and adds only its DTO projection. + - `retain_only_authored_paths` (moved from OpenHCS's serializer; it touches only lazy-dataclass metadata), `ObjectStateRegistry.MAIN_BRANCH` and `is_restoring_snapshot()` (pyqt-reactive read `_in_time_travel` privately). - `HistoryMigration` and `migration=` are deleted; a history whose `format_version` is not current is refused. -- **pyqt-reactive:** - - `core/path_cache.py`: `PathCacheKey` is an open `AutoRegisterMeta` family whose stored key is the snake-case class name; the library declares `General`, `FileSelection`, `DirectorySelection`, `CodeEditor`. `PathNameRole` becomes a family (`DirectoryPathRole`, `FilePathRole`). The compatibility aliases and unused keys are deleted. OpenHCS declares `PlateImport(PathCacheKey)` beside its one use. - - `ManagerStatusController` owns one marquee timer for its lifetime and restarts it. +- **pyqt-reactive 0.5.0:** + - `core/path_cache.py`: `PathCacheKey` is an open `AutoRegisterMeta` family whose stored key is the snake-case class name (`General`, `FileSelection`, `DirectorySelection`, `CodeEditor`); `PathNameRole` is a family (`DirectoryPathRole`, `FilePathRole`). The alias functions, unused keys and the pipeline/step/function roles are deleted. OpenHCS declares `PlateImport(PathCacheKey)` beside its one use. + - `ManagerStatusController` owns one marquee timer and restarts it (the header-timer leak). - `widgets/editable_table.py`: the editable table block, unchanged except the label lookup through `declared_field_label`. - - `widgets/grid_selector.py`: `GridSelectorWidget` over a `GridAddressing` protocol (`index`, `row_label`, `default_grid`); cell states, event targets and drag modes are class families. - - `widgets/time_travel.py` and `windows/snapshot_browser_window.py`: the widget, browser, navigation targets, `TimeTravelCommands`, the shortcut filter and the post-restore window navigator; hidden scopes and the child-scope owner are injected. - - `windows/config_window.py`: `ConfigWindow`, `ConfigEditSession`, `ConfigCodeDocumentDriver` over an injected render/parse protocol, `CodeEditorFormUpdater`, `ExternalCodeEditorPreference` (one environment variable name, read once). - - `widgets/shared/dock_layout.py`: dock pane, float controller, pane registry, layout store (key and version as parameters), shortcut lifecycle generic over the config type. - - `AbstractManagerWidget` stops reading `get_event_bus()`. -- **OpenHCS keeps only declarations and domain glue:** `PlateImport`, a `GridAddressed`→`GridAddressing` adapter and the well naming, the hidden dataset-list scope and step-scope owner for time travel, the dual-editor tab choice, the code-document renderer, the window specs and dock topology, `ShortcutConfig`. `GlobalEventBus`, `GuiEventBusBroadcaster` and `get_event_bus` are deleted; `DualEditorWindow` subscribes to its pipeline ObjectState. + - `widgets/grid_selector.py`: `GridSelectorWidget` over a `GridAddressing` ABC; cell looks and event targets are class families. OpenHCS's `GridAxisAddressing` adapts the `GridAddressed` axis (the kernel cannot subclass a Qt-library ABC). + - `widgets/time_travel.py` (`TimeTravelCommands`, `TimeTravelWidget`), `windows/snapshot_browser_window.py`, `services/time_travel_navigation.py` (`TimeTravelNavigator`; `FieldPathTarget`/`FunctionTokenTarget` replace a kind string). OpenHCS supplies the hidden dataset-list scope, `step_function_scope` (through `StepEditorScope`, not a second `functionstep_` parser) and the dual-editor tab choice. + - `windows/config_window.py` (`ConfigWindow`, `ConfigEditSession`, `ConfigCodeDocumentDriver`, `ConfigDocumentFormat` ABC), `services/code_editor_form_updater.py`, and `use_external_code_editor()` reading `FormGenConfig.external_editor_env_var`. OpenHCS keeps `OpenHCSConfigWindow` (document format adapter over `ConfigDocumentAuthority`, global-config window id). + - `widgets/shared/dock_layout.py` (`DockPane`, `DockFloatController`, `DockPanes`, `DockLayoutStore`) and `services/application_shortcuts.py` (`ShortcutLifecycle[ConfigT]`). OpenHCS keeps the window specs, the dock topology and `MainWindowEmbeddedWidgets(DockPanes)` naming its panes. + - `AbstractManagerWidget` stops reading `get_event_bus()`; `PatternDataManager.extract_func_and_kwargs` raises on a malformed entry (recorded by U5). +- **`GlobalEventBus` is deleted.** ObjectState already had the change subscriptions; what the bus restated were the session's own events. `DualEditorWindow.connect_session_events(session)` follows `PipelineChanged`/`PipelineImported` for its plate and `GlobalConfigChanged`, and follows its pipeline after a restore; `GuiEventBusBroadcaster` and `get_event_bus` are deleted. + +## Corrections to the plan + +- **View fields were not built.** At this head nothing needs a field excluded from history and dirty detection: U1 moved session state onto `Session`, the remaining widget view state (selection, current tab) has one owner each, and `ObjectState.metadata` contracts already hold state outside dirty detection. A mechanism without a consumer would be speculative; it belongs to the first surface with a consumer. +- **The UI-bridge server and window introspection did not move** (see Reassigned): they speak the bridge DTOs and the `UiBridgeOperation` family, which U4 moves first. +- The change subscription that replaces `GlobalEventBus` is the session's event log (U1), not a new ObjectState API. ## Persisted state -- `path_cache.json` (runtime convenience): stored keys are derived from class names and equal the old enum values for the kept contexts (`plate_import`, `general`, `file_selection`, `directory_selection`, `code_editor`), so the file loads unchanged; entries for deleted contexts are ignored. -- ObjectState history files (runtime state, reset on upgrade per U5): scope ids keep their `::` text. View fields are no longer written into snapshots. +- `path_cache.json` (runtime convenience): stored keys are derived from class names and equal the old enum values for the kept contexts (`plate_import`, `general`, `file_selection`, `directory_selection`, `code_editor`), so the file loads unchanged. +- ObjectState history files (runtime state, reset on upgrade per U5): scope ids keep their `::` text. - QSettings dock layout (`main_window/dock_layout_state`, version 2): key and version unchanged. +- `OPENHCS_USE_EXTERNAL_EDITOR` keeps its name (OpenHCS's form config declares it). ## Guards `tests/unit/pyqt_gui/test_generic_qt_machinery_guards.py`: -- `openhcs/core/path_cache.py`, `openhcs/core/field_label.py`, `time_travel_widget.py`, `snapshot_browser_window.py`, `time_travel_navigation.py`, `config_edit_session.py`, `config_window_code_document.py`, `code_editor_form_updater.py`, `gui_event_bus_broadcast.py` do not exist; no module under `openhcs/` defines `GlobalEventBus`, `get_event_bus`, `EditableTableController`, `CellEditor`, `PathCacheKey` (except subclasses), `MainWindowDockFloatController`, `MainWindowDockLayoutStore`, `TimeTravelShortcutEventFilter`, `AspectRatioContainer`. -- No `"::"` string literal in `openhcs/`, `external/ObjectState/src` or `external/pyqt-reactive/src` outside `objectstate/scope_key.py`, except reStructuredText directives and the execution-id prefix. -- `openhcs/core/xdg_paths.py` defines no `*legacy*`/`migrate_*` name. -- pyqt-reactive: no `Enum` subclass in `core/path_cache.py`, `enhanced_path_widget.py` or `grid_selector.py`. +- the ten moved modules do not exist; no module under `openhcs/` defines a moved name (`GlobalEventBus`, `get_event_bus`, `CellEditor`, `EditableTableController`, `MainWindowDockPane`, `MainWindowShortcutLifecycle`, `TimeTravelWidget`, `PlateViewWidget`, `ConfigEditSession`, `CodeEditorFormUpdater`, …); +- no `::` scope literal (exactly `::`, or a string starting or ending with it) and no `SCOPE_SEGMENT_SEPARATOR` in `openhcs/`, `external/ObjectState/src` or `external/pyqt-reactive/src` outside `objectstate/scope_key.py`; three external spellings are exempt (the `local::` execution id, IPv6 `::1`, a reStructuredText directive regex); +- `xdg_paths.py` defines no `*legacy*`/`migrate_*` name; the path, grid and time-travel library families define no `Enum`; +- `OPENHCS_USE_EXTERNAL_EDITOR` is named only in `reactor_providers.py`; no `pyqt_gui` module appends a `*`/`_` marker by hand. ## Tests -- Library: family tests for `PathCacheKey` (an application-declared context round-trips through the file) and `PathNameRole`; the marquee leak (repeated long messages leave one timer); `ScopeKey` grammar (including `/plate1` vs `/plate10`); `FieldInspection` over a lazy dataclass; view fields absent from snapshots and dirty state; moved editable-table, grid, time-travel, config-window and dock tests with stub domain adapters. -- OpenHCS: the source-binding editor, config-window, dock, time-travel navigation and UI-bridge tests keep their behavioural assertions against the moved classes; tests of `GlobalEventBus` wiring are replaced by one dual-editor test driven by a pipeline ObjectState change. +- Library: `test_path_name_roles.py` (an application-declared context round-trips through the file), the marquee leak (one timer after twenty long messages; fails without the fix), `test_scope_key.py` (`/plate1` vs `/plate10`), `test_inspection.py`, `test_authored_paths.py`, and moved tests for the editable table, grid selector, time travel, config window, dock layout, shortcuts and form updater with stub domain adapters. +- OpenHCS: the source-binding editor, config-window, time-travel navigation, plate-manager, pipeline-editor and UI-bridge tests keep their assertions against the moved classes; the `GlobalEventBus` wiring tests are replaced by dual-editor tests driven by session events. ## New-case experiments - A new remembered dialog context: today an enum member in OpenHCS's copy or in the library. After: one `PathCacheKey` subclass where it is used. - A new editable record table in any pyqt-reactive application: today copy 1,320 lines out of OpenHCS. After: `EditableTableController(table, DataclassFieldColumns.of(Row), ...)`. -- A non-microscopy grid (remote-sensing tiles): today the widget calls the microscopy-shaped `require_grid_axis()`. After: one `GridAddressing` adapter. -- A new child-scope rule (`parent`, `is within`): today a hand-written `split("::")` at the call site. After: a `ScopeKey` method call. +- A non-microscopy grid (remote-sensing tiles): today the widget calls the microscopy-shaped `require_grid_axis()`. After: one `GridAddressing` subclass. +- A new child-scope rule (`parent`, `is within`): today a hand-written `split("::")`. After: a `ScopeKey` method call. ## Done when -The guards pass; the three library PRs are open with their CI green; OpenHCS points its submodules at the library branch heads and its UI, source-binding editor, config-window, dock, time-travel and UI-bridge tests pass offscreen; the structural ratchet and NRA show no regression. +The guards pass; the three library PRs are open; OpenHCS points its submodules at the library branch heads and its UI tests pass offscreen; the structural ratchet and NRA show no regression. ## Reassigned @@ -84,4 +91,4 @@ The guards pass; the three library PRs are open with their CI green; OpenHCS poi | Bridge widget ids (`plate_manager`) and `WorkflowTerminalStateCriterion` (recorded by U2) | bridge descriptor vocabulary | U4, with the bridge | | `PyQtServiceAdapter.get_current_color_scheme` as a library protocol (recorded by U5) | called by pyqt-reactive `abstract_manager_widget.py:685` | U4 (service-adapter protocol in the toolkit-neutral tier) | | pyqt-reactive `PatternDataManager.extract_func_and_kwargs` returns `(None, {})` for a three-member tuple (recorded by U5) | `pattern_data_manager.py:120` | done here: it raises `TypeError` | -| `QApplication` teardown segfault after the main window closes (recorded by U5) | 1 in 5 runs, no Python frame | not reproduced in this surface's offscreen runs; stays recorded under U4 (generic Qt lifecycle) | +| `QApplication` teardown segfault after the main window closes (recorded by U5) | 1 in 5 runs, no Python frame | not investigated here; U4 (generic Qt lifecycle) | From d7c7ad6288f87ac47ff4cb8ad0c22ca6a9d3e3e7 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Sun, 11 Oct 2026 04:17:56 -0400 Subject: [PATCH 15/17] U3: cppipe import test patches OpenHCSConfigWindow Co-Authored-By: Claude Opus 5.5 --- tests/unit/pyqt_gui/test_plate_manager_cppipe_import.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/unit/pyqt_gui/test_plate_manager_cppipe_import.py b/tests/unit/pyqt_gui/test_plate_manager_cppipe_import.py index fa9025a8ea..ff709f1e91 100644 --- a/tests/unit/pyqt_gui/test_plate_manager_cppipe_import.py +++ b/tests/unit/pyqt_gui/test_plate_manager_cppipe_import.py @@ -173,7 +173,7 @@ def activateWindow(self): pass monkeypatch.setattr( - "openhcs.pyqt_gui.widgets.plate_manager.ConfigWindow", FakeConfigWindow + "openhcs.pyqt_gui.widgets.plate_manager.OpenHCSConfigWindow", FakeConfigWindow ) with session_gui() as gui: gui.session.invoke(AddDatasets, DatasetRootsRequest(roots=(str(root),))) From bea8c593e5a58919014cf90531dce95f71f0174f Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Sun, 11 Oct 2026 04:36:01 -0400 Subject: [PATCH 16/17] U3: point at the library branch heads after their CI fixes Co-Authored-By: Claude Opus 5.5 --- external/ObjectState | 2 +- external/pyqt-reactive | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/external/ObjectState b/external/ObjectState index 9e32ef3619..4317a8ca91 160000 --- a/external/ObjectState +++ b/external/ObjectState @@ -1 +1 @@ -Subproject commit 9e32ef3619aef50f817c77252a8ad7cc01035307 +Subproject commit 4317a8ca91aa8a74b341dbea15eacfbb2c786b9e diff --git a/external/pyqt-reactive b/external/pyqt-reactive index 86d9ccd08a..3a0b944fbb 160000 --- a/external/pyqt-reactive +++ b/external/pyqt-reactive @@ -1 +1 @@ -Subproject commit 86d9ccd08a0e5bea5960cd92faed0eeab8a08481 +Subproject commit 3a0b944fbbc1c9640fd15e00bb1c5687806df913 From 4df6da1d8e1458d385bf89ef7620634a8bb0587f Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Sun, 11 Oct 2026 06:09:41 -0400 Subject: [PATCH 17/17] U3: pin released python-introspect 0.2.6, objectstate 2.0.0, pyqt-reactive 0.5.0 Co-Authored-By: Claude Opus 5.5 --- external/ObjectState | 2 +- external/pyqt-reactive | 2 +- external/python-introspect | 2 +- pyproject.toml | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/external/ObjectState b/external/ObjectState index 4317a8ca91..4d74eacf98 160000 --- a/external/ObjectState +++ b/external/ObjectState @@ -1 +1 @@ -Subproject commit 4317a8ca91aa8a74b341dbea15eacfbb2c786b9e +Subproject commit 4d74eacf98fddc94f4d4cc3aaeef4b998a2ad03b diff --git a/external/pyqt-reactive b/external/pyqt-reactive index 3a0b944fbb..1bce7de2b3 160000 --- a/external/pyqt-reactive +++ b/external/pyqt-reactive @@ -1 +1 @@ -Subproject commit 3a0b944fbbc1c9640fd15e00bb1c5687806df913 +Subproject commit 1bce7de2b33edd5b054f596da2b321c9b7cf93fd diff --git a/external/python-introspect b/external/python-introspect index d8071f0f14..82a9ce5f8f 160000 --- a/external/python-introspect +++ b/external/python-introspect @@ -1 +1 @@ -Subproject commit d8071f0f14471416c3ee27b5dadd7b463382c3ff +Subproject commit 82a9ce5f8f67e0ad1a42656b3a0a7c24b4345545 diff --git a/pyproject.toml b/pyproject.toml index ba13a91608..27803093e1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -101,7 +101,7 @@ dependencies = [ # External dependencies (PyPI versions for production builds) "zmqruntime>=0.6,<0.7", "pycodify>=0.1.3,<0.2", - "objectstate>=1.4,<2", + "objectstate>=2.0,<3", "python-introspect>=0.2.6,<0.3", "metaclass-registry>=0.3.0,<0.4", "arraybridge>=0.4.1,<0.5",