diff --git a/CHANGELOG.md b/CHANGELOG.md index 00903fa..0abab14 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,14 @@ # Changelog +## 0.9.0 + +Requires `objectstate>=5.0.0,<6` and `formbridge>=0.2.0,<0.3` (#33). + +Field paths come from ObjectState's `DottedFieldPath` (`parent`, `lineage`, +`field_name`) instead of splitting dotted strings, and each form manager's +`_object_state_path_for_field` returns a `DottedFieldPath`. Parameter +conversion takes three arguments, matching FormBridge 0.2. + ## 0.8.0 Requires ObjectState 4 (`objectstate>=4.0.0,<5`) and `formbridge>=0.1.4,<0.2`. diff --git a/pyproject.toml b/pyproject.toml index a7bb4d6..7d6a2e6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,15 +25,15 @@ keywords = ["pyqt6", "forms", "reactive", "gui", "widgets", "dataclass"] dependencies = [ "annotated-types>=0.7.0", - "formbridge>=0.1.4,<0.2", + "formbridge>=0.2.0,<0.3", "magicgui>=0.7.0", "metaclass-registry>=0.4.0", "PyQt6>=6.4.0", - "objectstate>=4.0.0,<5", + "objectstate>=5.0.0,<6", "psutil>=5.9", "Pygments>=2.15", - "python-introspect>=0.2.6,<0.3", - "zmqruntime>=0.6.4,<0.7", + "python-introspect>=0.3.0,<0.4", + "zmqruntime>=0.6.5,<0.7", ] [project.optional-dependencies] diff --git a/requirements-ci.txt b/requirements-ci.txt index 902c19c..17e5621 100644 --- a/requirements-ci.txt +++ b/requirements-ci.txt @@ -1,3 +1 @@ # Source candidates needed to validate this checkout before coordinated publication. -# Empty: every first-party dependency is released (objectstate 4.0.0, -# formbridge 0.1.4, zmqruntime 0.6.4, metaclass-registry 0.4.0). diff --git a/src/pyqt_reactive/__init__.py b/src/pyqt_reactive/__init__.py index 1452823..1d2f867 100644 --- a/src/pyqt_reactive/__init__.py +++ b/src/pyqt_reactive/__init__.py @@ -19,7 +19,7 @@ - Cross-window reactive updates """ -__version__ = "0.8.0" +__version__ = "0.9.0" # Public API will be populated as modules are added __all__ = [ diff --git a/src/pyqt_reactive/bridge/windows.py b/src/pyqt_reactive/bridge/windows.py index 7bf78c7..67b456e 100644 --- a/src/pyqt_reactive/bridge/windows.py +++ b/src/pyqt_reactive/bridge/windows.py @@ -1794,7 +1794,7 @@ def _add_field_widget( widget: QWidget, action_role: str, ) -> None: - field_path = cls._field_path(form_manager, field_name) + field_path = form_manager._object_state_path_for_field(field_name).value if field_path not in state.parameters: return records[_WidgetGeometryKey.from_qwidget(widget)] = _WidgetFieldSemanticRecord( @@ -1805,12 +1805,6 @@ def _add_field_widget( ), ) - @staticmethod - def _field_path(form_manager: ParameterFormManager, field_name: str) -> str: - if form_manager.field_id: - return f"{form_manager.field_id}.{field_name}" - return field_name - def for_descriptor( self, descriptor: WidgetDescriptor, diff --git a/src/pyqt_reactive/forms/form_init_service.py b/src/pyqt_reactive/forms/form_init_service.py index 60c9858..df1c513 100644 --- a/src/pyqt_reactive/forms/form_init_service.py +++ b/src/pyqt_reactive/forms/form_init_service.py @@ -41,7 +41,7 @@ ParameterInfoSequence, ) from pyqt_reactive.theming.color_scheme import ColorScheme as PyQt6ColorScheme -from objectstate import get_base_config_type, has_lazy_resolution +from objectstate import DottedFieldPath, get_base_config_type, has_lazy_resolution if TYPE_CHECKING: from formbridge.forms.parameter_form_service import FormStructure @@ -77,8 +77,8 @@ class ExtractedParameters: description: ParameterDescriptionByPath | ParameterDescriptionProvider = field( default_factory=ParameterDescriptionByPath, metadata={ - 'project': lambda name, info, field_id: ( - f"{field_id}.{name}" if field_id else name, + 'project': lambda name, info, scope_path: ( + scope_path.child(name).value, info.description, ) } @@ -509,6 +509,7 @@ def _auto_generate_builders(): """Auto-generate all builder functions via introspection of their output types.""" def _extract_parameters(object_instance, exclude_params, initial_values, field_id=None): + scope_path = DottedFieldPath(field_id or "") param_info_dict = UnifiedParameterAnalyzer.analyze(object_instance, exclude_params=exclude_params or []) extracted = {} computed = {} @@ -521,7 +522,7 @@ def _extract_parameters(object_instance, exclude_params, initial_values, field_i if projection is None: raise ValueError(f"Unsupported extracted parameter field: {fld.name}") extracted[fld.name] = dict( - projection(name, info, field_id) + projection(name, info, scope_path) for name, info in param_info_dict.items() ) if initial_values and fld.metadata.get('initial_values'): diff --git a/src/pyqt_reactive/forms/form_value_validation.py b/src/pyqt_reactive/forms/form_value_validation.py index 0c843d3..1001385 100644 --- a/src/pyqt_reactive/forms/form_value_validation.py +++ b/src/pyqt_reactive/forms/form_value_validation.py @@ -2,6 +2,7 @@ from __future__ import annotations +from objectstate import DottedFieldPath from pyqt_reactive.protocols import CurrentValueValidatable @@ -23,11 +24,7 @@ def _validate_manager(self, manager) -> None: widget.validate_current_value() manager._convert_widget_value(widget.get_value(), field_name) except ValueError as exc: - field_path = ( - f"{manager.field_id}.{field_name}" - if manager.field_id - else field_name - ) + field_path = DottedFieldPath(manager.field_id).child(field_name).value raise FormValueValidationError( f"Invalid value for {field_path!r}: {exc}" ) from exc diff --git a/src/pyqt_reactive/forms/inline_dataclass_chrome.py b/src/pyqt_reactive/forms/inline_dataclass_chrome.py index d9eb7cd..312cca0 100644 --- a/src/pyqt_reactive/forms/inline_dataclass_chrome.py +++ b/src/pyqt_reactive/forms/inline_dataclass_chrome.py @@ -266,13 +266,11 @@ def field_names_under( self, owner_field_paths: tuple[DottedFieldPath, ...], ) -> tuple[str, ...]: - owner_parts = self.context.owner_path.parts return tuple( dict.fromkeys( - path.parts[-1] + path.field_name for path in owner_field_paths - if len(path.parts) == len(owner_parts) + 1 - and path.parts[: len(owner_parts)] == owner_parts + if path.value and path.parent == self.context.owner_path ) ) diff --git a/src/pyqt_reactive/forms/inline_dataclass_context.py b/src/pyqt_reactive/forms/inline_dataclass_context.py index dd5bb76..e37e1fa 100644 --- a/src/pyqt_reactive/forms/inline_dataclass_context.py +++ b/src/pyqt_reactive/forms/inline_dataclass_context.py @@ -26,10 +26,10 @@ class InlineDataclassChildFieldIdentity: @property def field_name(self) -> str: - parts = self.object_state_path.parts - if not parts: + field_name = self.object_state_path.field_name + if not field_name: raise ValueError("Inline dataclass child field identity cannot be root.") - return parts[-1] + return field_name @dataclass(frozen=True, slots=True) @@ -52,11 +52,7 @@ def from_inline_widget( param_info: "InlineDataclassWidgetInfo", current_value, ) -> "InlineDataclassFormContext": - owner_path = ( - DottedFieldPath(manager.field_id).child(param_info.name) - if manager.field_id - else DottedFieldPath(param_info.name) - ) + owner_path = manager._object_state_path_for_field(param_info.name) base_type = get_base_type_for_lazy(type(current_value)) or param_info.type return cls( state=manager.state, @@ -106,7 +102,9 @@ def child_has_inherited_preview(self, field_name: str) -> bool: def reset_child(self, field_name: str) -> None: identity = self.child_identity(field_name) container_value = self.state.parameters[self.owner_path.value] - default_value = self.state.signature_default(identity.object_state_path.value) + default_value = self.state.parameter_structure.declarations[ + identity.object_state_path.value + ].default_value if self.raw_child_value(field_name) == default_value: return updated_value = replace_raw(container_value, **{field_name: default_value}) @@ -124,7 +122,9 @@ def reset_children(self) -> None: for dataclass_field in dataclass_fields(self.owner_type): field_name = dataclass_field.name identity = self.child_identity(field_name) - default_value = self.state.signature_default(identity.object_state_path.value) + default_value = self.state.parameter_structure.declarations[ + identity.object_state_path.value + ].default_value if self.raw_child_value(field_name) != default_value: updates[field_name] = default_value diff --git a/src/pyqt_reactive/forms/parameter_form_chrome_sync.py b/src/pyqt_reactive/forms/parameter_form_chrome_sync.py index 2f4dc4c..480a5dd 100644 --- a/src/pyqt_reactive/forms/parameter_form_chrome_sync.py +++ b/src/pyqt_reactive/forms/parameter_form_chrome_sync.py @@ -26,7 +26,7 @@ def changed_field_visuals( param_name, changed_paths, refresh_compound_semantics=( - self._owner_path(param_name).value not in refreshed_compound_owner_paths + DottedFieldPath(self.manager.field_id).child(param_name).value not in refreshed_compound_owner_paths ), ) self.update_owning_groupbox_dirty_marker() @@ -107,7 +107,7 @@ def update_label_styling( manager_field_id=manager.field_id, param_name=param_name, ): - dotted_path = self._owner_path(param_name) + dotted_path = DottedFieldPath(manager.field_id).child(param_name) should_underline = dotted_path.contains_any(manager.state.signature_diff_fields) is_dirty = dotted_path.contains_any(manager.state.dirty_fields) @@ -125,12 +125,6 @@ def update_label_styling( self._compound_child_owner_paths(param_name, changed_paths), ) - def _owner_path(self, param_name: str) -> DottedFieldPath: - manager = self.manager - return DottedFieldPath( - f"{manager.field_id}.{param_name}" if manager.field_id else param_name - ) - def _compound_child_owner_paths( self, param_name: str, @@ -139,7 +133,7 @@ def _compound_child_owner_paths( if changed_paths is None: return None - owner_path = self._owner_path(param_name) + owner_path = DottedFieldPath(self.manager.field_id).child(param_name) child_paths: list[DottedFieldPath] = [] seen: set[str] = set() for changed_path in changed_paths: @@ -210,22 +204,19 @@ def state_changed_for_paths( else set() ) refreshed_fields: set[str] = set() + manager_path = DottedFieldPath(manager.field_id) for path in paths: - owner_field = self._direct_child_field_for_path(path) + field_path = DottedFieldPath(path) + owner_field = manager_path.direct_child_name(field_path) if owner_field is not None: refreshed_fields.add(owner_field) - if "." in path: - path_prefix, leaf_field = path.rsplit(".", 1) - else: - path_prefix = "" - leaf_field = path - if path_prefix == manager.field_id: - refreshed_fields.add(leaf_field) + if field_path.parent == manager_path: + refreshed_fields.add(field_path.field_name) for param_name in refreshed_fields: - owner_path = self._owner_path(param_name).value + owner_path = DottedFieldPath(manager.field_id).child(param_name).value refresh_compound_semantics = owner_path not in refreshed_compound_owner_paths refreshed_compound_owner_paths.add(owner_path) self.update_label_styling( @@ -280,7 +271,7 @@ def refresh_widgets_from_state(self) -> None: manager = self.manager for param_name, widget in manager.widgets.items(): if isinstance(widget, ValueSettable): - dotted_path = f"{manager.field_id}.{param_name}" if manager.field_id else param_name + dotted_path = DottedFieldPath(manager.field_id).child(param_name).value if dotted_path in manager.state.parameters: value = manager.state.parameters.get(dotted_path) manager._widget_service.update_widget_value(widget, value, param_name, False, manager) @@ -298,6 +289,7 @@ def refresh_widgets_for_paths(self, paths: Set[str]) -> set[str]: from objectstate.time_travel_profile import TimeTravelProfiler manager = self.manager + manager_path = DottedFieldPath(manager.field_id) missing = object() refreshed_container_paths: set[str] = set() refreshed_compound_owner_paths: set[str] = set() @@ -309,19 +301,10 @@ def refresh_widgets_for_paths(self, paths: Set[str]) -> set[str]: widgets=len(manager.widgets), ): for path in paths: - if "." in path: - path_prefix, leaf_field = path.rsplit(".", 1) - else: - path_prefix = "" - leaf_field = path - - owner_field = self._direct_child_field_for_path(path) + field_path = DottedFieldPath(path) + owner_field = manager_path.direct_child_name(field_path) if owner_field is not None: - owner_path = ( - f"{manager.field_id}.{owner_field}" - if manager.field_id - else owner_field - ) + owner_path = manager_path.child(owner_field).value if owner_path != path and owner_path not in refreshed_container_paths: owner_widget = manager.widgets.get(owner_field) raw_value = manager.state.parameters.get(owner_path, missing) @@ -379,7 +362,8 @@ def refresh_widgets_for_paths(self, paths: Set[str]) -> set[str]: refreshed_container_paths.add(owner_path) refreshed_compound_owner_paths.add(owner_path) - if path_prefix == manager.field_id: + if field_path.parent == manager_path: + leaf_field = field_path.field_name widget = manager.widgets.get(leaf_field) if isinstance(widget, ValueSettable): value = manager.state.parameters.get(path, missing) @@ -411,14 +395,8 @@ def refresh_widgets_for_paths(self, paths: Set[str]) -> set[str]: return refreshed_compound_owner_paths - def _direct_child_field_for_path(self, path: str) -> str | None: - return self.manager.form_tree.direct_child_field_for_path( - self.manager.field_id, - path, - ) - def dispatch_reset(self, param_name: str) -> None: - dotted_path = f"{self.manager.field_id}.{param_name}" if self.manager.field_id else param_name + dotted_path = DottedFieldPath(self.manager.field_id).child(param_name).value reset_value = self.manager.state.parameters.get(dotted_path) event = FieldChangeEvent(param_name, reset_value, self.manager, is_reset=True) FieldChangeDispatcher.instance().dispatch(event) diff --git a/src/pyqt_reactive/forms/parameter_form_manager.py b/src/pyqt_reactive/forms/parameter_form_manager.py index 3011184..7934f89 100644 --- a/src/pyqt_reactive/forms/parameter_form_manager.py +++ b/src/pyqt_reactive/forms/parameter_form_manager.py @@ -112,7 +112,7 @@ class FormTargetPathAccess: @classmethod def raw_path(cls, root, field_path: str): current = root - for part in tuple(part for part in field_path.split(".") if part): + for part in DottedFieldPath(field_path).parts: current = cls.raw_value(current, part) return current @@ -137,7 +137,7 @@ class ParameterFormTypeResolver: @staticmethod def field_type(manager: "ParameterFormManager", field_name: str, signature_type: Type) -> Type: - dotted_path = f"{manager.field_id}.{field_name}" if manager.field_id else field_name + dotted_path = manager._object_state_path_for_field(field_name).value if not manager.state.has_parameter_descendants(dotted_path): return signature_type @@ -704,11 +704,11 @@ def _create_widget_for_param(self, param_info: ParameterInfo) -> QWidget: from pyqt_reactive.forms.widget_creation_config import WidgetCreationPipeline logger.debug( - "[PFM_CREATE_PARAM] seq=%s field_id=%s param=%s widget_creation_type=%s", + "[PFM_CREATE_PARAM] seq=%s field_id=%s param=%s declaration=%s", self._pfm_seq, self.field_id, param_info.name, - param_info.widget_creation_type, + type(param_info).__qualname__, ) return WidgetCreationPipeline(self, param_info).run() @@ -729,7 +729,7 @@ def _create_nested_form_inline( current_value: Ignored (kept for ABC compatibility) """ # Build nested field id (dotted path) - nested_id = f"{self.field_id}.{param_name}" if self.field_id else param_name + nested_id = self._object_state_path_for_field(param_name).value # Create nested PFM (VIEW) that shares the same ObjectState (MODEL) nested_config = FormManagerConfig( @@ -777,7 +777,7 @@ def _convert_widget_value(self, value: WidgetValue, param_name: str) -> Paramete # Then apply service layer conversion (enums, basic types, Union handling, etc.) converted_value = self.service.convert_value_to_type( - converted_value, param_type, param_name, type(self.object_instance) + converted_value, param_type, param_name ) return converted_value @@ -834,7 +834,6 @@ def update_parameter(self, param_name: str, value: ParameterValue) -> None: value, self.parameter_types.get(param_name, type(value)), param_name, - type(self.object_instance), ) # Update corresponding widget if it exists @@ -1151,13 +1150,8 @@ def _flush_resolved_values_changed(self) -> None: ): flash_paths: list[str] = [] for path in ParameterFormManager._flash_paths_for_changed_paths(changed_paths): - if self.field_id: - if "." in path: - path_prefix = path.rsplit(".", 1)[0] - if path_prefix != self.field_id: - continue - else: - continue + if self.field_id and DottedFieldPath(path).parent.value != self.field_id: + continue queued_path = self._queue_leaf_flash_for_path( path, queue_flash=False, @@ -1167,9 +1161,9 @@ def _flush_resolved_values_changed(self) -> None: self.queue_flash_local_batch(flash_paths) if changed_paths: - sample_path = next(iter(changed_paths)) - sample_leaf = sample_path.split(".")[-1] if "." in sample_path else sample_path - sample_prefix = sample_path.rsplit(".", 1)[0] if "." in sample_path else None + sample_path = DottedFieldPath(next(iter(changed_paths))) + sample_leaf = sample_path.field_name + sample_prefix = sample_path.parent.value or None logger.debug(f"[FLASH TRAIL] prefix={sample_prefix}, leaf_field={sample_leaf}") @staticmethod @@ -1242,7 +1236,7 @@ def _queue_leaf_flash_for_path( # Find the prefix (groupbox) and leaf field name prefix = self.form_tree.matching_prefix(path) - leaf_field = path.split(".")[-1] if "." in path else path + leaf_field = DottedFieldPath(path).field_name if prefix: # Nested dataclass case: find groupbox and nested manager @@ -1407,17 +1401,12 @@ def _queue_structural_flash_for_object_state_path( for manager in self._iter_form_managers(): for field_name, widget in manager.widgets.items(): owner_path = manager._object_state_path_for_field(field_name) - if not DottedFieldPath(owner_path).contains_path(path): + if not owner_path.contains_path(path): continue - child_field_name = self._relative_child_field_name( - owner_path, - path, - ) structural_result = resolve_inline_dataclass_structural_target( inline_widget=widget, - inline_field_path=tuple(owner_path.split(".")), + inline_field_path=owner_path, display_path=path, - owner_child_field_name=child_field_name, ) if structural_result is None: continue @@ -1425,7 +1414,7 @@ def _queue_structural_flash_for_object_state_path( "form.structural_flash.match", manager=self.field_id, path=path, - owner=owner_path, + owner=owner_path.value, field=field_name, child=structural_result.child_field_name, target=type(structural_result.target).__qualname__, @@ -1448,10 +1437,10 @@ def _iter_form_managers(self): for nested_manager in self.nested_managers.values(): yield from nested_manager._iter_form_managers() - def _object_state_path_for_field(self, field_name: str) -> str: - """Return the ObjectState display path for a direct field.""" + def _object_state_path_for_field(self, field_name: str) -> DottedFieldPath: + """Bind a direct field to this manager's absolute ObjectState scope.""" - return f"{self.field_id}.{field_name}" if self.field_id else field_name + return DottedFieldPath(self.field_id).child(field_name) def owns_field_path(self, field_path: str) -> bool: """Whether the path is, or lies under, a field this form shows. @@ -1462,18 +1451,12 @@ def owns_field_path(self, field_path: str) -> bool: """ return any( - DottedFieldPath(self._object_state_path_for_field(parameter.name)).contains_path( + self._object_state_path_for_field(parameter.name).contains_path( field_path ) for parameter in self.form_structure.parameters ) - @staticmethod - def _relative_child_field_name(owner_path: str, path: str) -> str | None: - """Return the direct child field under an ObjectState owner path.""" - - return DottedFieldPath(owner_path).direct_child_name(path) - # PAINT-TIME API: ObjectState-path flash lookup inherited from VisualUpdateMixin. # Groupboxes and item delegates use the registered ObjectState path to get current flash color. diff --git a/src/pyqt_reactive/forms/parameter_form_tree_index.py b/src/pyqt_reactive/forms/parameter_form_tree_index.py index 258ea52..59034cf 100644 --- a/src/pyqt_reactive/forms/parameter_form_tree_index.py +++ b/src/pyqt_reactive/forms/parameter_form_tree_index.py @@ -54,17 +54,15 @@ def paths_for_manager(self, manager: Any, paths: set[str]) -> set[str]: field_id = manager.field_id if not field_id: return set(paths) - return { - path - for path in paths - if self.path_intersects_field_scope(path, field_id) - } + field_path = DottedFieldPath(field_id) + return {path for path in paths if field_path.intersects_path(path)} def child_managers_for_paths(self, manager: Any, paths: set[str]) -> tuple[tuple[Any, set[str]], ...]: """Return direct child managers whose field scope intersects changed paths.""" routed_paths: dict[int, tuple[Any, set[str]]] = {} + owner_path = DottedFieldPath(manager.field_id) for path in paths: - child_field = self.direct_child_field_for_path(manager.field_id, path) + child_field = owner_path.direct_child_name(path) if child_field is None: continue nested_manager = manager.nested_managers.get(child_field) @@ -76,26 +74,6 @@ def child_managers_for_paths(self, manager: Any, paths: set[str]) -> tuple[tuple routed_paths[manager_id][1].add(path) return tuple(routed_paths.values()) - @staticmethod - def direct_child_field_for_path(field_id: str, path: str) -> Optional[str]: - """Return the direct child field owning a dotted ObjectState path.""" - if field_id: - return DottedFieldPath(field_id).direct_child_name(path) - - if not path: - return None - return path.split(".", 1)[0].split("[", 1)[0] or None - - @staticmethod - def path_intersects_field_scope(path: str, field_id: str) -> bool: - path_field = DottedFieldPath(path) - field = DottedFieldPath(field_id) - return path_field.contains_path(field) or field.contains_path(path_field) - - @staticmethod - def path_belongs_to_field_scope(path: str, field_id: str) -> bool: - return DottedFieldPath(field_id).contains_path(path) - def _nested_manager_recursive(self, prefix: str, manager: Any) -> Optional[Any]: for nested_manager in manager.nested_managers.values(): if nested_manager.field_id == prefix: @@ -108,7 +86,7 @@ def _nested_manager_recursive(self, prefix: str, manager: Any) -> Optional[Any]: def _matching_prefix_recursive(self, path: str, manager: Any) -> Optional[str]: for nested_manager in manager.nested_managers.values(): prefix = nested_manager.field_id - if self.path_belongs_to_field_scope(path, prefix): + if DottedFieldPath(prefix).contains_path(path): deeper = self._matching_prefix_recursive(path, nested_manager) return deeper if deeper else prefix return None diff --git a/src/pyqt_reactive/forms/widget_creation_config.py b/src/pyqt_reactive/forms/widget_creation_config.py index ad95168..fc691da 100644 --- a/src/pyqt_reactive/forms/widget_creation_config.py +++ b/src/pyqt_reactive/forms/widget_creation_config.py @@ -1,38 +1,25 @@ -""" -Widget creation configuration - parametric pattern. - -Single source of truth for widget creation behavior (REGULAR, NESTED, and OPTIONAL_NESTED). -Mirrors the framework_config pattern. - -Architecture: -- Widget handlers: Custom logic for complex operations -- Unified config: Single _WIDGET_CREATION_CONFIG dict with all metadata -- Parametric dispatch: Handlers are typed callables (no eval strings) - -All three widget types (REGULAR, NESTED, OPTIONAL_NESTED) are now parametrized. -OPTIONAL_NESTED reuses the same nested form creation logic as NESTED, with additional -handlers for checkbox title widget and None/instance toggle logic. -""" +"""Qt field creation and lifecycle behavior follows ParameterInfo inheritance.""" from __future__ import annotations from abc import ABC, abstractmethod +from contextlib import nullcontext from dataclasses import dataclass -from enum import Enum import logging import itertools -from typing import Any, Callable, ClassVar, Optional, TYPE_CHECKING, Type, Tuple +from typing import Callable, ClassVar, TYPE_CHECKING from metaclass_registry import AutoRegisterMeta from python_introspect import resolve_optional from .widget_creation_types import ( - ParameterFormManager, ParameterInfo, DisplayInfo, FieldIds, - LayoutKind, OptionalTitleComponents, WidgetBuildContext, WidgetCreationConfig + ParameterFormManager, ParameterInfo, + OptionalTitleComponents, WidgetBuildContext ) from pyqt_reactive.services.field_change_dispatcher import FieldChangeDispatcher, FieldChangeEvent from pyqt_reactive.services.widget_service import WidgetService from pyqt_reactive.widgets.shared.responsive_layout_widgets import ResponsiveParameterRow from formbridge.forms.layout_constants import CURRENT_LAYOUT +from formbridge.forms.parameter_info_types import DirectDataclassInfo, InlineDataclassWidgetInfo, OptionalDataclassInfo logger = logging.getLogger(__name__) @@ -48,26 +35,6 @@ _WIDGET_CREATE_SEQ = itertools.count(1) -def _root_manager(manager: ParameterFormManager) -> ParameterFormManager: - """Return the root ParameterFormManager for a nested manager tree.""" - root = manager - while root._parent_manager is not None: - root = root._parent_manager - return root - - -class WidgetCreationType(Enum): - """ - Enum for widget creation strategies - mirrors MemoryType pattern. - - PyQt6 uses 3 parametric types: REGULAR, NESTED, and OPTIONAL_NESTED. - """ - REGULAR = "regular" - INLINE_DATACLASS = "inline_dataclass" - NESTED = "nested" - OPTIONAL_NESTED = "optional_nested" - - # ============================================================================ # WIDGET CREATION HANDLERS - Special-case logic (like framework handlers) # ============================================================================ @@ -413,11 +380,9 @@ def create_provenance_button( self, context: EnabledTitleWidgetMoveContext, ) -> ProvenanceButton: - dotted_path = ( - f"{context.request.nested_manager.field_id}.{context.request.enabled_field}" - if context.request.nested_manager.field_id - else context.request.enabled_field - ) + dotted_path = context.request.nested_manager._object_state_path_for_field( + context.request.enabled_field + ).value return self.create_title_provenance_button( state=context.request.nested_manager.state, dotted_path=dotted_path, @@ -473,10 +438,184 @@ def remove_empty_source_row( row_parent.setParent(None) -class WidgetCreationHandlers: - """Nominal owner for widget creation operation handlers.""" +class WidgetCreationConfig(ABC, metaclass=AutoRegisterMeta): + """The admitted declaration's MRO selects all Qt creation behavior.""" + + __registry_key__ = "parameter_type" + __skip_if_no_key__ = True + parameter_type: ClassVar[type[ParameterInfo] | None] = None + + @classmethod + def for_parameter(cls, info: ParameterInfo) -> WidgetCreationConfig: + for declaration in type(info).__mro__: + implementation = cls.__registry__.get(declaration) + if implementation is not None: + return implementation() + raise LookupError(f"No Qt form for {type(info).__qualname__}") + + @abstractmethod + def create_container(self, ctx: WidgetBuildContext) -> QWidget: + """Create this frontend's container.""" + raise NotImplementedError + + @abstractmethod + def create_main_widget(self, ctx: WidgetBuildContext) -> QWidget: + """Create this frontend's editor.""" + raise NotImplementedError + + def reset_callback(self, runtime: WidgetCreationRuntime) -> Callable[[], None]: + return lambda: runtime.ctx.manager.reset_parameter(runtime.ctx.param_info.name) + + def store_widget(self, runtime: WidgetCreationRuntime, on_change: Callable) -> None: + from pyqt_reactive.forms.widget_strategies import PyQt6WidgetEnhancer + widget = runtime.main_widget + runtime.ctx.manager.widgets[runtime.ctx.param_info.name] = widget + PyQt6WidgetEnhancer.connect_change_signal(widget, runtime.ctx.param_info.name, on_change) + if runtime.ctx.manager.read_only: + WidgetService.make_readonly(widget, runtime.ctx.manager.config.color_scheme) + + def unwrapped_type(self, info: ParameterInfo) -> type | None: + return None + + def content_update(self, rt: WidgetCreationRuntime): + return nullcontext() + + def configure_container(self, rt: WidgetCreationRuntime) -> None: + pass + + @abstractmethod + def create_layout(self, ctx: WidgetBuildContext) -> QLayout | None: + """Create the layout required by this owner's container.""" + raise NotImplementedError + + def add_title(self, rt: WidgetCreationRuntime) -> None: + """Containers with their own title need no additional title widget.""" + + def place_widget(self, rt: WidgetCreationRuntime) -> None: + rt.ctx.layout.addWidget(rt.main_widget) + + def add_reset_controls(self, rt: WidgetCreationRuntime) -> None: + if not rt.ctx.manager.read_only: + self.add_reset_button(rt) + + @abstractmethod + def add_reset_button(self, rt: WidgetCreationRuntime) -> None: + raise NotImplementedError + + def connect_structure(self, rt: WidgetCreationRuntime) -> None: + """Ordinary and direct dataclass fields have no presence checkbox.""" + + +class RegularWidgetCreation(WidgetCreationConfig): + parameter_type = ParameterInfo + + @staticmethod + def create_container(ctx: WidgetBuildContext) -> QWidget: + """Create container for REGULAR widget type.""" + from pyqt_reactive.widgets.shared.responsive_layout_widgets import ( + ResponsiveParameterRow, + ) + + return ResponsiveParameterRow( + parent=ctx.manager.parent(), + layout_config=ctx.layout_config, + ) + + @staticmethod + def create_main_widget(ctx: WidgetBuildContext) -> QWidget: + return ctx.manager._widget_creator(ctx.param_info.name, ctx.param_info.type, + ctx.current_value, ctx.field_ids['widget_id'], None) + + def content_update(self, rt: WidgetCreationRuntime): + return rt.ctx.container.layout_update() + + def create_layout(self, ctx: WidgetBuildContext) -> None: + # ResponsiveParameterRow owns and batches its internal layout. + return None + + def add_title(self, rt: WidgetCreationRuntime) -> None: + from pyqt_reactive.widgets.shared.clickable_help_components import HelpContext, LabelWithHelp + from pyqt_reactive.theming.color_scheme import ColorScheme as PyQt6ColorScheme + + dotted_path = rt.ctx.manager._object_state_path_for_field(rt.ctx.param_info.name).value + label = LabelWithHelp( + text=rt.ctx.display_info['field_label'], + help_context=HelpContext( + help_target=rt.ctx.manager.function_target, + param_name=rt.ctx.param_info.name, + param_description=rt.ctx.display_info['description'], + param_type=rt.ctx.param_info.type, + color_scheme=rt.ctx.manager.config.color_scheme or PyQt6ColorScheme(), + scope_accent_color=rt.ctx.manager._scope_accent_color, + ), + state=rt.ctx.manager.state, + dotted_path=dotted_path, + ) + rt.log_widget("label", label) + rt.ctx.container.set_label(label) + rt.ctx.manager.labels[rt.ctx.param_info.name] = label + label.set_underline(dotted_path in rt.ctx.manager.state.signature_diff_fields) + + def place_widget(self, rt: WidgetCreationRuntime) -> None: + rt.ctx.container.set_input(rt.main_widget) + + def add_reset_button(self, rt: WidgetCreationRuntime) -> None: + reset_button = _create_optimized_reset_button( + rt.ctx.manager.config.field_id, + rt.ctx.param_info.name, + self.reset_callback(rt), + ) + rt.log_widget("reset_button", reset_button) + rt.ctx.container.set_reset_button(reset_button) + rt.ctx.manager.reset_buttons[rt.ctx.param_info.name] = reset_button + + +class NestedWidgetCreation(WidgetCreationConfig): + parameter_type = DirectDataclassInfo + + @staticmethod + def create_container(ctx: WidgetBuildContext) -> GroupBoxWithHelp: + """Create container for NESTED widget type.""" + from pyqt_reactive.widgets.shared.clickable_help_components import GroupBoxWithHelp as GBH + from pyqt_reactive.theming.color_scheme import ColorScheme as PCS + from pyqt_reactive.forms.form_init_service import FormBuildOrchestrator + + manager = ctx.manager + param_info = ctx.param_info + display_info = ctx.display_info + unwrapped_type = ctx.unwrapped_type + color_scheme = manager.config.color_scheme or PCS() + # Get root manager for flash - nested managers share root's _flash_colors dict + root_manager = manager.form_tree.root() + # Flash keys must identify the *section* via a canonical dotted path. + # Example: "processing_config.path_planning_config" (not just "path_planning_config"). + flash_key = manager._object_state_path_for_field(param_info.name).value + + # CRITICAL: Use scope_accent_color from manager config (passed from parent window) + # No need to walk parent chain - it's stored directly in the manager + scope_accent_color = manager._scope_accent_color + + import logging + logger = logging.getLogger(__name__) + logger.debug(f"[CREATE_NESTED] field_id={manager.field_id}, manager._scope_accent_color={scope_accent_color}") + logger.debug( + "[CREATE_NESTED] param=%s title=%s widget=GroupBoxWithHelp", + param_info.name, + display_info.get('field_label') + ) + + container = GBH( + title=display_info['field_label'], + help_target=unwrapped_type, + color_scheme=color_scheme, + scope_accent_color=scope_accent_color, # Pass scope accent color + flash_key=flash_key, + flash_manager=root_manager + ) + return container - def _create_nested_form(ctx: WidgetBuildContext) -> QWidget: + @staticmethod + def create_main_widget(ctx: WidgetBuildContext) -> QWidget: """ Handler for creating nested form. @@ -510,7 +649,10 @@ def on_build_complete(): log = logging.getLogger(__name__) log.debug(f"[BUILD_COMPLETE] FIRED for {nested_manager.field_id}, widget_count={len(nested_manager.widgets)}, widgets={list(nested_manager.widgets.keys())}") - WidgetCreationHandlers._move_enabled_widget_to_title(nested_manager, manager, param_info.name, enabled_field) + EnabledTitleWidgetMoveAuthority().move(EnabledTitleWidgetMoveRequest( + nested_manager=nested_manager, parent_manager=manager, + nested_param_name=param_info.name, enabled_field=enabled_field, + )) log.debug(f"[BUILD_COMPLETE] After move_enabled_widget: enabled_widget exists={'enabled' in nested_manager.widgets}, widget_count={len(nested_manager.widgets)}") @@ -530,30 +672,145 @@ def on_build_complete(): logger.debug(f"🔍 _create_nested_form: build_form() returned for {nested_manager.field_id}, widgets={len(nested_manager.widgets)}") return result - def _move_enabled_widget_to_title(nested_manager, parent_manager, nested_param_name: str, enabled_field: str) -> None: - """ - Move the enabled widget from its normal form row to the GroupBoxWithHelp title. + def unwrapped_type(self, info: ParameterInfo) -> type: + return resolve_optional(info.type) - This allows the enabled field to go through normal widget creation (gets proper widget_id, - widgets dict registration, reset button, placeholder syncing) but just changes - its visual placement to the title area. + def configure_container(self, rt: WidgetCreationRuntime) -> None: + root_manager = rt.ctx.manager.form_tree.root() + root_manager.register_flash_groupbox(rt.ctx.container._flash_key, rt.ctx.container) + scope_color_scheme = rt.ctx.manager._scope_color_scheme + if scope_color_scheme is None: + scope_color_scheme = root_manager._scope_color_scheme + if scope_color_scheme: + rt.ctx.container.set_scope_color_scheme(scope_color_scheme) - The enabled checkbox is placed after the title label and help button in the title layout. - """ - logger.debug(f"🔍 _move_enabled_widget_to_title: enabled_field={enabled_field}, nested_param_name={nested_param_name}") - logger.debug(f"🔍 _move_enabled_widget_to_title: nested_manager.widgets keys={list(nested_manager.widgets.keys())}") - if isinstance(parent_manager, ParameterFormManager): - logger.debug(f"🔍 _move_enabled_widget_to_title: parent_manager.widgets keys={list(parent_manager.widgets.keys())}") - EnabledTitleWidgetMoveAuthority().move( - EnabledTitleWidgetMoveRequest( - nested_manager=nested_manager, - parent_manager=parent_manager, - nested_param_name=nested_param_name, - enabled_field=enabled_field, - ) + def create_layout(self, ctx: WidgetBuildContext) -> QLayout: + return ctx.container.layout() + + def reset_callback(self, runtime: WidgetCreationRuntime) -> Callable[[], None]: + nested_manager = runtime.ctx.manager.nested_managers.get(runtime.ctx.param_info.name) + if nested_manager is None: + raise RuntimeError(f"Nested dataclass {runtime.ctx.param_info.name!r} has no nested manager.") + return nested_manager.reset_all_parameters + + def add_reset_button(self, rt: WidgetCreationRuntime) -> None: + from PyQt6.QtWidgets import QPushButton + + reset_all_button = QPushButton("Reset All") + reset_all_button.setMaximumWidth(80) + reset_all_button.setFixedHeight(CURRENT_LAYOUT.button_height) + reset_all_button.setToolTip(f"Reset all parameters in {rt.ctx.display_info['field_label']} to defaults") + ResetButtonStyler.apply(reset_all_button, rt.ctx.manager.color_scheme) + rt.log_widget("reset_all", reset_all_button) + reset_all = self.reset_callback(rt) + reset_all_button.clicked.connect(lambda: reset_all()) + rt.ctx.container.addResetAllTitleWidget(reset_all_button) + + def store_widget(self, runtime: WidgetCreationRuntime, on_change: Callable) -> None: + runtime.ctx.manager.widgets[runtime.ctx.param_info.name] = runtime.ctx.container + + +class InlineWidgetCreation(NestedWidgetCreation): + parameter_type = InlineDataclassWidgetInfo + + @staticmethod + def create_container( + ctx: WidgetBuildContext, + ) -> InlineDataclassGroupBox: + """Create dataclass chrome for a registered inline dataclass editor.""" + from pyqt_reactive.widgets.shared.clickable_help_components import ( + InlineDataclassGroupBox, + ) + from pyqt_reactive.theming.color_scheme import ColorScheme as PCS + + manager = ctx.manager + param_info = ctx.param_info + display_info = ctx.display_info + unwrapped_type = ctx.unwrapped_type + color_scheme = manager.config.color_scheme or PCS() + root_manager = manager.form_tree.root() + flash_key = manager._object_state_path_for_field(param_info.name).value + scope_accent_color = manager._scope_accent_color + + return InlineDataclassGroupBox( + title=display_info['field_label'], + help_target=unwrapped_type, + color_scheme=color_scheme, + scope_accent_color=scope_accent_color, + flash_key=flash_key, + flash_manager=root_manager, + ) + + @staticmethod + def create_main_widget( + ctx: WidgetBuildContext, + ) -> QWidget: + """Create the registered inline editor for a structural dataclass value.""" + manager = ctx.manager + param_info = ctx.param_info + widget_factory = param_info.widget_factory() + return widget_factory( + manager=manager, + param_info=param_info, + display_info=ctx.display_info, + field_ids=ctx.field_ids, + current_value=ctx.current_value, + parent=manager, + ) + + def reset_callback(self, runtime: WidgetCreationRuntime) -> Callable[[], None]: + return runtime.ctx.container.reset_inline_dataclass_fields + + def store_widget(self, runtime: WidgetCreationRuntime, on_change: Callable) -> None: + from pyqt_reactive.forms.widget_strategies import PyQt6WidgetEnhancer + runtime.ctx.container.set_value_widget(runtime.main_widget) + super().store_widget(runtime, on_change) + PyQt6WidgetEnhancer.connect_change_signal(runtime.ctx.container, runtime.ctx.param_info.name, on_change) + + +class OptionalNestedWidgetCreation(NestedWidgetCreation): + parameter_type = OptionalDataclassInfo + + @staticmethod + def create_container(ctx: WidgetBuildContext) -> QGroupBox: + """Create container for OPTIONAL_NESTED widget type.""" + from PyQt6.QtWidgets import QGroupBox + from PyQt6.QtGui import QPalette + from pyqt_reactive.theming.color_scheme import ColorScheme as PCS + import logging + + manager = ctx.manager + display_info = ctx.display_info + field_ids = ctx.field_ids + color_scheme = manager.config.color_scheme or PCS() + container = QGroupBox() + palette = container.palette() + palette.setColor(QPalette.ColorRole.Window, color_scheme.to_qcolor(color_scheme.panel_bg)) + container.setPalette(palette) + container.setAutoFillBackground(True) + logger = logging.getLogger(__name__) + logger.debug( + "[OPTIONAL_NESTED] Created QGroupBox: name=%s title=%s autoFill=%s palette_window=%s", + field_ids.get('widget_id'), + display_info.get('field_label'), + container.autoFillBackground(), + palette.color(QPalette.ColorRole.Window).name() ) + return container + + def configure_container(self, rt: WidgetCreationRuntime) -> None: + # QGroupBox presence titles do not use GroupBoxWithHelp flash chrome. + pass + + def create_layout(self, ctx: WidgetBuildContext) -> QLayout: + from PyQt6.QtWidgets import QVBoxLayout + layout = QVBoxLayout(ctx.container) + layout.setSpacing(0) + layout.setContentsMargins(0, 0, 0, 0) + return layout - def _create_optional_title_widget(ctx: WidgetBuildContext): + @staticmethod + def create_title_widget(ctx: WidgetBuildContext): """ Handler for creating optional dataclass title widget with checkbox. @@ -636,7 +893,8 @@ def _create_optional_title_widget(ctx: WidgetBuildContext): reset_all_button=reset_all_button, ) - def _connect_optional_checkbox_logic(manager, param_info, checkbox, nested_form, nested_manager, title_label, help_btn, unwrapped_type): + @staticmethod + def connect_checkbox_logic(manager, param_info, checkbox, nested_form, nested_manager, title_label, help_btn, unwrapped_type): """ Handler for connecting optional dataclass checkbox toggle logic. @@ -689,369 +947,74 @@ def apply_initial_styling(): manager._on_build_complete_callbacks.append(apply_initial_styling) - def _create_regular_container(ctx: WidgetBuildContext) -> QWidget: - """Create container for REGULAR widget type.""" - from pyqt_reactive.widgets.shared.responsive_layout_widgets import ( - ResponsiveParameterRow, - ) - - return ResponsiveParameterRow( - parent=ctx.manager.parent(), - layout_config=ctx.layout_config, - ) - - def _create_nested_container(ctx: WidgetBuildContext) -> GroupBoxWithHelp: - """Create container for NESTED widget type.""" - from pyqt_reactive.widgets.shared.clickable_help_components import GroupBoxWithHelp as GBH - from pyqt_reactive.theming.color_scheme import ColorScheme as PCS - from pyqt_reactive.forms.form_init_service import FormBuildOrchestrator - - manager = ctx.manager - param_info = ctx.param_info - display_info = ctx.display_info - unwrapped_type = ctx.unwrapped_type - color_scheme = manager.config.color_scheme or PCS() - # Get root manager for flash - nested managers share root's _flash_colors dict - root_manager = _root_manager(manager) - # Flash keys must identify the *section* via a canonical dotted path. - # Example: "processing_config.path_planning_config" (not just "path_planning_config"). - flash_key = f"{manager.field_id}.{param_info.name}" if manager.field_id else param_info.name - - # CRITICAL: Use scope_accent_color from manager config (passed from parent window) - # No need to walk parent chain - it's stored directly in the manager - scope_accent_color = manager._scope_accent_color - - import logging - logger = logging.getLogger(__name__) - logger.debug(f"[CREATE_NESTED] field_id={manager.field_id}, manager._scope_accent_color={scope_accent_color}") - logger.debug( - "[CREATE_NESTED] param=%s title=%s widget=GroupBoxWithHelp", - param_info.name, - display_info.get('field_label') + def add_title(self, rt: WidgetCreationRuntime) -> None: + self.title_components = self.create_title_widget(rt.ctx) + rt.ctx.layout.addWidget(self.title_components.title_widget) + rt.log_widget("title_widget", self.title_components.title_widget) + rt.log_widget("presence_checkbox", self.title_components.checkbox) + + def place_widget(self, rt: WidgetCreationRuntime) -> None: + super().place_widget(rt) + rt.main_widget.setEnabled(rt.ctx.current_value is not None) + + def add_reset_button(self, rt: WidgetCreationRuntime) -> None: + self.title_components.reset_all_button.clicked.connect(self.reset_callback(rt)) + + def connect_structure(self, rt: WidgetCreationRuntime) -> None: + title = self.title_components + self.connect_checkbox_logic( + rt.ctx.manager, rt.ctx.param_info, title.checkbox, rt.main_widget, + rt.ctx.manager.nested_managers[rt.ctx.param_info.name], + title.title_label, title.help_btn, rt.ctx.unwrapped_type, ) - container = GBH( - title=display_info['field_label'], - help_target=unwrapped_type, - color_scheme=color_scheme, - scope_accent_color=scope_accent_color, # Pass scope accent color - flash_key=flash_key, - flash_manager=root_manager - ) - return container - - def _create_inline_dataclass_container( - ctx: WidgetBuildContext, - ) -> InlineDataclassGroupBox: - """Create dataclass chrome for a registered inline dataclass editor.""" - from pyqt_reactive.widgets.shared.clickable_help_components import ( - InlineDataclassGroupBox, - ) - from pyqt_reactive.theming.color_scheme import ColorScheme as PCS - - manager = ctx.manager - param_info = ctx.param_info - display_info = ctx.display_info - unwrapped_type = ctx.unwrapped_type - color_scheme = manager.config.color_scheme or PCS() - root_manager = _root_manager(manager) - flash_key = f"{manager.field_id}.{param_info.name}" if manager.field_id else param_info.name - scope_accent_color = manager._scope_accent_color - - return InlineDataclassGroupBox( - title=display_info['field_label'], - help_target=unwrapped_type, - color_scheme=color_scheme, - scope_accent_color=scope_accent_color, - flash_key=flash_key, - flash_manager=root_manager, - ) - - def _create_inline_dataclass_widget( - ctx: WidgetBuildContext, - ) -> QWidget: - """Create the registered inline editor for a structural dataclass value.""" - from formbridge.forms.parameter_info_types import InlineDataclassWidgetInfo - - manager = ctx.manager - param_info = ctx.param_info - if not isinstance(param_info, InlineDataclassWidgetInfo): - raise TypeError( - "INLINE_DATACLASS creation requires InlineDataclassWidgetInfo; " - f"got {type(param_info).__name__}." - ) - widget_factory = param_info.widget_factory() - return widget_factory( - manager=manager, - param_info=param_info, - display_info=ctx.display_info, - field_ids=ctx.field_ids, - current_value=ctx.current_value, - parent=manager, - ) - - def _create_optional_nested_container(ctx: WidgetBuildContext) -> QGroupBox: - """Create container for OPTIONAL_NESTED widget type.""" - from PyQt6.QtWidgets import QGroupBox - from PyQt6.QtGui import QPalette - from pyqt_reactive.theming.color_scheme import ColorScheme as PCS - import logging - - manager = ctx.manager - display_info = ctx.display_info - field_ids = ctx.field_ids - color_scheme = manager.config.color_scheme or PCS() - container = QGroupBox() - palette = container.palette() - palette.setColor(QPalette.ColorRole.Window, color_scheme.to_qcolor(color_scheme.panel_bg)) - container.setPalette(palette) - container.setAutoFillBackground(True) - logger = logging.getLogger(__name__) - logger.debug( - "[OPTIONAL_NESTED] Created QGroupBox: name=%s title=%s autoFill=%s palette_window=%s", - field_ids.get('widget_id'), - display_info.get('field_label'), - container.autoFillBackground(), - palette.color(QPalette.ColorRole.Window).name() - ) - return container - - def _setup_regular_layout(ctx: WidgetBuildContext) -> None: - """Setup layout for REGULAR widget type. - - For REGULAR widgets, container is a QWidget with a layout already set. - We need to configure the layout, not the container. - - If container is ResponsiveParameterRow, it manages its own layout so we skip setup. - """ - # Skip layout setup for ResponsiveParameterRow - it manages its own layout - container = ctx.container - if isinstance(container, ResponsiveParameterRow): - return - - layout = container.layout() - # QLayout.__bool__ returns False even when the layout exists, so we do not - # use a truthiness check here. For REGULAR rows we *require* that a layout - # has already been set (create_widget_parametric installs a QHBoxLayout), - # so if this ever ends up being None it's a programmer error and should - # raise loudly. - layout.setSpacing(ctx.layout_config.parameter_row_spacing) - layout.setContentsMargins(*ctx.layout_config.parameter_row_margins) - - def _setup_optional_nested_layout(ctx: WidgetBuildContext) -> None: - """Setup layout for OPTIONAL_NESTED widget type.""" - from PyQt6.QtWidgets import QVBoxLayout as QVL - container = ctx.container - container.setLayout(QVL()) - container.layout().setSpacing(0) - container.layout().setContentsMargins(0, 0, 0, 0) - - -# ============================================================================ -# UNIFIED WIDGET CREATION CONFIGURATION (typed, no eval strings) -# ============================================================================ - -_WIDGET_CREATION_CONFIG: dict[WidgetCreationType, WidgetCreationConfig] = { - WidgetCreationType.REGULAR: WidgetCreationConfig( - layout_type=LayoutKind.HORIZONTAL_ROW, - is_nested=False, - create_container=WidgetCreationHandlers._create_regular_container, - setup_layout=WidgetCreationHandlers._setup_regular_layout, - create_main_widget=lambda ctx: ctx.manager._widget_creator( - ctx.param_info.name, - ctx.param_info.type, - ctx.current_value, - ctx.field_ids['widget_id'], - None, - ), - needs_label=True, - needs_reset_button=True, - needs_unwrap_type=False, - ), - - WidgetCreationType.INLINE_DATACLASS: WidgetCreationConfig( - layout_type=LayoutKind.GROUPBOX_WITH_HELP, - is_nested=True, - create_container=WidgetCreationHandlers._create_inline_dataclass_container, - setup_layout=None, - create_main_widget=WidgetCreationHandlers._create_inline_dataclass_widget, - needs_label=False, - needs_reset_button=True, - needs_unwrap_type=True, - is_optional=False, - ), - - WidgetCreationType.NESTED: WidgetCreationConfig( - layout_type=LayoutKind.GROUPBOX_WITH_HELP, - is_nested=True, - create_container=WidgetCreationHandlers._create_nested_container, - setup_layout=None, - create_main_widget=WidgetCreationHandlers._create_nested_form, - needs_label=False, - needs_reset_button=True, - needs_unwrap_type=True, - is_optional=False, - ), - - WidgetCreationType.OPTIONAL_NESTED: WidgetCreationConfig( - layout_type=LayoutKind.PRECONFIGURED_GROUPBOX, - is_nested=True, - create_container=WidgetCreationHandlers._create_optional_nested_container, - setup_layout=WidgetCreationHandlers._setup_optional_nested_layout, - create_main_widget=WidgetCreationHandlers._create_nested_form, - needs_label=False, - needs_reset_button=True, - needs_unwrap_type=True, - is_optional=True, - needs_checkbox=True, - create_title_widget=WidgetCreationHandlers._create_optional_title_widget, - connect_checkbox_logic=WidgetCreationHandlers._connect_optional_checkbox_logic, - ), -} - - -# ============================================================================ -# WIDGET OPERATIONS - Direct access to typed config (no eval) -# ============================================================================ - -def _get_widget_operations(creation_type: WidgetCreationType) -> dict[str, Callable]: - """Get typed widget operations for a creation type.""" - config = _WIDGET_CREATION_CONFIG[creation_type] - ops = { - 'create_container': config.create_container, - 'create_main_widget': config.create_main_widget, - } - if config.setup_layout: - ops['setup_layout'] = config.setup_layout - if config.create_title_widget: - ops['create_title_widget'] = config.create_title_widget - if config.connect_checkbox_logic: - ops['connect_checkbox_logic'] = config.connect_checkbox_logic - return ops - - -class LayoutStrategy(ABC, metaclass=AutoRegisterMeta): - """Nominal layout creation strategy for widget containers.""" - - __registry_key__ = "layout_kind" - __skip_if_no_key__ = True - layout_kind: ClassVar[LayoutKind | None] = None - - @classmethod - def for_layout_kind(cls, layout_kind: LayoutKind) -> "LayoutStrategy": - return cls.__registry__[layout_kind]() - - @abstractmethod - def create_layout(self, container: QWidget) -> QLayout | None: - pass - - -class HorizontalRowLayoutStrategy(LayoutStrategy): - layout_kind = LayoutKind.HORIZONTAL_ROW - - def create_layout(self, container: QWidget) -> QLayout | None: - from PyQt6.QtWidgets import QHBoxLayout - if isinstance(container, ResponsiveParameterRow): - return None - return QHBoxLayout(container) - - -class VerticalBoxLayoutStrategy(LayoutStrategy): - layout_kind = LayoutKind.VERTICAL_BOX - - def create_layout(self, container: QWidget) -> QLayout: - from PyQt6.QtWidgets import QVBoxLayout - return QVBoxLayout(container) - - -class PreconfiguredGroupBoxLayoutStrategy(LayoutStrategy): - layout_kind = LayoutKind.PRECONFIGURED_GROUPBOX - - def create_layout(self, container: QWidget) -> None: - return None - - -class GroupBoxWithHelpLayoutStrategy(LayoutStrategy): - layout_kind = LayoutKind.GROUPBOX_WITH_HELP - - def create_layout(self, container: QWidget) -> QLayout | None: - return container.layout() - - -# ============================================================================ -# UNIFIED WIDGET CREATION FUNCTION -# ============================================================================ @dataclass class WidgetCreationRuntime: """Mutable state for one widget-creation pipeline execution.""" - manager: ParameterFormManager - param_info: ParameterInfo - creation_type: WidgetCreationType config: WidgetCreationConfig - ops: dict[str, Callable] ctx: WidgetBuildContext create_seq: int - container: QWidget | None = None - layout: QLayout | None = None - title_components: OptionalTitleComponents | None = None main_widget: QWidget | None = None - -class DataclassResetAllBinding(ABC, metaclass=AutoRegisterMeta): - """Nominal reset-all target for dataclass-shaped parameter containers.""" - - __registry_key__ = "creation_type" - __skip_if_no_key__ = True - creation_type: ClassVar[WidgetCreationType | None] = None - - @classmethod - def for_creation_type( - cls, - creation_type: WidgetCreationType, - ) -> "DataclassResetAllBinding": - return cls.__registry__[creation_type]() - - @abstractmethod - def reset_callback(self, runtime: WidgetCreationRuntime) -> Callable[[], None]: - """Return the reset-all action for one dataclass container.""" - pass - - -class NestedDataclassResetAllBinding(DataclassResetAllBinding): - """Reset-all for regular nested dataclasses delegates to their child manager.""" - - creation_type = WidgetCreationType.NESTED - - def reset_callback(self, runtime: WidgetCreationRuntime) -> Callable[[], None]: - nested_manager = runtime.manager.nested_managers.get(runtime.param_info.name) - if nested_manager is None: - raise RuntimeError( - f"Nested dataclass {runtime.param_info.name!r} has no nested manager." + def log_widget(self, stage: str, widget: QWidget | None) -> None: + rt = self + try: + parent_obj = None + widget_class = None + widget_name = None + widget_id = None + parent_class = None + if widget is not None: + parent_obj = widget.parent() + widget_class = type(widget).__name__ + widget_name = widget.objectName() + widget_id = id(widget) + if parent_obj is not None: + parent_class = type(parent_obj).__name__ + logger.debug( + "[WIDGET_CREATE] seq=%s stage=%s type=%s param=%s field_id=%s manager_seq=%s widget_cls=%s obj_name=%s id=%s parent_cls=%s", + rt.create_seq, + stage, + type(rt.config).__name__, + rt.ctx.param_info.name, + rt.ctx.manager.config.field_id, + rt.ctx.manager._pfm_seq, + widget_class, + widget_name, + widget_id, + parent_class, ) - return nested_manager.reset_all_parameters - - -class InlineDataclassResetAllBinding(DataclassResetAllBinding): - """Reset-all for inline dataclasses delegates to the inline value widget.""" - - creation_type = WidgetCreationType.INLINE_DATACLASS - - def reset_callback(self, runtime: WidgetCreationRuntime) -> Callable[[], None]: - return runtime.container.reset_inline_dataclass_fields + except Exception: + logger.debug("[WIDGET_CREATE] seq=%s stage=%s param=%s log_failed", rt.create_seq, stage, rt.ctx.param_info.name) class WidgetCreationPipeline: - """Named stages for creating one parameter widget.""" + """One shared lifecycle; the selected creation owner supplies every stage.""" def __init__(self, manager: ParameterFormManager, param_info: ParameterInfo) -> None: - from PyQt6.QtWidgets import QWidget - from pyqt_reactive.widgets.shared.clickable_help_components import GroupBoxWithHelp - from pyqt_reactive.theming.color_scheme import ColorScheme as PyQt6ColorScheme - - creation_type = WidgetCreationType[param_info.widget_creation_type] - config = _WIDGET_CREATION_CONFIG[creation_type] + config = WidgetCreationConfig.for_parameter(param_info) display_info = WidgetService.get_parameter_display_info( param_info.name, param_info.type, @@ -1060,9 +1023,7 @@ def __init__(self, manager: ParameterFormManager, param_info: ParameterInfo) -> ) field_ids = manager.service.generate_field_ids_direct(manager.config.field_id, param_info.name) current_value = manager.parameters.get(param_info.name) - unwrapped_type = None - if config.needs_unwrap_type: - unwrapped_type = resolve_optional(param_info.type) + unwrapped_type = config.unwrapped_type(param_info) ctx = WidgetBuildContext( manager=manager, param_info=param_info, @@ -1071,256 +1032,43 @@ def __init__(self, manager: ParameterFormManager, param_info: ParameterInfo) -> current_value=current_value, unwrapped_type=unwrapped_type, layout_config=CURRENT_LAYOUT, - qwidget_type=QWidget, - groupbox_with_help_type=GroupBoxWithHelp, - color_scheme_type=PyQt6ColorScheme, ) self.runtime = WidgetCreationRuntime( - manager=manager, - param_info=param_info, - creation_type=creation_type, config=config, - ops=_get_widget_operations(creation_type), ctx=ctx, create_seq=next(_WIDGET_CREATE_SEQ), ) - logger.debug( - "create_widget_parametric: config type=%s is_nested=%s is_optional=%s", - type(config).__name__, - config.is_nested, - config.is_optional, - ) + logger.debug("create_widget_parametric: config type=%s", type(config).__name__) def run(self) -> QWidget: - """Run the widget creation stages.""" self.create_container() rt = self.runtime - if isinstance(rt.container, ResponsiveParameterRow): - with rt.container.layout_update(): - self._run_content_stages() - else: - self._run_content_stages() - return self.runtime.container - - def _run_content_stages(self) -> None: - """Build one row's content within its owner's layout transaction.""" - - self.configure_nested_container() - self.setup_layout() - self.add_optional_title() - self.add_regular_label() - self.create_main_widget() - self.place_main_widget() - self.add_reset_controls() - self.connect_optional_checkbox() - self.store_and_connect_widget() - self.initialize_widget_semantics() + with rt.config.content_update(rt): + rt.config.configure_container(rt) + rt.ctx = rt.ctx.with_layout(rt.config.create_layout(rt.ctx)) + rt.config.add_title(rt) + self.create_main_widget() + rt.config.place_widget(rt) + rt.config.add_reset_controls(rt) + rt.config.connect_structure(rt) + rt.config.store_widget(rt, self.on_widget_change) + rt.ctx.manager.chrome_sync.update_label_styling(rt.ctx.param_info.name) + return rt.ctx.container def create_container(self) -> None: rt = self.runtime - rt.container = rt.ops['create_container'](rt.ctx) - rt.ctx = rt.ctx.with_container(rt.container) - self._log_widget("container", rt.container) - - def configure_nested_container(self) -> None: - from pyqt_reactive.widgets.shared.clickable_help_components import GroupBoxWithHelp - - rt = self.runtime - if not (rt.config.is_nested and isinstance(rt.container, GroupBoxWithHelp)): - return - - _root_manager(rt.manager).register_flash_groupbox(rt.container._flash_key, rt.container) - scope_color_scheme = rt.manager._scope_color_scheme - if scope_color_scheme is None: - scope_color_scheme = _root_manager(rt.manager)._scope_color_scheme - if scope_color_scheme: - rt.container.set_scope_color_scheme(scope_color_scheme) - - def setup_layout(self) -> None: - rt = self.runtime - rt.layout = LayoutStrategy.for_layout_kind(rt.config.layout_type).create_layout(rt.container) - rt.ctx = rt.ctx.with_layout(rt.layout) - if rt.ops.get('setup_layout'): - rt.ops['setup_layout'](rt.ctx) - if rt.config.layout_type is LayoutKind.PRECONFIGURED_GROUPBOX: - rt.layout = rt.container.layout() - rt.ctx = rt.ctx.with_layout(rt.layout) - - def add_optional_title(self) -> None: - rt = self.runtime - if not rt.config.is_optional: - return - rt.title_components = rt.ops['create_title_widget'](rt.ctx) - rt.layout.addWidget(rt.title_components.title_widget) - reset_button_class = None - reset_button_id = None - if rt.title_components.reset_all_button is not None: - reset_button_class = type(rt.title_components.reset_all_button).__name__ - reset_button_id = id(rt.title_components.reset_all_button) - logger.debug( - "[WIDGET_CREATE] seq=%s stage=title_widget type=%s param=%s manager_seq=%s title_cls=%s title_id=%s checkbox_cls=%s checkbox_id=%s reset_cls=%s reset_id=%s", - rt.create_seq, - rt.creation_type.value, - rt.param_info.name, - rt.manager._pfm_seq, - type(rt.title_components.title_widget).__name__, - id(rt.title_components.title_widget), - type(rt.title_components.checkbox).__name__, - id(rt.title_components.checkbox), - reset_button_class, - reset_button_id, - ) - - def add_regular_label(self) -> None: - from pyqt_reactive.widgets.shared.clickable_help_components import HelpContext, LabelWithHelp - from pyqt_reactive.theming.color_scheme import ColorScheme as PyQt6ColorScheme - - rt = self.runtime - if not rt.config.needs_label: - return - - dotted_path = f'{rt.manager.field_id}.{rt.param_info.name}' if rt.manager.field_id else rt.param_info.name - label = LabelWithHelp( - text=rt.ctx.display_info['field_label'], - help_context=HelpContext( - help_target=rt.manager.function_target, - param_name=rt.param_info.name, - param_description=rt.ctx.display_info['description'], - param_type=rt.param_info.type, - color_scheme=rt.manager.config.color_scheme or PyQt6ColorScheme(), - scope_accent_color=rt.manager._scope_accent_color, - ), - state=rt.manager.state, - dotted_path=dotted_path, - ) - self._log_widget("label", label) - if isinstance(rt.container, ResponsiveParameterRow): - rt.container.set_label(label) - else: - rt.layout.addWidget(label) - rt.manager.labels[rt.param_info.name] = label - label.set_underline(dotted_path in rt.manager.state.signature_diff_fields) + rt.ctx = rt.ctx.with_container(rt.config.create_container(rt.ctx)) + rt.log_widget("container", rt.ctx.container) def create_main_widget(self) -> None: rt = self.runtime - rt.main_widget = rt.ops['create_main_widget'](rt.ctx) - self._log_widget("main_widget", rt.main_widget) - - def place_main_widget(self) -> None: - rt = self.runtime - if rt.config.is_nested: - if rt.config.is_optional: - rt.main_widget.setEnabled(rt.ctx.current_value is not None) - rt.layout.addWidget(rt.main_widget) - elif isinstance(rt.container, ResponsiveParameterRow): - rt.container.set_input(rt.main_widget) - else: - rt.layout.addWidget(rt.main_widget, 1) - - def add_reset_controls(self) -> None: - rt = self.runtime - if not (rt.config.needs_reset_button and not rt.manager.read_only): - return - if rt.config.is_optional: - self._connect_optional_reset_button() - elif rt.config.is_nested: - self._add_dataclass_reset_all_button() - else: - self._add_regular_reset_button() - - def _connect_optional_reset_button(self) -> None: - rt = self.runtime - if rt.title_components is None or rt.title_components.reset_all_button is None: - return - nested_manager = rt.manager.nested_managers.get(rt.param_info.name) - if nested_manager: - rt.title_components.reset_all_button.clicked.connect(lambda: nested_manager.reset_all_parameters()) - - def _add_dataclass_reset_all_button(self) -> None: - from PyQt6.QtWidgets import QPushButton - - rt = self.runtime - reset_all_button = QPushButton("Reset All") - reset_all_button.setMaximumWidth(80) - reset_all_button.setFixedHeight(CURRENT_LAYOUT.button_height) - reset_all_button.setToolTip(f"Reset all parameters in {rt.ctx.display_info['field_label']} to defaults") - ResetButtonStyler.apply(reset_all_button, rt.manager.color_scheme) - self._log_widget("reset_all", reset_all_button) - reset_all = DataclassResetAllBinding.for_creation_type( - rt.creation_type - ).reset_callback(rt) - reset_all_button.clicked.connect(lambda: reset_all()) - rt.container.addResetAllTitleWidget(reset_all_button) - - def _add_regular_reset_button(self) -> None: - rt = self.runtime - reset_button = _create_optimized_reset_button( - rt.manager.config.field_id, - rt.param_info.name, - lambda: rt.manager.reset_parameter(rt.param_info.name), - ) - self._log_widget("reset_button", reset_button) - if isinstance(rt.container, ResponsiveParameterRow): - rt.container.set_reset_button(reset_button) - else: - rt.layout.addStretch() - rt.layout.addWidget(reset_button) - rt.manager.reset_buttons[rt.param_info.name] = reset_button - - def connect_optional_checkbox(self) -> None: - rt = self.runtime - if not (rt.config.needs_checkbox and rt.title_components): - return - nested_manager = rt.manager.nested_managers.get(rt.param_info.name) - if nested_manager: - rt.ops['connect_checkbox_logic']( - rt.manager, - rt.param_info, - rt.title_components.checkbox, - rt.main_widget, - nested_manager, - rt.title_components.title_label, - rt.title_components.help_btn, - rt.ctx.unwrapped_type, - ) - - def store_and_connect_widget(self) -> None: - from pyqt_reactive.forms.widget_strategies import PyQt6WidgetEnhancer - - rt = self.runtime - if rt.config.is_nested: - if rt.creation_type is WidgetCreationType.INLINE_DATACLASS: - rt.container.set_value_widget(rt.main_widget) - rt.manager.widgets[rt.param_info.name] = rt.container - PyQt6WidgetEnhancer.connect_change_signal( - rt.container, - rt.param_info.name, - self.on_widget_change, - ) - logger.debug( - "[CREATE_INLINE_DATACLASS] param_info.name=%s, stored inline container in manager.widgets", - rt.param_info.name, - ) - else: - rt.manager.widgets[rt.param_info.name] = rt.container - logger.debug("[CREATE_NESTED_DATACLASS] param_info.name=%s, stored container in manager.widgets", rt.param_info.name) - return - - rt.manager.widgets[rt.param_info.name] = rt.main_widget - PyQt6WidgetEnhancer.connect_change_signal(rt.main_widget, rt.param_info.name, self.on_widget_change) - if rt.manager.read_only: - WidgetService.make_readonly(rt.main_widget, rt.manager.config.color_scheme) - - def initialize_widget_semantics(self) -> None: - """Apply ObjectState dirty/default chrome once the widget is registered.""" - - rt = self.runtime - rt.manager.chrome_sync.update_label_styling(rt.param_info.name) + rt.main_widget = rt.config.create_main_widget(rt.ctx) + rt.log_widget("main_widget", rt.main_widget) def on_widget_change(self, pname, value) -> None: from objectstate import ObjectStateRegistry - manager = self.runtime.manager + manager = self.runtime.ctx.manager converted_value = manager._convert_widget_value(value, pname) event = FieldChangeEvent(pname, converted_value, manager) if manager.state and manager.state._parent_state is not None: @@ -1331,53 +1079,3 @@ def on_widget_change(self, pname, value) -> None: FieldChangeDispatcher.instance().dispatch(event) else: FieldChangeDispatcher.instance().dispatch(event) - - def _log_widget(self, stage: str, widget: QWidget | None) -> None: - rt = self.runtime - try: - parent_obj = None - widget_class = None - widget_name = None - widget_id = None - parent_class = None - if widget is not None: - parent_obj = widget.parent() - widget_class = type(widget).__name__ - widget_name = widget.objectName() - widget_id = id(widget) - if parent_obj is not None: - parent_class = type(parent_obj).__name__ - logger.debug( - "[WIDGET_CREATE] seq=%s stage=%s type=%s param=%s field_id=%s manager_seq=%s widget_cls=%s obj_name=%s id=%s parent_cls=%s", - rt.create_seq, - stage, - rt.creation_type.value, - rt.param_info.name, - rt.manager.config.field_id, - rt.manager._pfm_seq, - widget_class, - widget_name, - widget_id, - parent_class, - ) - except Exception: - logger.debug("[WIDGET_CREATE] seq=%s stage=%s param=%s log_failed", rt.create_seq, stage, rt.param_info.name) - - -# ============================================================================ -# VALIDATION -# ============================================================================ - -def _validate_widget_operations() -> None: - """Validate that all widget creation types have required operations.""" - for creation_type, config in _WIDGET_CREATION_CONFIG.items(): - if config.create_container is None: - raise RuntimeError(f"{creation_type.value}: create_container is required") - if config.create_main_widget is None: - raise RuntimeError(f"{creation_type.value}: create_main_widget is required") - - logger.debug(f"✅ Validated {len(_WIDGET_CREATION_CONFIG)} widget creation types") - - -# Run validation at module load time -_validate_widget_operations() diff --git a/src/pyqt_reactive/forms/widget_creation_types.py b/src/pyqt_reactive/forms/widget_creation_types.py index 22001ee..96230ac 100644 --- a/src/pyqt_reactive/forms/widget_creation_types.py +++ b/src/pyqt_reactive/forms/widget_creation_types.py @@ -12,12 +12,11 @@ """ from abc import ABC, abstractmethod, ABCMeta -from enum import Enum from typing import TypedDict, Callable, Optional, Any, Dict, Type from dataclasses import dataclass, replace as dataclass_replace # Import ParameterInfo ABC from shared UI module -from formbridge.forms.parameter_info_types import ParameterInfoBase as ParameterInfo +from formbridge.forms.parameter_info_types import ParameterInfo class DisplayInfo(TypedDict, total=False): @@ -33,12 +32,6 @@ class FieldIds(TypedDict, total=False): optional_checkbox_id: str -class LayoutKind(Enum): - """Nominal layout strategy for widget containers.""" - HORIZONTAL_ROW = "horizontal_row" - VERTICAL_BOX = "vertical_box" - PRECONFIGURED_GROUPBOX = "preconfigured_groupbox" - GROUPBOX_WITH_HELP = "groupbox_with_help" @dataclass(frozen=True) @@ -63,9 +56,6 @@ class WidgetBuildContext: container: Optional[Any] = None layout: Optional[Any] = None layout_config: Optional[Any] = None - qwidget_type: Optional[type] = None - groupbox_with_help_type: Optional[type] = None - color_scheme_type: Optional[type] = None def with_layout(self, layout: Any) -> 'WidgetBuildContext': return dataclass_replace(self, layout=layout) @@ -170,32 +160,3 @@ def _apply_to_nested_managers(self, callback: Callable[[str, Any], None]) -> Non Equivalent to: traverseComponentTree(callback) """ pass - - -# Type aliases for handler signatures -WidgetOperationHandler = Callable[[WidgetBuildContext], Any] - -OptionalTitleHandler = Callable[[WidgetBuildContext], OptionalTitleComponents] - -CheckboxLogicHandler = Callable[ - ['ParameterFormManager', 'ParameterInfo', Any, Any, Any, Any, Any, Type], - None -] - - -@dataclass -class WidgetCreationConfig: - """Type-safe configuration for a widget creation type.""" - layout_type: LayoutKind - is_nested: bool - create_container: WidgetOperationHandler - setup_layout: Optional[WidgetOperationHandler] - create_main_widget: WidgetOperationHandler - needs_label: bool - needs_reset_button: bool - needs_unwrap_type: bool - is_optional: bool = False - needs_checkbox: bool = False - is_enableable: bool = False - create_title_widget: Optional[OptionalTitleHandler] = None - connect_checkbox_logic: Optional[CheckboxLogicHandler] = None diff --git a/src/pyqt_reactive/services/enabled_field_styling_service.py b/src/pyqt_reactive/services/enabled_field_styling_service.py index 9b9cb4c..2685679 100644 --- a/src/pyqt_reactive/services/enabled_field_styling_service.py +++ b/src/pyqt_reactive/services/enabled_field_styling_service.py @@ -171,7 +171,7 @@ def on_enabled_field_changed(self, manager, param_name: str, value: Any) -> None if value is None: # Lazy field - get the resolved value from ObjectState # This ensures we get the actual resolved value, not the raw parameter - full_path = f"{manager.field_id}.enabled" if manager.field_id else "enabled" + full_path = manager._object_state_path_for_field(param_name).value resolved_value = manager.state.get_resolved_value(full_path) if resolved_value is None: # True is the default for enabled fields diff --git a/src/pyqt_reactive/services/field_change_dispatcher.py b/src/pyqt_reactive/services/field_change_dispatcher.py index c282cb3..dff52aa 100644 --- a/src/pyqt_reactive/services/field_change_dispatcher.py +++ b/src/pyqt_reactive/services/field_change_dispatcher.py @@ -11,7 +11,7 @@ from dataclasses import dataclass from typing import TYPE_CHECKING, Any - +from objectstate import DottedFieldPath from formbridge.flash_trace import flash_trace @@ -41,8 +41,17 @@ class FieldDispatchContext: event: FieldChangeEvent source: "ParameterFormManager" root: "ParameterFormManager" - source_path: str - root_path: str + field_path: DottedFieldPath + + @classmethod + def from_event(cls, event: FieldChangeEvent) -> "FieldDispatchContext": + source = event.source_manager + return cls( + event=event, + source=source, + root=source.form_tree.root(), + field_path=source._object_state_path_for_field(event.field_name), + ) class DispatchReentrancyGuard: @@ -87,40 +96,6 @@ def exit(self, source: "ParameterFormManager") -> None: source._dispatching = False -class FieldDispatchContextFactory: - """Builds the path context shared by dispatch stages.""" - - def build(self, event: FieldChangeEvent) -> FieldDispatchContext: - source = event.source_manager - return FieldDispatchContext( - event=event, - source=source, - root=self._get_root_manager(source), - source_path=self._field_path(source, event.field_name), - root_path=self._get_full_path(source, event.field_name), - ) - - def _get_root_manager(self, manager: "ParameterFormManager") -> "ParameterFormManager": - current = manager - while current._parent_manager is not None: - current = current._parent_manager - return current - - def _field_path(self, manager: "ParameterFormManager", field_name: str) -> str: - if manager.field_id: - return f"{manager.field_id}.{field_name}" - return field_name - - def _get_full_path(self, source: "ParameterFormManager", field_name: str) -> str: - parts = [field_name] - current = source - while current is not None: - if current.field_id: - parts.insert(0, current.field_id) - current = current._parent_manager - return ".".join(parts) - - class SourceStateUpdateStage: """Applies the edited value to ObjectState and syncs the source widget.""" @@ -133,13 +108,13 @@ def run(self, context: FieldDispatchContext) -> set[str]: context.source.before_mutation() if context.event.is_reset: changed_paths = context.source.state.update_parameter( - context.source_path, + context.field_path.value, context.event.value, ) else: with ObjectStateRegistry.defer_live_invalidations(): changed_paths = context.source.state.update_parameter( - context.source_path, + context.field_path.value, context.event.value, ) context.source.sync_after_model_field_change( @@ -150,7 +125,7 @@ def run(self, context: FieldDispatchContext) -> set[str]: reset_note = " (reset to None)" if context.event.is_reset else "" logger.info( " ✅ Updated state.parameters[%s]%s", - context.source_path, + context.field_path.value, reset_note, ) return changed_paths @@ -318,20 +293,20 @@ def run(self, context: FieldDispatchContext) -> None: logger.debug(" source.field_id=%s", context.source.field_id) logger.debug(" root.field_id=%s", context.root.field_id) logger.debug(" event.field_name=%s", context.event.field_name) - logger.debug(" full_path=%s", context.root_path) + logger.debug(" field_path=%s", context.field_path.value) logger.debug(" value type=%s", type(context.event.value).__name__) - context.root.parameter_changed.emit(context.root_path, context.event.value) + context.root.parameter_changed.emit(context.field_path.value, context.event.value) logger.debug( " ✅ Emitted parameter_changed(%s, ...) from root", - context.root_path, + context.field_path.value, ) self._emit_cross_window(context) def _emit_cross_window(self, context: FieldDispatchContext) -> None: root_manager = context.root - full_path = context.root_path + full_path = context.field_path.value logger.debug( f" 🔍 _emit_cross_window: checking should_skip_updates() for {root_manager.field_id}" ) @@ -362,7 +337,6 @@ class FieldChangeDispatcher: def __init__(self) -> None: self._guard = DispatchReentrancyGuard() - self._context_factory = FieldDispatchContextFactory() self._source_state_update = SourceStateUpdateStage() self._sibling_placeholder_refresh = SiblingPlaceholderRefreshStage() self._live_context_notification = LiveContextNotificationStage() @@ -394,13 +368,12 @@ def dispatch(self, event: FieldChangeEvent) -> None: repr(event.value)[:50], ) - context = self._context_factory.build(event) + context = FieldDispatchContext.from_event(event) flash_trace( "dispatch.context", source=context.source.field_id, root=context.root.field_id, - source_path=context.source_path, - root_path=context.root_path, + field_path=context.field_path.value, reset=context.event.is_reset, ) self._source_state_update.run(context) diff --git a/src/pyqt_reactive/services/parameter_ops_service.py b/src/pyqt_reactive/services/parameter_ops_service.py index e723219..12c9791 100644 --- a/src/pyqt_reactive/services/parameter_ops_service.py +++ b/src/pyqt_reactive/services/parameter_ops_service.py @@ -7,7 +7,7 @@ Key features: 1. Type-safe dispatch using ParameterInfo discriminated unions -2. Auto-discovery of handlers via ParameterServiceABC +2. Inheritance-aware dispatch through Python's singledispatchmethod 3. Placeholder resolution with live context from other windows 4. Consistent widget update + signal emission """ @@ -15,23 +15,17 @@ from __future__ import annotations import logging -from typing import TYPE_CHECKING +from functools import singledispatchmethod from formbridge.core.performance_monitor import get_monitor, timer -from formbridge.services.parameter_service_abc import ParameterServiceABC - -if TYPE_CHECKING: - from formbridge.forms.parameter_info_types import ( - DirectDataclassInfo, - GenericInfo, - InlineDataclassWidgetInfo, - OptionalDataclassInfo, - ) +from formbridge.forms.parameter_info_types import ( + DirectDataclassInfo, InlineDataclassWidgetInfo, OptionalDataclassInfo, ParameterInfo, +) logger = logging.getLogger(__name__) -class ParameterOpsService(ParameterServiceABC): +class ParameterOpsService: """ Consolidated service for parameter reset and placeholder refresh. @@ -50,7 +44,6 @@ class ParameterOpsService(ParameterServiceABC): def __init__(self): """Initialize with widget operations dependency.""" - super().__init__() from pyqt_reactive.forms.widget_operations import WidgetOperations self.widget_ops = WidgetOperations @@ -72,10 +65,6 @@ def _get_effective_context_obj(manager): return None - def _get_handler_prefix(self) -> str: - """Return handler method prefix for auto-discovery.""" - return '_reset_' - # ========== PARAMETER RESET (from ParameterResetService) ========== def reset_parameter(self, manager, param_name: str) -> None: @@ -84,7 +73,7 @@ def reset_parameter(self, manager, param_name: str) -> None: if info is None: # Parameter not in form structure (e.g., enabled rendered in header) # Fall back to direct state reset - dotted_path = f'{manager.field_id}.{param_name}' if manager.field_id else param_name + dotted_path = manager._object_state_path_for_field(param_name).value manager.state.reset_parameter(dotted_path) reset_value = manager.state.parameters.get(dotted_path) @@ -105,13 +94,18 @@ def reset_parameter(self, manager, param_name: str) -> None: event = FieldChangeEvent(param_name, reset_value, manager, is_reset=True) FieldChangeDispatcher.instance().dispatch(event) return - self.dispatch(info, manager) + self._reset(info, manager) + + @singledispatchmethod + def _reset(self, info: ParameterInfo, manager) -> None: + """An ordinary value widget resets through its canonical field address.""" + self._reset_value_widget_parameter(info.name, manager) - def _reset_OptionalDataclassInfo(self, info: OptionalDataclassInfo, manager) -> None: + @_reset.register(OptionalDataclassInfo) + def _reset_optional(self, info: OptionalDataclassInfo, manager) -> None: """Reset Optional[Dataclass] field - sync checkbox and reset nested manager.""" param_name = info.name - # CRITICAL: Compute full dotted path for nested PFMs - dotted_path = f'{manager.field_id}.{param_name}' if manager.field_id else param_name + dotted_path = manager._object_state_path_for_field(param_name).value # MODEL mutation through ObjectState (handles tracking) manager.state.reset_parameter(dotted_path) reset_value = manager.state.parameters.get(dotted_path) @@ -134,7 +128,8 @@ def _reset_OptionalDataclassInfo(self, info: OptionalDataclassInfo, manager) -> if nested_manager: nested_manager.reset_all_parameters() - def _reset_DirectDataclassInfo(self, info: DirectDataclassInfo, manager) -> None: + @_reset.register(DirectDataclassInfo) + def _reset_dataclass(self, info: DirectDataclassInfo, manager) -> None: """Reset direct Dataclass field - reset nested manager only. NOTE: We do NOT call update_widget_value on the container widget here. @@ -147,14 +142,8 @@ def _reset_DirectDataclassInfo(self, info: DirectDataclassInfo, manager) -> None if nested_manager: nested_manager.reset_all_parameters() - def _reset_GenericInfo(self, info: GenericInfo, manager) -> None: - """Reset generic field to signature default. - - MODEL mutation through ObjectState, then VIEW-only widget updates. - """ - self._reset_value_widget_parameter(info.name, manager) - - def _reset_InlineDataclassWidgetInfo( + @_reset.register(InlineDataclassWidgetInfo) + def _reset_inline( self, info: InlineDataclassWidgetInfo, manager, @@ -164,7 +153,7 @@ def _reset_InlineDataclassWidgetInfo( def _reset_value_widget_parameter(self, param_name: str, manager) -> None: """Reset a parameter whose form widget owns its value directly.""" - dotted_path = f'{manager.field_id}.{param_name}' if manager.field_id else param_name + dotted_path = manager._object_state_path_for_field(param_name).value manager.state.reset_parameter(dotted_path) reset_value = manager.state.parameters.get(dotted_path) @@ -315,7 +304,7 @@ def refresh_all_placeholders(self, manager) -> None: if not PyQt6WidgetEnhancer.supports_placeholder(widget): continue - full_path = f"{manager.field_id}.{param_name}" if manager.field_id else param_name + full_path = manager._object_state_path_for_field(param_name).value if manager.state.parameters.get(full_path) is None: with monitor.measure(): manager._widget_service.show_resolved_value_as_placeholder( diff --git a/src/pyqt_reactive/services/value_collection_service.py b/src/pyqt_reactive/services/value_collection_service.py index 307d8b5..70b77c1 100644 --- a/src/pyqt_reactive/services/value_collection_service.py +++ b/src/pyqt_reactive/services/value_collection_service.py @@ -7,28 +7,23 @@ Key features: 1. Type-safe dispatch using ParameterInfo discriminated unions -2. Auto-discovery of handlers via ParameterServiceABC +2. Inheritance-aware dispatch through Python's singledispatchmethod 3. Auto-unpacking of dataclass fields """ from __future__ import annotations -from typing import Any, Optional, Dict, TYPE_CHECKING +from typing import Any, Optional, Dict +from functools import singledispatchmethod from dataclasses import fields as dataclass_fields import logging -from formbridge.services.parameter_service_abc import ParameterServiceABC +from formbridge.forms.parameter_info_types import DirectDataclassInfo, OptionalDataclassInfo, ParameterInfo from .widget_service import WidgetService -if TYPE_CHECKING: - from .parameter_info_types import ( - OptionalDataclassInfo, - DirectDataclassInfo, - ) - logger = logging.getLogger(__name__) -class ValueCollectionService(ParameterServiceABC): +class ValueCollectionService: """ Consolidated service for value collection and unpacking. @@ -42,10 +37,6 @@ class ValueCollectionService(ParameterServiceABC): service.unpack_to_self(target, source, prefix="config_") """ - def _get_handler_prefix(self) -> str: - """Return handler method prefix for auto-discovery.""" - return '_collect_' - # ========== NESTED VALUE COLLECTION (from NestedValueCollectionService) ========== def collect_nested_value( @@ -61,16 +52,21 @@ def collect_nested_value( the appropriate handler based on its type. """ info = manager.form_structure.get_parameter_info(param_name) - return self.dispatch(info, manager, nested_manager) + return self._collect(info, manager, nested_manager) + + @singledispatchmethod + def _collect(self, info: ParameterInfo, manager, nested_manager) -> Dict[str, Any]: + return nested_manager.state.get_current_values() - def _collect_OptionalDataclassInfo( + @_collect.register(OptionalDataclassInfo) + def _collect_optional( self, info: 'OptionalDataclassInfo', manager, nested_manager ) -> Optional[Any]: """Collect value for Optional[Dataclass] parameter.""" - from .parameter_type_utils import ParameterTypeUtils + from formbridge.forms.parameter_type_utils import ParameterTypeUtils param_name = info.name param_type = info.type @@ -93,7 +89,8 @@ def _collect_OptionalDataclassInfo( inner_type = ParameterTypeUtils.get_optional_inner_type(param_type) return inner_type(**nested_values) - def _collect_DirectDataclassInfo( + @_collect.register(DirectDataclassInfo) + def _collect_dataclass( self, info: 'DirectDataclassInfo', manager, @@ -113,12 +110,6 @@ def _collect_DirectDataclassInfo( return param_type(**nested_values) - def _collect_GenericInfo(self, info, manager, nested_manager) -> Dict[str, Any]: - """Collect value as raw dict (fallback for non-dataclass types).""" - # Uses all 3 params - info/manager kept for interface consistency with other _collect_* methods - _ = info, manager # Silence unused warnings - interface requires these params - return nested_manager.state.get_current_values() - # ========== DATACLASS RECONSTRUCTION (from DataclassReconstructionUtils) ========== @staticmethod diff --git a/src/pyqt_reactive/services/widget_service.py b/src/pyqt_reactive/services/widget_service.py index 94aa0ca..a6c712a 100644 --- a/src/pyqt_reactive/services/widget_service.py +++ b/src/pyqt_reactive/services/widget_service.py @@ -133,7 +133,7 @@ def get_parameter_display_info(param_name: str, param_type: Type, resolved_description = param_info.description if resolved_description is None: - dotted = f"{manager.field_id}.{param_name}" if manager.field_id else param_name + dotted = manager._object_state_path_for_field(param_name).value raw = manager.state.parameter_descriptions.get(dotted) if raw is not None: resolved_description = raw if isinstance(raw, str) else raw.description @@ -317,7 +317,7 @@ def show_resolved_value_as_placeholder(self, widget: QWidget, param_name: str, m """ from objectstate import LazyDefaultPlaceholderService - full_path = f"{manager.field_id}.{param_name}" if manager.field_id else param_name + full_path = manager._object_state_path_for_field(param_name).value resolved_value = manager.state.get_resolved_value(full_path) placeholder_text = LazyDefaultPlaceholderService._format_placeholder_text( resolved_value, manager.config.placeholder_prefix diff --git a/src/pyqt_reactive/services/window_navigation.py b/src/pyqt_reactive/services/window_navigation.py index 9df1c67..f6e1fc5 100644 --- a/src/pyqt_reactive/services/window_navigation.py +++ b/src/pyqt_reactive/services/window_navigation.py @@ -8,7 +8,7 @@ from enum import Enum from typing import TYPE_CHECKING, TypeVar -from objectstate import ObjectState +from objectstate import DottedFieldPath, ObjectState from PyQt6.QtWidgets import QWidget if TYPE_CHECKING: @@ -354,7 +354,7 @@ def readiness( return RegisteredWindowNavigationReadiness( wait_reason=NavigationWaitReason.ROOT_WIDGETS, ) - if "." in request.field_path and not self._nested_manager_exists( + if DottedFieldPath(request.field_path).parent.value and not self._nested_manager_exists( form_manager, request.field_path, ): @@ -379,9 +379,7 @@ def _nested_manager_exists( field_path: str, ) -> bool: current_manager = form_manager - path_parts = field_path.split(".") - - for part in path_parts[:-1]: + for part in DottedFieldPath(field_path).parent.parts: if part not in current_manager.nested_managers: return FormFieldWindowNavigationDriver._is_inline_dataclass_field( current_manager, diff --git a/src/pyqt_reactive/widgets/function_pane.py b/src/pyqt_reactive/widgets/function_pane.py index cecd981..451bf4a 100644 --- a/src/pyqt_reactive/widgets/function_pane.py +++ b/src/pyqt_reactive/widgets/function_pane.py @@ -369,7 +369,7 @@ def flash_title_mask_widgets(self) -> tuple[QWidget, ...]: def _move_enabled_widget_to_title(self): """Move enabled widget using shared groupbox machinery.""" - from pyqt_reactive.forms.widget_creation_config import WidgetCreationHandlers + from pyqt_reactive.forms.widget_creation_config import EnabledTitleWidgetMoveAuthority, EnabledTitleWidgetMoveRequest if self._enabled_widget_moved or not self.form_manager: return @@ -381,12 +381,12 @@ def _move_enabled_widget_to_title(self): return self._enabled_widget_moved = True - WidgetCreationHandlers._move_enabled_widget_to_title( - self.form_manager, - self, - self.form_manager.field_id, - enabled_field, - ) + EnabledTitleWidgetMoveAuthority().move(EnabledTitleWidgetMoveRequest( + nested_manager=self.form_manager, + parent_manager=self, + nested_param_name=self.form_manager.field_id, + enabled_field=enabled_field, + )) logger.debug(f"Moved enabled widget to title for function {self.func.__name__}") def set_scope_color_scheme(self, scheme) -> None: diff --git a/src/pyqt_reactive/widgets/shared/clickable_help_components.py b/src/pyqt_reactive/widgets/shared/clickable_help_components.py index a2c2d33..0c905c7 100644 --- a/src/pyqt_reactive/widgets/shared/clickable_help_components.py +++ b/src/pyqt_reactive/widgets/shared/clickable_help_components.py @@ -6,7 +6,7 @@ from dataclasses import dataclass from typing import Optional, Union -from objectstate import ObjectState, ObjectStateRegistry +from objectstate import DottedFieldPath, ObjectState, ObjectStateRegistry from PyQt6.QtCore import QRect, QRectF, QSize, Qt, QTimer, pyqtSignal from PyQt6.QtGui import QColor, QCursor, QFont, QPainter from PyQt6.QtWidgets import ( @@ -552,7 +552,7 @@ def _navigate_to_source(self, source_scope_id: str | None, source_type: type) -> source_type: The TYPE that has the concrete value (may differ from local container type due to MRO inheritance) """ - field_name = self._dotted_path.rsplit(".", 1)[-1] + field_name = DottedFieldPath(self._dotted_path).field_name # Another config of this same object supplies the value: the source state is # this state, whether or not it is registered under a scope id. diff --git a/src/pyqt_reactive/widgets/shared/config_action_tabs.py b/src/pyqt_reactive/widgets/shared/config_action_tabs.py index 46d57dd..dc2af3d 100644 --- a/src/pyqt_reactive/widgets/shared/config_action_tabs.py +++ b/src/pyqt_reactive/widgets/shared/config_action_tabs.py @@ -5,6 +5,7 @@ from collections.abc import Sequence from dataclasses import dataclass +from objectstate import DottedFieldPath from PyQt6.QtCore import pyqtSignal from PyQt6.QtWidgets import QPushButton, QWidget from python_introspect import Enableable @@ -30,7 +31,7 @@ class ConfigAction: @property def enabled_path(self) -> str: """The nested config's ``Enableable`` field, which switches the action on.""" - return f"{self.field_name}.{Enableable.require_parameter_name()}" + return DottedFieldPath(self.field_name).child(Enableable.require_parameter_name()).value class ConfigActionTabs(TabbedFormWidget): diff --git a/src/pyqt_reactive/widgets/shared/config_hierarchy_tree.py b/src/pyqt_reactive/widgets/shared/config_hierarchy_tree.py index 6772153..252709b 100644 --- a/src/pyqt_reactive/widgets/shared/config_hierarchy_tree.py +++ b/src/pyqt_reactive/widgets/shared/config_hierarchy_tree.py @@ -22,7 +22,7 @@ from metaclass_registry import AutoRegisterMeta from objectstate.lazy_factory import get_base_type_for_lazy, is_lazy_dataclass -from objectstate import is_ui_hidden_config_type +from objectstate import DottedFieldPath, is_ui_hidden_config_type from pyqt_reactive.widgets.shared.config_tree_contracts import ( ConfigTreeFlashManager, ScopeColorSchemeHost, @@ -526,9 +526,9 @@ def _compute_prefixes(self, fields: set) -> set: """Compute field paths and all their ancestor prefixes.""" prefixes = set() for field_path in fields: - parts = field_path.split('.') - for i in range(1, len(parts) + 1): - prefixes.add('.'.join(parts[:i])) + path = DottedFieldPath(field_path) + prefixes.add(path.value) + prefixes.update(ancestor.value for ancestor in path.parent.lineage) return prefixes def _matches_prefix(self, path: str, field_name: str, alt_name: str, prefixes: set) -> bool: @@ -632,7 +632,7 @@ def _add_ui_visible_dataclasses_to_tree( continue label = display_name if is_root else f"{field.name} ({display_name})" - path = field.name if not parent_path else f"{parent_path}.{field.name}" + path = DottedFieldPath(parent_path).child(field.name).value alt_name = self._to_snake_case(base_type.__name__) alt_path = alt_name if not parent_path else f"{parent_path}.{alt_name}" diff --git a/src/pyqt_reactive/widgets/shared/manager_item_display_builder.py b/src/pyqt_reactive/widgets/shared/manager_item_display_builder.py index c7f7a7b..bb470c7 100644 --- a/src/pyqt_reactive/widgets/shared/manager_item_display_builder.py +++ b/src/pyqt_reactive/widgets/shared/manager_item_display_builder.py @@ -203,7 +203,7 @@ def add_field_path(field_path: str) -> None: is_enabled = True if is_enableable(config_type): enabled_field = Enableable.require_parameter_name() - enabled_path = f"{path}.{enabled_field}" if path else enabled_field + enabled_path = DottedFieldPath(path).child(enabled_field).value resolved_enabled = state.get_resolved_value(enabled_path) is_enabled = resolved_enabled is True if is_enabled: @@ -227,7 +227,7 @@ def add_field_path(field_path: str) -> None: continue for field_name in registered_fields: - full_path = f"{path}.{field_name}" if path else field_name + full_path = DottedFieldPath(path).child(field_name).value add_field_path(full_path) logger.debug( "PREVIEW: Added always_viewable field %s for %s", diff --git a/src/pyqt_reactive/widgets/shared/scrollable_form_mixin.py b/src/pyqt_reactive/widgets/shared/scrollable_form_mixin.py index 0378df5..623f8a8 100644 --- a/src/pyqt_reactive/widgets/shared/scrollable_form_mixin.py +++ b/src/pyqt_reactive/widgets/shared/scrollable_form_mixin.py @@ -8,6 +8,7 @@ from dataclasses import dataclass from typing import TYPE_CHECKING, Any, Optional +from objectstate import DottedFieldPath from PyQt6.QtWidgets import QScrollArea, QWidget from pyqt_reactive.services.window_navigation import ( @@ -221,18 +222,19 @@ def _resolve_scroll_target( warn_missing: bool = True, ) -> Optional[ScrollTarget]: """Resolve a dotted form path to a concrete widget and section path.""" - parts = field_name.split('.') + path = DottedFieldPath(field_name) + if not path.parts: + return None current_manager = self.form_manager - section_parts = [] + section_path = DottedFieldPath("") - for i, part in enumerate(parts[:-1]): + for i, part in enumerate(path.parent.parts): if part not in current_manager.nested_managers: inline_target = self._resolve_inline_dataclass_child_target( - field_name=field_name, + field_path=path, current_manager=current_manager, - section_parts=section_parts, + section_path=section_path, inline_field_name=part, - child_field_name=parts[i + 1], ) if inline_target is not None: return inline_target @@ -240,27 +242,25 @@ def _resolve_scroll_target( logger.warning(f"❌ Part '{part}' not in nested_managers at depth {i}") return None current_manager = current_manager.nested_managers[part] - section_parts.append(part) + section_path = section_path.child(part) - leaf_name = parts[-1] - section_path = ".".join(section_parts) + leaf_name = path.field_name groupbox_widget = None target_widget = None if leaf_name in current_manager.nested_managers: nested_manager = current_manager.nested_managers[leaf_name] - section_parts.append(leaf_name) - section_path = ".".join(section_parts) - groupbox_widget = self.form_manager.form_tree.groupbox_for_prefix(section_path) + section_path = section_path.child(leaf_name) + groupbox_widget = self.form_manager.form_tree.groupbox_for_prefix(section_path.value) if nested_manager.widgets: first_param_name = next(iter(nested_manager.widgets.keys())) target_widget = nested_manager.widgets[first_param_name] elif leaf_name in current_manager.widgets: target_widget = current_manager.widgets[leaf_name] inline_owner_target = self._resolve_inline_dataclass_owner_target( - field_name=field_name, + field_path=path, current_manager=current_manager, - section_parts=section_parts, + section_path=section_path, inline_field_name=leaf_name, ) if inline_owner_target is not None: @@ -279,7 +279,7 @@ def _resolve_scroll_target( return ScrollTarget( field_name=field_name, leaf_name=leaf_name, - section_path=section_path, + section_path=section_path.value, target_widget=target_widget, groupbox_widget=groupbox_widget, current_manager=current_manager, @@ -289,9 +289,9 @@ def _resolve_scroll_target( def _resolve_inline_dataclass_owner_target( self, *, - field_name: str, + field_path: DottedFieldPath, current_manager: Any, - section_parts: list[str], + section_path: DottedFieldPath, inline_field_name: str, ) -> Optional[ScrollTarget]: """Resolve an inline dataclass owner path to its structural container.""" @@ -303,20 +303,20 @@ def _resolve_inline_dataclass_owner_target( if not isinstance(inline_widget, QWidget): return None - inline_prefix = tuple(section_parts + [inline_field_name]) + inline_field_path = section_path.child(inline_field_name) structural_result = resolve_inline_dataclass_structural_target( inline_widget=inline_widget, - inline_field_path=inline_prefix, - display_path=field_name, + inline_field_path=inline_field_path, + display_path=field_path.value, ) if structural_result is None: return None structural_target = structural_result.target return ScrollTarget( - field_name=field_name, + field_name=field_path.value, leaf_name=structural_result.child_field_name, - section_path=".".join(inline_prefix), + section_path=inline_field_path.value, target_widget=structural_target.scroll_widget(), groupbox_widget=None, current_manager=current_manager, @@ -327,11 +327,10 @@ def _resolve_inline_dataclass_owner_target( def _resolve_inline_dataclass_child_target( self, *, - field_name: str, + field_path: DottedFieldPath, current_manager: Any, - section_parts: list[str], + section_path: DottedFieldPath, inline_field_name: str, - child_field_name: str, ) -> Optional[ScrollTarget]: """Resolve a child field rendered inside an inline dataclass widget.""" from pyqt_reactive.protocols import ( @@ -348,19 +347,18 @@ def _resolve_inline_dataclass_child_target( if not isinstance(inline_widget, InlineDataclassGroupBox): return None - inline_prefix = tuple(section_parts + [inline_field_name]) + inline_field_path = section_path.child(inline_field_name) structural_result = resolve_inline_dataclass_structural_target( inline_widget=inline_widget, - inline_field_path=inline_prefix, - display_path=field_name, - owner_child_field_name=child_field_name, + inline_field_path=inline_field_path, + display_path=field_path.value, ) if structural_result is not None: structural_target = structural_result.target return ScrollTarget( - field_name=field_name, + field_name=field_path.value, leaf_name=structural_result.child_field_name, - section_path=".".join(section_parts + [inline_field_name]), + section_path=inline_field_path.value, target_widget=structural_target.scroll_widget(), groupbox_widget=None, current_manager=current_manager, @@ -368,6 +366,9 @@ def _resolve_inline_dataclass_child_target( structural_flash_target=structural_target, ) + child_field_name = inline_field_path.direct_child_name(field_path) + if child_field_name is None: + return None target_widget: QWidget if isinstance(inline_widget, ChildFieldNavigationTargetProvider): child_widget = inline_widget.child_field_navigation_target(child_field_name) @@ -377,11 +378,10 @@ def _resolve_inline_dataclass_child_target( else: target_widget = inline_widget - section_path = ".".join(section_parts + [inline_field_name]) return ScrollTarget( - field_name=field_name, + field_name=field_path.value, leaf_name=child_field_name, - section_path=section_path, + section_path=inline_field_path.value, target_widget=target_widget, groupbox_widget=None, current_manager=current_manager, @@ -390,14 +390,9 @@ def _resolve_inline_dataclass_child_target( def _resolve_nearest_ancestor_scroll_target(self, field_name: str) -> Optional[ScrollTarget]: """Resolve the nearest visible ancestor for a missing deep field path.""" - parts = tuple(part for part in field_name.split(".") if part) - if len(parts) <= 1: - return None - - for prefix_length in range(len(parts) - 1, 0, -1): - ancestor_path = ".".join(parts[:prefix_length]) + for ancestor_path in DottedFieldPath(field_name).parent.lineage: target = self._resolve_scroll_target( - ancestor_path, + ancestor_path.value, warn_missing=False, ) if target is not None: diff --git a/src/pyqt_reactive/widgets/structural_table.py b/src/pyqt_reactive/widgets/structural_table.py index 4e5f3ea..3fa4977 100644 --- a/src/pyqt_reactive/widgets/structural_table.py +++ b/src/pyqt_reactive/widgets/structural_table.py @@ -9,6 +9,7 @@ from PyQt6.QtGui import QPainterPath from PyQt6.QtWidgets import QWidget, QTableWidget from objectstate import ( + DottedFieldPath, ObjectStateSubfieldSemantic, ObjectStateSubfieldSemanticIndex, StructuralFieldPath, @@ -273,9 +274,8 @@ class InlineDataclassStructuralTarget: def resolve_inline_dataclass_structural_target( *, inline_widget: QWidget | None, - inline_field_path: tuple[str, ...], + inline_field_path: DottedFieldPath, display_path: str, - owner_child_field_name: str | None = None, ) -> InlineDataclassStructuralTarget | None: """Resolve an inline dataclass structural visual target through widget contracts.""" @@ -290,35 +290,31 @@ def resolve_inline_dataclass_structural_target( ): return None - if tuple(display_path.split(".")) == inline_field_path: + if inline_field_path.value and display_path == inline_field_path.value: return InlineDataclassStructuralTarget( - child_field_name=inline_field_path[-1], + child_field_name=inline_field_path.field_name, target=StructuralDescendantMaskTarget(inline_widget), ) structural_path = StructuralFieldPath.from_display_path(display_path) + child_field_name = inline_field_path.direct_child_name( + structural_path.owner_field_path if structural_path is not None else display_path + ) + if child_field_name is None: + return None + + child_identity = inline_widget.child_field_identity(child_field_name) if structural_path is not None: - owner_parts = structural_path.owner_field_path.parts - if ( - len(owner_parts) > len(inline_field_path) - and owner_parts[: len(inline_field_path)] == inline_field_path - ): - child_field_name = owner_parts[len(inline_field_path)] - child_identity = inline_widget.child_field_identity(child_field_name) - target = inline_widget.child_subfield_navigation_target( - child_identity, - structural_path.relative_path, + target = inline_widget.child_subfield_navigation_target( + child_identity, + structural_path.relative_path, + ) + if target is not None: + return InlineDataclassStructuralTarget( + child_field_name=child_field_name, + target=target, ) - if target is not None: - return InlineDataclassStructuralTarget( - child_field_name=child_field_name, - target=target, - ) - - if owner_child_field_name is None: - return None - child_identity = inline_widget.child_field_identity(owner_child_field_name) target = inline_widget.child_subfield_navigation_target( child_identity, StructuralValuePath(), @@ -326,7 +322,7 @@ def resolve_inline_dataclass_structural_target( if target is None: return None return InlineDataclassStructuralTarget( - child_field_name=owner_child_field_name, + child_field_name=child_field_name, target=target, ) diff --git a/tests/test_field_mutation_authorization.py b/tests/test_field_mutation_authorization.py index 3a68362..cb72f2b 100644 --- a/tests/test_field_mutation_authorization.py +++ b/tests/test_field_mutation_authorization.py @@ -3,6 +3,7 @@ from types import SimpleNamespace import pytest +from objectstate import DottedFieldPath from pyqt_reactive.services.field_change_dispatcher import ( FieldChangeEvent, @@ -36,8 +37,7 @@ def test_field_mutation_authorization_precedes_object_state_write() -> None: event=event, source=source, root=source, - source_path="threshold", - root_path="threshold", + field_path=DottedFieldPath("threshold"), ) with pytest.raises(RuntimeError, match="mutation rejected"): diff --git a/tests/test_inline_dataclass_chrome.py b/tests/test_inline_dataclass_chrome.py index e76f6a5..b134305 100644 --- a/tests/test_inline_dataclass_chrome.py +++ b/tests/test_inline_dataclass_chrome.py @@ -2,7 +2,9 @@ from __future__ import annotations -from dataclasses import dataclass, field +from dataclasses import dataclass, field, replace + +import pytest from PyQt6.QtCore import pyqtSignal from PyQt6.QtWidgets import QWidget @@ -91,7 +93,8 @@ def _inline_param_info(): def test_inline_dataclass_context_uses_typed_child_paths_and_resets(qapp) -> None: - from objectstate import ObjectStateRegistry + from objectstate import DottedFieldPath, ObjectStateRegistry + from pyqt_reactive.forms.inline_dataclass_chrome import InlineDataclassChildChrome from pyqt_reactive.forms.inline_dataclass_context import ( InlineDataclassFormContext, ) @@ -109,6 +112,18 @@ def test_inline_dataclass_context_uses_typed_child_paths_and_resets(qapp) -> Non assert identity.object_state_path.value == "config.source_filters" assert identity.manager_path.value == "config.source_filters" assert identity.owner_type is InlineChildConfig + assert identity.field_name == "source_filters" + chrome = InlineDataclassChildChrome(context) + assert chrome.field_names_under(tuple(map(DottedFieldPath, ( + "config.enabled", "config.source_filters", "config.enabled", + "config", "", "config.source_filters.value", "other.enabled", + )))) == ("enabled", "source_filters") + root_chrome = InlineDataclassChildChrome(replace(context, owner_path=DottedFieldPath(""))) + assert root_chrome.field_names_under(tuple(map(DottedFieldPath, ( + "", "config", "config.enabled", "enabled", "config", + )))) == ("config", "enabled") + with pytest.raises(ValueError, match="cannot be root"): + _ = replace(identity, object_state_path=DottedFieldPath("")).field_name manager.state.update_parameter( "config", diff --git a/tests/test_parameter_form_tree_index.py b/tests/test_parameter_form_tree_index.py index cd1c27e..97517e7 100644 --- a/tests/test_parameter_form_tree_index.py +++ b/tests/test_parameter_form_tree_index.py @@ -118,28 +118,17 @@ def test_child_managers_for_paths_routes_only_direct_owners() -> None: } -def test_direct_child_field_for_path_uses_structural_objectstate_paths() -> None: - """List element changes route to the owning dataclass field.""" - assert ( - ParameterFormTreeIndex.direct_child_field_for_path( - "processing_config", - "processing_config.variable_components[1]", - ) - == "variable_components" - ) - assert ( - ParameterFormTreeIndex.direct_child_field_for_path( - "", - "processing_config.variable_components[1]", - ) - == "processing_config" +def test_child_managers_route_structural_paths_at_root_and_nested_scope() -> None: + """List element changes route through the same path owner at either scope.""" + tree, source_bindings, source_filters, _ = _manager_tree() + root = source_bindings._parent_manager + path = "source_bindings.source_filters[0].match_type" + + assert tree.child_managers_for_paths(root, {path, "", "unrelated.value"}) == ( + (source_bindings, {path}), ) - assert ( - ParameterFormTreeIndex.direct_child_field_for_path( - "", - "source_bindings.source_filters[0].match_type", - ) - == "source_bindings" + assert tree.child_managers_for_paths(source_bindings, {path}) == ( + (source_filters, {path}), ) diff --git a/tests/test_sectioned_dataclass_editor.py b/tests/test_sectioned_dataclass_editor.py index 485d54d..ebd6759 100644 --- a/tests/test_sectioned_dataclass_editor.py +++ b/tests/test_sectioned_dataclass_editor.py @@ -51,6 +51,21 @@ class Drawing: layers: tuple[str, ...] | None = None +@dataclass +class DrawingBase: + pass + + +@dataclass(frozen=True) +class DrawingHolder: + config: Drawing + + +@dataclass(frozen=True) +class DrawingRoot: + outer: DrawingHolder + + MARKS = TupleFieldSection( title="Marks", field_name="marks", @@ -100,6 +115,100 @@ def editor(qapp): qapp.sendPostedEvents(None, QEvent.Type.DeferredDelete) +@pytest.fixture +def form_editor(qapp, owner): + from objectstate import DottedFieldPath, ObjectState, ObjectStateRegistry, set_base_config_type + from pyqt_reactive.forms.inline_dataclass_context import InlineDataclassFormContext + from pyqt_reactive.forms.parameter_form_manager import FormManagerConfig, ParameterFormManager + from pyqt_reactive.theming import ColorScheme + + value = Drawing(marks=(Mark(Circle, "a"),)) + source = { + "": value, + "config": DrawingHolder(value), + "outer.config": DrawingRoot(DrawingHolder(value)), + }[owner] + set_base_config_type(DrawingBase) + state = ObjectState(source, scope_id="sectioned-structural-target") + ObjectStateRegistry.register(state, _skip_snapshot=True) + path = DottedFieldPath(owner) + scheme = ColorScheme() + manager = ParameterFormManager( + state=state, + config=FormManagerConfig( + field_id=path.parent.value, + exclude_params=["config", "marks", "notes", "layers"], + color_scheme=scheme, + ), + ) + context = InlineDataclassFormContext( + state=state, manager=manager, owner_path=path, + local_owner_path=DottedFieldPath(path.field_name), owner_type=Drawing, + color_scheme=scheme, scope_accent_color=None, + ) + widget = DrawingEditor( + RawDisplayValue.shown_as_edited(Drawing, value), form_context=context, + ) + try: + yield widget + finally: + widget.deleteLater() + manager.deleteLater() + qapp.sendPostedEvents(None, QEvent.Type.DeferredDelete) + ObjectStateRegistry.clear() + + +@pytest.mark.parametrize( + ("owner", "display_path", "child"), + [ + ("config", "config.marks[0].label", "marks"), + ("outer.config", "outer.config.marks[0].label", "marks"), + ("", "marks[0].label", "marks"), + ("config", "other.marks[0].label", None), + ], +) +def test_structural_target_keeps_typed_owner_and_indexed_child(form_editor, owner, display_path, child): + from objectstate import DottedFieldPath + from pyqt_reactive.widgets.structural_table import ( + StructuralMaskedContainerTarget, + StructuralTableCellTarget, + resolve_inline_dataclass_structural_target, + ) + + result = resolve_inline_dataclass_structural_target( + inline_widget=form_editor, + inline_field_path=DottedFieldPath(owner), + display_path=display_path, + ) + if child is None: + assert result is None + else: + assert result.child_field_name == child + assert isinstance(result.target, StructuralMaskedContainerTarget) + cell = result.target.masked_target + assert isinstance(cell, StructuralTableCellTarget) + assert cell.table is form_editor.table("marks") + assert cell.row_index == 0 + assert cell.column_index == 1 + + +def test_structural_target_exact_owner_and_root_are_distinct(editor): + from objectstate import DottedFieldPath + from pyqt_reactive.widgets.structural_table import ( + StructuralDescendantMaskTarget, + resolve_inline_dataclass_structural_target, + ) + + result = resolve_inline_dataclass_structural_target( + inline_widget=editor, inline_field_path=DottedFieldPath("config"), display_path="config", + ) + assert result.child_field_name == "config" + assert isinstance(result.target, StructuralDescendantMaskTarget) + assert resolve_inline_dataclass_structural_target( + inline_widget=editor, inline_field_path=DottedFieldPath(""), display_path="", + ) is None + + def test_sections_are_built_from_declarations(editor) -> None: assert isinstance(editor.chrome, StandaloneChildChrome) assert isinstance(editor.title_checkbox, NoTitleCheckbox) diff --git a/tests/test_widget_projection_safety.py b/tests/test_widget_projection_safety.py index 7661579..222438a 100644 --- a/tests/test_widget_projection_safety.py +++ b/tests/test_widget_projection_safety.py @@ -381,7 +381,6 @@ class MappingConfig: widget.get_value(), dict[str, Mode], "modes", - MappingConfig, ) state.update_parameter("modes", converted) diff --git a/tests/test_widgets.py b/tests/test_widgets.py index 20c393f..9be8054 100644 --- a/tests/test_widgets.py +++ b/tests/test_widgets.py @@ -4,6 +4,8 @@ from dataclasses import dataclass from enum import Enum +import pytest + class QuotedAnnotationMode(Enum): A = "a" @@ -246,7 +248,9 @@ class Component(Enum): def test_placeholder_refresh_preserves_concrete_checkbox_group_default(qapp): """Concrete list defaults must not be repainted as inherited placeholders.""" from enum import Enum - from types import SimpleNamespace + from types import MethodType, SimpleNamespace + + from pyqt_reactive.forms.parameter_form_manager import ParameterFormManager from pyqt_reactive.forms.widget_strategies import create_pyqt6_widget from pyqt_reactive.services.parameter_ops_service import ParameterOpsService @@ -274,6 +278,9 @@ class Component(Enum): get_resolved_value=lambda path: [Component.SITE], ), ) + manager._object_state_path_for_field = MethodType( + ParameterFormManager._object_state_path_for_field, manager + ) service.refresh_all_placeholders(manager) @@ -3209,11 +3216,13 @@ def set_value(self, value): def test_parameter_reset_overwrites_invalid_typed_literal_editor(qapp): """Reset discards invalid transient text without weakening commit parsing.""" - from types import SimpleNamespace + from types import MethodType, SimpleNamespace + + from pyqt_reactive.forms.parameter_form_manager import ParameterFormManager import pytest - from formbridge.forms.parameter_info_types import GenericInfo + from formbridge.forms.parameter_info_types import ParameterInfo from pyqt_reactive.forms.widget_strategies import TypedLiteralContainerEdit from pyqt_reactive.services.parameter_ops_service import ParameterOpsService from pyqt_reactive.services.widget_service import WidgetService @@ -3235,7 +3244,7 @@ def reset_parameter(self, dotted_path): assert dotted_path == "percentiles" self.parameters[dotted_path] = reset_value - info = GenericInfo( + info = ParameterInfo( name="percentiles", type=tuple[int, ...], current_value=(1, 2, 3), @@ -3253,6 +3262,9 @@ def reset_parameter(self, dotted_path): _widget_service=widget_service, object_instance=None, ) + manager._object_state_path_for_field = MethodType( + ParameterFormManager._object_state_path_for_field, manager + ) ParameterOpsService().reset_parameter(manager, "percentiles") @@ -3599,24 +3611,78 @@ def _visual_repaint(self) -> None: assert widget.repaint_updates == 1 -def test_dispatch_context_skips_empty_root_field_id(): - """Root form managers use empty field ids and must not prefix dotted paths.""" - from types import SimpleNamespace - +@pytest.mark.parametrize("root_id", ("", "config")) +@pytest.mark.parametrize("is_reset", (False, True)) +def test_nested_dispatch_uses_one_state_address_for_mutation_and_signals(qapp, root_id, is_reset): + from dataclasses import dataclass, field + from objectstate import DottedFieldPath, ObjectState, ObjectStateRegistry, set_base_config_type + from pyqt_reactive.forms.parameter_form_manager import FormManagerConfig, ParameterFormManager from pyqt_reactive.services.field_change_dispatcher import ( - FieldChangeEvent, - FieldDispatchContextFactory, + FieldChangeDispatcher, FieldChangeEvent, FieldDispatchContext, ) + from pyqt_reactive.theming import ColorScheme - root = SimpleNamespace(field_id="", _parent_manager=None) - child = SimpleNamespace( - field_id="well_filter_config", - _parent_manager=root, - ) + @dataclass + class DispatchBase: + pass - context = FieldDispatchContextFactory().build( - FieldChangeEvent("well_filter", "A01", child) - ) + @dataclass + class DispatchChild: + value: int = 1 - assert context.source_path == "well_filter_config.well_filter" - assert context.root_path == "well_filter_config.well_filter" + @dataclass + class DispatchConfig: + child: DispatchChild = field(default_factory=DispatchChild) + + @dataclass + class DispatchRoot: + config: DispatchConfig = field(default_factory=DispatchConfig) + + set_base_config_type(DispatchBase) + state = ObjectState(DispatchRoot(), scope_id="dispatch-scope") + ObjectStateRegistry.register(state, _skip_snapshot=True) + path = "config.child.value" + callbacks = [] + + def before_mutation(): + callbacks.append(state.parameters[path]) + source.update_parameter("value", 99) + + root = ParameterFormManager( + state=state, + config=FormManagerConfig( + field_id=root_id, + scope_id="dispatch-scope", + color_scheme=ColorScheme(), + use_scroll_area=False, + before_mutation=before_mutation, + ), + ) + try: + parent = root.nested_managers["config"] if not root_id else root + source = parent.nested_managers["child"] + changed, contexts = [], [] + root.parameter_changed.connect(lambda name, value: changed.append((name, value))) + root.context_changed.connect(lambda scope, name: contexts.append((scope, name))) + event = FieldChangeEvent("value", 7, source, is_reset=is_reset) + context = FieldDispatchContext.from_event(event) + assert context.field_path == DottedFieldPath(path) + assert context.source is source + assert context.root is root + source._in_reset = is_reset + FieldChangeDispatcher.instance().dispatch(event) + assert state.parameters[path] == 7 + assert "config.config.child.value" not in state.parameters + assert changed == [(path, 7)] + assert contexts == [("dispatch-scope", path)] + assert callbacks == [1] + assert source._dispatching is False + if not is_reset: + source.reset_parameter("value") + assert state.parameters[path] == 1 + assert changed == [(path, 7), (path, 1)] + assert contexts == [("dispatch-scope", path), ("dispatch-scope", path)] + assert source._dispatching is False + finally: + root.deleteLater() + ObjectStateRegistry.clear()