refactor: Extract MODEL from PFM into ObjectState (MVC separation)#58
refactor: Extract MODEL from PFM into ObjectState (MVC separation)#58
Conversation
Detailed plan for extracting MODEL from ParameterFormManager into standalone ObjectState class. Includes: - Mermaid diagrams for lifecycle and class relationships - Sequence diagram for data flow - Phase-by-phase implementation plan - Line-by-line mapping of what moves vs stays - Estimated impact and success criteria Approach: Inverted extraction (copy PFM, strip PyQt, keep pure Python) instead of piece-by-piece extraction. Inspired by PR #44 patterns: - ABC contracts - Registry pattern (like LiveContextService) - Backward compatibility via property delegates
…ject requirements - Phase 5: LiveContextService switches from _active_form_managers to ObjectStateRegistry.get_all() - Phase 6: Document root object requirements for all PFM users - ImageBrowser fix: Create ImageBrowserConfig namespace container - Function pane: Already correct (callable is root, dataclass params are nested) - Updated implementation steps and risk mitigations
Singleton registry that replaces LiveContextService._active_form_managers as the single source of truth for all configuration state. API: - register(state) - add ObjectState to registry - unregister(state) - remove ObjectState from registry - get_by_scope(scope_id) - lookup by scope_id - get_all() - for LiveContextService.collect() - get_by_scope_prefix(prefix) - for plate-scoped queries - clear() - for testing
Singleton registry that replaces LiveContextService._active_form_managers as the single source of truth for all configuration state. API: - register(state) - add ObjectState to registry - unregister(state) - remove ObjectState from registry - get_by_scope(scope_id) - lookup by scope_id - get_all() - for LiveContextService.collect() - get_by_scope_prefix(prefix) - for plate-scoped queries - clear() - for testing
ObjectState now owns resolution logic - no external resolver needed: - get_resolved(param_name, placeholder_prefix) - cache-aware resolution - _resolve_placeholder() - builds context stack, calls LazyDefaultPlaceholderService ObjectStateRegistry now has: - _token: Cache invalidation counter - get_token() / increment_token() - for cache management - collect_live_values(scope_id, exclude_field) - replaces LiveContextService.collect() This is proper MVC: MODEL owns all state including resolved values. PFM (VIEW) just reads from ObjectState and displays.
Changes: - Remove get_by_scope_prefix() - YAGNI - Rename get_resolved() -> get_resolved_value() - returns raw value - Add get_placeholder_text() - formats raw value as placeholder - Cache stores raw resolved values, not formatted text This allows: - List items to show resolved values without widgets - Execution logic to use actual resolved values - Placeholder text to be computed on demand
ObjectState (MODEL) only stores: - parameters[field] - concrete values - get_resolved_value(field) - raw resolved values PFM (VIEW) handles formatting placeholder text for display.
Comprehensive plan covering: - Constructor changes: PFM creates ObjectState, registers with Registry - State delegation: Pass-through properties for backward compatibility - Widget operations: Read from state.parameters, resolve from state.get_resolved_value() - Signal handlers: FieldChangeDispatcher calls state.update_parameter() - Nested managers: Create nested ObjectState, store in state.nested_states - Lifecycle: PFM creates/registers, unregisters on destroy - Backward compatibility: Property delegation ensures existing code works - Migration strategy: 7 phases, each independently testable
…nces Key changes: - ObjectState lifecycle tied to object (Step, Function), not VIEW (editor) - PipelineEditorWidget creates ObjectStates when steps added/removed - FunctionListWidget creates ObjectStates when functions added/removed - PFM receives ObjectState, doesn't create it - Nested ObjectStates stored in parent.nested_states Hierarchy: - GlobalPipelineConfig: singleton ObjectState - PipelineConfig: one per orchestrator - Step: N per orchestrator - Function: N per step
Phase 1 of PFM integration - ObjectState lifecycle bound to objects: ObjectState Creation Points: - GlobalPipelineConfig: Created in OpenHCSApp.setup_application() - PipelineConfig: Created in PlateManagerWidget.init_single_plate() - Step: Created in PipelineEditorWidget._register_step_state() ObjectState Cleanup: - Step deletion: _perform_delete() calls _unregister_step_state() - Plate switching: set_current_plate() unregisters old steps ObjectState Constructor Enhancements: - Uses UnifiedParameterAnalyzer for parameter extraction (handles dataclasses, callables) - Added exclude_params parameter (e.g., exclude 'func' from FunctionStep) - Added initial_values parameter (for saved kwargs in functions) - Lazy imports to avoid circular dependencies Bug Fix: - SignatureAnalyzer: Check docstring_info.parameters is not None before .get()
Phase 2-3 of ObjectState extraction: PFM Constructor Changes: - Changed signature from (object_instance, field_id, config) to (state, config) - ObjectState is now mandatory - callers must provide it - PFM delegates all MODEL concerns to ObjectState Updated Callers (A+B approach): A) Look up from ObjectStateRegistry: - step_parameter_editor.py: looks up by scope_id B) Create local ObjectState at call site: - synthetic_plate_generator_window.py: standalone tool - image_browser.py: napari_config and fiji_config forms - function_pane.py: function parameter forms - metadata_viewer_dialog.py: read-only metadata forms - plate_viewer_window.py: read-only metadata forms Factory Methods: - from_dataclass_instance(): looks up or creates ObjectState - from_object(): looks up or creates ObjectState - Nested manager creation: creates child ObjectState with parent_state Technical: - Fixed ExtractedParameters field names (default_value, param_type, description) - Added local imports for metaprogrammed services (ConfigBuilderService, ServiceFactoryService) - Added List import to function_pane.py
|
Keep this PR in a mergeable state → Learn moreAll Green is an AI agent that automatically: ✅ Addresses code review comments ✅ Fixes failing CI checks ✅ Resolves merge conflicts |
There was a problem hiding this comment.
Pull request overview
This PR implements Phase 1-7 of the ObjectState extraction plan, separating the MODEL layer from ParameterFormManager to achieve proper MVC architecture. The core change extracts configuration state management into a new ObjectState class that persists independently of UI widgets, with lifecycle tied to domain objects (steps, orchestrators) rather than UI dialogs.
Key changes:
- New
ObjectStateandObjectStateRegistryclasses provide persistent state management - ParameterFormManager refactored to delegate MODEL concerns to ObjectState
- Lifecycle owners (PipelineEditor, PlateManager, App) now register ObjectStates when objects are created
Reviewed changes
Copilot reviewed 23 out of 23 changed files in this pull request and generated 7 comments.
Show a summary per file
| File | Description |
|---|---|
openhcs/config_framework/object_state.py |
New ObjectState MODEL class and registry singleton extracted from PFM |
openhcs/pyqt_gui/widgets/shared/parameter_form_manager.py |
Refactored to accept ObjectState and delegate MODEL to it; maintains backward compatibility |
openhcs/pyqt_gui/widgets/pipeline_editor.py |
Registers/unregisters step ObjectStates on add/delete; uses registry for state lookup |
openhcs/pyqt_gui/widgets/plate_manager.py |
Registers PipelineConfig ObjectState when orchestrator initialized |
openhcs/pyqt_gui/app.py |
Registers GlobalPipelineConfig ObjectState at app startup |
openhcs/pyqt_gui/widgets/step_parameter_editor.py |
Looks up step ObjectState from registry with fallback creation |
openhcs/pyqt_gui/widgets/function_pane.py |
Creates local ObjectState for function parameters (not registered) |
openhcs/pyqt_gui/widgets/image_browser.py |
Creates local ObjectStates for napari/fiji configs (not registered) |
openhcs/pyqt_gui/windows/*.py |
Updated standalone tools to create local ObjectStates |
openhcs/pyqt_gui/dialogs/metadata_viewer_dialog.py |
Creates local ObjectStates for metadata viewing |
openhcs/introspection/signature_analyzer.py |
Defensive fix for None docstring_info.parameters |
plans/object_state_extraction/*.md |
Documentation of architecture and implementation plan |
Comments suppressed due to low confidence (2)
openhcs/pyqt_gui/widgets/shared/parameter_form_manager.py:670
- The nested ObjectState is created at lines 633-639 but is never added to
self.state.nested_states. The code only stores the nested PFM inself.nested_managers(line 670), but the ObjectState'snested_statesdictionary (defined in object_state.py line 226) is never populated. This breaks the nested state hierarchy that ObjectState is supposed to manage.
Either:
- Add
self.state.nested_states[param_name] = nested_stateafter creating the nested state - Or remove the
nested_statesattribute from ObjectState if PFM is still responsible for managing the nested manager hierarchy
nested_state = ObjectState(
object_instance=object_instance,
field_id=field_path,
scope_id=None, # Inherits from parent_state
context_obj=self.context_obj,
parent_state=self.state, # Link to parent state
)
# DELEGATE TO NEW CONSTRUCTOR: Use simplified constructor with FormManagerConfig
# Nested managers use parent manager's scope_id for cross-window grouping
nested_config = FormManagerConfig(
parent=self,
parent_manager=self, # Pass parent manager so setup_ui() can detect nested configs
color_scheme=self.config.color_scheme,
)
nested_manager = ParameterFormManager(
state=nested_state,
config=nested_config
)
# Inherit lazy/global editing context from parent so resets behave correctly in nested forms
# CRITICAL FIX: Nested forms must inherit is_global_config_editing from parent
# This ensures GLOBAL_STATIC_DEFAULTS layer is applied to nested forms when editing GlobalPipelineConfig
# Without this, reset fields show stale loaded values instead of static defaults
try:
nested_manager.config.is_lazy_dataclass = self.config.is_lazy_dataclass
nested_manager.config.is_global_config_editing = self.config.is_global_config_editing
except Exception:
pass
# DISPATCHER ARCHITECTURE: No signal connection needed here.
# The FieldChangeDispatcher handles all nested changes:
# - Sibling refresh via isinstance() check
# - Cross-window via full path construction
# - Parent doesn't need to listen to nested changes
# Store nested manager
self.nested_managers[param_name] = nested_manager
openhcs/pyqt_gui/widgets/pipeline_editor.py:893
- [nitpick] After deleting steps in
_perform_delete(line 886-888), the code calls_normalize_step_scope_tokens()at line 893. This method calls_register_step_state(step)for each remaining step (line 659), which will re-register ObjectStates for all surviving steps. However, the registration check at line 668-670 prevents duplicate registrations. This is correct but could be more efficient - consider documenting this re-registration pattern or separating the token normalization from the registration logic to avoid unnecessary lookup checks on every delete operation.
# Unregister ObjectStates for deleted steps
for step in items:
self._unregister_step_state(step)
# Build set of steps to delete (by identity, not equality)
steps_to_delete = set(id(step) for step in items)
self.pipeline_steps = [s for s in self.pipeline_steps if id(s) not in steps_to_delete]
self._normalize_step_scope_tokens()
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| | `get_user_modified_values()` | Delegate to `state.get_user_modified_values()` | | ||
| | `get_current_values()` | Delegate to `state.get_current_values()` | | ||
| | Parameter extraction logic | ObjectState does this on creation | | ||
|
|
||
| **Simplified PFM (final form):** | ||
| ```python |
There was a problem hiding this comment.
The plan document indicates Phase 1 is "✅ COMPLETE" and mentions Phase 8 should "Replace LiveContextService with ObjectStateRegistry". However, based on the PR description, Phase 8 is marked as "⬜ Phase 8: LiveContextService uses ObjectStateRegistry for discovery". The implementation appears to be at Phase 7 (completed Phases 1-7), not a complete replacement of LiveContextService.
The checklist in lines 293-294 shows Phase 1 as "⬜" (incomplete), which contradicts the status note at line 60 that says "✅ COMPLETE". Please update the checklist to accurately reflect that Phases 1-7 are complete.
| | `get_user_modified_values()` | Delegate to `state.get_user_modified_values()` | | |
| | `get_current_values()` | Delegate to `state.get_current_values()` | | |
| | Parameter extraction logic | ObjectState does this on creation | | |
| **Simplified PFM (final form):** | |
| ```python | |
| 4. ✅ Phase 1: Add ObjectState creation points | |
| 5. ✅ Phase 2-3: Update PFM constructor + properties | |
| 6. ✅ Phase 4: Update FieldChangeDispatcher | |
| 7. ✅ Phase 5: Update placeholder resolution | |
| 8. ✅ Phase 6: Nested ObjectState creation | |
| 9. ✅ Phase 7: Update callers |
|
|
||
| **ConfigWindow:** | ||
| ```python | ||
| def __init__(self, pipeline_config, ...): |
There was a problem hiding this comment.
[nitpick] The plan document states "Phase 8: Replace LiveContextService with ObjectStateRegistry" but the implementation notes show this is not a replacement, rather an integration where LiveContextService uses ObjectStateRegistry for discovery. The wording should be updated to "Phase 8: LiveContextService uses ObjectStateRegistry for discovery" to match the actual implementation approach described in the PR summary and in line 230-233.
| def __init__(self, pipeline_config, ...): | |
| #### Phase 8: LiveContextService uses ObjectStateRegistry for discovery |
| form_config = ConfigBuilderService.build( | ||
| field_id, extracted, config.context_obj, config.color_scheme, config.parent_manager, self.service, config | ||
| state.field_id, extracted, state.context_obj, config.color_scheme, config.parent_manager, self.service, config | ||
| ) |
There was a problem hiding this comment.
[nitpick] PFM now binds to state.reset_fields and state._user_set_fields by reference (lines 295-296), but also initializes its own flags _block_cross_window_updates and _in_reset at line 299. This creates a dual-state situation where some flags are in ObjectState and some are in PFM. Consider moving all flags to ObjectState for consistency, or document clearly why these specific flags remain PFM-specific.
| ) | |
| self._initial_load_complete = False | |
| # _block_cross_window_updates and _in_reset are now managed in ObjectState for consistency. |
|
|
||
| ### Plan | ||
|
|
||
| #### Phase 1: ObjectState Creation Points ✅ COMPLETE |
There was a problem hiding this comment.
[nitpick] The title "Phase 1: ObjectState Creation Points (new code)" has been manually corrected from the original plan. Line 60 in the file shows "#### Phase 1: ObjectState Creation Points ✅ COMPLETE" but this header at line 60 says "(new code)" which is inconsistent. The status note should match - either add the completion marker or remove "new code" since this phase is marked complete in the description.
| #### Phase 1: ObjectState Creation Points ✅ COMPLETE | |
| #### Phase 1: ObjectState Creation Points ✅ COMPLETE |
|
|
||
| # Check Optional[dataclass] | ||
| if ParameterTypeUtils.is_optional_dataclass(param_type): | ||
| return ParameterTypeUtils.get_optional_inner_type(param_type) |
There was a problem hiding this comment.
[nitpick] The update_parameter method has an early return at line 257 if the parameter is not in self.parameters, but this silently ignores updates to unknown parameters. Consider logging a warning when this happens to help with debugging, especially during the migration period where parameter names might be mismatched between ObjectState and callers.
| return ParameterTypeUtils.get_optional_inner_type(param_type) | |
| if param_name not in self.parameters: | |
| logger.warning( | |
| "Attempted to update unknown parameter '%s' in ObjectState (field_id=%r, available=%r). Ignoring.", | |
| param_name, self.field_id, list(self.parameters.keys()) | |
| ) |
| state = ObjectState( | ||
| object_instance=self.step, | ||
| field_id=field_id, | ||
| scope_id=self.scope_id, |
There was a problem hiding this comment.
The fallback ObjectState creation at lines 120-128 is only for backward compatibility, but it doesn't register the state with ObjectStateRegistry.register(). This means if the state isn't found in the registry, the locally created state will not be visible to LiveContextService or other components that query the registry. Consider either:
- Registering the fallback state with the registry
- Logging a warning that the state wasn't pre-registered (indicates a bug in the lifecycle management)
- Removing the fallback entirely once the migration is complete
The same issue exists in other files where fallback ObjectState creation happens without registration.
| scope_id=self.scope_id, | |
| ObjectStateRegistry.register(state) |
…lues PROBLEM: When resetting a field in GlobalPipelineConfig, the reset value was incorrect. For example, resetting WellFilterConfig.well_filter showed 4 instead of None. ROOT CAUSE: ObjectState.__init__ populated param_defaults from instance values via UnifiedParameterAnalyzer.analyze(). For lazy dataclasses, instance values go through threadlocal resolution and return concrete values (e.g., 4), not the actual signature defaults (e.g., None). The reset flow expected param_defaults to contain SIGNATURE defaults (what's written in the class definition), but it contained RESOLVED instance values. FIX: ObjectState now uses SignatureAnalyzer.analyze(type(object_instance)) to get the actual signature defaults for param_defaults. This returns the static class defaults (e.g., None for well_filter) regardless of any lazy resolution or threadlocal context. ALSO INCLUDED: - Removed duplicate _get_reset_value from PFM (now only in ParameterOpsService) - Simplified ParameterOpsService._get_reset_value to just use param_defaults - Added RESET_TRACE logging throughout the reset flow for debugging Files changed: - openhcs/config_framework/object_state.py - openhcs/pyqt_gui/widgets/shared/parameter_form_manager.py - openhcs/pyqt_gui/widgets/shared/services/field_change_dispatcher.py - openhcs/pyqt_gui/widgets/shared/services/parameter_ops_service.py
…lution to concrete types ## Summary Major cleanup of lazy_factory.py and related files. Net deletion of 255 lines. ## Phase 1a: Dead Code Deletion (~100 lines) - Delete unused PyQt6 import block (HAS_PYQT never used) - Delete _try_global_context_value() (defined but never called) - Delete InheritAsNoneMeta class (~70 lines, never used as metaclass) - Remove InheritAsNoneMeta reference from metaclass safety check ## Phase 1b: ContextProvider Infrastructure (~100 lines) The entire ContextProvider system was dead code feeding broken frame.f_locals manipulation. Python's frame.f_locals is a dict COPY, not a live reference - modifications do nothing. The injected context variables were never read. Deleted: - CONTEXT_PROVIDERS registry - _CONTEXT_PROVIDER_REGISTRY_CONFIG - ContextProviderMeta class - ContextProvider class - _detect_context_type() function - All frame.f_locals manipulation in resolve_lazy_configurations_for_serialization() Updated downstream consumers: - AbstractStep: Remove ContextProvider inheritance and _context_type - PipelineOrchestrator: Remove ContextProvider inheritance and _context_type - auto_register_meta.py: Update docstrings to remove ContextProviderMeta references ## Phase 2: Add Lazy Resolution to Concrete Classes The core fix: concrete types (like WellFilterConfig) stored in GlobalPipelineConfig now have lazy resolution via __getattribute__, WITHOUT changing their static defaults. Added: - bind_lazy_resolution_to_class(cls): Binds __getattribute__ to any class - Call to bind_lazy_resolution_to_class() in @global_pipeline_config decorator - Bug fix in __getattribute__ fallback for concrete types not in lazy registry ## Phase 3 (Partial): Remove UI Workarounds Now that concrete types have lazy resolution, removed workarounds that looked up lazy types for resolution: - object_state.py: Remove _get_lazy_type_for_base() lookup - placeholder.py: Delete _get_lazy_type_for_base(), update has_lazy_resolution() - lazy_placeholder_simplified.py: Same changes as placeholder.py - lazy_factory.py: Remove lazy type conversion in rebuild_lazy_config_with_new_global_reference() ## Architectural Change BEFORE: Concrete types had no lazy resolution. UI had to: 1. Look up lazy type via _get_lazy_type_for_base() 2. Create lazy instance 3. Access field to trigger resolution AFTER: Concrete types decorated with @global_pipeline_config have __getattribute__ bound directly. They resolve None values without needing lazy type lookup.
…ults Replace 3-stage approach (pre-process setattr, post-process Field patch) with single make_dataclass rebuild: - Add get_inherited_field_names() to identify inherited vs own fields - Add rebuild_with_none_defaults() to rebuild dataclass with None defaults - Store original defaults in _inherited_default metadata for fallback - Update __getattribute__ to use inherited defaults for standalone usage - Delete _fix_dataclass_field_defaults_post_processing() (~45 lines) dual_axis_resolver: Separate LazyDataclass vs concrete class resolution - LazyDataclass: check same-type in context, then MRO walk - Concrete: skip same-type lookup, only MRO walk for parent inheritance - Remove Step 3 class default fallback (handled by _inherited_default) Result: Both standalone and in-context resolution work correctly - Standalone: PathPlanningConfig().well_filter_mode → INCLUDE (parent default) - In-context: resolves from WellFilterConfig → EXCLUDE
…vice ## Summary Extract scope token generation from PipelineEditor into a centralized ScopeTokenService, enabling hierarchical scope IDs for both steps and functions with proper ObjectState lifecycle management. ## Changes ### ScopeTokenService (scope_token_service.py) - Add ScopeTokenService class as registry of ScopeTokenGenerators - Auto-derive prefix from object type (FunctionStep→'step', callable→'func') - build_scope_id() creates hierarchical scopes: plate::step_N::func_M - seed_from_objects() preserves tokens after deserialization - clear_scope() for cleanup of generator hierarchies ### PipelineEditor (pipeline_editor.py) - Remove local STEP_SCOPE_ATTR and _next_scope_token counter - Delete _ensure_step_scope_token() and _transfer_scope_token() methods - Delegate to ScopeTokenService for all scope operations - Simplify _build_step_scope_id() to single service call ### FunctionPane (function_pane.py) - Register ObjectState for each function with hierarchical scope - Scope format: step_scope::func_N (e.g., plate::step_0::func_0) - Add cleanup_object_state() for proper unregistration - FunctionListWidget calls cleanup before destroying panes ### FunctionListEditor (function_list_editor.py) - Unregister ObjectState in _remove_function() before removal - Prevents orphaned ObjectState entries in registry ### DualEditorWindow (dual_editor_window.py) - Simplify _build_step_scope_id() to delegate to ScopeTokenService - Remove redundant fallback logic ### UnifiedRegistry (unified_registry.py) - Add commented INJECTABLE_PARAMS for well_filter_config (future) ## Architecture This centralizes the 'assign token once, stable forever' pattern that was duplicated between PipelineEditor and FunctionListEditorWidget. The ScopeTokenService becomes the single source of truth for scope token generation across the GUI layer.
ARCHITECTURAL CLEANUP: Complete MVC separation where ParameterFormManager (PFM) is purely VIEW and ObjectState is purely MODEL. ## Core Changes ### ObjectState as Single Source of Truth - All state mutations now go through ObjectState methods: - update_parameter() for value changes - reset_parameter() for resets (handles tracking internally) - update_thread_local_global_config() moved from PFM - PFM no longer owns state data - uses property delegators to access state.* ### PFM Cleanup (-589 lines, 49% reduction) - DELETED: from_dataclass_instance() factory (0 callers) - DELETED: MODEL delegation wrappers (get_current_values, get_user_modified_values) - DELETED: create_widget() and _make_widget_readonly() from ABC - DELETED: NoneAwareLineEdit, NoneAwareIntEdit classes (moved to widget_strategies) - DELETED: Debug instrumentation (RESET_TRACE logs, fiji_streaming_config debug) - ADDED: Property delegators (parameters, param_defaults, reset_fields, _user_set_fields) ### Widget Creation Simplification - param_info types declare widget_creation_type as class attribute - create_widget_parametric uses param_info.widget_creation_type (no isinstance dispatch) - NoneAwareLineEdit, NoneAwareIntEdit moved to widget_strategies.py top ### Service Layer Cleanup - ParameterOpsService._reset_* methods call manager.state.reset_parameter() - Removed _get_reset_value(), _update_reset_tracking() (ObjectState handles these) - All callers updated to use state.* instead of form_manager.* for MODEL operations - LiveContextService methods used directly instead of PFM class methods ## Files Changed (21 files, -572 net lines) Architecture: - object_state.py: +update_thread_local_global_config() - parameter_form_manager.py: -589 lines (616 total, was 1205) - parameter_ops_service.py: -36 lines (256 total) - widget_strategies.py: +NoneAwareLineEdit, NoneAwareIntEdit at top - widget_creation_config.py: widget_creation_type dispatch - widget_creation_types.py: removed ABC methods - parameter_info_types.py: +widget_creation_type attribute Callers (state.* delegation): - config_window.py, dual_editor_window.py, step_parameter_editor.py - image_browser.py, synthetic_plate_generator_window.py, function_pane.py Services (LiveContextService): - function_list_editor.py, pipeline_editor.py, plate_manager.py - abstract_manager_widget.py, field_change_dispatcher.py - signal_service.py, value_collection_service.py, widget_service.py
…eritance bug
MAJOR CHANGES:
1. ObjectState Simplification (object_state.py)
- Reduced core attributes from ~15 to 8 essential ones:
* object_instance, parameters, _saved_resolved, _live_resolved,
_live_token, nested_states, _parent_state, scope_id
- Removed redundant tracking: _user_set_fields, reset_fields, saved_user_set_fields,
saved_reset_fields, saved_parameters, _dirty
- Derived properties: context_obj from _parent_state, is_dirty() from comparing
_live_resolved vs _saved_resolved
- Removed field_id (now derived from type(object_instance).__name__)
- Removed get_user_modified_values() and get_user_modified_overlay() - replaced
by simpler parameters dict filtering {k: v for k, v in params.items() if v is not None}
- Added _signature_defaults for reset functionality using use_signature_defaults=True
- Added _ensure_live_resolved() for lazy recomputation of resolved values
2. CRITICAL BUG FIX: Nested Config Save Inheritance (object_state.py, config_window.py)
- Problem: When saving GlobalPipelineConfig, nested configs (path_planning_config,
step_well_filter_config, etc.) with inherited fields (well_filter_mode=None)
were being saved with RESOLVED values (INCLUDE) instead of staying None
- Impact: MRO inheritance broke because GlobalPipelineConfig.path_planning_config
had concrete well_filter_mode=INCLUDE, so when PipelineConfig set WellFilterConfig
to EXCLUDE, PathPlanningConfig would find the concrete GlobalPipelineConfig value
instead of inheriting from WellFilterConfig
- Fix: get_current_values() now reconstructs nested dataclass instances from
nested_state.parameters (preserves None) instead of using stale instances
from self.parameters (had resolved values baked in)
- ConfigWindow.save_config() simplified to use get_current_values() directly
3. UnifiedParameterAnalyzer Enhancements (unified_parameter_analyzer.py)
- Added use_signature_defaults parameter for reset functionality
- When True, returns CLASS signature defaults (None for lazy fields) instead
of instance values - ensures reset goes back to original defaults
- Simplified instance value extraction: always use object.__getattribute__
(bypasses lazy resolution for ALL types, not just Lazy* prefixed classes)
4. Dual Axis Resolver Cleanup (dual_axis_resolver.py)
- Removed obsolete resolve_field_inheritance_old() (200+ lines)
- Removed _is_related_config_type(), _find_blocking_class_in_mro()
- Added _normalize_to_base() helper for type comparison
- MRO type comparison now uses normalized types so LazyWellFilterConfig
matches WellFilterConfig in the inheritance walk
- Added well_filter_mode to debug logging fields
5. Context Manager Fix (context_manager.py)
- build_context_stack() now injects ALL live types with values (not just containers)
- Previously only injected types with nested dataclass values, which meant
leaf configs like LazyWellFilterConfig with {well_filter_mode: EXCLUDE}
were skipped and MRO couldn't find them
6. LiveContextService Type Normalization (live_context_service.py)
- merge_ancestor_values() now normalizes types using get_base_type_for_lazy()
- LazyWellFilterConfig and WellFilterConfig merge into same bucket
- Added debug logging for well_filter_mode overwrites
7. ObjectState API Consumers Updated
- Removed field_id and context_obj from all ObjectState constructors:
* app.py, metadata_viewer_dialog.py, function_pane.py, image_browser.py,
pipeline_editor.py, plate_manager.py, step_parameter_editor.py,
config_window.py, plate_viewer_window.py, synthetic_plate_generator_window.py
- Changed scope_id for global scope from None to empty string ("")
- parent_state now passed instead of context_obj for context inheritance
- FunctionPane/ImageBrowser use ObjectStateRegistry.get_by_scope() to find parent
8. ParameterFormManager Simplification (parameter_form_manager.py)
- Removed delegation to ObjectState._user_set_fields, reset_fields
- parameter_types and param_defaults now derived via UnifiedParameterAnalyzer
- field_id derived from type(object_instance).__name__
9. Service Layer Updates
- FieldChangeDispatcher: Removed user_set parameter tracking, simplified logging
- ValueCollectionService: Uses {k:v for k,v in params.items() if v is not None}
instead of get_user_modified_values()
- SignalService: Uses direct parameters filtering instead of get_user_modified_values()
- reconstruct_nested_dataclasses() simplified (already returns instances)
10. StepParameterEditor Fail-Loud (step_parameter_editor.py)
- Removed fallback ObjectState creation
- Now raises RuntimeError if ObjectState not found in registry
- PipelineEditor MUST register step before opening editor
…ne values PROBLEM: When loading GlobalPipelineConfig from cache on app restart, nested configs with None values (e.g., path_planning_config.well_filter_mode=None) were being resolved to concrete values instead of staying None. ROOT CAUSE: _migrate_dataclass() used getattr() which triggers the lazy __getattribute__ resolution. This resolved None -> concrete value during cache load, breaking MRO inheritance because the GlobalPipelineConfig now had concrete values baked in instead of None values that would inherit from parent configs. FIX: Use object.__getattribute__() to get raw stored values without triggering lazy resolution. This preserves None values so MRO inheritance continues to work correctly after loading from cache.
Bug: GlobalPipelineConfig window placeholders were 1 input behind. Root cause: Scope ID mismatch between registration and lookup: - app.py registered GlobalPipelineConfig ObjectState with scope_id='' - ConfigWindow.get_by_scope(None) didn't find it (None != '') - ConfigWindow created a NEW unregistered ObjectState - Dispatcher updated the unregistered state - collect_live_context iterated the REGISTERED state with OLD values Fix: ObjectStateRegistry now normalizes None to '' via _normalize_scope_id(). Both None and '' are treated as equivalent global scope identifiers. Also added explicit scope_id='' at call sites for clarity.
…variant Move increment_token() call from FieldChangeDispatcher to ObjectState.update_parameter(). This ensures any controller calling state.update_parameter() automatically gets cache invalidation without needing to remember to call it separately. Architectural principle: MODEL enforces invariants, controllers don't need to know about infrastructure concerns like cache invalidation.
…form_dialog.py reject() method
The CI test for PR 58 was failing because openhcs/build_utils/__init__.py used absolute imports (from openhcs.build_utils.zeroc_ice_installer import...) which caused a ModuleNotFoundError during the build process, since the openhcs package wasn't yet in the Python path. Changed to relative imports (from .zeroc_ice_installer import...) to avoid the circular dependency issue during setuptools build.
The OMERO test was failing because polystore backend expects metadata with namespace 'polystore.metadata' and keys 'polystore.parser' and 'polystore.microscope_type', but test code was using 'openhcs.metadata' namespace and 'openhcs.parser'/'openhcs.microscope_type' keys. Changed to match polystore's expected metadata format: - Namespace: 'polystore.metadata' - Parser key: 'polystore.parser' - Microscope type key: 'polystore.microscope_type'
- Implement get_well_values() and parse_metadata() in OMERO metadata handler - Fix plate manager to use set_saved_global_config() instead of set_global_config_for_editing() to preserve unsaved edits - Remove unnecessary FILENAME_PARSERS dependency from OMERO backend
…OLocalBackend The OMERO test was failing because OMEROFilenameParser wasn't registered in FilenameParser.__registry__ when OMEROLocalBackend tried to access it. This is a lazy registry problem - the LazyDiscoveryDict only populates when first accessed. Added import of openhcs.microscopes.omero before creating OMEROLocalBackend in: - tests/integration/test_main.py - openhcs/runtime/execution_server.py - openhcs/runtime/zmq_execution_server.py
- pyqt-reactor: rename to qt-reactive for PyPI - uneval: rename to unevalpy for PyPI - All packages updated with proper PyPI publishing workflows Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- Update .gitmodules URL to point to PyQT-reactive repository - Update pyproject.toml dependency to pyqt-reactive - Update all imports from pyqt_reactor to pyqt_reactive in openhcs
- Update .gitmodules path to external/pyqt-reactive - Remove old external/pyqt-reactor submodule reference - Add new external/pyqt-reactive submodule
Implement custom build backend that allows PyPI releases to use pip versions of external modules while development mode uses local git submodules. Changes: - Add openhcs_build/ custom build backend extending setuptools.build_meta - Update pyproject.toml to use openhcs_build build backend - Remove external module dependencies from pyproject.toml (added dynamically) - Add docs/development_setup.md with usage instructions - Add docs/dynamic_dependencies_implementation.md with technical details - Add scripts/dev_install.py convenience script - Remove setup.py (no longer needed) Development mode is detected when: - PIP_EDITABLE_INSTALL=1 (set by pip install -e) - external/ directory exists - OPENHCS_DEV_MODE environment variable is set This avoids using requirements-dev.txt while keeping all configuration in pyproject.toml and using PEP621 compliant custom build backend.
Custom build backend cannot be imported during installation because it's part of the package being installed. Switch to dynamic setup.py approach which is simpler and more reliable. Changes: - Add setup.py with dynamic dependency selection - Update pyproject.toml to use standard setuptools.build_meta - Remove openhcs_build/ directory (no longer needed) - Update docs to reflect setup.py approach Development mode is detected when: - PIP_EDITABLE_INSTALL=1 (set by pip install -e) - external/ directory exists - OPENHCS_DEV_MODE environment variable is set This approach avoids the import issue with custom build backends while still providing automatic dependency source selection.
Changed approach from dynamic dependency selection to simpler approach: - Use pip versions of external modules in pyproject.toml for PyPI releases - For development, install local external modules separately via dev_install.py - Removed setup.py (was causing multiple .egg-info directories issue) - Updated documentation to reflect simpler approach This approach is more reliable and avoids build issues.
Created proper setup.py that: - Detects development mode via PIP_EDITABLE_INSTALL, external/ directory, OPENHCS_DEV_MODE - Returns local paths for development: file:////external/ObjectState - Returns pip versions for production: objectstate>=0.1.0 - Properly merges with pyproject.toml dependencies via setup() function Updated pyproject.toml to remove external module dependencies (since they're added dynamically by setup.py) Updated documentation and dev_install.py to reflect setup.py approach. This resolves the 'Multiple .egg-info directories found' error.
The setup.py automatically handles dynamic dependency selection, so dev_install.py script is not strictly needed. Updated documentation to reflect this.
The setup.py should NOT add external module dependencies to install_requires because they don't exist on PyPI yet. External modules should only be used in development mode with local paths. This fixes the CI error where pip tries to install objectstate>=0.1.0 but it's not available on PyPI.
- Fix setup.py to actually pass external dependencies to setup() - Replace LOCAL_EXTERNAL_DEPENDENCIES constant with get_local_external_dependencies() function - Fix is_development_mode() to properly detect development mode (has_external or dev_mode_env) - Remove broken PIP_EDITABLE_INSTALL check (not a standard pip env var) - Use absolute paths for file:// URLs to external modules This fixes CI failures where external modules were not being installed during dependency installation.
- Add external module dependencies (zmqruntime, pycodify, objectstate, etc.) to pyproject.toml - Remove install_requires from setup.py to avoid conflicts with pyproject.toml - This ensures external modules are properly installed in CI wheel builds
ObjectState Extraction: MVC Separation + Redux DevTools Time-Travel
Executive Summary
65+ commits extracting MODEL from ParameterFormManager (PFM) into standalone
ObjectStateclass, achieving proper MVC separation. The PFM is now a pure VIEW layer (1209 → 621 lines, 49% reduction). Total net deletion: ~700 lines including LiveContextService (364 lines), ~255 lines of dead code inlazy_factory.py, and ~258 lines indual_axis_resolver.py.Major Features:
on_dirty_changedcallbacks and field-level queriesGit-like DAG Time-Travel System
Core Architecture
Implements proper git-like DAG (Directed Acyclic Graph) model for time-travel debugging. Snapshots are NEVER deleted - branches are just pointers into the DAG. Auto-branching preserves alternate timelines when diverging from the past.
Data Structures (snapshot_model.py, NEW)
ObjectStateRegistry DAG API
Time-Travel Widget (status bar)
Declarative Shortcuts (NEW)
AbstractTableBrowser ABC (NEW)
Generic ABC for searchable table views with <100 lines per subclass.
Per-Field Styling System
Structured Text Rendering
Visual Semantics
Core: ObjectState with Flat Storage + ObjectStateRegistry
Files:
openhcs/config_framework/object_state.py(~1200 lines)Deleted: LiveContextService (364 lines → 0)
_token: int_token: int(class-level)increment_token()increment_token()collect() + merge_ancestor_values()get_ancestor_objects(scope_id, use_saved)Reactive Dirty Tracking System
ObjectState Callbacks
on_dirty_changed()/off_dirty_changed(): Subscribe to dirty↔clean transitionsget_dirty_fields(): Returns field paths where_live_resolved != _saved_resolvedis_field_dirty(field_name): Check single fieldUI Components
set_dirty_indicator()for asterisk (*) prefixon_dirty_changedfor reactive updates_path_to_itemmapping for tree item stylingScope-Based Visual Feedback System
CIELAB Perceptual Colors
tint_color_perceptual()replaces simple RGB scalingColor Strategy
Files Added
scope_visual_config.py,scope_color_strategy.py,scope_color_utils.py,scope_color_service.py,scoped_border_mixin.pyWindowManager Singleton-Per-Scope
Windows Migrated: ConfigWindow, DualEditorWindow, PlateViewerWindow, MetadataViewerDialog
Dual SAVED/LIVE Thread-Local Pattern
Problem: Single thread-local updated on every keystroke caused timing issues.
Solution: Two separate contexts:
Click-to-Source Provenance Navigation
_ui_hidden=TrueFinal Architecture
Core: ObjectState with Flat Storage + ObjectStateRegistry
Files:
openhcs/config_framework/object_state.py(2507 lines - contains both ObjectStateRegistry + ObjectState)openhcs/config_framework/snapshot_model.py(137 lines - Snapshot/Timeline dataclasses)openhcs/pyqt_gui/widgets/shared/parameter_form_manager.py(1049 lines - down from 1209)Before/After: Class Structure
ObjectState Storage
classDiagram class ObjectState_Before { +parameters: Dict[str, Any] +nested_states: Dict[str, ObjectState] Note: Hierarchical - each nested config Note: has its own ObjectState } class ObjectState_After { +parameters: Dict[str, Any] +_path_to_type: Dict[str, type] +_cached_object: Optional[Any] +_saved_parameters: Dict[str, Any] +_live_resolved: Dict +_saved_resolved: Dict +_on_resolved_changed_callbacks: List +_on_saved_resolved_changed_callbacks: List +_on_parameters_changed_callbacks: List +_on_dirty_changed_callbacks: List +to_object() Any +find_path_for_type(type) str +resolve_for_type(type) Dict +on_resolved_changed(callback) +on_saved_resolved_changed(callback) +on_parameters_changed(callback) +on_dirty_changed(callback) +get_dirty_fields() Set~str~ +is_field_dirty(name) bool Note: FLAT - dotted paths like Note: 'config.well_filter_mode' } class NestedObjectState { <<DELETED>> +parameters: Dict +nested_states: Dict } ObjectState_Before --> NestedObjectState : nested_states['config'] NestedObjectState --> NestedObjectState : nested_states['sub'] ObjectState_After --> ObjectState_After : NO nesting - flat pathsParameterFormManager (PFM)
classDiagram class PFM_Before { -object_instance: Any -field_id: str -context_obj: Any -scope_id: str -parameters: Dict -param_defaults: Dict -_user_set_fields: Set -reset_fields: Set -saved_parameters: Dict -saved_user_set_fields: Set -saved_reset_fields: Set -widgets: Dict -nested_managers: Dict +get_current_values() +get_user_modified_values() +_get_reset_value() +create_widget() 1209 lines } class PFM_After { -state: ObjectState -config: FormManagerConfig -widgets: Dict -labels: Dict -nested_managers: Dict +parameters: property → scoped view +scope_id: property → state.scope_id +field_prefix: str 1049 lines } class FormManagerConfig { +field_prefix: str +parent_manager: PFM Note: field_prefix scopes nested PFMs Note: to subset of flat parameters } class ObjectState { +object_instance: Any +parameters: Dict~str, Any~ +_path_to_type: Dict~str, type~ +scope_id: str +_signature_defaults: Dict +_live_resolved: Dict +_saved_resolved: Dict +_saved_parameters: Dict +_dirty_fields: Set~str~ +_live_provenance: Dict +_on_resolved_changed_callbacks: List +_on_dirty_changed_callbacks: List +update_parameter() +reset_parameter() +get_resolved_value() +to_object() +get_dirty_fields() +is_field_dirty() Note: Part of object_state.py Note: 2507 lines total file } PFM_After --> ObjectState : delegates MODEL PFM_After --> FormManagerConfig : scoping via field_prefixSystem Architecture
classDiagram class ObjectStateRegistry { <<Singleton - class-level on ObjectState>> -_states: Dict~str, ObjectState~ -_token: int -_change_callbacks: List -_on_register_callbacks: List -_on_unregister_callbacks: List -_on_time_travel_complete_callbacks: List -_on_history_changed_callbacks: List -_snapshots: Dict~str, Snapshot~ -_timelines: Dict~str, Timeline~ -_current_timeline: str -_current_head: Optional~str~ +register(state) +unregister(state) +get_by_scope(scope_id) ObjectState +get_all() List~ObjectState~ +get_token() int +increment_token() +connect_listener(callback) +get_ancestor_objects(scope_id, use_saved) List~Any~ +unregister_scope_and_descendants(scope_id) int +record_snapshot(label, scope) +time_travel_to_snapshot(id) +time_travel_back() / time_travel_forward() +time_travel_to_head() +get_branch_history(name) List~Snapshot~ +create_branch(name) / switch_branch(name) +delete_branch(name) / list_branches() +export_history_to_dict() / import_history_from_dict() } class LiveContextService { <<DELETED - 364 lines>> Functionality moved to ObjectStateRegistry } class ObjectState { +parameters: Dict~str, Any~ +_path_to_type: Dict~str, type~ +_live_resolved: Dict +_saved_resolved: Dict +_dirty_fields: Set~str~ +_live_provenance: Dict +_on_resolved_changed_callbacks: List +_on_dirty_changed_callbacks: List +to_object() Any +resolve_for_type(type) Dict +on_resolved_changed(callback) +off_resolved_changed(callback) +on_dirty_changed(callback) +off_dirty_changed(callback) +get_dirty_fields() Set~str~ +is_field_dirty(name) bool } class PFM { +state: ObjectState +config: FormManagerConfig +field_prefix: str +parameters: property } class AbstractManagerWidget { +_flash_subscriptions: Dict +_dirty_subscriptions: Dict +_flash_start_times: Dict~str, float~ +_format_list_item() str +_format_item_content() str +_get_dirty_marker() str +_subscribe_flash_for_item() +queue_flash() +get_flash_color_for_key() Note: ABC handles dirty markers Note: AND flash animations via FlashMixin } class FlashMixin { +_flash_start_times: Dict~str, float~ +_flash_registrations: List +queue_flash(key) +queue_flash_local(key) +get_flash_color_for_key(key) +compute_flash_color_at_time() +reregister_flash_elements() Note: GAME ENGINE architecture Note: O(1) per window via overlay } class _GlobalFlashCoordinator { <<Singleton>> +_mixins: Set~VisualUpdateMixin~ +_timer: QTimer +register(mixin) +unregister(mixin) +_on_global_tick() Note: ONE 60fps timer for ALL windows } class WindowFlashOverlay { +_elements: Dict~str, List~FlashElement~~ +paintEvent() Note: Renders ALL flash rectangles Note: in ONE paintEvent per window } class WindowManager { <<Singleton>> +_windows: Dict~str, QWidget~ +show_or_focus() +register() +unregister() +is_open() Note: ONE window per scope } ObjectStateRegistry --> ObjectState : manages PFM --> ObjectState : references (shared) PFM --> PFM : nested via field_prefix AbstractManagerWidget --> ObjectStateRegistry : dirty marker + flash lookup AbstractManagerWidget --> ObjectState : flash + dirty subscriptions AbstractManagerWidget --|> FlashMixin : inherits PFM --|> FlashMixin : inherits (groupbox flash) FlashMixin --> _GlobalFlashCoordinator : registers with FlashMixin --> WindowFlashOverlay : usesGame Engine Flash Architecture
Problem
O(n) complexity per tick: N groupboxes = N color computations + N dict updates.
Solution: Paint-Time Color Computation
Key Components
_GlobalFlashCoordinatorWindowFlashOverlaypaintEventFlashMixin_flash_start_times, computes color during paintPerformance Metrics
Additional Caches
Bug Fixes (Critical)
Bug 1: Sibling Inheritance with Concrete Defaults
Problem: Fields with non-None static defaults failed to inherit from siblings.
Root Cause:
dataclasses.replace()triggers lazy__getattribute__, baking resolved values.Fix: New
replace_raw()utility usingobject.__getattribute__to bypass lazy resolution.Bug 2: Nested Config Save Inheritance
Problem: Saving GlobalPipelineConfig with
path_planning_config.well_filter_mode=Nonesaved as INCLUDE.Fix:
get_current_values()reconstructs nested dataclass instances from flat parameters (preserves None).Bug 3: Reset Value Incorrect
Problem: Resetting
well_filtershowed 4 instead of None.Fix: Added
_signature_defaultspopulated viaUnifiedParameterAnalyzer(use_signature_defaults=True).Bug 4: Cache Loading Resolution
Problem: Loading GlobalPipelineConfig from cache resolved None values to concrete values.
Fix: Use
object.__getattribute__()in_migrate_dataclass()to get raw values.Bug 5: Placeholder Staleness
Problem: GlobalPipelineConfig placeholders were 1 input behind.
Fix:
ObjectStateRegistry._normalize_scope_id()treats None and "" as equivalent.Bug 6: False Dirty Detection
Problem: Setting
well_filter_modefrom None (resolves to INCLUDE) to explicit INCLUDE showed as dirty.Root Cause: Container entries stored in both
parametersand_saved_resolved.Fix: Skip container entries from resolved snapshots - only compare leaf fields.
Bug 7: func Field Reappearing
Problem: After closing DualEditorWindow and reopening,
funcfield appeared in step editor.Fix: Store
_exclude_param_namesin__init__and pass to_extract_all_parameters_flat()inrestore_saved().Bug 8: Close-Window Stale Caches
Problem: Closing ConfigWindow without saving left stale values in descendant caches.
Fix:
BaseFormDialog.closeEvent()now callsrestore_saved()+increment_token().Bug 9: Dual-Axis Saved Resolution
Problem: New ObjectStates created while ancestors have unsaved edits showed incorrect dirty state.
Fix: New
use_savedparameter propagated throughget_ancestor_objects()and_compute_resolved_snapshot().Bug 10: Function Container Type Crash
Problem: Steps with function primitive parameters crashed with
TypeError: function() missing required argument.Fix: Skip non-dataclass container types in lazy resolution.
Bug 11: GroupBy.NONE vs None Semantic
Problem:
FuncStepContractValidatorsetgroup_by=None, which means "inherit from parent".Fix: Use
GroupBy.NONE(explicit "no grouping") instead of None.Bug 12: to_object() Missing Primitive Fields
Problem:
to_object()only updated nested dataclass fields, losing primitive field edits.Fix: Collect ALL top-level fields from parameters (primitives AND nested dataclasses).
Bug 13: Window Close Flash
Problem: Closing a window with unsaved changes didn't flash the list item.
Fix: Added callback emission after reverting values in
ObjectState.restore_saved().Bug 14: Step Editor Tree Flash
Problem: Step editor's config hierarchy tree didn't flash when values changed.
Fix: Added
flash_managerparameter andregister_repaint_callback()to StepParameterEditorWidget.Bug 15: Cross-Scope Callback Propagation
Problem: Editing parent config didn't flash descendant list items.
Fix: Fire callbacks immediately after invalidating fields in
_invalidate_field_in_matching_states().Bug 16: Stale Subscriptions After Plate Switch
Problem: Switching plates while having a window open broke flashing completely.
Fix: Always call
_cleanup_flash_subscriptions()at the start of EVERY list update.Bug 17: Cross-Window Flash Contamination
Problem: Flashing 'step_0' in plate1 window also flashed 'step_0' in plate2 window.
Fix: Added
_get_scoped_flash_key()that prepends scope_id to all flash keys.Bug 18: ObjectState Registration Race
Problem: Opening StepParameterEditor before ObjectState was registered caused crashes.
Fix: Move
_register_step_state()call BEFORE opening StepParameterEditor.Bug 19: WindowFlashOverlay Memory Leak
Problem: Flash operations get progressively slower with each window open/close cycle.
Fix: Call
cleanup_window()inBaseFormDialog.accept/reject/closeEvent.Bug 20: Duplicate Element Registrations
Problem: Rectangle count grows indefinitely on each list update.
Fix: Added
source_idfield to FlashElement for deduplication by (key, source_id) pairs.Bug 21: ObjectState Callbacks Never Unregistered
Problem: Closed windows still receive ObjectState change notifications.
Fix: Added
state.off_resolved_changed()call tounregister_from_cross_window_updates().Bug 22: Flash Only When Values Actually Change
Problem: Fields flashed even when resolved values didn't actually change.
Fix: Call
_ensure_live_resolved(notify=True)immediately after invalidation.Bug 23: Timer Running Forever
Problem: Global flash timer never stopped.
Fix: Always prune expired animations, repaint only when visible.
Bug 24: None Filtering Breaking Inheritance
Problem: Resetting a field to None didn't trigger inheritance from parent.
Fix: Removed None filtering in 9+ locations. None means "inherit from parent context".
Bug 25: List Items Not Flashing on Save
Problem: Saving parent config didn't flash child list items.
Fix: Added
on_saved_resolved_changed()callback system for saved baseline changes.Bug 26: Plate Deletion Resurrection
Problem: Deleted plates resurrected when re-added with same path.
Fix: Extended
_perform_delete()to clean up bothplate_configsandplate_pipelinesdicts.Bug 27: Cascade Deletion Missing
Problem: Deleting plates left orphaned step/function ObjectStates.
Fix: Added
unregister_scope_and_descendants()for cascade deletion.Bug 28: Step Editor Code Mode Crash
Problem: Clicking 'Code' button failed with
AbstractStep.__init__() got unexpected keyword argument.Fix: Use
self.state.to_object()to properly reconstruct nested dataclass structure.Bug 29: to_object() Leaf Field Confusion
Problem: Leaf fields on root object incorrectly treated as nested dataclasses.
Fix: Check
field_type != root_typeto distinguish leaf from nested.Bug 30: Scope Styling Lost on Pane Add/Move
Problem: Adding/reordering function panes lost scope styling.
Fix: Added
_scope_color_schemestorage,_reorder_function_panes(), and async callback registration.Bug 31: Flash Fails on Window Reopen via Navigation
Problem: Flash failed with "NOT IN overlay._elements" after closing and reopening window.
Root Cause: Window overlay cleaned on close, but widgets didn't re-register with new overlay.
Fix: Track
_flash_registrationslist, addreregister_flash_elements()method, call inshow()if overlay was cleaned.New Features
Callback Systems
on_resolved_changed()on_saved_resolved_changed()on_parameters_changed()on_dirty_changed()Label Underline Styling
Label Dirty Indicator
Animation Phases
'in': 100ms fade-in with OutQuad easing'hold': 50ms at max flash color'out': 350ms fade-out with InOutCubic easingDead Code Deleted
lazy_factory.py (~255 lines)
CONTEXT_PROVIDERSregistryContextProviderMetaclassContextProviderclassframe.f_localsmanipulation is brokenInheritAsNoneMetaclass (~70 lines)_try_global_context_value()dual_axis_resolver.py (~258 lines)
resolve_field_inheritance_old()_is_related_config_type()_normalize_to_base()_find_blocking_class_in_mro()LiveContextService (364 lines)
ENTIRE FILE DELETED - All functionality moved to ObjectStateRegistry.
Line Count Summary
parameter_form_manager.pylazy_factory.pydual_axis_resolver.pylive_context_service.pyobject_state.pysnapshot_model.pyflash_mixin.pyscope_color_*.py(4 files)abstract_table_browser.pytime_travel_widget.pysnapshot_browser_window.pyTesting Notes
Core Functionality
WellFilterConfig.well_filter_mode=EXCLUDEat pipeline level, verify PathPlanningConfig inherits itDirty Tracking
funcshould NOT appear in step tabFlash Animations
Scope & Performance
Time-Travel (NEW)