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 new file mode 100644 index 0000000000..55c1b9bc9b --- /dev/null +++ b/docs/refactor/consolidation-20261009/U3-generic-qt-machinery.md @@ -0,0 +1,94 @@ +# 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`; 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 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 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` 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. +- 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`: +- 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: `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` 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; OpenHCS points its submodules at the library branch heads and its UI 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 investigated here; U4 (generic Qt lifecycle) | diff --git a/external/ObjectState b/external/ObjectState index c75a457182..4d74eacf98 160000 --- a/external/ObjectState +++ b/external/ObjectState @@ -1 +1 @@ -Subproject commit c75a457182e6b158ce68c759cb1ef5c86c849afa +Subproject commit 4d74eacf98fddc94f4d4cc3aaeef4b998a2ad03b diff --git a/external/pyqt-reactive b/external/pyqt-reactive index 269e896ba8..1bce7de2b3 160000 --- a/external/pyqt-reactive +++ b/external/pyqt-reactive @@ -1 +1 @@ -Subproject commit 269e896ba8ec3050ae305e4ac9c46a1c01c2cb9b +Subproject commit 1bce7de2b33edd5b054f596da2b321c9b7cf93fd diff --git a/external/python-introspect b/external/python-introspect index 4bb7d132d7..82a9ce5f8f 160000 --- a/external/python-introspect +++ b/external/python-introspect @@ -1 +1 @@ -Subproject commit 4bb7d132d7a789c0ab9eaea556b4e4628b26737b +Subproject commit 82a9ce5f8f67e0ad1a42656b3a0a7c24b4345545 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/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/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/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/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/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/main.py b/openhcs/pyqt_gui/main.py index 1c69fa6a76..0e88fc96f2 100644 --- a/openhcs/pyqt_gui/main.py +++ b/openhcs/pyqt_gui/main.py @@ -29,6 +29,8 @@ QWidget, ) 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, @@ -38,6 +40,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 +79,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,32 +95,21 @@ EmbeddedCodeDocumentRegistrationABC, ) from openhcs.pyqt_gui.services.main_window_workflows import ( - MainWindowDockLayoutStore, - MainWindowDockPane, MainWindowEmbeddedWidgets, MainWindowLifecycleWorkflow, MainWindowPipelineActions, - MainWindowShortcutLifecycle, - 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, @@ -258,13 +250,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 +465,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 +495,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 +517,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 +530,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 +641,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) @@ -861,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) @@ -869,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) @@ -945,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, + target_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) @@ -1019,29 +1021,29 @@ 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_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, @@ -1102,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/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/main_window_workflows.py b/openhcs/pyqt_gui/services/main_window_workflows.py index a3a0e2ea09..2f2e3afcd0 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) - - 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()) +class MainWindowEmbeddedWidgets(DockPanes): + """The OpenHCS main window's panes, addressed by their window ids.""" - 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.""" @@ -848,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/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/service_adapter.py b/openhcs/pyqt_gui/services/service_adapter.py index 8b8a2aed93..cde5929286 100644 --- a/openhcs/pyqt_gui/services/service_adapter.py +++ b/openhcs/pyqt_gui/services/service_adapter.py @@ -4,34 +4,16 @@ 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 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__) -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.""" @@ -50,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() @@ -169,7 +148,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, @@ -306,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/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/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 5b4c0b55cd..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 @@ -134,7 +135,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 = ( @@ -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) @@ -541,7 +533,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: @@ -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/openhcs/pyqt_gui/services/window_handlers.py b/openhcs/pyqt_gui/services/window_handlers.py index 32c8c801f0..09491d9c48 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: @@ -58,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( @@ -106,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, @@ -154,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: @@ -186,7 +187,7 @@ def create_plate_config_window( ) return None - window = ConfigWindow( + window = OpenHCSConfigWindow( tabs=( ConfigWindowTabSpec( state=state, @@ -250,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/image_browser.py b/openhcs/pyqt_gui/widgets/image_browser.py index c705ee166f..6feb1765c6 100644 --- a/openhcs/pyqt_gui/widgets/image_browser.py +++ b/openhcs/pyqt_gui/widgets/image_browser.py @@ -5,13 +5,16 @@ view them in Napari with configurable display settings. """ +from openhcs.core.axes import Axis, AxisFamily, GridAddressed +from pyqt_reactive.widgets.grid_selector import GridAddressing, GridSelectorWidget +from objectstate import ScopeKey import logging import subprocess import time 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 @@ -47,17 +50,46 @@ 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, - 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 + + +@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.""" + + 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 +631,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=GridAxisAddressing(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) @@ -793,8 +830,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)) @@ -1486,7 +1523,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 +1562,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/pipeline_editor.py b/openhcs/pyqt_gui/widgets/pipeline_editor.py index 9d482ebc6b..3d0d89c923 100644 --- a/openhcs/pyqt_gui/widgets/pipeline_editor.py +++ b/openhcs/pyqt_gui/widgets/pipeline_editor.py @@ -5,9 +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 @@ -99,23 +100,18 @@ 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__) StepFunctionDeclaration = FunctionSpec | dict[str, FunctionSpec] | None -PIPELINE_EDITOR_EXTERNAL_EDITOR_ENV = "OPENHCS_USE_EXTERNAL_EDITOR" SHOW_PIPELINE_DEBUG_TOOLBAR = False @@ -135,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.""" @@ -388,7 +373,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 ------------------------------------------------------------------- @@ -525,7 +509,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)) @@ -605,13 +589,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 --------------------------------------------- @@ -682,6 +661,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 ) @@ -809,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 806f17b094..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 @@ -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, @@ -98,13 +98,10 @@ 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, ) -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 @@ -114,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, @@ -339,7 +328,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 ------------------------------------------------------- @@ -445,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}, ) @@ -477,13 +465,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, @@ -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/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/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/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/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/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/pyqt_gui/windows/dual_editor_window.py b/openhcs/pyqt_gui/windows/dual_editor_window.py index 7ab031c1c4..3e28510053 100644 --- a/openhcs/pyqt_gui/windows/dual_editor_window.py +++ b/openhcs/pyqt_gui/windows/dual_editor_window.py @@ -7,10 +7,10 @@ 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 import ScopeKey from objectstate.object_state import ObjectStateRegistry from PyQt6.QtCore import QTimer, pyqtSignal from PyQt6.QtGui import QShowEvent @@ -24,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, @@ -45,7 +47,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 +306,7 @@ 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_events = None self._orchestrator_config_signal = None self._compiled_artifact_signal = None self._runtime_artifact_signal = None @@ -699,19 +709,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 +776,57 @@ 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) + pipeline_scope = PipelineScopeIdentity.from_plate_scope(self.plate_scope).scope_id - This is the OpenHCS "set and forget" pattern - one handler receives ALL updates. + 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)) - 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 + self._pipeline_restore_subscription = ( + ObjectStateRegistry.add_time_travel_complete_callback(follow_restored_pipeline) + ) + self.destroyed.connect(self._pipeline_restore_subscription.release) - # Only refresh if this is for our orchestrator - if not self.orchestrator: - return + def _on_session_record(self, record) -> None: + self.on_session_event(record.event) - # 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 + @singledispatchmethod + def on_session_event(self, event: SessionEvent) -> None: + del event + + @on_session_event.register + def _(self, event: PipelineChanged) -> None: + self._follow_pipeline(event.scope_id) - logger.debug(f"Step editor received config change: {type(config).__name__}") + @on_session_event.register + def _(self, event: PipelineImported) -> None: + self._follow_pipeline(event.scope_id) - # 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 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 +851,11 @@ 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._pipeline_restore_subscription.release() + self._session_events = None orchestrator_signal = self._orchestrator_config_signal if orchestrator_signal is not None: @@ -999,13 +989,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: @@ -1300,3 +1283,31 @@ 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.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( + scope_id: str, + 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): + DualEditorWindowNavigationDriver(window).select_tab_for_field_path( + target.to_field_path() + ) 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/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/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/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: 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/pyproject.toml b/pyproject.toml index 3139640895..27803093e1 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>=2.0,<3", + "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] 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/pyqt_gui/test_dual_editor_window_context.py b/tests/pyqt_gui/test_dual_editor_window_context.py index a95569f32f..399739f987 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,24 @@ 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 release(self) -> None: + self.close() def test_dual_editor_window_cleans_cross_window_subscriptions() -> None: window = DualEditorWindow.__new__(DualEditorWindow) - event_bus = RecordingEventBus() + relay = RecordingRelay() + restore_subscription = RecordingRelay() orchestrator_signal = RecordingSignal() - window._event_bus = event_bus + 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 @@ -50,10 +57,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 and restore_subscription.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 +71,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/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/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_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_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 == [] 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_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_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_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_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_pipeline_editor_widget.py b/tests/unit/pyqt_gui/test_pipeline_editor_widget.py index 75818291c8..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 @@ -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/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),))) 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_plate_view_grid_model.py b/tests/unit/pyqt_gui/test_plate_view_grid_model.py index dd7effd9c5..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,69 +1,18 @@ -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 +from openhcs.pyqt_gui.widgets.image_browser import GridAxisAddressing +WELLS = GridAxisAddressing(Microscopy.Well) -def test_plate_grid_model_parses_standard_well_coordinates() -> None: - model = PlateGridModel.from_wells({"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_well_axis_places_standard_well_ids_on_the_plate_grid() -> None: + model = GridModel.of(WELLS, {"B02", "C04", "AA10"}) - -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(WELLS, set()).dimensions == Microscopy.Well.default_grid + assert Microscopy.Well.row_label(27) == "AA" 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/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/pyqt_gui/test_ui_agent_bridge.py b/tests/unit/pyqt_gui/test_ui_agent_bridge.py index 885f55da94..731c7b797b 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 @@ -156,14 +157,11 @@ ) 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, ) from openhcs.pyqt_gui.services.main_window_workflows import ( - MainWindowDockPane, MainWindowEmbeddedWidgets, ) from openhcs.pyqt_gui.services.reactor_providers import OpenHCSCodegenProvider @@ -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, @@ -490,7 +491,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 +990,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 +1051,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_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"), 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 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( { 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): diff --git a/tests/unit/test_time_travel_navigation.py b/tests/unit/test_time_travel_navigation.py index 540772d58d..f50d36f49f 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, + target_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, )