From 6bee865edad5690080d8d61ef830949ad8c5f113 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 16 Oct 2026 01:28:21 -0400 Subject: [PATCH 1/7] WIP: migrate declared field states and remove constructor patch scopes --- .../forms/widget_creation_config.py | 16 +- src/pyqt_reactive/forms/widget_operations.py | 2 +- src/pyqt_reactive/forms/widget_strategies.py | 68 ++-- .../protocols/widget_adapters.py | 330 ++++++++---------- .../services/parameter_ops_service.py | 5 +- src/pyqt_reactive/services/widget_service.py | 5 +- src/pyqt_reactive/widgets/__init__.py | 4 +- .../widgets/function_list_editor.py | 5 - src/pyqt_reactive/widgets/function_pane.py | 4 +- .../widgets/no_scroll_spinbox.py | 100 ++---- .../widgets/sectioned_dataclass_editor.py | 6 +- .../widgets/shared/abstract_manager_widget.py | 3 +- .../widgets/shared/checkbox_group_widget.py | 6 +- .../shared/manager_action_controller.py | 4 +- src/pyqt_reactive/windows/config_window.py | 18 +- 15 files changed, 240 insertions(+), 336 deletions(-) diff --git a/src/pyqt_reactive/forms/widget_creation_config.py b/src/pyqt_reactive/forms/widget_creation_config.py index ad95168b..0be16e6b 100644 --- a/src/pyqt_reactive/forms/widget_creation_config.py +++ b/src/pyqt_reactive/forms/widget_creation_config.py @@ -38,7 +38,7 @@ if TYPE_CHECKING: from PyQt6.QtWidgets import QGroupBox, QLayout, QWidget - from pyqt_reactive.widgets.no_scroll_spinbox import NoneAwareCheckBox + from pyqt_reactive.widgets.no_scroll_spinbox import InheritedCheckBox from pyqt_reactive.widgets.shared.clickable_help_components import ( GroupBoxWithHelp, InlineDataclassGroupBox, @@ -133,7 +133,7 @@ class EnabledTitleWidgetMoveContext: source_row: ResponsiveParameterRow | None enabled_widget: QWidget title_widget: QWidget - checkbox_widget: NoneAwareCheckBox | None + checkbox_widget: InheritedCheckBox | None enabled_reset_button: QWidget | None provenance_button: QWidget | None = None @@ -169,7 +169,7 @@ def resolve_context( self, request: EnabledTitleWidgetMoveRequest, ) -> EnabledTitleWidgetMoveContext: - from pyqt_reactive.widgets.no_scroll_spinbox import NoneAwareCheckBox + from pyqt_reactive.widgets.no_scroll_spinbox import InheritedCheckBox enabled_widget = self.enabled_widget(request) enabled_reset_button = request.nested_manager.reset_buttons.get( @@ -179,7 +179,7 @@ def resolve_context( source_layout = self.source_layout(enabled_widget) source_row = self.responsive_source_row(enabled_widget) checkbox_widget = None - if isinstance(enabled_widget, NoneAwareCheckBox): + if isinstance(enabled_widget, InheritedCheckBox): checkbox_widget = enabled_widget logger.debug( @@ -282,7 +282,7 @@ def wrap_checkbox_for_title(self, context: EnabledTitleWidgetMoveContext) -> Non @staticmethod def wrap_checkbox_widget_for_title( - checkbox_widget: NoneAwareCheckBox, + checkbox_widget: InheritedCheckBox, color_scheme, ) -> QWidget: """Return the standard compact title-row container for an enableable checkbox.""" @@ -319,7 +319,7 @@ def bind_title_toggle(self, context: EnabledTitleWidgetMoveContext) -> None: @staticmethod def bind_title_label_to_checkbox( container: GroupBoxWithHelp, - checkbox_widget: NoneAwareCheckBox, + checkbox_widget: InheritedCheckBox, ) -> None: """Make an enableable group title click toggle the owning checkbox.""" @@ -563,7 +563,7 @@ def _create_optional_title_widget(ctx: WidgetBuildContext): from PyQt6.QtWidgets import QWidget, QHBoxLayout, QLabel, QPushButton from PyQt6.QtCore import Qt from PyQt6.QtGui import QFont - from pyqt_reactive.widgets.no_scroll_spinbox import NoneAwareCheckBox + from pyqt_reactive.widgets.no_scroll_spinbox import InheritedCheckBox from pyqt_reactive.widgets.shared.clickable_help_components import HelpButton from formbridge.forms.layout_constants import CURRENT_LAYOUT @@ -580,7 +580,7 @@ def _create_optional_title_widget(ctx: WidgetBuildContext): title_layout.setContentsMargins(*CURRENT_LAYOUT.parameter_row_margins) # Checkbox (compact, no text) - checkbox = NoneAwareCheckBox() + checkbox = InheritedCheckBox() checkbox.setObjectName(field_ids['optional_checkbox_id']) # Title checkbox ONLY controls None vs Instance, NOT the enabled field checkbox.setChecked(current_value is not None) diff --git a/src/pyqt_reactive/forms/widget_operations.py b/src/pyqt_reactive/forms/widget_operations.py index d96dc03e..01b2911f 100644 --- a/src/pyqt_reactive/forms/widget_operations.py +++ b/src/pyqt_reactive/forms/widget_operations.py @@ -154,7 +154,7 @@ def get_all_value_widgets(container: Any) -> list: collected.extend(container.findChildren(widget_types)) # Fallback: also include any child that declares ValueGettable via ABC - # (e.g., NoneAwareLineEdit/CheckBox which are registered virtually, not in WIDGET_IMPLEMENTATIONS) + # (e.g., InheritedLineEdit/CheckBox which are registered virtually, not in WIDGET_IMPLEMENTATIONS) try: from PyQt6.QtCore import QObject for widget in container.findChildren(QObject): diff --git a/src/pyqt_reactive/forms/widget_strategies.py b/src/pyqt_reactive/forms/widget_strategies.py index 333b6333..321a88ad 100644 --- a/src/pyqt_reactive/forms/widget_strategies.py +++ b/src/pyqt_reactive/forms/widget_strategies.py @@ -13,6 +13,7 @@ from magicgui.widgets import Widget as MagicGuiWidget from magicgui.widgets import create_widget +from objectstate.field_value import FieldValue from objectstate import DataclassFieldAccess from PyQt6.QtGui import QIntValidator, QValidator from PyQt6.QtWidgets import ( @@ -56,7 +57,7 @@ from pyqt_reactive.qt_types import QtKeySequenceText from pyqt_reactive.theming.color_scheme import ColorScheme as PyQt6ColorScheme from pyqt_reactive.widgets import ( - NoneAwareCheckBox, + InheritedCheckBox, NoScrollComboBox, NoScrollDoubleSpinBox, ) @@ -107,10 +108,10 @@ def disconnect_debounced_text_signal(self, callback: ParameterSignalCallback) -> emitter.disconnect() -class NoneAwareTextValueMixin(DebouncedTextSignalMixin): +class FieldTextValueMixin(DebouncedTextSignalMixin, PlaceholderStateMixin): """Shared text assignment for widgets that encode None as empty text.""" - def set_value(self, value: ParameterValue | None) -> None: + def _write_concrete_value(self, value: ParameterValue | None) -> None: if value is None: text_value = "" else: @@ -125,9 +126,12 @@ def disconnect_change_signal(self, callback: ParameterSignalCallback) -> None: """Implement ChangeSignalEmitter ABC.""" self.disconnect_debounced_text_signal(callback) + def _signal_sources(self): + return ((self, self.textChanged),) + -class NoneAwareLineEdit( - NoneAwareTextValueMixin, +class InheritedLineEdit( + FieldTextValueMixin, PlaceholderStateMixin, QLineEdit, ValueGettable, @@ -137,16 +141,12 @@ class NoneAwareLineEdit( ): """QLineEdit that properly handles None values for lazy dataclass contexts.""" - def get_value(self): - """Get value, returning None for empty text instead of empty string.""" - text = self.text().strip() - if text == "": - return None - return text + def _read_concrete_value(self): + return self.text().strip() -class NoneAwareIntEdit( - NoneAwareTextValueMixin, +class InheritedIntEdit( + FieldTextValueMixin, PlaceholderStateMixin, QLineEdit, ValueGettable, @@ -166,7 +166,7 @@ def set_value(self, value: ParameterValue | None) -> None: self._committed_value = value super().set_value(value) - def get_value(self): + def _read_concrete_value(self): """Return the edited integer, or the committed value for invalid text.""" is_acceptable, value = self._interpreted_text_value() if is_acceptable: @@ -188,7 +188,7 @@ def _current_text_is_committable(self) -> bool: def _commit_current_text(self) -> int | None: is_acceptable, value = self._interpreted_text_value() if not is_acceptable: - raise RuntimeError("Cannot commit invalid NoneAwareIntEdit text") + raise RuntimeError("Cannot commit invalid InheritedIntEdit text") self._committed_value = value return value @@ -198,7 +198,7 @@ def _interpreted_text_value(self) -> tuple[bool, int | None]: return True, None validator = self.validator() if validator is None: - raise TypeError("NoneAwareIntEdit requires a QIntValidator before value extraction") + raise TypeError("InheritedIntEdit requires a QIntValidator before value extraction") state, _, _ = validator.validate(text, 0) if state != QValidator.State.Acceptable: return False, None @@ -206,7 +206,7 @@ def _interpreted_text_value(self) -> tuple[bool, int | None]: class TypedLiteralContainerEdit( - NoneAwareTextValueMixin, + FieldTextValueMixin, PlaceholderStateMixin, QLineEdit, ValueGettable, @@ -231,11 +231,14 @@ def configure_annotation(self, param_name: str, param_type: type) -> None: self._param_type = param_type def set_value(self, value: ParameterValue | None) -> None: + if not FieldValue.of(value).has_value: + super().set_value(value) + return value = self._project_container(value) self._validate_container(value) super().set_value(project_literal_edit_value(value)) - def get_value(self) -> ParameterValue | None: + def _read_concrete_value(self) -> ParameterValue | None: """Parse the complete editor text or fail before it reaches ObjectState.""" return self._parse_current_text() @@ -384,7 +387,7 @@ def validate(self, text: str, position: int): class TypedLiteralUnionEdit( - NoneAwareTextValueMixin, + FieldTextValueMixin, PlaceholderStateMixin, QLineEdit, ValueGettable, @@ -417,10 +420,13 @@ def __init__(self, param_name: str, param_type: type, parent=None): ) def set_value(self, value: ParameterValue | None) -> None: + if not FieldValue.of(value).has_value: + super().set_value(value) + return projected = project_literal_edit_value(value) super().set_value(projected) - def get_value(self) -> ParameterValue | None: + def _read_concrete_value(self) -> ParameterValue | None: return self._parse_text(self.text()) def validate_current_value(self) -> None: @@ -515,8 +521,8 @@ class UnsupportedWidgetTypeError(TypeError): class DirectWidgetFactory: """Typed factory for simple widgets that bypass magicgui.""" - def create_int(self, current_value: ParameterValue | None = None) -> NoneAwareIntEdit: - widget = NoneAwareIntEdit() + def create_int(self, current_value: ParameterValue | None = None) -> InheritedIntEdit: + widget = InheritedIntEdit() if current_value is not None: widget.set_value(current_value) return widget @@ -531,14 +537,14 @@ def create_float(self, current_value: ParameterValue | None = None) -> NoScrollD widget.clear() return widget - def create_bool(self, current_value: ParameterValue | None = None) -> NoneAwareCheckBox: - widget = NoneAwareCheckBox() + def create_bool(self, current_value: ParameterValue | None = None) -> InheritedCheckBox: + widget = InheritedCheckBox() if current_value is not None: widget.setChecked(bool(current_value)) return widget def create_string(self, current_value: ParameterValue | None = None) -> QLineEdit: - widget = NoneAwareLineEdit() + widget = InheritedLineEdit() widget.set_value(current_value) return widget @@ -595,7 +601,7 @@ def create_from_items( layout.setSpacing(2) for value, label in items: - checkbox = NoneAwareCheckBox() + checkbox = InheritedCheckBox() checkbox.setText(label) checkbox.setObjectName(f"{param_name}_{label}") widget._checkboxes[value] = checkbox @@ -954,13 +960,13 @@ def create_enhanced_path_widget(param_name: str = "", current_value: ParameterVa def _create_none_aware_int_widget(): - """Factory function for NoneAwareIntEdit widgets.""" - return NoneAwareIntEdit() + """Factory function for InheritedIntEdit widgets.""" + return InheritedIntEdit() def _create_none_aware_checkbox(): - """Factory function for NoneAwareCheckBox widgets.""" - return NoneAwareCheckBox() + """Factory function for InheritedCheckBox widgets.""" + return InheritedCheckBox() def convert_widget_value_to_type( @@ -1221,7 +1227,7 @@ def apply_checkbox(self, widget: QCheckBox, placeholder_text: str) -> None: was_blocked = widget.blockSignals(True) try: widget.setChecked(default_value) - if isinstance(widget, NoneAwareCheckBox): + if isinstance(widget, InheritedCheckBox): widget.set_placeholder_preview(default_value) finally: widget.blockSignals(was_blocked) diff --git a/src/pyqt_reactive/protocols/widget_adapters.py b/src/pyqt_reactive/protocols/widget_adapters.py index c64eb905..7a2f386d 100644 --- a/src/pyqt_reactive/protocols/widget_adapters.py +++ b/src/pyqt_reactive/protocols/widget_adapters.py @@ -18,6 +18,7 @@ from collections.abc import Callable from enum import Enum from typing import Any +from objectstate.field_value import FieldValue, Inherit, Unset, Value try: from PyQt6.QtCore import QLocale, QObject @@ -61,43 +62,126 @@ class PyQtWidgetMeta(_QtMetaclass, ABCMeta): if PYQT6_AVAILABLE: + class WidgetValue(Value): + """A concrete draft read from its control, not a second stored model value.""" + __slots__ = ('reader',) + + def __init__(self, reader): + self.reader = reader + + @property + def value(self): + return self.reader() + + class PlaceholderStateMixin(PlaceholderStateTrackable): - """Nominal placeholder-state storage for Qt widget adapters.""" + """Own one input draft and its native signal subscriptions. + Inherit and Unset are retained as distinct input states. Concrete input lives + in the Qt control and is read lazily, so incomplete text is not parsed while + the user is typing. Model paints never publish input events. + """ widget_capabilities = frozenset({WidgetCapability.PLACEHOLDER_STATE}) def __init__(self, *args, **kwargs): - self._placeholder_state_active = False - self._cached_placeholder_text: str | None = None - self._cached_placeholder_resolved_value: Any = None + self._draft = Unset() + self._input_callbacks = [] + self._native_connections = {} + self._cached_placeholder_text = None + self._cached_placeholder_resolved_value = None super().__init__(*args, **kwargs) - def has_placeholder_state(self) -> bool: - return self._placeholder_state_active or self._cached_placeholder_text is not None + def _signal_sources(self): + return () + + def _ensure_input_tracking(self): + for owner, signal in self._signal_sources(): + if owner not in self._native_connections: + signal.connect(self._native_input) + self._native_connections[owner] = signal + + def _native_input(self, *arguments): + self.clear_placeholder_state() + self._prepare_input() + self._draft = WidgetValue(self._read_concrete_value) + self.clear_placeholder_cache() + self._apply_draft_style() + for callback in tuple(self._input_callbacks): + callback(self.get_value()) + + def _prepare_input(self): + pass + + def _apply_draft_style(self): + pass + + def _read_concrete_value(self): + raise TypeError(f"{type(self).__name__} does not own a concrete input control") - def mark_placeholder_state(self) -> None: - self._placeholder_state_active = True + def _write_blank_value(self, value): + self._write_concrete_value(None) - def clear_placeholder_state(self) -> None: - self._placeholder_state_active = False + def get_value(self): + self._ensure_input_tracking() + return self._draft.resolve(lambda: Inherit()) - def cached_placeholder_text(self) -> str | None: + def set_value(self, value): + self._ensure_input_tracking() + draft = FieldValue.of(value) + if self._draft.inherited and not draft.inherited: + self.clear_placeholder_state() + previous = self.blockSignals(True) + try: + self._draft = draft + if draft.has_value: + self._write_concrete_value(value) + if value is not None: + self._draft = WidgetValue(self._read_concrete_value) + else: + self._write_blank_value(value) + self._apply_draft_style() + finally: + self.blockSignals(previous) + + def connect_change_signal(self, callback): + self._ensure_input_tracking() + if callback not in self._input_callbacks: + self._input_callbacks.append(callback) + + def disconnect_change_signal(self, callback): + if callback in self._input_callbacks: + self._input_callbacks.remove(callback) + + def has_placeholder_state(self): + return self._draft.inherited + + def mark_placeholder_state(self): + self._draft = Inherit() + + def clear_placeholder_state(self): + if self._draft.inherited: + self.setStyleSheet("") + self._draft = WidgetValue(self._read_concrete_value) + self.clear_placeholder_cache() + + def cached_placeholder_text(self): return self._cached_placeholder_text - def set_cached_placeholder_text(self, placeholder_text: str) -> None: - self._cached_placeholder_text = placeholder_text + def set_cached_placeholder_text(self, text): + self._cached_placeholder_text = text - def cached_placeholder_resolved_value(self) -> Any: + def cached_placeholder_resolved_value(self): return self._cached_placeholder_resolved_value - def set_cached_placeholder_resolved_value(self, value: Any) -> None: + def set_cached_placeholder_resolved_value(self, value): self._cached_placeholder_resolved_value = value - def clear_placeholder_cache(self) -> None: + def clear_placeholder_cache(self): self._cached_placeholder_text = None self._cached_placeholder_resolved_value = None + class LineEditAdapter(PlaceholderStateMixin, QLineEdit, ValueGettable, ValueSettable, PlaceholderCapable, ChangeSignalEmitter, metaclass=PyQtWidgetMeta): """ @@ -112,12 +196,12 @@ class LineEditAdapter(PlaceholderStateMixin, QLineEdit, ValueGettable, ValueSett _widget_id = "line_edit" - def get_value(self) -> Any: + def _read_concrete_value(self) -> Any: """Implement ValueGettable ABC.""" text = self.text().strip() - return None if text == "" else text + return text - def set_value(self, value: Any) -> None: + def _write_concrete_value(self, value: Any) -> None: """Implement ValueSettable ABC.""" self.setText("" if value is None else str(value)) @@ -125,17 +209,10 @@ def set_placeholder(self, text: str) -> None: """Implement PlaceholderCapable ABC.""" self.setPlaceholderText(text) - def connect_change_signal(self, callback: Callable[[Any], None]) -> None: - """Implement ChangeSignalEmitter ABC.""" - self.textChanged.connect(lambda: callback(self.get_value())) - def disconnect_change_signal(self, callback: Callable[[Any], None]) -> None: - """Implement ChangeSignalEmitter ABC.""" - try: - self.textChanged.disconnect(callback) - except TypeError: - # Signal not connected - ignore - pass + + def _signal_sources(self): + return ((self, self.textChanged),) class KeySequenceEditAdapter( PlaceholderStateMixin, QKeySequenceEdit, @@ -148,46 +225,23 @@ class KeySequenceEditAdapter( _widget_id = "key_sequence_edit" - def __init__(self, *args: Any, **kwargs: Any) -> None: - super().__init__(*args, **kwargs) - self._change_signal_wrappers: dict[ - Callable[[Any], None], - Callable[[], None], - ] = {} - def get_value(self) -> str: + def _read_concrete_value(self) -> str: """Return the complete key sequence in portable text form.""" return self.keySequence().toString( QKeySequence.SequenceFormat.PortableText ) - def set_value(self, value: Any) -> None: + def _write_concrete_value(self, value: Any) -> None: """Assign a portable key sequence.""" self.setKeySequence(QKeySequence("" if value is None else str(value))) - def connect_change_signal(self, callback: Callable[[Any], None]) -> None: - """Commit only after Qt has finished capturing the sequence.""" - if callback in self._change_signal_wrappers: - return - def wrapper() -> None: - callback(self.get_value()) - self._change_signal_wrappers[callback] = wrapper - self.editingFinished.connect(wrapper) - - def disconnect_change_signal(self, callback: Callable[[Any], None]) -> None: - """Disconnect callbacks registered at this semantic boundary.""" - - wrapper = self._change_signal_wrappers.pop(callback, None) - if wrapper is None: - return - try: - self.editingFinished.disconnect(wrapper) - except TypeError: - pass + def _signal_sources(self): + return ((self, self.editingFinished),) class SpinBoxAdapter(PlaceholderStateMixin, QSpinBox, ValueGettable, ValueSettable, PlaceholderCapable, @@ -207,13 +261,10 @@ def __init__(self, parent=None): self.setSpecialValueText(" ") # Empty special value = None self.setRange(-2147483648, 2147483647) # Default int range - def get_value(self) -> Any: - """Implement ValueGettable ABC.""" - if self.value() == self.minimum() and self.specialValueText(): - return None + def _read_concrete_value(self): return self.value() - def set_value(self, value: Any) -> None: + def _write_concrete_value(self, value: Any) -> None: """Implement ValueSettable ABC.""" if value is None: self.setValue(self.minimum()) @@ -229,16 +280,10 @@ def configure_range(self, minimum: float, maximum: float) -> None: """Implement RangeConfigurable ABC.""" self.setRange(int(minimum), int(maximum)) - def connect_change_signal(self, callback: Callable[[Any], None]) -> None: - """Implement ChangeSignalEmitter ABC.""" - self.valueChanged.connect(lambda: callback(self.get_value())) - def disconnect_change_signal(self, callback: Callable[[Any], None]) -> None: - """Implement ChangeSignalEmitter ABC.""" - try: - self.valueChanged.disconnect(callback) - except TypeError: - pass + + def _signal_sources(self): + return ((self, self.valueChanged),) class DoubleSpinBoxAdapter(PlaceholderStateMixin, QDoubleSpinBox, ValueGettable, ValueSettable, @@ -272,13 +317,10 @@ def textFromValue(self, value: float) -> str: # noqa: N802 - Qt virtual method value, "f", QLocale.FloatingPointPrecisionOption.FloatingPointShortest ) - def get_value(self) -> Any: - """Implement ValueGettable ABC.""" - if self.value() == self.minimum() and self.specialValueText(): - return None + def _read_concrete_value(self): return self.value() - def set_value(self, value: Any) -> None: + def _write_concrete_value(self, value: Any) -> None: """Implement ValueSettable ABC.""" if value is None: self.setValue(self.minimum()) @@ -293,16 +335,10 @@ def configure_range(self, minimum: float, maximum: float) -> None: """Implement RangeConfigurable ABC.""" self.setRange(minimum, maximum) - def connect_change_signal(self, callback: Callable[[Any], None]) -> None: - """Implement ChangeSignalEmitter ABC.""" - self.valueChanged.connect(lambda: callback(self.get_value())) - def disconnect_change_signal(self, callback: Callable[[Any], None]) -> None: - """Implement ChangeSignalEmitter ABC.""" - try: - self.valueChanged.disconnect(callback) - except TypeError: - pass + + def _signal_sources(self): + return ((self, self.valueChanged),) class ComboBoxAdapter(PlaceholderStateMixin, QComboBox, ValueGettable, ValueSettable, PlaceholderCapable, @@ -316,13 +352,13 @@ class ComboBoxAdapter(PlaceholderStateMixin, QComboBox, ValueGettable, ValueSett _widget_id = "combo_box" - def get_value(self) -> Any: + def _read_concrete_value(self) -> Any: """Implement ValueGettable ABC.""" if self.currentIndex() < 0: - return None + return Unset() return self.itemData(self.currentIndex()) - def set_value(self, value: Any) -> None: + def _write_concrete_value(self, value: Any) -> None: """Implement ValueSettable ABC.""" # Find index of item with matching data for i in range(self.count()): @@ -351,16 +387,10 @@ def populate_enum(self, enum_type: type) -> None: for enum_value in enum_type: self.addItem(enum_value.name, enum_value) - def connect_change_signal(self, callback: Callable[[Any], None]) -> None: - """Implement ChangeSignalEmitter ABC.""" - self.currentIndexChanged.connect(lambda: callback(self.get_value())) - def disconnect_change_signal(self, callback: Callable[[Any], None]) -> None: - """Implement ChangeSignalEmitter ABC.""" - try: - self.currentIndexChanged.disconnect(callback) - except TypeError: - pass + + def _signal_sources(self): + return ((self, self.currentIndexChanged),) class CheckBoxAdapter(PlaceholderStateMixin, ThemedCheckBox, ValueGettable, ValueSettable, @@ -373,24 +403,18 @@ class CheckBoxAdapter(PlaceholderStateMixin, ThemedCheckBox, ValueGettable, Valu _widget_id = "check_box" - def get_value(self) -> Any: + def _read_concrete_value(self) -> Any: """Implement ValueGettable ABC.""" return self.isChecked() - def set_value(self, value: Any) -> None: + def _write_concrete_value(self, value: Any) -> None: """Implement ValueSettable ABC.""" self.setChecked(bool(value) if value is not None else False) - def connect_change_signal(self, callback: Callable[[Any], None]) -> None: - """Implement ChangeSignalEmitter ABC.""" - self.stateChanged.connect(lambda: callback(self.get_value())) - def disconnect_change_signal(self, callback: Callable[[Any], None]) -> None: - """Implement ChangeSignalEmitter ABC.""" - try: - self.stateChanged.disconnect(callback) - except TypeError: - pass + + def _signal_sources(self): + return ((self, self.stateChanged),) class CheckboxGroupAdapter(PlaceholderStateMixin, QGroupBox, ValueGettable, ValueSettable, @@ -399,7 +423,7 @@ class CheckboxGroupAdapter(PlaceholderStateMixin, QGroupBox, ValueGettable, Valu """ Adapter for checkbox group (List[Enum]) implementing OpenHCS ABCs. - Manages a group of NoneAwareCheckBox widgets for multi-selection. + Manages a group of InheritedCheckBox widgets for multi-selection. Returns List[Enum] or None (for placeholder state). This eliminates duck typing - instead of checking for _checkboxes attribute, @@ -421,52 +445,12 @@ def checkbox_widgets(self) -> tuple[Any, ...]: """Return checkboxes managed by this group.""" return tuple(self._checkboxes.values()) - def get_value(self) -> Any: - """ - Implement ValueGettable ABC. - - Returns: - - None if all checkboxes are in placeholder state (inherit from parent) - - List[Enum] of checked items if any checkbox has been clicked - """ - # Check if any checkbox has a concrete value (not placeholder) - has_concrete_value = any( - checkbox.get_value() is not None - for checkbox in self._checkboxes.values() - ) - - if not has_concrete_value: - # All checkboxes are in placeholder state - return None to inherit from parent - return None + def _read_concrete_value(self): + return [value for value, checkbox in self.checkbox_items() if checkbox.isChecked()] - # All checkboxes are concrete (signal handler converted them) - # Return list of enum values where checkbox is checked - return [ - enum_val for enum_val, checkbox in self._checkboxes.items() - if checkbox.get_value() == True - ] - - def set_value(self, value: Any) -> None: - """ - Implement ValueSettable ABC. - - Args: - value: None (placeholder state) or List[Enum] (concrete values) - """ - if value is None: - # None means inherit from parent - initialize all checkboxes in placeholder state - for checkbox in self._checkboxes.values(): - checkbox.set_value(None) - elif isinstance(value, list): - # Explicit list - set concrete values - for enum_value, checkbox in self._checkboxes.items(): - # Set to True if in list, False if not (both are concrete values) - checkbox.set_value(enum_value in value) - else: - raise TypeError( - "CheckboxGroupAdapter values must be list or None, " - f"got {type(value).__name__}." - ) + def _write_concrete_value(self, value): + for member, checkbox in self.checkbox_items(): + checkbox.set_value(member in value if value is not None else False) def set_resolved_value_preview(self, value: Any) -> None: """Preview a resolved enum list while preserving raw placeholder state.""" @@ -489,42 +473,20 @@ def set_resolved_value_preview(self, value: Any) -> None: checkbox.set_resolved_value_preview(enum_value in inherited_values) self.mark_placeholder_state() - def connect_change_signal(self, callback: Callable[[Any], None]) -> None: - """ - Implement ChangeSignalEmitter ABC. - Connects to all checkboxes in the group. - """ - for checkbox in self._checkboxes.values(): - checkbox.stateChanged.connect( - self._make_checkbox_group_handler(callback) - ) - def _make_checkbox_group_handler(self, callback: Callable[[Any], None]) -> Callable[[Any], None]: - """Return a handler that promotes the whole enum list to concrete.""" - def handler(_state: Any) -> None: - for checkbox in self.checkbox_widgets(): - checkbox.convert_placeholder_to_concrete() - self.clear_placeholder_state() - callback(self.get_value()) - return handler - def has_placeholder_state(self) -> bool: - return super().has_placeholder_state() or any( - widget_supports_capability(checkbox, WidgetCapability.PLACEHOLDER_STATE) - and isinstance(checkbox, PlaceholderStateTrackable) - and checkbox.has_placeholder_state() - for checkbox in self.checkbox_widgets() - ) + def _signal_sources(self): + return tuple((checkbox, checkbox.stateChanged) for checkbox in self.checkbox_widgets()) + + def _prepare_input(self): + for checkbox in self.checkbox_widgets(): + checkbox.convert_placeholder_to_concrete() - def disconnect_change_signal(self, callback: Callable[[Any], None]) -> None: - """Implement ChangeSignalEmitter ABC.""" - for checkbox in self._checkboxes.values(): - try: - checkbox.stateChanged.disconnect(callback) - except TypeError: - pass + def _write_blank_value(self, value): + for checkbox in self.checkbox_widgets(): + checkbox.set_value(value) # Manual registration of adapters in WIDGET_IMPLEMENTATIONS diff --git a/src/pyqt_reactive/services/parameter_ops_service.py b/src/pyqt_reactive/services/parameter_ops_service.py index e723219e..80604266 100644 --- a/src/pyqt_reactive/services/parameter_ops_service.py +++ b/src/pyqt_reactive/services/parameter_ops_service.py @@ -13,6 +13,7 @@ """ from __future__ import annotations +from objectstate.field_value import FieldValue import logging from typing import TYPE_CHECKING @@ -184,7 +185,7 @@ def _reset_value_widget_parameter(self, param_name: str, manager) -> None: skip_context_behavior=False, manager=manager, ) - if reset_value is None: + if FieldValue.of(reset_value).inherited: self.refresh_single_placeholder(manager, param_name) # DELETED: _get_reset_value - ObjectState owns defaults @@ -224,7 +225,7 @@ def refresh_single_placeholder(self, manager, field_name: str) -> None: # Use manager.parameters (scoped) not state.parameters (full paths) current_value = manager.parameters.get(field_name) logger.debug(f"🔬 RESET_TRACE: current_value={repr(current_value)[:50]}") - if current_value is not None: + if not FieldValue.of(current_value).inherited: logger.debug(f"🔬 RESET_TRACE: value is not None, no placeholder needed") return diff --git a/src/pyqt_reactive/services/widget_service.py b/src/pyqt_reactive/services/widget_service.py index 94aa0cad..bda583e9 100644 --- a/src/pyqt_reactive/services/widget_service.py +++ b/src/pyqt_reactive/services/widget_service.py @@ -1,3 +1,4 @@ +from objectstate.field_value import FieldValue """ Consolidated Widget Service. @@ -301,9 +302,9 @@ def _apply_context_behavior( logger.debug("Skipping context behavior without param_name or object_instance") return - if value is None: + if FieldValue.of(value).inherited: self.show_resolved_value_as_placeholder(widget, param_name, manager) - elif value is not None: + else: if self.widget_enhancer.has_placeholder_state(widget): logger.debug("Clearing placeholder state for concrete value") self.widget_enhancer._clear_placeholder_state(widget) diff --git a/src/pyqt_reactive/widgets/__init__.py b/src/pyqt_reactive/widgets/__init__.py index c6f62f7b..31c8afd1 100644 --- a/src/pyqt_reactive/widgets/__init__.py +++ b/src/pyqt_reactive/widgets/__init__.py @@ -9,7 +9,7 @@ from .no_scroll_spinbox import ( IgnoreWheelEventMixin, - NoneAwareCheckBox, + InheritedCheckBox, ) from .no_scroll_spinbox import ( NoScrollComboBox as NoScrollComboBox, @@ -27,7 +27,7 @@ __all__ = [ *(widget_type.__name__ for widget_type in IgnoreWheelEventMixin.__subclasses__()), - "NoneAwareCheckBox", + "InheritedCheckBox", "StatusIndicator", "StatusState", "ThemedCheckBox", diff --git a/src/pyqt_reactive/widgets/function_list_editor.py b/src/pyqt_reactive/widgets/function_list_editor.py index 20f1bc90..867ec1ce 100644 --- a/src/pyqt_reactive/widgets/function_list_editor.py +++ b/src/pyqt_reactive/widgets/function_list_editor.py @@ -1179,11 +1179,6 @@ def _update_function_object_states( new_entries=new_entries, ) - def _patch_lazy_constructors(self): - """Context manager that patches lazy dataclass constructors to preserve None vs concrete distinction.""" - from objectstate import patch_lazy_constructors - - return patch_lazy_constructors() def _move_function(self, index, direction): """Move function up or down. diff --git a/src/pyqt_reactive/widgets/function_pane.py b/src/pyqt_reactive/widgets/function_pane.py index cecd9813..6cb237b5 100644 --- a/src/pyqt_reactive/widgets/function_pane.py +++ b/src/pyqt_reactive/widgets/function_pane.py @@ -573,9 +573,9 @@ def create_parameter_widget( # Boolean parameters if param_type == bool: - from pyqt_reactive.widgets import NoneAwareCheckBox + from pyqt_reactive.widgets import InheritedCheckBox - widget = NoneAwareCheckBox() + widget = InheritedCheckBox() widget.set_value(current_value) # Use set_value to handle None properly widget.toggled.connect( lambda checked: self.handle_parameter_change(param_name, checked) diff --git a/src/pyqt_reactive/widgets/no_scroll_spinbox.py b/src/pyqt_reactive/widgets/no_scroll_spinbox.py index 69c154e9..849c7ce2 100644 --- a/src/pyqt_reactive/widgets/no_scroll_spinbox.py +++ b/src/pyqt_reactive/widgets/no_scroll_spinbox.py @@ -76,39 +76,13 @@ def setCurrentIndex(self, index: int): self._placeholder_active = (index == -1) self.update() - def get_value(self): - """Implement ValueGettable ABC.""" - if self.currentIndex() < 0: - return None - return self.itemData(self.currentIndex()) - - def set_value(self, value): - """Implement ValueSettable ABC.""" - # Find index of item with matching data - for i in range(self.count()): - if self.itemData(i) == value: - self.setCurrentIndex(i) - return - # Value not found - clear selection - self.setCurrentIndex(-1) - - def get_value(self): - """Get current value (item data at current index).""" - if self.currentIndex() < 0: - return None - return self.itemData(self.currentIndex()) - - def set_value(self, value): - """Set current value by finding matching item data.""" - if value is None: - self.setCurrentIndex(-1) - else: - for i in range(self.count()): - if self.itemData(i) == value: - self.setCurrentIndex(i) - return - # Value not found - clear selection - self.setCurrentIndex(-1) + + + + + + + def paintEvent(self, event): """Override to draw placeholder text when currentIndex == -1.""" @@ -146,14 +120,10 @@ def paintEvent(self, event): super().paintEvent(event) -class CheckboxValueState(Enum): - """Value ownership state for None-aware checkboxes.""" - PLACEHOLDER = "placeholder" - CONCRETE = "concrete" -class NoneAwareCheckBox( +class InheritedCheckBox( PlaceholderStateMixin, ThemedCheckBox, ValueGettable, @@ -173,55 +143,39 @@ class NoneAwareCheckBox( def __init__(self, parent=None): super().__init__(parent) - self._value_state = CheckboxValueState.CONCRETE + # Prevent horizontal stretching - checkbox should only be as wide as its content from PyQt6.QtWidgets import QSizePolicy self.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed) - def get_value(self): - """Get value, returning None if in placeholder state.""" - if self.is_placeholder(): - return None + def _read_concrete_value(self): return self.isChecked() - def is_placeholder(self) -> bool: - """Return whether the checkbox is currently displaying inherited state.""" - return self._value_state is CheckboxValueState.PLACEHOLDER + def is_placeholder(self): + return self.has_placeholder_state() - def set_value(self, value): - """Set value, handling None by leaving in placeholder state.""" - if value is None: - # Don't change state - placeholder system will set the preview value - self._value_state = CheckboxValueState.PLACEHOLDER - # Set grey palette for placeholder checkmark + def _write_concrete_value(self, value): + self.setChecked(bool(value)) + + def _signal_sources(self): + return ((self, self.stateChanged),) + + def _apply_draft_style(self): + if self.has_placeholder_state(): self._apply_placeholder_palette() else: - self._value_state = CheckboxValueState.CONCRETE - self.setChecked(bool(value)) - # Restore normal palette self._apply_concrete_palette() - def connect_change_signal(self, callback: Callable[[bool | None], None]) -> None: - """Implement ChangeSignalEmitter ABC.""" - def emit_concrete_value() -> None: - self.convert_placeholder_to_concrete() - callback(self.get_value()) - self.stateChanged.connect(emit_concrete_value) - def disconnect_change_signal(self, callback: Callable[[bool | None], None]) -> None: - """Implement ChangeSignalEmitter ABC.""" - try: - self.stateChanged.disconnect(callback) - except TypeError: - pass + def set_placeholder_preview(self, checked: bool) -> None: """Display an inherited checkbox value without making it concrete.""" signals_blocked = self.blockSignals(True) try: self.setChecked(checked) - self._value_state = CheckboxValueState.PLACEHOLDER + self.mark_placeholder_state() self._apply_placeholder_palette() finally: @@ -234,18 +188,14 @@ def set_resolved_value_preview(self, value) -> None: return if not isinstance(value, bool): raise TypeError( - "NoneAwareCheckBox resolved previews require bool or None values, " + "InheritedCheckBox resolved previews require bool or None values, " f"got {type(value).__name__}." ) self.set_placeholder_preview(value) - def convert_placeholder_to_concrete(self) -> None: - """Keep the displayed value but mark it as a user-controlled value.""" - if not self.is_placeholder(): - return - self._value_state = CheckboxValueState.CONCRETE + def convert_placeholder_to_concrete(self): self.clear_placeholder_state() - self._apply_concrete_palette() + self._apply_draft_style() def toggle(self) -> None: """Toggle as a user action, committing placeholder state first.""" diff --git a/src/pyqt_reactive/widgets/sectioned_dataclass_editor.py b/src/pyqt_reactive/widgets/sectioned_dataclass_editor.py index 675f23a4..8895b567 100644 --- a/src/pyqt_reactive/widgets/sectioned_dataclass_editor.py +++ b/src/pyqt_reactive/widgets/sectioned_dataclass_editor.py @@ -74,7 +74,7 @@ EditableTableLayout, RowSuggestionMap, ) -from pyqt_reactive.widgets.no_scroll_spinbox import NoneAwareCheckBox +from pyqt_reactive.widgets.no_scroll_spinbox import InheritedCheckBox from pyqt_reactive.widgets.shared.abstract_table_browser import ColumnDef from pyqt_reactive.widgets.shared.clickable_help_components import InlineDataclassGroupBox from pyqt_reactive.widgets.shared.scoped_table_widget import ScopedTableWidget @@ -448,7 +448,7 @@ def attached_to( provenance_button: QWidget | None, ) -> AttachedTitleCheckbox: title_row = EnabledTitleWidgetMoveAuthority() - checkbox = NoneAwareCheckBox(groupbox) + checkbox = InheritedCheckBox(groupbox) checkbox.setToolTip(tooltip) checkbox.toggled.connect(lambda checked: on_toggled(bool(checked))) title_widget = title_row.wrap_checkbox_widget_for_title(checkbox, groupbox.color_scheme) @@ -481,7 +481,7 @@ class AttachedTitleCheckbox(TitleCheckbox): """The checkbox, its reset button and its provenance button in one title row.""" groupbox: InlineDataclassGroupBox - checkbox: NoneAwareCheckBox + checkbox: InheritedCheckBox title_widget: QWidget reset_button: QPushButton provenance_button: QWidget | None diff --git a/src/pyqt_reactive/widgets/shared/abstract_manager_widget.py b/src/pyqt_reactive/widgets/shared/abstract_manager_widget.py index 44009b20..6857a8a5 100644 --- a/src/pyqt_reactive/widgets/shared/abstract_manager_widget.py +++ b/src/pyqt_reactive/widgets/shared/abstract_manager_widget.py @@ -34,7 +34,7 @@ LAYOUT_ROLE, StyledText, ) -from objectstate import DottedFieldPath, ObjectStateRegistry, patch_lazy_constructors +from objectstate import DottedFieldPath, ObjectStateRegistry from pyqt_reactive.widgets.mixins import ( CrossWindowPreviewMixin, ) @@ -252,7 +252,6 @@ def _action_operations(self) -> ManagerActionOperations: validate_code_action=lambda: True, code_payload=self.CODE_EDITOR_PAYLOAD, pre_code_execution=self._pre_code_execution, - patch_lazy_constructors=patch_lazy_constructors, migrate_code_namespace=self.handle_code_execution_error, validate_code_namespace=self.validate_executed_code, apply_code_namespace=self.apply_executed_code, diff --git a/src/pyqt_reactive/widgets/shared/checkbox_group_widget.py b/src/pyqt_reactive/widgets/shared/checkbox_group_widget.py index b4be6e3d..167407d6 100644 --- a/src/pyqt_reactive/widgets/shared/checkbox_group_widget.py +++ b/src/pyqt_reactive/widgets/shared/checkbox_group_widget.py @@ -10,7 +10,7 @@ from PyQt6.QtWidgets import QVBoxLayout from PyQt6.QtCore import pyqtSignal -from pyqt_reactive.widgets import NoneAwareCheckBox +from pyqt_reactive.widgets import InheritedCheckBox from pyqt_reactive.protocols.widget_adapters import CheckboxGroupAdapter @@ -18,7 +18,7 @@ class CheckboxGroupWidget(CheckboxGroupAdapter): """ Multi-selection checkbox group for List[Enum] parameters. - Uses NoneAwareCheckBox pattern consistently with bool parameters: + Uses InheritedCheckBox pattern consistently with bool parameters: - Initialize all checkboxes with set_value(None) for placeholder state - Use set_value() instead of setChecked() to properly track placeholder state - Use get_value() in get_selected_values() to distinguish placeholder vs concrete @@ -48,7 +48,7 @@ def __init__(self, param_name: str, enum_type: Type[Enum], current_value: Option # Create checkbox for each enum value for enum_val in enum_type: - checkbox = NoneAwareCheckBox() + checkbox = InheritedCheckBox() checkbox.setText(enum_val.name.replace('_', ' ').title()) # Initialize with None (placeholder state) or concrete value diff --git a/src/pyqt_reactive/widgets/shared/manager_action_controller.py b/src/pyqt_reactive/widgets/shared/manager_action_controller.py index 821e97b9..41beb945 100644 --- a/src/pyqt_reactive/widgets/shared/manager_action_controller.py +++ b/src/pyqt_reactive/widgets/shared/manager_action_controller.py @@ -51,7 +51,6 @@ class ManagerActionOperations: validate_code_action: Callable[[], bool] code_payload: Any pre_code_execution: Callable[[], None] - patch_lazy_constructors: Callable[[], AbstractContextManager[Any]] migrate_code_namespace: Callable[[str, Exception, dict], Optional[dict]] validate_code_namespace: Callable[[dict], bool] apply_code_namespace: Callable[[dict], bool] @@ -171,8 +170,7 @@ def _execute_code_namespace( namespace = {} try: - with operations.patch_lazy_constructors(): - exec(code, namespace) + exec(code, namespace) except TypeError as error: migrated_namespace = operations.migrate_code_namespace(code, error, namespace) if migrated_namespace is not None: diff --git a/src/pyqt_reactive/windows/config_window.py b/src/pyqt_reactive/windows/config_window.py index 21b215a5..c2cb75a4 100644 --- a/src/pyqt_reactive/windows/config_window.py +++ b/src/pyqt_reactive/windows/config_window.py @@ -18,14 +18,7 @@ WindowCodeDocumentDriver, use_external_code_editor, ) -from objectstate import ( - DottedFieldPath, - ObjectState, - ObjectStateEditSession, - ObjectStateRegistry, - ObjectStateTransactionCheckpoint, - patch_lazy_constructors, -) +from objectstate import DottedFieldPath, ObjectState, ObjectStateEditSession, ObjectStateRegistry, ObjectStateTransactionCheckpoint from objectstate.lazy_factory import LazyDataclass, retain_only_authored_paths from PyQt6.QtCore import QSize, pyqtSignal from PyQt6.QtWidgets import ( @@ -183,11 +176,10 @@ def apply_source(self, source: str) -> None: self._apply_config(self._config_from_source(source)) def _config_from_source(self, source: str) -> ConfigT: - with patch_lazy_constructors(): - return self._document_format.from_source( - source, - expected_config_type=self._config_type, - ) + return self._document_format.from_source( + source, + expected_config_type=self._config_type, + ) @dataclass(frozen=True, slots=True) From fb1c044b5147b363bf0f61c7af6ecb11e3161652 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 16 Oct 2026 02:30:44 -0400 Subject: [PATCH 2/7] Own placeholder admission in WidgetService; preserve null through real forms --- .../forms/parameter_form_chrome_sync.py | 6 +- src/pyqt_reactive/forms/widget_strategies.py | 25 ++-- .../protocols/widget_adapters.py | 4 + .../services/field_change_dispatcher.py | 13 -- .../services/parameter_ops_service.py | 23 +--- src/pyqt_reactive/services/widget_service.py | 12 +- .../widgets/enhanced_path_widget.py | 30 ++--- .../widgets/shared/checkbox_group_widget.py | 112 ++---------------- 8 files changed, 49 insertions(+), 176 deletions(-) diff --git a/src/pyqt_reactive/forms/parameter_form_chrome_sync.py b/src/pyqt_reactive/forms/parameter_form_chrome_sync.py index 2f4dc4cc..0ff33a3e 100644 --- a/src/pyqt_reactive/forms/parameter_form_chrome_sync.py +++ b/src/pyqt_reactive/forms/parameter_form_chrome_sync.py @@ -5,7 +5,7 @@ from collections.abc import Iterable from typing import Any, Set -from objectstate import DottedFieldPath +from objectstate import DottedFieldPath, FieldValue from pyqt_reactive.services.field_change_dispatcher import FieldChangeDispatcher, FieldChangeEvent @@ -384,7 +384,7 @@ def refresh_widgets_for_paths(self, paths: Set[str]) -> set[str]: if isinstance(widget, ValueSettable): value = manager.state.parameters.get(path, missing) if value is not missing: - skip_context_behavior = value is None + skip_context_behavior = FieldValue.of(value).inherited manager._widget_service.update_widget_value( widget, value, @@ -392,7 +392,7 @@ def refresh_widgets_for_paths(self, paths: Set[str]) -> set[str]: skip_context_behavior, manager, ) - if value is None: + if FieldValue.of(value).inherited: manager._parameter_ops_service.refresh_single_placeholder( manager, leaf_field, diff --git a/src/pyqt_reactive/forms/widget_strategies.py b/src/pyqt_reactive/forms/widget_strategies.py index 321a88ad..46bc6527 100644 --- a/src/pyqt_reactive/forms/widget_strategies.py +++ b/src/pyqt_reactive/forms/widget_strategies.py @@ -13,7 +13,7 @@ from magicgui.widgets import Widget as MagicGuiWidget from magicgui.widgets import create_widget -from objectstate.field_value import FieldValue +from objectstate.field_value import FieldValue, Unset from objectstate import DataclassFieldAccess from PyQt6.QtGui import QIntValidator, QValidator from PyQt6.QtWidgets import ( @@ -243,8 +243,8 @@ def _read_concrete_value(self) -> ParameterValue | None: return self._parse_current_text() def validate_current_value(self) -> None: - """Validate the editor through the same parser used for value extraction.""" - self._parse_current_text() + """Validate local input, without parsing an inherited or absent draft.""" + self.get_value() def connect_change_signal(self, callback: ParameterSignalCallback) -> None: """Emit only complete literals of the declared container kind.""" @@ -430,7 +430,7 @@ def _read_concrete_value(self) -> ParameterValue | None: return self._parse_text(self.text()) def validate_current_value(self) -> None: - self._parse_text(self.text()) + self.get_value() def connect_change_signal(self, callback: ParameterSignalCallback) -> None: self.connect_debounced_text_signal( @@ -521,26 +521,21 @@ class UnsupportedWidgetTypeError(TypeError): class DirectWidgetFactory: """Typed factory for simple widgets that bypass magicgui.""" - def create_int(self, current_value: ParameterValue | None = None) -> InheritedIntEdit: + def create_int(self, current_value: ParameterValue = Unset()) -> InheritedIntEdit: widget = InheritedIntEdit() - if current_value is not None: - widget.set_value(current_value) + widget.set_value(current_value) return widget - def create_float(self, current_value: ParameterValue | None = None) -> NoScrollDoubleSpinBox: + def create_float(self, current_value: ParameterValue = Unset()) -> NoScrollDoubleSpinBox: widget = NoScrollDoubleSpinBox() widget.setRange(WidgetConfig.NUMERIC_RANGE_MIN, WidgetConfig.NUMERIC_RANGE_MAX) widget.setDecimals(WidgetConfig.FLOAT_PRECISION) - if current_value is not None: - widget.setValue(float(current_value)) - else: - widget.clear() + widget.set_value(current_value) return widget - def create_bool(self, current_value: ParameterValue | None = None) -> InheritedCheckBox: + def create_bool(self, current_value: ParameterValue = Unset()) -> InheritedCheckBox: widget = InheritedCheckBox() - if current_value is not None: - widget.setChecked(bool(current_value)) + widget.set_value(current_value) return widget def create_string(self, current_value: ParameterValue | None = None) -> QLineEdit: diff --git a/src/pyqt_reactive/protocols/widget_adapters.py b/src/pyqt_reactive/protocols/widget_adapters.py index 7a2f386d..e5755bca 100644 --- a/src/pyqt_reactive/protocols/widget_adapters.py +++ b/src/pyqt_reactive/protocols/widget_adapters.py @@ -267,8 +267,10 @@ def _read_concrete_value(self): def _write_concrete_value(self, value: Any) -> None: """Implement ValueSettable ABC.""" if value is None: + self.setSpecialValueText(" ") self.setValue(self.minimum()) else: + self.setSpecialValueText("") self.setValue(int(value)) def set_placeholder(self, text: str) -> None: @@ -323,8 +325,10 @@ def _read_concrete_value(self): def _write_concrete_value(self, value: Any) -> None: """Implement ValueSettable ABC.""" if value is None: + self.setSpecialValueText(" ") self.setValue(self.minimum()) else: + self.setSpecialValueText("") self.setValue(float(value)) def set_placeholder(self, text: str) -> None: diff --git a/src/pyqt_reactive/services/field_change_dispatcher.py b/src/pyqt_reactive/services/field_change_dispatcher.py index c282cb39..e8d67bcc 100644 --- a/src/pyqt_reactive/services/field_change_dispatcher.py +++ b/src/pyqt_reactive/services/field_change_dispatcher.py @@ -233,19 +233,6 @@ def _refresh_single_field(self, manager: "ParameterFormManager", field_name: str logger.warning(f" ⏭️ Field {field_name} not in widgets, skipping") return - # FIX: Check current value instead of _user_set_fields. - # Even if a field is in _user_set_fields, if its value is None it should - # show a placeholder (inherited from parent). This is critical for code-mode - # which sets all fields (adding them to _user_set_fields) but many have None - # values that should display as placeholders. - current_value = manager.parameters.get(field_name) - if current_value is not None: - if DEBUG_DISPATCHER: - logger.info( - f" ⏭️ Field {field_name} has concrete value ({type(current_value).__name__}), skipping placeholder refresh" - ) - return - if DEBUG_DISPATCHER: logger.info(f" ✅ Refreshing placeholder for {manager.field_id}.{field_name}") diff --git a/src/pyqt_reactive/services/parameter_ops_service.py b/src/pyqt_reactive/services/parameter_ops_service.py index 80604266..4735a572 100644 --- a/src/pyqt_reactive/services/parameter_ops_service.py +++ b/src/pyqt_reactive/services/parameter_ops_service.py @@ -13,7 +13,6 @@ """ from __future__ import annotations -from objectstate.field_value import FieldValue import logging from typing import TYPE_CHECKING @@ -185,8 +184,6 @@ def _reset_value_widget_parameter(self, param_name: str, manager) -> None: skip_context_behavior=False, manager=manager, ) - if FieldValue.of(reset_value).inherited: - self.refresh_single_placeholder(manager, param_name) # DELETED: _get_reset_value - ObjectState owns defaults # DELETED: _update_reset_tracking - ObjectState.reset_parameter handles tracking @@ -221,14 +218,6 @@ def refresh_single_placeholder(self, manager, field_name: str) -> None: if not PyQt6WidgetEnhancer.supports_placeholder(widget): return - # Only refresh if value is None (needs placeholder) - # Use manager.parameters (scoped) not state.parameters (full paths) - current_value = manager.parameters.get(field_name) - logger.debug(f"🔬 RESET_TRACE: current_value={repr(current_value)[:50]}") - if not FieldValue.of(current_value).inherited: - logger.debug(f"🔬 RESET_TRACE: value is not None, no placeholder needed") - return - if manager._widget_service.show_resolved_value_as_placeholder(widget, field_name, manager): # Keep enabled-field styling in sync when placeholder changes the visual state if field_name == 'enabled': @@ -316,11 +305,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 - if manager.state.parameters.get(full_path) is None: - with monitor.measure(): - manager._widget_service.show_resolved_value_as_placeholder( - widget, param_name, manager - ) - elif PyQt6WidgetEnhancer.has_placeholder_state(widget): - PyQt6WidgetEnhancer._clear_placeholder_state(widget) + with monitor.measure(): + manager._widget_service.show_resolved_value_as_placeholder( + widget, param_name, manager + ) diff --git a/src/pyqt_reactive/services/widget_service.py b/src/pyqt_reactive/services/widget_service.py index bda583e9..77d5dd3b 100644 --- a/src/pyqt_reactive/services/widget_service.py +++ b/src/pyqt_reactive/services/widget_service.py @@ -302,12 +302,7 @@ def _apply_context_behavior( logger.debug("Skipping context behavior without param_name or object_instance") return - if FieldValue.of(value).inherited: - self.show_resolved_value_as_placeholder(widget, param_name, manager) - else: - if self.widget_enhancer.has_placeholder_state(widget): - logger.debug("Clearing placeholder state for concrete value") - self.widget_enhancer._clear_placeholder_state(widget) + self.show_resolved_value_as_placeholder(widget, param_name, manager) def show_resolved_value_as_placeholder(self, widget: QWidget, param_name: str, manager) -> bool: """Preview an unset field: show the value it resolves to; return whether one was shown. @@ -319,6 +314,11 @@ 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 + raw_value = manager.state.parameters[full_path] + if not FieldValue.of(raw_value).inherited: + if self.widget_enhancer.has_placeholder_state(widget): + self._dispatch_widget_update(widget, raw_value) + return False 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/widgets/enhanced_path_widget.py b/src/pyqt_reactive/widgets/enhanced_path_widget.py index ec507541..f3581e07 100644 --- a/src/pyqt_reactive/widgets/enhanced_path_widget.py +++ b/src/pyqt_reactive/widgets/enhanced_path_widget.py @@ -282,7 +282,7 @@ def __init__( self._apply_styling() self._setup_signals() - self.set_path(current_value) + self.set_value(current_value) def minimumSizeHint(self) -> QSize: # noqa: N802 - Qt virtual method name """Preserve a usable editor width beside the browse affordance.""" @@ -335,9 +335,9 @@ def _on_text_changed(self, text: str): """Handle text change in path input.""" self.path_changed.emit(text) - def set_path(self, value: Path | str | None) -> None: + def _write_concrete_value(self, value: Path | str | None) -> None: """Set path value without triggering signals.""" - self.path_input.blockSignals(True) + previous = self.path_input.blockSignals(True) try: if value is not None: # Set actual value @@ -347,33 +347,18 @@ def set_path(self, value: Path | str | None) -> None: # For None values, clear the text to show placeholder self.path_input.clear() finally: - self.path_input.blockSignals(False) + self.path_input.blockSignals(previous) - def get_path(self) -> str | None: + def _read_concrete_value(self) -> str | None: """Get current path value, returning None for empty strings.""" text = self.path_input.text().strip() if text == "": return None return text - def get_value(self) -> str | None: - """Implement ValueGettable ABC - alias for get_path().""" - return self.get_path() - def set_value(self, value: Path | str | None) -> None: - """Implement ValueSettable ABC - alias for set_path().""" - self.set_path(value) - def connect_change_signal(self, callback: Callable[[str], None]) -> None: - """Implement ChangeSignalEmitter ABC.""" - self.path_changed.connect(callback) - def disconnect_change_signal(self, callback: Callable[[str], None]) -> None: - """Implement ChangeSignalEmitter ABC.""" - try: - self.path_changed.disconnect(callback) - except TypeError: - pass def _open_dialog(self): """Open appropriate Qt dialog based on behavior.""" @@ -399,7 +384,7 @@ def _open_dialog(self): if selected_path: path_obj = Path(selected_path) - self.set_path(path_obj) + self.set_value(path_obj) self.path_changed.emit(str(path_obj)) # Cache the selection (directory for files, path itself for directories) @@ -408,3 +393,6 @@ def _open_dialog(self): except Exception as e: logger.error(f"Failed to open dialog: {e}") + + def _signal_sources(self): + return ((self, self.path_changed),) diff --git a/src/pyqt_reactive/widgets/shared/checkbox_group_widget.py b/src/pyqt_reactive/widgets/shared/checkbox_group_widget.py index 167407d6..7df9291e 100644 --- a/src/pyqt_reactive/widgets/shared/checkbox_group_widget.py +++ b/src/pyqt_reactive/widgets/shared/checkbox_group_widget.py @@ -1,117 +1,31 @@ -""" -CheckboxGroupWidget for List[Enum] parameters. +"""Enum-list layout over the shared Qt field-input owner.""" -Provides explicit type-based dispatch instead of duck typing with monkey-patched methods. -""" - -from typing import List, Optional, Type from enum import Enum -from PyQt6.QtWidgets import QVBoxLayout from PyQt6.QtCore import pyqtSignal +from PyQt6.QtWidgets import QVBoxLayout -from pyqt_reactive.widgets import InheritedCheckBox +from objectstate import Unset from pyqt_reactive.protocols.widget_adapters import CheckboxGroupAdapter +from pyqt_reactive.widgets import InheritedCheckBox class CheckboxGroupWidget(CheckboxGroupAdapter): - """ - Multi-selection checkbox group for List[Enum] parameters. - - Uses InheritedCheckBox pattern consistently with bool parameters: - - Initialize all checkboxes with set_value(None) for placeholder state - - Use set_value() instead of setChecked() to properly track placeholder state - - Use get_value() in get_selected_values() to distinguish placeholder vs concrete - """ + """Lay out enum choices; the adapter owns values and input subscriptions.""" - # Signal emitted when selection changes selection_changed = pyqtSignal() - def __init__(self, param_name: str, enum_type: Type[Enum], current_value: Optional[List[Enum]] = None, parent=None): - """ - Initialize checkbox group widget. - - Args: - param_name: Parameter name for display - enum_type: Enum type for checkbox options - current_value: Initial selected values (None for placeholder state) - parent: Parent widget - """ - super().__init__(param_name.replace('_', ' ').title(), parent) - - self._checkboxes = {} + def __init__(self, param_name: str, enum_type: type[Enum], current_value=Unset(), parent=None): + super().__init__(parent) + self.setTitle(param_name.replace('_', ' ').title()) self._enum_type = enum_type - layout = QVBoxLayout(self) layout.setContentsMargins(8, 8, 8, 8) layout.setSpacing(4) - - # Create checkbox for each enum value - for enum_val in enum_type: + for member in enum_type: checkbox = InheritedCheckBox() - checkbox.setText(enum_val.name.replace('_', ' ').title()) - - # Initialize with None (placeholder state) or concrete value - if current_value is None: - checkbox.set_value(None) - else: - checkbox.set_value(enum_val in current_value) - - # Connect signal to emit selection_changed - checkbox.stateChanged.connect(self._on_checkbox_changed) - + checkbox.setText(member.name.replace('_', ' ').title()) layout.addWidget(checkbox) - self._checkboxes[enum_val] = checkbox - - def _on_checkbox_changed(self): - """Handle checkbox state change - convert all checkboxes from placeholder to concrete.""" - # When ANY checkbox is clicked, convert ALL checkboxes from placeholder to concrete - # This ensures consistent behavior: either all inherit (None) or all are explicit - for checkbox in self.checkbox_widgets(): - checkbox.convert_placeholder_to_concrete() - - self.selection_changed.emit() - - def get_selected_values(self) -> Optional[List[Enum]]: - """ - Get selected enum values, returning None if all checkboxes are in placeholder state. - - Treats List[Enum] like a list of independent bools: - - If ALL checkboxes are in placeholder state → return None (inherit from parent) - - If ANY checkbox has been clicked → ALL become concrete, return list of checked items - - Note: The signal handler ensures that clicking ANY checkbox converts ALL to concrete, - so we should never have a mixed state (some placeholder, some concrete). - """ - # Check if any checkbox has a concrete value (not placeholder) - has_concrete_value = any( - checkbox.get_value() is not None - for checkbox in self._checkboxes.values() - ) - - if not has_concrete_value: - # All checkboxes are in placeholder state - return None to inherit from parent - return None - - # All checkboxes are concrete (signal handler converted them) - # Return list of enum values where checkbox is checked - return [ - enum_val for enum_val, checkbox in self._checkboxes.items() - if checkbox.get_value() == True - ] - - def set_selected_values(self, values: Optional[List[Enum]]) -> None: - """ - Set selected values. - - Args: - values: List of enum values to select, or None for placeholder state - """ - if values is None: - # Set all checkboxes to placeholder state - for checkbox in self._checkboxes.values(): - checkbox.set_value(None) - else: - # Set concrete values - for enum_val, checkbox in self._checkboxes.items(): - checkbox.set_value(enum_val in values) + self._checkboxes[member] = checkbox + self.set_value(current_value) + self.connect_change_signal(lambda _value: self.selection_changed.emit()) From ef7243ca29d137ac21b2e140f5587eb10e5603c4 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 16 Oct 2026 05:17:42 -0400 Subject: [PATCH 3/7] Bind form mutations to field events and derive enabled paint from backend state --- src/pyqt_reactive/forms/form_init_service.py | 3 +- .../forms/inline_dataclass_context.py | 14 +- .../forms/parameter_form_chrome_sync.py | 86 ++----- .../forms/parameter_form_manager.py | 158 +++--------- .../forms/widget_creation_config.py | 20 +- .../services/enabled_field_styling_service.py | 243 +++--------------- .../services/field_change_dispatcher.py | 178 +++++-------- .../services/parameter_ops_service.py | 219 +--------------- src/pyqt_reactive/services/signal_service.py | 33 +-- src/pyqt_reactive/services/widget_service.py | 33 +-- .../widgets/function_list_editor.py | 4 +- .../widgets/sectioned_dataclass_editor.py | 2 +- .../shared/clickable_help_components.py | 4 +- .../widgets/shared/config_action_tabs.py | 4 +- .../shared/manager_item_display_builder.py | 4 +- tests/test_declared_field_form.py | 159 ++++++++++++ tests/test_form_config_scheme.py | 27 ++ 17 files changed, 414 insertions(+), 777 deletions(-) create mode 100644 tests/test_declared_field_form.py create mode 100644 tests/test_form_config_scheme.py diff --git a/src/pyqt_reactive/forms/form_init_service.py b/src/pyqt_reactive/forms/form_init_service.py index 60c98589..73038312 100644 --- a/src/pyqt_reactive/forms/form_init_service.py +++ b/src/pyqt_reactive/forms/form_init_service.py @@ -100,7 +100,6 @@ class DerivationContext: """Context for computing derived config values via properties.""" context_obj: ParameterFormConfig | FormContext | None extracted: ExtractedParameters - color_scheme: PyQt6ColorScheme | None @property def global_config_type(self) -> Type: @@ -557,7 +556,7 @@ def _build_config(field_id, extracted, context_obj, color_scheme, parent_manager use_scroll_area=use_scroll_area ) - ctx = DerivationContext(context_obj, extracted, color_scheme) + ctx = DerivationContext(context_obj, extracted) vars(config).update(vars(ctx)) from formbridge.forms.parameter_form_service import ParameterAnalysisInput diff --git a/src/pyqt_reactive/forms/inline_dataclass_context.py b/src/pyqt_reactive/forms/inline_dataclass_context.py index dd5bb761..bf06ff3a 100644 --- a/src/pyqt_reactive/forms/inline_dataclass_context.py +++ b/src/pyqt_reactive/forms/inline_dataclass_context.py @@ -92,10 +92,10 @@ def child_type(self, field_name: str) -> type | None: return None def raw_child_value(self, field_name: str): - return self.state.parameters.get(self.child_path(field_name).value) + return DottedFieldPath(self.child_path(field_name).value).bind(self.state).raw def resolved_child_value(self, field_name: str): - return self.state.get_resolved_value(self.child_path(field_name).value) + return DottedFieldPath(self.child_path(field_name).value).bind(self.state).value def child_has_inherited_preview(self, field_name: str) -> bool: return ( @@ -105,8 +105,8 @@ 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) + container_value = DottedFieldPath(self.owner_path.value).bind(self.state).raw + default_value = DottedFieldPath(identity.object_state_path.value).bind(self.state).default if self.raw_child_value(field_name) == default_value: return updated_value = replace_raw(container_value, **{field_name: default_value}) @@ -119,12 +119,12 @@ def reset_child(self, field_name: str) -> None: def reset_children(self) -> None: """Reset every declared inline dataclass child field in one update.""" - container_value = self.state.parameters[self.owner_path.value] + container_value = DottedFieldPath(self.owner_path.value).bind(self.state).raw updates = {} 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 = DottedFieldPath(identity.object_state_path.value).bind(self.state).default if self.raw_child_value(field_name) != default_value: updates[field_name] = default_value @@ -154,7 +154,7 @@ def _sync_owner_widget(self, raw_value) -> None: "is not registered." ) - resolved_value = self.state.get_resolved_value(self.owner_path.value) + resolved_value = DottedFieldPath(self.owner_path.value).bind(self.state).value with SignalService.block_signals(owner_widget): if isinstance(owner_widget, RawResolvedValueSettable): owner_widget.set_raw_value_with_resolved_preview( diff --git a/src/pyqt_reactive/forms/parameter_form_chrome_sync.py b/src/pyqt_reactive/forms/parameter_form_chrome_sync.py index 0ff33a3e..a563b8ae 100644 --- a/src/pyqt_reactive/forms/parameter_form_chrome_sync.py +++ b/src/pyqt_reactive/forms/parameter_form_chrome_sync.py @@ -6,7 +6,6 @@ from typing import Any, Set from objectstate import DottedFieldPath, FieldValue -from pyqt_reactive.services.field_change_dispatcher import FieldChangeDispatcher, FieldChangeEvent class ParameterFormChromeSync: @@ -34,12 +33,6 @@ def changed_field_visuals( for field_name in self.manager.reset_buttons: self.manager._update_reset_button_styling(field_name) - def enabled_field_visuals(self, value: Any) -> None: - from python_introspect import Enableable - - self.manager._enabled_field_styling_service.on_enabled_field_changed( - self.manager, Enableable.require_parameter_name(), value - ) def fields_materialized(self, field_names: Iterable[str]) -> None: """Apply existing chrome authorities to newly visible form fields.""" @@ -281,9 +274,8 @@ def refresh_widgets_from_state(self) -> None: 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 - 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) + value = DottedFieldPath(dotted_path).bind(manager.state).raw + manager._widget_service.update_widget_value(widget, value, param_name, False, manager) for nested_manager in manager.nested_managers.values(): nested_manager.chrome_sync.refresh_widgets_from_state() @@ -298,7 +290,6 @@ def refresh_widgets_for_paths(self, paths: Set[str]) -> set[str]: from objectstate.time_travel_profile import TimeTravelProfiler manager = self.manager - missing = object() refreshed_container_paths: set[str] = set() refreshed_compound_owner_paths: set[str] = set() @@ -324,18 +315,15 @@ def refresh_widgets_for_paths(self, paths: Set[str]) -> set[str]: ) 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) + field = DottedFieldPath(owner_path).bind(manager.state) + raw_value = field.raw with TimeTravelProfiler.phase( "pyqt.chrome.resolve_owner_value", manager_field_id=manager.field_id, owner_field=owner_field, ): - resolved_value = manager.state.get_resolved_value(owner_path) - combined_update = ( - raw_value is not missing - and raw_value != resolved_value - and isinstance(owner_widget, RawResolvedValueSettable) - ) + resolved_value = field.value + combined_update = isinstance(owner_widget, RawResolvedValueSettable) if combined_update: with TimeTravelProfiler.phase( "pyqt.chrome.update_owner_raw_resolved_value", @@ -347,30 +335,24 @@ def refresh_widgets_for_paths(self, paths: Set[str]) -> set[str]: resolved_value, ) elif isinstance(owner_widget, ValueSettable): - if raw_value is not missing: - with TimeTravelProfiler.phase( - "pyqt.chrome.update_owner_widget_value", - manager_field_id=manager.field_id, - owner_field=owner_field, - ): - manager._widget_service.update_widget_value( - owner_widget, - raw_value, - owner_field, - True, - manager, - ) + with TimeTravelProfiler.phase( + "pyqt.chrome.update_owner_widget_value", + manager_field_id=manager.field_id, + owner_field=owner_field, + ): + manager._widget_service.update_widget_value( + owner_widget, raw_value, owner_field, True, manager, + ) if ( not combined_update and isinstance(owner_widget, ResolvedValuePreviewSettable) ): - if raw_value is missing or raw_value != resolved_value: - with TimeTravelProfiler.phase( - "pyqt.chrome.update_owner_resolved_preview", - manager_field_id=manager.field_id, - owner_field=owner_field, - ): - owner_widget.set_resolved_value_preview(resolved_value) + with TimeTravelProfiler.phase( + "pyqt.chrome.update_owner_resolved_preview", + manager_field_id=manager.field_id, + owner_field=owner_field, + ): + owner_widget.set_resolved_value_preview(resolved_value) if owner_widget is not None: self._refresh_compound_widget_semantics( owner_widget, @@ -382,21 +364,13 @@ def refresh_widgets_for_paths(self, paths: Set[str]) -> set[str]: if path_prefix == manager.field_id: widget = manager.widgets.get(leaf_field) if isinstance(widget, ValueSettable): - value = manager.state.parameters.get(path, missing) - if value is not missing: - skip_context_behavior = FieldValue.of(value).inherited - manager._widget_service.update_widget_value( - widget, - value, - leaf_field, - skip_context_behavior, - manager, - ) - if FieldValue.of(value).inherited: - manager._parameter_ops_service.refresh_single_placeholder( - manager, - leaf_field, - ) + value = DottedFieldPath(path).bind(manager.state).raw + skip_context_behavior = FieldValue.of(value).inherited + manager._widget_service.update_widget_value( + widget, value, leaf_field, skip_context_behavior, manager, + ) + if FieldValue.of(value).inherited: + manager._parameter_ops_service.refresh_single_placeholder(manager, leaf_field) with TimeTravelProfiler.phase( "pyqt.chrome.refresh_widgets_children", @@ -416,9 +390,3 @@ def _direct_child_field_for_path(self, path: str) -> str | None: 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 - 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 3011184b..7b54ca37 100644 --- a/src/pyqt_reactive/forms/parameter_form_manager.py +++ b/src/pyqt_reactive/forms/parameter_form_manager.py @@ -9,6 +9,7 @@ DataclassFieldAccess, DottedFieldPath, FieldAccessError, + FieldProxy, register_hierarchy_relationship, unregister_hierarchy_relationship, ) @@ -27,7 +28,7 @@ from pyqt_reactive.widgets.structural_table import StructuralFlashTarget -from contextlib import contextmanager +from contextlib import ExitStack, contextmanager from formbridge.forms.parameter_info_types import ParameterInfo from formbridge.forms.parameter_value_contracts import ( @@ -37,7 +38,7 @@ ParameterValue, WidgetValue, ) -from pyqt_reactive.services.field_change_dispatcher import FieldChangeDispatcher, FieldChangeEvent +from pyqt_reactive.services.field_change_dispatcher import FieldChangeDispatcher, FieldResetEvent, FieldWriteEvent # LiveContextService deleted - functionality moved to ObjectStateRegistry from formbridge.services.flag_context_manager import FlagContextManager @@ -138,24 +139,13 @@ 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 - if not manager.state.has_parameter_descendants(dotted_path): - return signature_type - - state_type = manager.state.type_for_path(dotted_path) - if isinstance(state_type, type) and is_dataclass(state_type): - return state_type - return signature_type + value = DottedFieldPath(dotted_path).bind(manager.state).value + return type(value) if is_dataclass(value) else signature_type @staticmethod - def scoped_parameters(state, field_id: str) -> ParameterDefaultsByName: - """Project ObjectState's flat parameters into one form manager scope.""" - return ParameterDefaultsByName( - ( - parameter_path.field_name, - state.parameters[parameter_path.value], - ) - for parameter_path in state.direct_parameter_paths(field_id) - ) + def scoped_parameters(fields) -> ParameterDefaultsByName: + """Project admitted direct fields into this form's input-name boundary.""" + return ParameterDefaultsByName((field.name, field.raw) for field in fields.children) class ParameterFormManager( @@ -220,21 +210,8 @@ class ParameterFormManager( @property def parameters(self) -> ParameterDefaultsByName: - """Get parameters scoped to this PFM's field_id. - - With flat storage, filters state.parameters to only include fields - under this PFM's prefix, and strips the prefix from keys. - - Example: - state.parameters = { - 'well_filter_config.well_filter': 2, - 'well_filter_config.enabled': True, - 'some_other_field': 'value' - } - PFM with field_id='well_filter_config' returns: - {'well_filter': 2, 'enabled': True} - """ - return ParameterFormTypeResolver.scoped_parameters(self.state, self.field_id) + """Project admitted raw drafts onto this form's local input names.""" + return ParameterFormTypeResolver.scoped_parameters(self.fields) @property def form_build_complete(self) -> bool: @@ -275,41 +252,18 @@ def run_once() -> None: @property def parameter_types(self) -> ParameterTypesByName: - """Derive parameter types from object_instance using UnifiedParameterAnalyzer. - - Single code path for all object types - that's the point of UnifiedParameterAnalyzer. - Uses self.object_instance (target object for this PFM's scope), NOT self.state.object_instance (root). - Filters by self.parameters keys (already scoped/stripped for nested PFMs). - """ - from python_introspect import UnifiedParameterAnalyzer - - param_info_dict = UnifiedParameterAnalyzer.analyze(self.object_instance) - return ParameterTypesByName( - (name, info.param_type) - for name, info in param_info_dict.items() - if name in self.parameters - ) + """Read annotations from this scope's canonical declarations.""" + return ParameterTypesByName((field.name, field.annotation) for field in self.fields.children) @property def param_defaults(self) -> ParameterDefaultsByName: - """Derive defaults from object_instance (the saved baseline). - - Uses self.object_instance (target object for this PFM's scope), NOT self.state.object_instance (root). - Uses self.parameters keys (already scoped/stripped for nested PFMs). - """ - if not is_dataclass(type(self.object_instance)): - return ParameterDefaultsByName() - return ParameterDefaultsByName( - DataclassFieldAccess.raw_items( - self.object_instance, - iter(self.parameters.keys()), - ) - ) + """Read declaration defaults, never confuse them with a saved baseline.""" + return ParameterDefaultsByName((field.name, field.default) for field in self.fields.children) @property def _parameter_descriptions(self) -> Dict[str, str]: - """Delegate to ObjectState._parameter_descriptions.""" - return self.state.parameter_descriptions + """Project declaration descriptions onto UI/document addresses.""" + return {field.address.value: field.description for field in self.state.fields} def __init__(self, state: "ObjectState", config: Optional[FormManagerConfig] = None): """ @@ -359,7 +313,7 @@ def __init__(self, state: "ObjectState", config: Optional[FormManagerConfig] = N # _extraction_target is editable config object (e.g., PipelineConfig) target_obj = state.saved_object if self.field_id: - resolved_target = state.get_resolved_value(self.field_id) + resolved_target = DottedFieldPath(self.field_id).bind(state).value if resolved_target is not None: target_obj = resolved_target elif config.function_target is not None: @@ -382,9 +336,11 @@ def __init__(self, state: "ObjectState", config: Optional[FormManagerConfig] = N with timer(f"ParameterFormManager.__init__ ({target_type_name})", threshold_ms=5.0): QWidget.__init__(self, config.parent) self._disposed = False + self._state_observers = ExitStack() - # Store ObjectState reference - PFM delegates MODEL to state + # The namespace admits this form's declarations once, before controls. self.state = state + self.fields = DottedFieldPath(self.field_id).bind(state) if self.field_id else state.fields self._form_widget: Optional[QWidget] = None self._extra_repaint_callbacks: List[Callable[[], None]] = [] @@ -456,9 +412,7 @@ def __init__(self, state: "ObjectState", config: Optional[FormManagerConfig] = N param_info_dict = UnifiedParameterAnalyzer.analyze( target_obj, exclude_params=config.exclude_params ) - scoped_parameters = ParameterFormTypeResolver.scoped_parameters( - state, self.field_id - ) + scoped_parameters = ParameterFormTypeResolver.scoped_parameters(self.fields) derived_param_types = { name: ParameterFormTypeResolver.field_type(self, name, info.param_type) for name, info in param_info_dict.items() @@ -476,7 +430,7 @@ def __init__(self, state: "ObjectState", config: Optional[FormManagerConfig] = N param_type=param_type, # Provide descriptions as a dotted-path dict (optionally deferred) # so downstream lookup is simple and collision-free. - description=lambda: state.parameter_descriptions, + description=lambda: self._parameter_descriptions, object_instance=target_obj, # Use nested object for nested PFMs ) form_config = ConfigBuilderService.build( @@ -571,17 +525,11 @@ def __init__(self, state: "ObjectState", config: Optional[FormManagerConfig] = N with timer(" Connect signals", threshold_ms=1.0): SignalService.connect_all_signals(self) - # NOTE: Cross-window registration now handled by CALLER using: - # with SignalService.cross_window_registration(manager): - # dialog.exec() - # For backward compatibility during migration, we still register here - # TODO: Remove this after all callers are updated to use context manager - SignalService.register_cross_window_signals(self) # Flash animation: Subscribe to resolved value changes (root only) # NOTE: _init_visual_update_mixin() is called earlier (before setup_ui) if self._parent_manager is None: - self.state.on_resolved_changed(self._on_resolved_values_changed) + self._state_observers.callback(self.fields.subscribe_value(self._on_resolved_values_changed)) logger.debug( f"🔔 CALLBACK_LEAK_DEBUG: Registered callback for {self.field_id} (PFM id={id(self)}), " f"total callbacks on ObjectState: {len(self.state._on_resolved_changed_callbacks)}, " @@ -590,7 +538,7 @@ def __init__(self, state: "ObjectState", config: Optional[FormManagerConfig] = N # Materialized state changes: Subscribe once (root only) if self._parent_manager is None: - self.state.on_state_changed(self._on_materialized_state_changed) + self._state_observers.callback(self.fields.subscribe(self._on_materialized_state_changed)) # STEP 8: _user_set_fields starts empty and is populated only when user edits widgets # (via _emit_parameter_change). Do NOT populate during initialization, as that would @@ -826,15 +774,9 @@ def update_parameter(self, param_name: str, value: ParameterValue) -> None: With flat storage, prepends field_id to create full dotted path. """ - if param_name not in self.parameters: - return - - # Convert value using service layer + field = DottedFieldPath(self._object_state_path_for_field(param_name)).bind(self.state) converted_value = self.service.convert_value_to_type( - value, - self.parameter_types.get(param_name, type(value)), - param_name, - type(self.object_instance), + value, field.annotation, field.name, field.declaring_type, ) # Update corresponding widget if it exists @@ -868,12 +810,12 @@ def update_parameter(self, param_name: str, value: ParameterValue) -> None: with ObjectStateRegistry.atomic("edit parameter", scope_id=self.scope_id): # Route through dispatcher for consistent behavior (sibling refresh, cross-window, etc.) # This is INSIDE the atomic block so parent step updates are also coalesced - event = FieldChangeEvent(param_name, converted_value, self) + event = FieldWriteEvent(field, self, converted_value) FieldChangeDispatcher.instance().dispatch(event) logger.debug(f"[ATOMIC_CHECK] Exited atomic block for {param_name}") else: # Route through dispatcher for consistent behavior (sibling refresh, cross-window, etc.) - event = FieldChangeEvent(param_name, converted_value, self) + event = FieldWriteEvent(field, self, converted_value) FieldChangeDispatcher.instance().dispatch(event) def reset_parameter(self, param_name: str) -> None: @@ -881,13 +823,9 @@ def reset_parameter(self, param_name: str) -> None: With flat storage, prepends field_id to create full dotted path. """ - if param_name not in self.parameters: - return - + field = DottedFieldPath(self._object_state_path_for_field(param_name)).bind(self.state) with FlagContextManager.reset_context(self, block_cross_window=False): - self._parameter_ops_service.reset_parameter(self, param_name) - - self.chrome_sync.dispatch_reset(param_name) + FieldChangeDispatcher.instance().dispatch(FieldResetEvent(field, self)) # Update label styling after reset self.chrome_sync.update_label_styling(param_name) @@ -940,9 +878,6 @@ def sync_after_model_field_change( refreshed_compound_owner_paths, ) - def sync_enabled_field_visuals(self, value: ParameterValue) -> None: - """Synchronize enabled-field dependent styling after an enabled change.""" - self.chrome_sync.enabled_field_visuals(value) def register_field_flash_target( self, @@ -1037,9 +972,8 @@ def dispose(self) -> None: from objectstate import ObjectStateRegistry ObjectStateRegistry.disconnect_listener(self._on_live_context_changed) - if self._parent_manager is None: - self.state.off_resolved_changed(self._on_resolved_values_changed) - self.state.off_state_changed(self._on_materialized_state_changed) + self._state_observers.close() + self._enabled_field_styling_service.invalidate_widget_cache(self) if self.context_obj is not None and self._parent_manager is None: unregister_hierarchy_relationship(type(self.object_instance)) ObjectStateRegistry.increment_token(notify=False) @@ -1051,8 +985,9 @@ def refresh_widgets_from_state(self): """ self.chrome_sync.refresh_widgets_from_state() - def _on_materialized_state_changed(self, changed_paths: Set[str]) -> None: - """Refresh dirty/signature chrome after materialized ObjectState changes.""" + def _on_materialized_state_changed(self, changed_fields: Set[FieldProxy]) -> None: + """Project admitted changes onto the UI's navigation/paint addresses.""" + changed_paths = {field.address.value for field in changed_fields} if self._parent_manager is not None: return if changed_paths: @@ -1067,7 +1002,7 @@ def _on_materialized_state_changed(self, changed_paths: Set[str]) -> None: # ==================== GROUPBOX FLASH ANIMATION (FlashMixin) ==================== - def _on_resolved_values_changed(self, changed_paths: Set[str]): + def _on_resolved_values_changed(self, changed_fields: Set[FieldProxy]): """Handle resolved value changes - queue flashes AND refresh placeholders. SCOPE-AWARE: This callback is fired by THIS window's ObjectState, so we only @@ -1083,7 +1018,7 @@ def _on_resolved_values_changed(self, changed_paths: Set[str]): if self._parent_manager is not None: return # Only root manager handles this - self._pending_resolved_changed_paths.update(changed_paths) + self._pending_resolved_changed_paths.update(field.address.value for field in changed_fields) if self._resolved_changed_flush_scheduled: return self._resolved_changed_flush_scheduled = True @@ -1101,7 +1036,6 @@ def _flush_resolved_values_changed(self) -> None: deferred_state_refresh_paths = self._pending_path_scoped_state_refresh or set() state_refresh_paths = changed_paths | deferred_state_refresh_paths - from objectstate import ObjectStateRegistry from objectstate.time_travel_profile import TimeTravelProfiler logger.debug( @@ -1129,19 +1063,11 @@ def _flush_resolved_values_changed(self) -> None: ) self._pending_path_scoped_state_refresh = None - if ObjectStateRegistry.is_restoring_snapshot(): - # CRITICAL: Refresh enabled styling for all managers after time-travel - # Widget updates bypass the FieldChangeDispatcher (signals blocked), so styling - # isn't triggered automatically. We must manually sync styling to match restored state. - with TimeTravelProfiler.phase( - "pyqt.form.refresh_enabled_styling", - scope=self.state.scope_id, - ): - self._apply_to_nested_managers( - lambda _, manager: ( - manager._enabled_field_styling_service.refresh_enabled_styling(manager) - ) - ) + # Backend edits and history restores use the same canonical paint path. + with TimeTravelProfiler.phase( + "pyqt.form.refresh_enabled_styling", scope=self.state.scope_id, + ): + self._enabled_field_styling_service.refresh_enabled_styling(self) # For each changed path, register and queue a LEAF flash with TimeTravelProfiler.phase( diff --git a/src/pyqt_reactive/forms/widget_creation_config.py b/src/pyqt_reactive/forms/widget_creation_config.py index 0be16e6b..60413ba4 100644 --- a/src/pyqt_reactive/forms/widget_creation_config.py +++ b/src/pyqt_reactive/forms/widget_creation_config.py @@ -29,7 +29,6 @@ ParameterFormManager, ParameterInfo, DisplayInfo, FieldIds, LayoutKind, OptionalTitleComponents, WidgetBuildContext, WidgetCreationConfig ) -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 @@ -1295,7 +1294,7 @@ def store_and_connect_widget(self) -> None: PyQt6WidgetEnhancer.connect_change_signal( rt.container, rt.param_info.name, - self.on_widget_change, + rt.manager.update_parameter, ) logger.debug( "[CREATE_INLINE_DATACLASS] param_info.name=%s, stored inline container in manager.widgets", @@ -1307,7 +1306,7 @@ def store_and_connect_widget(self) -> None: 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) + PyQt6WidgetEnhancer.connect_change_signal(rt.main_widget, rt.param_info.name, rt.manager.update_parameter) if rt.manager.read_only: WidgetService.make_readonly(rt.main_widget, rt.manager.config.color_scheme) @@ -1317,21 +1316,6 @@ def initialize_widget_semantics(self) -> None: rt = self.runtime rt.manager.chrome_sync.update_label_styling(rt.param_info.name) - def on_widget_change(self, pname, value) -> None: - from objectstate import ObjectStateRegistry - - manager = self.runtime.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: - with ObjectStateRegistry.atomic( - "edit parameter", - scope_id=manager.scope_id, - ): - FieldChangeDispatcher.instance().dispatch(event) - else: - FieldChangeDispatcher.instance().dispatch(event) - def _log_widget(self, stage: str, widget: QWidget | None) -> None: rt = self.runtime try: diff --git a/src/pyqt_reactive/services/enabled_field_styling_service.py b/src/pyqt_reactive/services/enabled_field_styling_service.py index 9b9cb4cf..6fe3b9fd 100644 --- a/src/pyqt_reactive/services/enabled_field_styling_service.py +++ b/src/pyqt_reactive/services/enabled_field_styling_service.py @@ -1,5 +1,8 @@ """Enabled Field Styling Service - Visual styling for enabled/disabled states.""" +from objectstate import FieldProxy, FieldValue +from python_introspect import Enableable + import logging from collections.abc import Iterable from typing import Any @@ -7,7 +10,7 @@ from objectstate.time_travel_profile import TimeTravelProfiler from PyQt6 import sip -from PyQt6.QtWidgets import QCheckBox, QGraphicsOpacityEffect, QLabel, QWidget +from PyQt6.QtWidgets import QGraphicsOpacityEffect, QLabel, QWidget from pyqt_reactive.protocols import PlaceholderStateTrackable, ValueGettable @@ -18,11 +21,12 @@ class EnabledFieldStylingService: """ Service for applying visual styling based on enabled field state. - Stateless service that encapsulates all enabled field styling operations. + Backend fields own enablement. This owner keeps widget discovery and the + last painted effective state, never an alternative data value. """ def __init__(self): - """Initialize enabled field styling service (stateless - imports dependencies).""" + """Keep presentation resources bounded by their manager lifetimes.""" from pyqt_reactive.forms.widget_operations import WidgetOperations self.widget_ops = WidgetOperations self._last_enabled_values: WeakKeyDictionary[Any, bool] = WeakKeyDictionary() @@ -64,145 +68,50 @@ def apply_materialized_enabled_styling( self._last_enabled_values.pop(manager, None) self.apply_initial_enabled_styling(manager) - def apply_initial_enabled_styling(self, manager) -> None: - """ - Apply initial enabled field styling based on resolved value from widget. - - This is called once after all widgets are created to ensure initial styling matches the enabled state. + @staticmethod + def _enabled_field(manager): + return next((field for field in manager.fields.children if Enableable.is_parameter_key(field.name)), None) - CRITICAL: This should NOT be called for optional dataclass nested managers when instance is None. - The None state dimming is handled by the optional dataclass checkbox handler. - - Args: - manager: ParameterFormManager instance - """ - # Check if this is a nested manager inside an optional dataclass with None instance + def apply_initial_enabled_styling(self, manager) -> None: + """Paint this materialized scope from its canonical effective value.""" if self._should_skip_optional_dataclass_styling(manager, "INITIAL ENABLED STYLING"): return - self._last_enabled_values.pop(manager, None) - logger.debug( - "[INITIAL_STYLING] field_id=%s, cleared cache to force initial styling", - manager.field_id, - ) - - # Get the enabled widget - enabled_widget = manager.widgets.get('enabled') - if not enabled_widget: - logger.debug(f"[INITIAL_STYLING] field_id={manager.field_id}, no enabled widget found") - return - if sip.isdeleted(enabled_widget): - logger.debug(f"[INITIAL_STYLING] field_id={manager.field_id}, enabled widget was deleted") - return + field = self._enabled_field(manager) + if field is not None: + self._paint_enabled_scope(manager, field) - # Get resolved value from checkbox - if isinstance(enabled_widget, QCheckBox): - resolved_value = enabled_widget.isChecked() - logger.debug(f"[INITIAL_STYLING] field_id={manager.field_id}, resolved_value={resolved_value} (from checkbox)") - else: - # Fallback to parameter value - resolved_value = manager.parameters.get('enabled') - if resolved_value is None: - resolved_value = True # Default to enabled if we can't resolve - logger.debug(f"[INITIAL_STYLING] field_id={manager.field_id}, resolved_value={resolved_value} (from parameter)") - - # Call the enabled handler with the resolved value - self.on_enabled_field_changed(manager, 'enabled', resolved_value) - def refresh_enabled_styling(self, manager) -> None: - """ - Refresh enabled styling for a form and all nested forms. - - This should be called when context changes that might affect inherited enabled values. - Similar to placeholder refresh, but for enabled field styling. - - CRITICAL: Skip optional dataclass nested managers when instance is None. - - Args: - manager: ParameterFormManager instance - """ - # Check if this is a nested manager inside an optional dataclass with None instance + """Own the single enabled-paint traversal for this form subtree.""" if self._should_skip_optional_dataclass_styling(manager, "REFRESH ENABLED STYLING"): return - - # Refresh this form's enabled styling if it has an enabled field - if 'enabled' in manager.parameters: - # Get the enabled widget to read the CURRENT resolved value - enabled_widget = manager.widgets.get('enabled') - if enabled_widget and sip.isdeleted(enabled_widget): - logger.debug(f"[REFRESH ENABLED STYLING] field_id={manager.field_id}, enabled widget was deleted") - return - if enabled_widget and isinstance(enabled_widget, QCheckBox): - # Use the checkbox's current state (which reflects resolved placeholder) - resolved_value = enabled_widget.isChecked() - else: - # Fallback to parameter value - resolved_value = manager.parameters.get('enabled') - if resolved_value is None: - resolved_value = True - - # Apply styling with the resolved value - self.on_enabled_field_changed(manager, 'enabled', resolved_value) - - # Recursively refresh all nested forms' enabled styling + field = self._enabled_field(manager) + if field is not None: + self._paint_enabled_scope(manager, field) for nested_manager in manager.nested_managers.values(): self.refresh_enabled_styling(nested_manager) - - def on_enabled_field_changed(self, manager, param_name: str, value: Any) -> None: - """ - Apply visual styling when 'enabled' parameter changes. - This handler is connected for ANY form that has an 'enabled' parameter. - When enabled resolves to False, apply visual dimming WITHOUT blocking input. + def on_enabled_field_changed(self, manager, field: FieldProxy) -> None: + """A changed enable declaration invalidates its scope and descendants.""" + if Enableable.is_parameter_key(field.name): + self.refresh_enabled_styling(manager) - PERFORMANCE: Early exit if value unchanged to avoid redundant styling. + @staticmethod + def _is_enabled(field: FieldProxy) -> bool: + value = field.value + return FieldValue.of(value).has_value and bool(value) - Args: - manager: ParameterFormManager instance - param_name: Parameter name (should be 'enabled') - value: New value (True/False/None) - """ - if param_name != 'enabled': + def _paint_enabled_scope(self, manager, field: FieldProxy) -> None: + effective = self._is_enabled(field) and not self._is_any_ancestor_disabled(manager) + if manager in self._last_enabled_values and self._last_enabled_values[manager] == effective: return - - logger.debug(f"[ENABLED_HANDLER] >>> CALLED for field_id={manager.field_id}, value={value}") - - # Resolve lazy value - 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" - resolved_value = manager.state.get_resolved_value(full_path) - if resolved_value is None: - # True is the default for enabled fields - resolved_value = True - else: - resolved_value = value - - # PERFORMANCE: Skip if value hasn't changed - if ( - manager in self._last_enabled_values - and self._last_enabled_values[manager] == resolved_value - ): - logger.debug(f"[ENABLED_HANDLER] field_id={manager.field_id}, SKIP (value unchanged: {resolved_value})") - return - self._last_enabled_values[manager] = resolved_value - - logger.debug(f"[ENABLED_HANDLER] field_id={manager.field_id}, resolved_value={resolved_value}") - - # Get direct widgets (excluding nested managers) - CACHED + self._last_enabled_values[manager] = effective direct_widgets = self._get_direct_widgets(manager) - logger.debug(f"[ENABLED_HANDLER] field_id={manager.field_id}, direct_widgets count={len(direct_widgets)}") - - # Check if this is a nested config - is_nested_config = manager._parent_manager is not None and any( - nested_manager == manager for nested_manager in manager._parent_manager.nested_managers.values() - ) - - if is_nested_config: - self._apply_nested_config_styling(manager, resolved_value) + if manager.form_tree.owning_groupbox(manager) is not None: + self._apply_nested_config_styling(manager, effective) else: - self._apply_top_level_styling(manager, resolved_value, direct_widgets) + self._apply_top_level_styling(manager, effective, direct_widgets) + def _should_skip_optional_dataclass_styling(self, manager, log_prefix: str) -> bool: """ @@ -268,30 +177,15 @@ def _get_direct_widgets(self, manager): return direct_widgets def _is_any_ancestor_disabled(self, manager) -> bool: - """ - Check if any ancestor form has enabled=False. - - This is used to determine if a nested config should remain dimmed - even if its own enabled field is True. - - Args: - manager: ParameterFormManager instance - - Returns: - True if any ancestor has enabled=False, False otherwise - """ current = manager._parent_manager while current is not None: - if 'enabled' in current.parameters: - enabled_widget = current.widgets.get('enabled') - if enabled_widget and sip.isdeleted(enabled_widget): - return False - if enabled_widget and isinstance(enabled_widget, QCheckBox): - if not enabled_widget.isChecked(): - return True + field = self._enabled_field(current) + if field is not None and not self._is_enabled(field): + return True current = current._parent_manager return False + def _apply_nested_config_styling(self, manager, resolved_value: bool) -> None: """ Apply styling to a nested config (inside GroupBox). @@ -308,11 +202,7 @@ def _apply_nested_config_styling(self, manager, resolved_value: bool) -> None: logger.debug(f"[ENABLED HANDLER] field_id={manager.field_id}, applying to GroupBox container") - # Check if ANY ancestor has enabled=False - ancestor_is_disabled = self._is_any_ancestor_disabled(manager) - logger.debug(f"[ENABLED HANDLER] field_id={manager.field_id}, ancestor_is_disabled={ancestor_is_disabled}") - - should_dim = ancestor_is_disabled or not resolved_value + should_dim = not resolved_value widgets = self._get_value_widgets(group_box) with TimeTravelProfiler.phase( "pyqt.enabled.apply_nested", @@ -330,58 +220,9 @@ def _apply_nested_config_styling(self, manager, resolved_value: bool) -> None: group_box.update() def _apply_top_level_styling(self, manager, resolved_value: bool, direct_widgets: list) -> None: - """ - Apply styling to a top-level form (step, function). - - Args: - manager: ParameterFormManager instance - resolved_value: Resolved enabled value (True/False) - direct_widgets: List of direct widgets (excluding nested managers) - """ - if resolved_value: - # Enabled=True: Remove dimming from direct widgets - logger.debug(f"[ENABLED HANDLER] field_id={manager.field_id}, removing dimming (enabled=True)") - self._set_widgets_dimmed(direct_widgets, False) - - # Trigger refresh of all nested configs' enabled styling - logger.debug(f"[ENABLED HANDLER] Refreshing nested configs' enabled styling") - for nested_manager in manager.nested_managers.values(): - self.refresh_enabled_styling(nested_manager) - else: - # Enabled=False: Apply dimming to direct widgets + ALL nested configs - logger.debug(f"[ENABLED HANDLER] field_id={manager.field_id}, applying dimming (enabled=False)") - self._set_widgets_dimmed( - direct_widgets, - True, - skip_labels=True, - ) + """Paint only this scope; refresh_enabled_styling owns descendant work.""" + self._set_widgets_dimmed(direct_widgets, not resolved_value, skip_labels=not resolved_value) - # Also dim all nested configs - logger.debug(f"[ENABLED HANDLER] Dimming nested configs, found {len(manager.nested_managers)} nested managers") - logger.debug(f"[ENABLED HANDLER] Available widget keys: {list(manager.widgets.keys())}") - for param_name, nested_manager in manager.nested_managers.items(): - group_box = manager.widgets.get(param_name) - logger.debug(f"[ENABLED HANDLER] Checking nested config {param_name}, group_box={group_box.__class__.__name__ if group_box else 'None'}") - if not group_box: - logger.debug(f"[ENABLED HANDLER] ⚠️ No group_box found for nested config {param_name}, trying nested_manager.field_id={nested_manager.field_id}") - # Try using the nested manager's field_id instead - group_box = manager.widgets.get(nested_manager.field_id) - if not group_box: - logger.debug(f"[ENABLED HANDLER] ⚠️ Still no group_box found, skipping") - continue - if sip.isdeleted(group_box): - continue - - widgets_to_dim = self._get_value_widgets(group_box) - logger.debug(f"[ENABLED HANDLER] Applying dimming to nested config {param_name}, found {len(widgets_to_dim)} widgets") - changed = self._set_widgets_dimmed( - widgets_to_dim, - True, - preserve_placeholder_paint=False, - ) - - if changed: - group_box.update() def _get_value_widgets(self, container) -> list: """Return cached nominal value widgets for a stable form container.""" diff --git a/src/pyqt_reactive/services/field_change_dispatcher.py b/src/pyqt_reactive/services/field_change_dispatcher.py index e8d67bcc..5de923da 100644 --- a/src/pyqt_reactive/services/field_change_dispatcher.py +++ b/src/pyqt_reactive/services/field_change_dispatcher.py @@ -1,15 +1,13 @@ -""" -Unified Field Change Dispatcher. - -Centralizes all field change handling into a single event-driven dispatcher. -Replaces callback spaghetti with a clean architecture. -""" +"""Apply admitted field mutations and update their owning form tree.""" from __future__ import annotations +from abc import ABC, abstractmethod +from objectstate import FieldAccessError, FieldProxy + import logging from dataclasses import dataclass -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, ClassVar from formbridge.flash_trace import flash_trace @@ -24,25 +22,55 @@ DEBUG_DISPATCHER = False -@dataclass -class FieldChangeEvent: - """Immutable event representing a field change.""" +@dataclass(frozen=True) +class FieldChangeEvent(ABC): + """An admitted field and its UI source; each operation owns its mutation.""" + field: FieldProxy + source_manager: "ParameterFormManager" + is_reset: ClassVar[bool] = False + + def __post_init__(self): + if self.field.state is not self.source_manager.state: + raise FieldAccessError("The field belongs to a different editing state") + + @property + def field_name(self): + return self.field.name - field_name: str # Leaf field name - value: Any # New value - source_manager: "ParameterFormManager" # Where change originated - is_reset: bool = False # True if this is a reset operation (don't track as user-set) + @abstractmethod + def apply(self) -> set[str]: + """Perform the sole canonical model transaction for this operation.""" @dataclass(frozen=True) -class FieldDispatchContext: - """Resolved dispatch facts shared by field-change stages.""" +class FieldWriteEvent(FieldChangeEvent): + value: Any + + def apply(self): + from objectstate import ObjectStateRegistry + with ObjectStateRegistry.defer_live_invalidations(): + return self.field.write(self.value) + + +@dataclass(frozen=True) +class FieldResetEvent(FieldChangeEvent): + is_reset: ClassVar[bool] = True + + def apply(self): + return self.field.reset() + + +@dataclass(frozen=True) +class FieldDispatchContext: + """One prepared UI route; the event retains the field and source facts.""" event: FieldChangeEvent - source: "ParameterFormManager" root: "ParameterFormManager" - source_path: str - root_path: str + + @classmethod + def for_event(cls, event): + return cls(event, event.source_manager.form_tree.root()) + class DispatchReentrancyGuard: @@ -54,11 +82,11 @@ def log_start(self, event: FieldChangeEvent) -> None: reset_tag = " [RESET]" if event.is_reset else "" source = event.source_manager logger.info( - "🚀 DISPATCH%s: %s.%s = %s", + "🚀 DISPATCH%s: %s.%s (%s)", reset_tag, source.field_id, event.field_name, - repr(event.value)[:50], + type(event).__name__, ) def enter(self, source: "ParameterFormManager") -> bool: @@ -87,80 +115,27 @@ 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.""" + """Apply the operation once, then synchronize the source view.""" def run(self, context: FieldDispatchContext) -> set[str]: - # ObjectState.update_parameter() enforces the invariant: - # state mutation -> global cache invalidation. - from objectstate import ObjectStateRegistry - - if context.source.before_mutation is not None: - context.source.before_mutation() - if context.event.is_reset: - changed_paths = context.source.state.update_parameter( - context.source_path, - context.event.value, - ) - else: - with ObjectStateRegistry.defer_live_invalidations(): - changed_paths = context.source.state.update_parameter( - context.source_path, - context.event.value, - ) - context.source.sync_after_model_field_change( - context.event.field_name, - changed_paths=changed_paths, - ) - if DEBUG_DISPATCHER: - reset_note = " (reset to None)" if context.event.is_reset else "" - logger.info( - " ✅ Updated state.parameters[%s]%s", - context.source_path, - reset_note, - ) + source = context.event.source_manager + if source.before_mutation is not None: + source.before_mutation() + changed_paths = context.event.apply() + source.sync_after_model_field_change(context.event.field_name, changed_paths=changed_paths) return changed_paths + class SiblingPlaceholderRefreshStage: """Refreshes inherited placeholders in sibling forms under the same parent.""" def run(self, context: FieldDispatchContext) -> None: - parent = context.source._parent_manager + parent = context.event.source_manager._parent_manager if parent is None: if DEBUG_DISPATCHER: logger.info(" ℹ️ No parent manager (root-level field)") @@ -192,7 +167,7 @@ def _refresh_sibling_if_affected( name: str, sibling: "ParameterFormManager", ) -> bool: - if sibling is context.source: + if sibling is context.event.source_manager: if DEBUG_DISPATCHER: logger.debug(" ⏭️ Skipping %s (is source)", name) return False @@ -286,15 +261,6 @@ def flush(self) -> None: ) -class EnabledFieldStyleStage: - """Keeps the special enabled field's visuals in sync with model changes.""" - - def run(self, context: FieldDispatchContext) -> None: - if context.event.field_name != "enabled": - return - context.source.sync_enabled_field_visuals(context.event.value) - if DEBUG_DISPATCHER: - logger.info(" ✅ Applied enabled styling") class RootSignalStage: @@ -302,23 +268,23 @@ class RootSignalStage: def run(self, context: FieldDispatchContext) -> None: logger.debug("🔔 DISPATCHER: Emitting parameter_changed from root") - logger.debug(" source.field_id=%s", context.source.field_id) + logger.debug(" source.field_id=%s", context.event.source_manager.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(" value type=%s", type(context.event.value).__name__) + logger.debug(" full_path=%s", context.event.field.address.value) + logger.debug(" value type=%s", type(context.event.field.raw).__name__) - context.root.parameter_changed.emit(context.root_path, context.event.value) + context.root.parameter_changed.emit(context.event.field.address.value, context.event.field.raw) logger.debug( " ✅ Emitted parameter_changed(%s, ...) from root", - context.root_path, + context.event.field.address.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.event.field.address.value logger.debug( f" 🔍 _emit_cross_window: checking should_skip_updates() for {root_manager.field_id}" ) @@ -349,11 +315,9 @@ 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() - self._enabled_field_style = EnabledFieldStyleStage() self._root_signals = RootSignalStage() @classmethod @@ -375,25 +339,25 @@ def dispatch(self, event: FieldChangeEvent) -> None: return logger.debug( - "🔬 RESET_TRACE: DISPATCHER: is_reset=%s, field=%s, value=%s", + "🔬 RESET_TRACE: DISPATCHER: is_reset=%s, field=%s, previous=%s", event.is_reset, event.field_name, - repr(event.value)[:50], + repr(event.field.raw)[:50], ) - context = self._context_factory.build(event) + context = FieldDispatchContext.for_event(event) flash_trace( "dispatch.context", - source=context.source.field_id, + source=context.event.source_manager.field_id, root=context.root.field_id, - source_path=context.source_path, - root_path=context.root_path, + source_path=context.event.field.address.value, + root_path=context.event.field.address.value, reset=context.event.is_reset, ) self._source_state_update.run(context) self._sibling_placeholder_refresh.run(context) self._live_context_notification.run(context) - self._enabled_field_style.run(context) + source._enabled_field_styling_service.on_enabled_field_changed(source, event.field) self._root_signals.run(context) except Exception: diff --git a/src/pyqt_reactive/services/parameter_ops_service.py b/src/pyqt_reactive/services/parameter_ops_service.py index 4735a572..293c1f0f 100644 --- a/src/pyqt_reactive/services/parameter_ops_service.py +++ b/src/pyqt_reactive/services/parameter_ops_service.py @@ -1,205 +1,24 @@ -""" -Consolidated Parameter Operations Service. - -Merges: -- ParameterResetService: Type-safe parameter reset with discriminated union dispatch -- PlaceholderRefreshService: Placeholder resolution and live context management - -Key features: -1. Type-safe dispatch using ParameterInfo discriminated unions -2. Auto-discovery of handlers via ParameterServiceABC -3. Placeholder resolution with live context from other windows -4. Consistent widget update + signal emission -""" +"""Refresh inherited previews; admitted mutation events own writes and resets.""" from __future__ import annotations + import logging -from typing import TYPE_CHECKING 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, - ) logger = logging.getLogger(__name__) -class ParameterOpsService(ParameterServiceABC): - """ - Consolidated service for parameter reset and placeholder refresh. - - Examples: - service = ParameterOpsService() - - # Reset parameter: - service.reset_parameter(manager, param_name) - - # Refresh placeholders with live context: - service.refresh_with_live_context(manager) - - # Refresh all placeholders in a form: - service.refresh_all_placeholders(manager) - """ - - def __init__(self): - """Initialize with widget operations dependency.""" - super().__init__() - from pyqt_reactive.forms.widget_operations import WidgetOperations - self.widget_ops = WidgetOperations - - @staticmethod - def _get_effective_context_obj(manager): - """ - Get the context object to use when building the context stack. - - Falls back to the parent manager's object_instance when this manager - has no explicit context_obj so nested configs still see their parent - layer for sibling inheritance. - """ - if manager.context_obj is not None: - return manager.context_obj - - parent = manager._parent_manager - if parent is not None: - return parent.object_instance - - 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: - """Reset parameter using type-safe dispatch.""" - info = manager.form_structure.get_parameter_info(param_name) - 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 - manager.state.reset_parameter(dotted_path) - reset_value = manager.state.parameters.get(dotted_path) - - # Invalidate cache token - from objectstate import ObjectStateRegistry - ObjectStateRegistry.increment_token() - - # Update widget directly if exists - if param_name in manager.widgets: - widget = manager.widgets[param_name] - from .signal_service import SignalService - with SignalService.block_signals(widget): - manager._widget_service.overwrite_widget_value( - widget, reset_value, param_name, skip_context_behavior=False, manager=manager - ) - # Emit event for consistency - from .field_change_dispatcher import FieldChangeDispatcher, FieldChangeEvent - event = FieldChangeEvent(param_name, reset_value, manager, is_reset=True) - FieldChangeDispatcher.instance().dispatch(event) - return - self.dispatch(info, manager) - - def _reset_OptionalDataclassInfo(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 - # MODEL mutation through ObjectState (handles tracking) - manager.state.reset_parameter(dotted_path) - reset_value = manager.state.parameters.get(dotted_path) - - if param_name in manager.widgets: - container = manager.widgets[param_name] - from .widget_service import WidgetService - from .signal_service import SignalService - - checkbox = WidgetService.find_optional_checkbox(manager, param_name) - if checkbox: - with SignalService.block_signals(checkbox): - checkbox.setChecked(reset_value is not None and reset_value.enabled) - - group = WidgetService.find_group_box(container) - if group: - group.setEnabled(reset_value is not None) - - nested_manager = manager.nested_managers.get(param_name) - if nested_manager: - nested_manager.reset_all_parameters() - - def _reset_DirectDataclassInfo(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. - DirectDataclass fields use GroupBoxWithHelp containers which don't implement - ValueSettable (they're just containers, not value widgets). The nested manager's - reset_all_parameters() call handles resetting all the actual value widgets inside. - """ - param_name = info.name - nested_manager = manager.nested_managers.get(param_name) - 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( - self, - info: InlineDataclassWidgetInfo, - manager, - ) -> None: - """Reset inline dataclass value widget to its signature default.""" - self._reset_value_widget_parameter(info.name, manager) - - 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 - manager.state.reset_parameter(dotted_path) - reset_value = manager.state.parameters.get(dotted_path) - - from objectstate import ObjectStateRegistry - ObjectStateRegistry.increment_token() - - if param_name not in manager.widgets: - return - - widget = manager.widgets[param_name] - from .signal_service import SignalService - with SignalService.block_signals(widget): - manager._widget_service.overwrite_widget_value( - widget, - reset_value, - param_name, - skip_context_behavior=False, - manager=manager, - ) - - # DELETED: _get_reset_value - ObjectState owns defaults - # DELETED: _update_reset_tracking - ObjectState.reset_parameter handles tracking - - # ========== PLACEHOLDER REFRESH (from PlaceholderRefreshService) ========== - - # DELETED: refresh_affected_siblings - moved to FieldChangeDispatcher +class ParameterOpsService: + """Refresh inherited previews for already materialized form trees.""" def refresh_single_placeholder(self, manager, field_name: str) -> None: """Refresh placeholder for a single field in a manager. - Only updates if: - 1. The field exists as a widget in the manager - 2. The current value is None (needs placeholder) - - Uses ObjectState.get_resolved_value() for resolution, then formats for display. + WidgetService admits inherited fields and paints their canonical read; + literal None and absence do not become inheritance. Args: manager: The manager containing the field @@ -218,18 +37,7 @@ def refresh_single_placeholder(self, manager, field_name: str) -> None: if not PyQt6WidgetEnhancer.supports_placeholder(widget): return - if manager._widget_service.show_resolved_value_as_placeholder(widget, field_name, manager): - # Keep enabled-field styling in sync when placeholder changes the visual state - if field_name == 'enabled': - try: - resolved_value = manager._widget_ops.get_value(widget) - manager._enabled_field_styling_service.on_enabled_field_changed( - manager, 'enabled', resolved_value - ) - except Exception: - logger.exception("Failed to apply enabled styling after placeholder refresh") - else: - logger.warning(f" ⚠️ No placeholder text computed") + manager._widget_service.show_resolved_value_as_placeholder(widget, field_name, manager) def refresh_with_live_context(self, manager, defer: bool = False) -> None: """Refresh placeholders using live values from tree registry. @@ -256,13 +64,7 @@ def _deferred_refresh_with_live_context(self, manager) -> None: def _refresh_form_tree(self, manager) -> None: """Refresh one manager subtree without overlapping recursive traversals.""" self._refresh_placeholder_tree(manager) - manager._apply_to_nested_managers( - lambda _name, nested_manager: ( - nested_manager._enabled_field_styling_service.refresh_enabled_styling( - nested_manager - ) - ) - ) + manager._enabled_field_styling_service.refresh_enabled_styling(manager) self._refresh_root_flash_registrations(manager) def _refresh_placeholder_tree(self, manager) -> None: @@ -279,10 +81,7 @@ def _refresh_root_flash_registrations(self, manager) -> None: manager.reregister_flash_elements() def refresh_all_placeholders(self, manager) -> None: - """Refresh placeholder text for all widgets in a form. - - Uses ObjectState.get_resolved_value() for resolution, then formats for display. - """ + """Refresh previews through WidgetService's inheritance admission.""" with timer(f"_refresh_all_placeholders ({manager.field_id})", threshold_ms=5.0): if not manager.object_instance: logger.debug(f"[PLACEHOLDER] {manager.field_id}: No obj_type, skipping") diff --git a/src/pyqt_reactive/services/signal_service.py b/src/pyqt_reactive/services/signal_service.py index 8116db0f..af6482af 100644 --- a/src/pyqt_reactive/services/signal_service.py +++ b/src/pyqt_reactive/services/signal_service.py @@ -13,7 +13,8 @@ 4. Cross-window registration with RAII cleanup """ -from contextlib import contextmanager +from contextlib import ExitStack, contextmanager +from PyQt6.QtCore import QSignalBlocker from functools import partial from typing import Any, Callable, Optional, TYPE_CHECKING from weakref import ReferenceType, ref @@ -54,18 +55,11 @@ class SignalService: @contextmanager def block_signals(*widgets: QWidget): """Context manager for blocking widget signals.""" - for widget in widgets: - if widget is not None: - widget.blockSignals(True) - logger.debug(f"Blocked signals on {type(widget).__name__}") - - try: - yield - finally: + with ExitStack() as blocked: for widget in widgets: if widget is not None: - widget.blockSignals(False) - logger.debug(f"Unblocked signals on {type(widget).__name__}") + blocked.callback(QSignalBlocker(widget).unblock) + yield @staticmethod def with_signals_blocked(widget: QWidget, operation: Callable) -> None: @@ -131,22 +125,6 @@ def _dispose_destroyed_manager( if manager is not None: manager.dispose() - @staticmethod - def register_cross_window_signals(manager: Any) -> None: - """Register manager for cross-window updates (only root managers). - - SIMPLIFIED: No N×N signal wiring. LiveContextService.increment_token() - notifies all listeners via simple callbacks. - """ - if manager._parent_manager is not None: - return - - # Snapshot initial values for change detection (non-None only) - manager._initial_values_on_open = {k: v for k, v in manager.state.parameters.items() if v is not None} - - from objectstate import ObjectStateRegistry - logger.debug(f"🔍 REGISTER: {manager.field_id} (total: {len(ObjectStateRegistry.get_all())})") - # ========== CROSS-WINDOW REGISTRATION (from CrossWindowRegistration) ========== @staticmethod @@ -162,7 +140,6 @@ def cross_window_registration(manager: 'ParameterFormManager'): return try: - SignalService.register_cross_window_signals(manager) yield manager finally: manager.dispose() diff --git a/src/pyqt_reactive/services/widget_service.py b/src/pyqt_reactive/services/widget_service.py index 77d5dd3b..c33038ae 100644 --- a/src/pyqt_reactive/services/widget_service.py +++ b/src/pyqt_reactive/services/widget_service.py @@ -1,3 +1,5 @@ + +from objectstate import DottedFieldPath from objectstate.field_value import FieldValue """ Consolidated Widget Service. @@ -135,7 +137,7 @@ def get_parameter_display_info(param_name: str, param_type: Type, if resolved_description is None: dotted = f"{manager.field_id}.{param_name}" if manager.field_id else param_name - raw = manager.state.parameter_descriptions.get(dotted) + raw = DottedFieldPath(dotted).bind(manager.state).description if raw is not None: resolved_description = raw if isinstance(raw, str) else raw.description @@ -305,21 +307,21 @@ def _apply_context_behavior( self.show_resolved_value_as_placeholder(widget, param_name, manager) def show_resolved_value_as_placeholder(self, widget: QWidget, param_name: str, manager) -> bool: - """Preview an unset field: show the value it resolves to; return whether one was shown. + """Preview an inherited field; return whether a value was shown. - The preview is the state's resolved read of the field. The field's own - value stays unset, and the source of the shown value is the same state's - ``get_provenance`` answer for the same path. + The admitted field owns the resolved read and provenance. Its draft + remains Inherit; literal None and Unset are not inherited previews. """ from objectstate import LazyDefaultPlaceholderService full_path = f"{manager.field_id}.{param_name}" if manager.field_id else param_name - raw_value = manager.state.parameters[full_path] + field = DottedFieldPath(full_path).bind(manager.state) + raw_value = field.raw if not FieldValue.of(raw_value).inherited: if self.widget_enhancer.has_placeholder_state(widget): self._dispatch_widget_update(widget, raw_value) return False - resolved_value = manager.state.get_resolved_value(full_path) + resolved_value = field.value placeholder_text = LazyDefaultPlaceholderService._format_placeholder_text( resolved_value, manager.config.placeholder_prefix ) @@ -358,20 +360,7 @@ def update_checkbox_group(self, widget: QWidget, value: Any) -> None: def get_widget_value(self, widget: QWidget) -> Any: """Get widget value using ABC-based polymorphism.""" - from pyqt_reactive.protocols.widget_protocols import ( - PlaceholderStateTrackable, - ValueGettable, - WidgetCapability, - widget_supports_capability, - ) - if ( - widget_supports_capability(widget, WidgetCapability.PLACEHOLDER_STATE) - and isinstance(widget, PlaceholderStateTrackable) - and widget.has_placeholder_state() - ): - return None - + from pyqt_reactive.protocols.widget_protocols import ValueGettable if isinstance(widget, ValueGettable): return widget.get_value() - - return None + raise TypeError(f"{type(widget).__name__} does not own a field value") diff --git a/src/pyqt_reactive/widgets/function_list_editor.py b/src/pyqt_reactive/widgets/function_list_editor.py index 867ec1ce..ea3ae16f 100644 --- a/src/pyqt_reactive/widgets/function_list_editor.py +++ b/src/pyqt_reactive/widgets/function_list_editor.py @@ -1455,9 +1455,9 @@ def refresh_from_context(self) -> None: # ObjectState is authoritative. return - self.current_group_by = state.get_resolved_value("processing_config.group_by") + self.current_group_by = state.fields.processing_config.group_by.value self.current_variable_components = ( - state.get_resolved_value("processing_config.variable_components") or [] + state.fields.processing_config.variable_components.value or [] ) self._refresh_component_button() diff --git a/src/pyqt_reactive/widgets/sectioned_dataclass_editor.py b/src/pyqt_reactive/widgets/sectioned_dataclass_editor.py index 8895b567..4e425526 100644 --- a/src/pyqt_reactive/widgets/sectioned_dataclass_editor.py +++ b/src/pyqt_reactive/widgets/sectioned_dataclass_editor.py @@ -841,7 +841,7 @@ def in_form( param_info=param_info, current_value=current_value, ) - resolved = context.state.get_resolved_value(context.owner_path.value) + resolved = DottedFieldPath(context.owner_path.value).bind(context.state).value return cls( RawDisplayValue( cls.value_type, diff --git a/src/pyqt_reactive/widgets/shared/clickable_help_components.py b/src/pyqt_reactive/widgets/shared/clickable_help_components.py index a2c2d335..c81a4d2d 100644 --- a/src/pyqt_reactive/widgets/shared/clickable_help_components.py +++ b/src/pyqt_reactive/widgets/shared/clickable_help_components.py @@ -1,5 +1,7 @@ """PyQt6 clickable help components - clean architecture without circular imports.""" +from objectstate import DottedFieldPath + import inspect import logging from collections.abc import Callable @@ -530,7 +532,7 @@ def _provenance_source(self) -> tuple[str | None, type] | None: """ if not self._state or not self._dotted_path: return None - return self._state.get_provenance(self._dotted_path) + return DottedFieldPath(self._dotted_path).bind(self._state).provenance def _has_provenance(self) -> bool: """Whether this field's value is inherited from another field.""" diff --git a/src/pyqt_reactive/widgets/shared/config_action_tabs.py b/src/pyqt_reactive/widgets/shared/config_action_tabs.py index 46d57dda..d4f67ac0 100644 --- a/src/pyqt_reactive/widgets/shared/config_action_tabs.py +++ b/src/pyqt_reactive/widgets/shared/config_action_tabs.py @@ -2,6 +2,8 @@ from __future__ import annotations +from objectstate import DottedFieldPath + from collections.abc import Sequence from dataclasses import dataclass @@ -84,7 +86,7 @@ def enabled_actions(self) -> tuple[ConfigAction, ...]: return tuple( action for action in self.actions - if self.state.get_resolved_value(action.enabled_path) is True + if DottedFieldPath(action.enabled_path).bind(self.state).value is True ) def set_target_available(self, available: bool) -> None: 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 c7f7a7b9..f3770b08 100644 --- a/src/pyqt_reactive/widgets/shared/manager_item_display_builder.py +++ b/src/pyqt_reactive/widgets/shared/manager_item_display_builder.py @@ -137,7 +137,7 @@ def build_from_format( and item_format.detail_line_field is not None and state is not None ): - resolved_detail = state.get_resolved_value(item_format.detail_line_field) + resolved_detail = DottedFieldPath(item_format.detail_line_field).bind(state).value if resolved_detail is not None: detail_line = str(resolved_detail) @@ -204,7 +204,7 @@ def add_field_path(field_path: str) -> None: if is_enableable(config_type): enabled_field = Enableable.require_parameter_name() enabled_path = f"{path}.{enabled_field}" if path else enabled_field - resolved_enabled = state.get_resolved_value(enabled_path) + resolved_enabled = DottedFieldPath(enabled_path).bind(state).value is_enabled = resolved_enabled is True if is_enabled: add_field_path(enabled_path) diff --git a/tests/test_declared_field_form.py b/tests/test_declared_field_form.py new file mode 100644 index 00000000..dbc20a11 --- /dev/null +++ b/tests/test_declared_field_form.py @@ -0,0 +1,159 @@ +"""Native controls consume admitted fields; painting and reset do not double-edit.""" +from contextlib import contextmanager +from dataclasses import dataclass, field + +import pytest +from objectstate import FieldAccessError, Inherit, LazyDataclassFactory, ObjectState, Unset, set_base_config_type, set_global_config +from PyQt6.QtCore import QEvent, QEventLoop, QTimer, Qt +from PyQt6.QtTest import QTest +from PyQt6.QtWidgets import QLineEdit + +from pyqt_reactive.forms.parameter_form_manager import ParameterFormManager +from pyqt_reactive.services.signal_service import SignalService + + +@contextmanager +def shown_form(app): + @dataclass + class Nested: + enabled: bool | None = True + text: str = 'nested' + @dataclass + class Settings: + enabled: bool | None = True + text: str | None = None + count: int | None = 4 + nested: Nested = field(default_factory=Nested) + set_base_config_type(Settings) + set_global_config(Settings, Settings(text='global text', count=77)) + lazy = LazyDataclassFactory.make_lazy_simple(Settings) + state = ObjectState(lazy(text=None, nested=Nested()), scope_id='declared-fields-form-test') + manager = ParameterFormManager(state) + try: + if not manager.form_build_complete: + built, deadline = QEventLoop(), QTimer() + deadline.setSingleShot(True) + deadline.timeout.connect(built.quit) + manager.register_form_build_completion_callback(built.quit) + manager.form_build_failed.connect(lambda _error: built.quit()) + try: + deadline.start(2000) + built.exec() + finally: + deadline.stop() + assert manager.form_build_failure is None, manager.form_build_failure + assert manager.form_build_complete + manager.show() + app.processEvents() + yield state, manager + finally: + manager.dispose() + manager.close() + manager.deleteLater() + app.sendPostedEvents(None, QEvent.Type.DeferredDelete) + assert state._on_state_changed_callbacks == [] + assert state._on_resolved_changed_callbacks == [] + + +def test_open_form_preserves_null_absence_and_inheritance(qapp): + with shown_form(qapp) as (state, manager): + text, count = manager.widgets['text'], manager.widgets['count'] + assert text.get_value() is None + assert isinstance(count.get_value(), Inherit) + assert state.fields.count.value == 77 + assert isinstance(manager._widget_service.get_widget_value(count), Inherit) + state.fields.count.write(None) + qapp.processEvents() + assert count.get_value() is None + assert state.to_object().count is None + state.fields.text.write(Unset()) + qapp.processEvents() + assert isinstance(text.get_value(), Unset) + state.fields.text.write(None) + qapp.processEvents() + assert text.get_value() is None + + +def test_native_input_and_reset_publish_one_canonical_change(qapp): + with shown_form(qapp) as (state, manager): + hooks, published = [], [] + manager.before_mutation = lambda: hooks.append('mutation') + manager.parameter_changed.connect(lambda name, value: published.append((name, value))) + with pytest.raises(FieldAccessError): + manager.update_parameter('not_a_declaration', 'rejected') + with pytest.raises(FieldAccessError): + manager.reset_parameter('not_a_declaration') + assert hooks == published == [] + loop, deadline = QEventLoop(), QTimer() + deadline.setSingleShot(True) + deadline.timeout.connect(loop.quit) + release = state.fields.text.subscribe(lambda _fields: loop.quit() if state.fields.text.raw == 'typed' else None) + try: + text = manager.widgets['text'] + text.setFocus() + QTest.keyClicks(text, 'typed') + if state.fields.text.raw != 'typed': + deadline.start(1000) + loop.exec() + assert state.fields.text.raw == 'typed' + finally: + deadline.stop() + release() + assert len(hooks) == len(published) == 1 + assert published[0] == ('text', 'typed') + QTest.mouseClick(manager.reset_buttons['text'], Qt.MouseButton.LeftButton) + qapp.processEvents() + assert len(hooks) == len(published) == 2 + assert published[-1][0] == 'text' and isinstance(published[-1][1], Inherit) + assert isinstance(text.get_value(), Inherit) + assert state.fields.text.value == 'global text' + + +def test_enabled_paint_uses_backend_values_for_open_and_nested_forms(qapp): + with shown_form(qapp) as (state, manager): + text = manager.widgets['text'] + nested = manager.nested_managers['nested'] + nested_text = nested.widgets['text'] + # A stale painted checkbox is not a second enable-state authority. + with SignalService.block_signals(manager.widgets['enabled']): + manager.widgets['enabled'].setChecked(False) + manager._enabled_field_styling_service.refresh_enabled_styling(manager) + assert state.fields.enabled.value is True + assert text.property('enabled_field_dimmed') is not True + assert nested_text.property('enabled_field_dimmed') is not True + state.fields.enabled.write(False) + qapp.processEvents() + assert text.property('enabled_field_dimmed') is True + assert nested_text.property('enabled_field_dimmed') is True + assert text.isEnabled() and nested_text.isEnabled() + state.fields.enabled.write(True) + qapp.processEvents() + assert text.property('enabled_field_dimmed') is False + assert nested_text.property('enabled_field_dimmed') is False + state.fields.enabled.write(None) + qapp.processEvents() + assert manager.widgets['enabled'].get_value() is None + assert state.fields.enabled.value is None + assert text.property('enabled_field_dimmed') is True + + +def test_signal_scope_restores_each_widgets_original_block_state(qapp): + text = QLineEdit() + changes = [] + text.textChanged.connect(changes.append) + try: + with SignalService.block_signals(text): + with SignalService.block_signals(text): + text.setText('inner') + assert text.signalsBlocked() + text.setText('outer') + assert not text.signalsBlocked() and changes == [] + text.setText('native') + assert changes == ['native'] + text.blockSignals(True) + with SignalService.block_signals(text): + text.setText('still blocked') + assert text.signalsBlocked() and changes == ['native'] + finally: + text.deleteLater() + qapp.sendPostedEvents(None, QEvent.Type.DeferredDelete) diff --git a/tests/test_form_config_scheme.py b/tests/test_form_config_scheme.py new file mode 100644 index 00000000..ff2d24cb --- /dev/null +++ b/tests/test_form_config_scheme.py @@ -0,0 +1,27 @@ +"""The form configuration owns its resolved scheme, not its analysis context.""" +from dataclasses import dataclass + +import pytest +from objectstate import set_base_config_type +from formbridge.forms.parameter_form_service import ParameterFormService +from pyqt_reactive.forms.form_init_service import ConfigBuilderService, ExtractedParameters +from pyqt_reactive.forms.parameter_form_manager import FormManagerConfig +from pyqt_reactive.theming import ColorScheme + + +@pytest.mark.parametrize('explicit', [False, True], ids=['default', 'explicit']) +def test_config_derivation_preserves_the_resolved_color_scheme(qapp, explicit): + @dataclass + class Settings: + text: str = 'hello' + set_base_config_type(Settings) + scheme = ColorScheme() if explicit else None + result = ConfigBuilderService.build( + '', ExtractedParameters(default_value={'text': 'hello'}, param_type={'text': str}, + object_instance=Settings()), + None, scheme, None, ParameterFormService(), FormManagerConfig(color_scheme=scheme), + ) + assert isinstance(result.config.color_scheme, ColorScheme) + if explicit: + assert result.config.color_scheme is scheme + assert result.config.color_scheme.styles.generate_config_window_style() From 02db991680a80d69c14221b0b109d80bfcd60bd3 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 16 Oct 2026 11:15:09 -0400 Subject: [PATCH 4/7] Share numeric field rendering and native input admission across Qt spinboxes --- .../forms/parameter_form_chrome_sync.py | 4 +- src/pyqt_reactive/forms/widget_strategies.py | 6 +- .../protocols/widget_adapters.py | 173 +++++++++++------- .../services/enabled_field_styling_service.py | 2 +- src/pyqt_reactive/services/widget_service.py | 2 +- .../widgets/enhanced_path_widget.py | 2 +- .../widgets/no_scroll_spinbox.py | 2 +- tests/test_numeric_field_meaning.py | 86 +++++++++ tests/test_numeric_roundtrip.py | 6 +- 9 files changed, 203 insertions(+), 80 deletions(-) create mode 100644 tests/test_numeric_field_meaning.py diff --git a/src/pyqt_reactive/forms/parameter_form_chrome_sync.py b/src/pyqt_reactive/forms/parameter_form_chrome_sync.py index a563b8ae..96a759d0 100644 --- a/src/pyqt_reactive/forms/parameter_form_chrome_sync.py +++ b/src/pyqt_reactive/forms/parameter_form_chrome_sync.py @@ -365,11 +365,11 @@ def refresh_widgets_for_paths(self, paths: Set[str]) -> set[str]: widget = manager.widgets.get(leaf_field) if isinstance(widget, ValueSettable): value = DottedFieldPath(path).bind(manager.state).raw - skip_context_behavior = FieldValue.of(value).inherited + skip_context_behavior = FieldValue.is_inherited(value) manager._widget_service.update_widget_value( widget, value, leaf_field, skip_context_behavior, manager, ) - if FieldValue.of(value).inherited: + if FieldValue.is_inherited(value): manager._parameter_ops_service.refresh_single_placeholder(manager, leaf_field) with TimeTravelProfiler.phase( diff --git a/src/pyqt_reactive/forms/widget_strategies.py b/src/pyqt_reactive/forms/widget_strategies.py index 46bc6527..9a6bdac8 100644 --- a/src/pyqt_reactive/forms/widget_strategies.py +++ b/src/pyqt_reactive/forms/widget_strategies.py @@ -127,7 +127,7 @@ def disconnect_change_signal(self, callback: ParameterSignalCallback) -> None: self.disconnect_debounced_text_signal(callback) def _signal_sources(self): - return ((self, self.textChanged),) + return ((self, self.textChanged, self._native_input),) class InheritedLineEdit( @@ -231,7 +231,7 @@ def configure_annotation(self, param_name: str, param_type: type) -> None: self._param_type = param_type def set_value(self, value: ParameterValue | None) -> None: - if not FieldValue.of(value).has_value: + if not FieldValue.of(value).available(): super().set_value(value) return value = self._project_container(value) @@ -420,7 +420,7 @@ def __init__(self, param_name: str, param_type: type, parent=None): ) def set_value(self, value: ParameterValue | None) -> None: - if not FieldValue.of(value).has_value: + if not FieldValue.of(value).available(): super().set_value(value) return projected = project_literal_edit_value(value) diff --git a/src/pyqt_reactive/protocols/widget_adapters.py b/src/pyqt_reactive/protocols/widget_adapters.py index e5755bca..04e0e327 100644 --- a/src/pyqt_reactive/protocols/widget_adapters.py +++ b/src/pyqt_reactive/protocols/widget_adapters.py @@ -21,8 +21,8 @@ from objectstate.field_value import FieldValue, Inherit, Unset, Value try: - from PyQt6.QtCore import QLocale, QObject - from PyQt6.QtGui import QKeySequence + from PyQt6.QtCore import QLocale, QObject, QSignalBlocker + from PyQt6.QtGui import QKeySequence, QValidator from PyQt6.QtWidgets import ( QComboBox, QDoubleSpinBox, @@ -95,9 +95,9 @@ def _signal_sources(self): return () def _ensure_input_tracking(self): - for owner, signal in self._signal_sources(): + for owner, signal, receiver in self._signal_sources(): if owner not in self._native_connections: - signal.connect(self._native_input) + signal.connect(receiver) self._native_connections[owner] = signal def _native_input(self, *arguments): @@ -118,9 +118,12 @@ def _apply_draft_style(self): def _read_concrete_value(self): raise TypeError(f"{type(self).__name__} does not own a concrete input control") - def _write_blank_value(self, value): + def _write_null_value(self): self._write_concrete_value(None) + def _write_blank_value(self, value): + self._write_null_value() + def get_value(self): self._ensure_input_tracking() return self._draft.resolve(lambda: Inherit()) @@ -128,21 +131,26 @@ def get_value(self): def set_value(self, value): self._ensure_input_tracking() draft = FieldValue.of(value) - if self._draft.inherited and not draft.inherited: - self.clear_placeholder_state() previous = self.blockSignals(True) try: + self.clear_placeholder_state() self._draft = draft - if draft.has_value: - self._write_concrete_value(value) - if value is not None: - self._draft = WidgetValue(self._read_concrete_value) - else: - self._write_blank_value(value) + draft.present_to(self) self._apply_draft_style() finally: self.blockSignals(previous) + def present_value(self, value): + """Paint concrete data; numeric controls provide their null view.""" + if value is None: + self._write_null_value() + else: + self._write_concrete_value(value) + self._draft = WidgetValue(self._read_concrete_value) + + def present_directive(self, value): + self._write_blank_value(value) + def connect_change_signal(self, callback): self._ensure_input_tracking() if callback not in self._input_callbacks: @@ -153,13 +161,13 @@ def disconnect_change_signal(self, callback): self._input_callbacks.remove(callback) def has_placeholder_state(self): - return self._draft.inherited + return FieldValue.is_inherited(self._draft) def mark_placeholder_state(self): self._draft = Inherit() def clear_placeholder_state(self): - if self._draft.inherited: + if FieldValue.is_inherited(self._draft): self.setStyleSheet("") self._draft = WidgetValue(self._read_concrete_value) self.clear_placeholder_cache() @@ -212,7 +220,7 @@ def set_placeholder(self, text: str) -> None: def _signal_sources(self): - return ((self, self.textChanged),) + return ((self, self.textChanged, self._native_input),) class KeySequenceEditAdapter( PlaceholderStateMixin, QKeySequenceEdit, @@ -241,70 +249,113 @@ def _write_concrete_value(self, value: Any) -> None: def _signal_sources(self): - return ((self, self.editingFinished),) + return ((self, self.editingFinished, self._native_input),) + + + class SpinBoxValueAdapter(PlaceholderStateMixin): + """Own numeric draft painting and native admission for both Qt spinboxes. + Qt special-value text is only a view at its numeric minimum. The draft + owns meaning: literal null displays None; directives display their + placeholder. A real minimum remains ordinary numeric data. + """ - class SpinBoxAdapter(PlaceholderStateMixin, QSpinBox, ValueGettable, ValueSettable, PlaceholderCapable, + def __init__(self, parent=None): + super().__init__(parent) + self.setRange(*self._default_range) + self._write_blank_value(self._draft) + self._ensure_input_tracking() + + def _read_concrete_value(self): + return self.value() + + def _write_concrete_value(self, value): + self.setSpecialValueText("") + self.setValue(self._numeric_type(value)) + + def _paint_special_text(self, text): + with QSignalBlocker(self): + self.setSpecialValueText(text) + self.setValue(self.minimum()) + + def _write_null_value(self): + self._paint_special_text("None") + + def _write_blank_value(self, value): + self._paint_special_text(self.lineEdit().placeholderText() or repr(value)) + + def set_placeholder(self, text): + self.lineEdit().setPlaceholderText(text) + with QSignalBlocker(self): + self._draft.present_to(self) + + def configure_range(self, minimum, maximum): + with QSignalBlocker(self): + self.setRange(self._numeric_type(minimum), self._numeric_type(maximum)) + self.set_value(self._draft) + + def _signal_sources(self): + return ( + (self, self.valueChanged, self._native_input), + (self.lineEdit(), self.lineEdit().textEdited, self._native_text_input), + ) + + def _native_text_input(self, text): + # Qt emits textEdited before updating its number. Changed numbers + # are admitted by valueChanged; only an unchanged number needs + # this path to turn a model directive/null into native input. + if isinstance(self._draft, WidgetValue): + return + state, _text, _position = self.validate(text, len(text)) + if state == QValidator.State.Acceptable and self.valueFromText(text) == self.value(): + self._native_input() + + def _prepare_input(self): + # Qt rewrites its editor even when assigning the same empty text. + # Retire the special paint once; do not repaint partial native input. + if self.specialValueText(): + self.setSpecialValueText("") + + + class SpinBoxAdapter(SpinBoxValueAdapter, QSpinBox, ValueGettable, ValueSettable, PlaceholderCapable, RangeConfigurable, ChangeSignalEmitter, metaclass=PyQtWidgetMeta): """ Adapter for QSpinBox implementing OpenHCS ABCs. - Handles None values using special value text mechanism. - When value is None, displays placeholder text at minimum value. + Shared draft behavior comes from SpinBoxValueAdapter. """ _widget_id = "spin_box" + _numeric_type = int + _default_range = (-2147483648, 2147483647) - def __init__(self, parent=None): - super().__init__(parent) - # Configure for None-aware behavior - self.setSpecialValueText(" ") # Empty special value = None - self.setRange(-2147483648, 2147483647) # Default int range - def _read_concrete_value(self): - return self.value() - def _write_concrete_value(self, value: Any) -> None: - """Implement ValueSettable ABC.""" - if value is None: - self.setSpecialValueText(" ") - self.setValue(self.minimum()) - else: - self.setSpecialValueText("") - self.setValue(int(value)) - def set_placeholder(self, text: str) -> None: - """Implement PlaceholderCapable ABC.""" - # For spinbox, placeholder is shown when at minimum with special value text - self.setSpecialValueText(text) - def configure_range(self, minimum: float, maximum: float) -> None: - """Implement RangeConfigurable ABC.""" - self.setRange(int(minimum), int(maximum)) - def _signal_sources(self): - return ((self, self.valueChanged),) - class DoubleSpinBoxAdapter(PlaceholderStateMixin, QDoubleSpinBox, ValueGettable, ValueSettable, + class DoubleSpinBoxAdapter(SpinBoxValueAdapter, QDoubleSpinBox, ValueGettable, ValueSettable, PlaceholderCapable, RangeConfigurable, ChangeSignalEmitter, metaclass=PyQtWidgetMeta): """ Adapter for QDoubleSpinBox implementing OpenHCS ABCs. - Handles None values and floating-point ranges. + Shared draft behavior with localized floating-point presentation. """ _widget_id = "double_spin_box" + _numeric_type = float + _default_range = (-1e308, 1e308) def __init__(self, parent=None): from pyqt_reactive.forms.widget_strategies import WidgetConfig super().__init__(parent) - self.setSpecialValueText(" ") - self.setRange(-1e308, 1e308) # Default float range - self.setDecimals(WidgetConfig.FLOAT_PRECISION) # Use configured precision + with QSignalBlocker(self): + self.setDecimals(WidgetConfig.FLOAT_PRECISION) def textFromValue(self, value: float) -> str: # noqa: N802 - Qt virtual method """Display the shortest localized decimal that preserves the value.""" @@ -319,30 +370,12 @@ def textFromValue(self, value: float) -> str: # noqa: N802 - Qt virtual method value, "f", QLocale.FloatingPointPrecisionOption.FloatingPointShortest ) - def _read_concrete_value(self): - return self.value() - def _write_concrete_value(self, value: Any) -> None: - """Implement ValueSettable ABC.""" - if value is None: - self.setSpecialValueText(" ") - self.setValue(self.minimum()) - else: - self.setSpecialValueText("") - self.setValue(float(value)) - def set_placeholder(self, text: str) -> None: - """Implement PlaceholderCapable ABC.""" - self.setSpecialValueText(text) - def configure_range(self, minimum: float, maximum: float) -> None: - """Implement RangeConfigurable ABC.""" - self.setRange(minimum, maximum) - def _signal_sources(self): - return ((self, self.valueChanged),) class ComboBoxAdapter(PlaceholderStateMixin, QComboBox, ValueGettable, ValueSettable, PlaceholderCapable, @@ -394,7 +427,7 @@ def populate_enum(self, enum_type: type) -> None: def _signal_sources(self): - return ((self, self.currentIndexChanged),) + return ((self, self.currentIndexChanged, self._native_input),) class CheckBoxAdapter(PlaceholderStateMixin, ThemedCheckBox, ValueGettable, ValueSettable, @@ -418,7 +451,7 @@ def _write_concrete_value(self, value: Any) -> None: def _signal_sources(self): - return ((self, self.stateChanged),) + return ((self, self.stateChanged, self._native_input),) class CheckboxGroupAdapter(PlaceholderStateMixin, QGroupBox, ValueGettable, ValueSettable, @@ -482,7 +515,7 @@ def set_resolved_value_preview(self, value: Any) -> None: def _signal_sources(self): - return tuple((checkbox, checkbox.stateChanged) for checkbox in self.checkbox_widgets()) + return tuple((checkbox, checkbox.stateChanged, self._native_input) for checkbox in self.checkbox_widgets()) def _prepare_input(self): for checkbox in self.checkbox_widgets(): diff --git a/src/pyqt_reactive/services/enabled_field_styling_service.py b/src/pyqt_reactive/services/enabled_field_styling_service.py index 6fe3b9fd..3814ac94 100644 --- a/src/pyqt_reactive/services/enabled_field_styling_service.py +++ b/src/pyqt_reactive/services/enabled_field_styling_service.py @@ -99,7 +99,7 @@ def on_enabled_field_changed(self, manager, field: FieldProxy) -> None: @staticmethod def _is_enabled(field: FieldProxy) -> bool: value = field.value - return FieldValue.of(value).has_value and bool(value) + return FieldValue.of(value).available() and bool(value) def _paint_enabled_scope(self, manager, field: FieldProxy) -> None: effective = self._is_enabled(field) and not self._is_any_ancestor_disabled(manager) diff --git a/src/pyqt_reactive/services/widget_service.py b/src/pyqt_reactive/services/widget_service.py index c33038ae..67da96db 100644 --- a/src/pyqt_reactive/services/widget_service.py +++ b/src/pyqt_reactive/services/widget_service.py @@ -317,7 +317,7 @@ def show_resolved_value_as_placeholder(self, widget: QWidget, param_name: str, m full_path = f"{manager.field_id}.{param_name}" if manager.field_id else param_name field = DottedFieldPath(full_path).bind(manager.state) raw_value = field.raw - if not FieldValue.of(raw_value).inherited: + if not FieldValue.is_inherited(raw_value): if self.widget_enhancer.has_placeholder_state(widget): self._dispatch_widget_update(widget, raw_value) return False diff --git a/src/pyqt_reactive/widgets/enhanced_path_widget.py b/src/pyqt_reactive/widgets/enhanced_path_widget.py index f3581e07..5e26216f 100644 --- a/src/pyqt_reactive/widgets/enhanced_path_widget.py +++ b/src/pyqt_reactive/widgets/enhanced_path_widget.py @@ -395,4 +395,4 @@ def _open_dialog(self): logger.error(f"Failed to open dialog: {e}") def _signal_sources(self): - return ((self, self.path_changed),) + return ((self, self.path_changed, self._native_input),) diff --git a/src/pyqt_reactive/widgets/no_scroll_spinbox.py b/src/pyqt_reactive/widgets/no_scroll_spinbox.py index 849c7ce2..f1878f4d 100644 --- a/src/pyqt_reactive/widgets/no_scroll_spinbox.py +++ b/src/pyqt_reactive/widgets/no_scroll_spinbox.py @@ -158,7 +158,7 @@ def _write_concrete_value(self, value): self.setChecked(bool(value)) def _signal_sources(self): - return ((self, self.stateChanged),) + return ((self, self.stateChanged, self._native_input),) def _apply_draft_style(self): if self.has_placeholder_state(): diff --git a/tests/test_numeric_field_meaning.py b/tests/test_numeric_field_meaning.py new file mode 100644 index 00000000..d6b3b03c --- /dev/null +++ b/tests/test_numeric_field_meaning.py @@ -0,0 +1,86 @@ +"""Qt numeric storage is a view; the field member owns null/inherit/absence.""" +import pytest +from PyQt6.QtCore import QEvent +from PyQt6.QtTest import QTest +from objectstate import Inherit, Unset +from objectstate.field_value import Value +from pyqt_reactive.protocols import SpinBoxAdapter, DoubleSpinBoxAdapter +from pyqt_reactive.widgets.no_scroll_spinbox import NoScrollSpinBox, NoScrollDoubleSpinBox + + +@pytest.fixture(params=[SpinBoxAdapter, DoubleSpinBoxAdapter, NoScrollSpinBox, NoScrollDoubleSpinBox]) +def numeric(qapp, request): + widget = request.param() + widget.show() + qapp.processEvents() + try: + yield widget + finally: + widget.close() + widget.deleteLater() + qapp.sendPostedEvents(None, QEvent.Type.DeferredDelete) + + +def test_painting_retains_each_member_and_real_minimum_without_input(numeric): + calls = [] + numeric.connect_change_signal(calls.append) + for member, expected in [(Inherit(), Inherit()), (Unset(), Unset()), (None, None), (Value(None), None), (Value(7), 7), (0, 0), (numeric.minimum(), numeric.minimum())]: + numeric.set_value(member) + assert numeric.get_value() == expected + assert numeric.specialValueText() == '' + assert calls == [] + numeric.set_value(None) + assert numeric.text() == 'None' + numeric.set_placeholder('Inherited: 77') + assert numeric.get_value() is None and numeric.text() == 'None' + numeric.set_value(Inherit()) + assert numeric.text() == 'Inherited: 77' + assert calls == [] + + +def test_native_typing_admits_zero_once_and_a_real_minimum(numeric): + numeric.configure_range(-10, 10) + calls = [] + numeric.connect_change_signal(calls.append) + for member in (None, Inherit(), Unset()): + numeric.set_value(member) + calls.clear() + numeric.lineEdit().selectAll() + QTest.keyClicks(numeric.lineEdit(), '0') + assert numeric.get_value() == 0 + assert calls == [0] + numeric.set_value(member) + numeric.lineEdit().selectAll() + QTest.keyClicks(numeric.lineEdit(), '-10') + assert numeric.get_value() == -10 + assert numeric.specialValueText() == '' + assert calls[-1] == -10 + + +def test_native_minimum_is_admitted_when_qt_number_does_not_change(numeric): + numeric.configure_range(0, 10) + calls = [] + numeric.connect_change_signal(calls.append) + for member in (None, Inherit(), Unset()): + numeric.set_value(member) + calls.clear() + assert numeric.value() == 0 + numeric.lineEdit().selectAll() + QTest.keyClicks(numeric.lineEdit(), '0') + assert numeric.get_value() == 0 + assert calls == [0] + + +def test_range_configuration_does_not_commit_null_or_emit_input(numeric): + calls = [] + numeric.connect_change_signal(calls.append) + numeric.set_value(None) + numeric.configure_range(-5, 5) + assert numeric.get_value() is None and numeric.text() == 'None' + numeric.blockSignals(True) + numeric.set_value(Unset()) + numeric.configure_range(-3, 3) + assert numeric.signalsBlocked() + numeric.blockSignals(False) + assert isinstance(numeric.get_value(), Unset) + assert calls == [] diff --git a/tests/test_numeric_roundtrip.py b/tests/test_numeric_roundtrip.py index e4ace1bb..b1414868 100644 --- a/tests/test_numeric_roundtrip.py +++ b/tests/test_numeric_roundtrip.py @@ -4,6 +4,7 @@ from PyQt6.QtCore import QLocale, Qt from PyQt6.QtTest import QTest +from objectstate import Inherit from pyqt_reactive.protocols import DoubleSpinBoxAdapter from pyqt_reactive.widgets import NoScrollDoubleSpinBox @@ -60,9 +61,12 @@ def test_placeholder_and_configured_decimal_precision_are_preserved(qapp): widget = NoScrollDoubleSpinBox() widget.setLocale(QLocale.c()) widget.set_placeholder("Inherited") + widget.set_value(Inherit()) + assert isinstance(widget.get_value(), Inherit) + assert widget.text() == "Inherited" widget.set_value(None) assert widget.get_value() is None - assert widget.text() == "Inherited" + assert widget.text() == "None" widget.setDecimals(2) widget.set_value(1.234) assert widget.get_value() == 1.23 From f0acb19410c9e59cfcbda5322bd8f554021ffd80 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 16 Oct 2026 11:54:42 -0400 Subject: [PATCH 5/7] Resolve form configuration types through the shared field-access owner --- src/pyqt_reactive/forms/parameter_form_manager.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/pyqt_reactive/forms/parameter_form_manager.py b/src/pyqt_reactive/forms/parameter_form_manager.py index 7b54ca37..27f733a8 100644 --- a/src/pyqt_reactive/forms/parameter_form_manager.py +++ b/src/pyqt_reactive/forms/parameter_form_manager.py @@ -1,7 +1,7 @@ """PyQt parameter form manager - VIEW layer for ObjectState MODEL.""" import logging -from dataclasses import dataclass, is_dataclass, replace +from dataclasses import dataclass, replace from typing import TYPE_CHECKING, Callable, Dict, List, Optional, Set, Type # FlashableGroupBox not extracted - OpenHCS specific @@ -119,7 +119,7 @@ def raw_path(cls, root, field_path: str): @classmethod def raw_value(cls, instance, field_name: str): - if is_dataclass(type(instance)): + if DataclassFieldAccess.is_container(instance): return DataclassFieldAccess.raw_value(instance, field_name) if type(instance).__dictoffset__ == 0: @@ -140,7 +140,7 @@ class ParameterFormTypeResolver: 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 value = DottedFieldPath(dotted_path).bind(manager.state).value - return type(value) if is_dataclass(value) else signature_type + return type(value) if DataclassFieldAccess.is_container(value) else signature_type @staticmethod def scoped_parameters(fields) -> ParameterDefaultsByName: From 869006063a50dac2d87d7ccefc6dc058b47a1749 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 16 Oct 2026 15:22:32 -0400 Subject: [PATCH 6/7] Use declared field types and info-owned widget selection --- .../forms/inline_dataclass_context.py | 5 +- .../forms/parameter_form_manager.py | 65 ++----------------- .../forms/widget_creation_config.py | 2 +- .../protocols/widget_adapters.py | 2 +- .../services/enabled_field_styling_service.py | 30 +-------- src/pyqt_reactive/services/widget_service.py | 16 ++--- .../widgets/shared/config_hierarchy_tree.py | 40 ++++-------- 7 files changed, 32 insertions(+), 128 deletions(-) diff --git a/src/pyqt_reactive/forms/inline_dataclass_context.py b/src/pyqt_reactive/forms/inline_dataclass_context.py index bf06ff3a..ed2b583f 100644 --- a/src/pyqt_reactive/forms/inline_dataclass_context.py +++ b/src/pyqt_reactive/forms/inline_dataclass_context.py @@ -6,7 +6,8 @@ from typing import TYPE_CHECKING from objectstate import DottedFieldPath -from objectstate.lazy_factory import get_base_type_for_lazy, replace_raw +from objectstate.lazy_factory import replace_raw +from python_introspect import resolve_declared_type if TYPE_CHECKING: from objectstate import ObjectState @@ -57,7 +58,7 @@ def from_inline_widget( if manager.field_id else DottedFieldPath(param_info.name) ) - base_type = get_base_type_for_lazy(type(current_value)) or param_info.type + base_type = resolve_declared_type(param_info.type) return cls( state=manager.state, manager=manager, diff --git a/src/pyqt_reactive/forms/parameter_form_manager.py b/src/pyqt_reactive/forms/parameter_form_manager.py index 27f733a8..c4ec3ed4 100644 --- a/src/pyqt_reactive/forms/parameter_form_manager.py +++ b/src/pyqt_reactive/forms/parameter_form_manager.py @@ -6,7 +6,6 @@ # FlashableGroupBox not extracted - OpenHCS specific from objectstate import ( - DataclassFieldAccess, DottedFieldPath, FieldAccessError, FieldProxy, @@ -107,47 +106,6 @@ class FormManagerConfig: before_mutation: Optional[Callable[[], None]] = None -class FormTargetPathAccess: - """Path resolver for form targets that may be dataclasses or object-backed.""" - - @classmethod - def raw_path(cls, root, field_path: str): - current = root - for part in tuple(part for part in field_path.split(".") if part): - current = cls.raw_value(current, part) - return current - - @classmethod - def raw_value(cls, instance, field_name: str): - if DataclassFieldAccess.is_container(instance): - return DataclassFieldAccess.raw_value(instance, field_name) - - if type(instance).__dictoffset__ == 0: - raise FieldAccessError(f"{type(instance).__name__} has no addressable instance fields.") - - storage = vars(instance) - if field_name not in storage: - raise FieldAccessError( - f"{type(instance).__name__}.{field_name} is not stored on the instance." - ) - return storage[field_name] - - -class ParameterFormTypeResolver: - """Resolve visible form field types from ObjectState and signature authorities.""" - - @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 - value = DottedFieldPath(dotted_path).bind(manager.state).value - return type(value) if DataclassFieldAccess.is_container(value) else signature_type - - @staticmethod - def scoped_parameters(fields) -> ParameterDefaultsByName: - """Project admitted direct fields into this form's input-name boundary.""" - return ParameterDefaultsByName((field.name, field.raw) for field in fields.children) - - class ParameterFormManager( QWidget, ParameterFormManagerABC, @@ -211,7 +169,7 @@ class ParameterFormManager( @property def parameters(self) -> ParameterDefaultsByName: """Project admitted raw drafts onto this form's local input names.""" - return ParameterFormTypeResolver.scoped_parameters(self.fields) + return ParameterDefaultsByName((field.name, field.raw) for field in self.fields.children) @property def form_build_complete(self) -> bool: @@ -313,13 +271,8 @@ def __init__(self, state: "ObjectState", config: Optional[FormManagerConfig] = N # _extraction_target is editable config object (e.g., PipelineConfig) target_obj = state.saved_object if self.field_id: - resolved_target = DottedFieldPath(self.field_id).bind(state).value - if resolved_target is not None: - target_obj = resolved_target - elif config.function_target is not None: - target_obj = config.function_target - else: - target_obj = FormTargetPathAccess.raw_path(target_obj, self.field_id) + target_obj = (config.function_target if config.function_target is not None + else DottedFieldPath(self.field_id).bind(state).value) from python_introspect import is_enableable @@ -412,12 +365,8 @@ def __init__(self, state: "ObjectState", config: Optional[FormManagerConfig] = N param_info_dict = UnifiedParameterAnalyzer.analyze( target_obj, exclude_params=config.exclude_params ) - scoped_parameters = ParameterFormTypeResolver.scoped_parameters(self.fields) - derived_param_types = { - name: ParameterFormTypeResolver.field_type(self, name, info.param_type) - for name, info in param_info_dict.items() - if name in scoped_parameters - } + scoped_parameters = self.parameters + derived_param_types = {field.name: field.annotation for field in self.fields.children} # Include enabled field in normal processing (will be moved to title later) default_value = scoped_parameters @@ -652,11 +601,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 info_class=%s", self._pfm_seq, self.field_id, param_info.name, - param_info.widget_creation_type, + type(param_info).__name__, ) return WidgetCreationPipeline(self, param_info).run() diff --git a/src/pyqt_reactive/forms/widget_creation_config.py b/src/pyqt_reactive/forms/widget_creation_config.py index 60413ba4..7b3a9b5d 100644 --- a/src/pyqt_reactive/forms/widget_creation_config.py +++ b/src/pyqt_reactive/forms/widget_creation_config.py @@ -1049,7 +1049,7 @@ def __init__(self, manager: ParameterFormManager, param_info: ParameterInfo) -> 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] + creation_type = param_info.creation_type(WidgetCreationType) config = _WIDGET_CREATION_CONFIG[creation_type] display_info = WidgetService.get_parameter_display_info( param_info.name, diff --git a/src/pyqt_reactive/protocols/widget_adapters.py b/src/pyqt_reactive/protocols/widget_adapters.py index 04e0e327..893fd193 100644 --- a/src/pyqt_reactive/protocols/widget_adapters.py +++ b/src/pyqt_reactive/protocols/widget_adapters.py @@ -126,7 +126,7 @@ def _write_blank_value(self, value): def get_value(self): self._ensure_input_tracking() - return self._draft.resolve(lambda: Inherit()) + return self._draft.resolve(lambda: Inherit()).stored def set_value(self, value): self._ensure_input_tracking() diff --git a/src/pyqt_reactive/services/enabled_field_styling_service.py b/src/pyqt_reactive/services/enabled_field_styling_service.py index 3814ac94..8c01c06f 100644 --- a/src/pyqt_reactive/services/enabled_field_styling_service.py +++ b/src/pyqt_reactive/services/enabled_field_styling_service.py @@ -74,7 +74,7 @@ def _enabled_field(manager): def apply_initial_enabled_styling(self, manager) -> None: """Paint this materialized scope from its canonical effective value.""" - if self._should_skip_optional_dataclass_styling(manager, "INITIAL ENABLED STYLING"): + if not manager.fields.scope_available: return self._last_enabled_values.pop(manager, None) field = self._enabled_field(manager) @@ -83,7 +83,7 @@ def apply_initial_enabled_styling(self, manager) -> None: def refresh_enabled_styling(self, manager) -> None: """Own the single enabled-paint traversal for this form subtree.""" - if self._should_skip_optional_dataclass_styling(manager, "REFRESH ENABLED STYLING"): + if not manager.fields.scope_available: return field = self._enabled_field(manager) if field is not None: @@ -113,32 +113,6 @@ def _paint_enabled_scope(self, manager, field: FieldProxy) -> None: self._apply_top_level_styling(manager, effective, direct_widgets) - def _should_skip_optional_dataclass_styling(self, manager, log_prefix: str) -> bool: - """ - Check if this is a nested manager inside an optional dataclass with None instance. - - Args: - manager: ParameterFormManager instance - log_prefix: Prefix for log messages - - Returns: - True if styling should be skipped, False otherwise - """ - if manager._parent_manager is not None: - for param_name, nested_manager in manager._parent_manager.nested_managers.items(): - if nested_manager is manager: - param_type = manager._parent_manager.parameter_types.get(param_name) - if param_type: - from formbridge.forms.parameter_type_utils import ParameterTypeUtils - if ParameterTypeUtils.is_optional_dataclass(param_type): - instance = manager._parent_manager.parameters.get(param_name) - logger.debug(f"[{log_prefix}] field_id={manager.field_id}, optional dataclass check: param_name={param_name}, instance={instance}, is_none={instance is None}") - if instance is None: - logger.debug(f"[{log_prefix}] field_id={manager.field_id}, skipping (optional dataclass instance is None)") - return True - break - return False - def _get_direct_widgets(self, manager): """ Get widgets that belong to this form, excluding nested ParameterFormManager widgets. diff --git a/src/pyqt_reactive/services/widget_service.py b/src/pyqt_reactive/services/widget_service.py index 67da96db..04622e99 100644 --- a/src/pyqt_reactive/services/widget_service.py +++ b/src/pyqt_reactive/services/widget_service.py @@ -316,18 +316,16 @@ def show_resolved_value_as_placeholder(self, widget: QWidget, param_name: str, m full_path = f"{manager.field_id}.{param_name}" if manager.field_id else param_name field = DottedFieldPath(full_path).bind(manager.state) - raw_value = field.raw - if not FieldValue.is_inherited(raw_value): + raw = field.raw_member + resolved = field.resolved_member + if not raw.inherited_from(resolved): if self.widget_enhancer.has_placeholder_state(widget): - self._dispatch_widget_update(widget, raw_value) + self._dispatch_widget_update(widget, raw.stored) return False - resolved_value = field.value - placeholder_text = LazyDefaultPlaceholderService._format_placeholder_text( - resolved_value, manager.config.placeholder_prefix + placeholder_text = LazyDefaultPlaceholderService.format_placeholder( + resolved, manager.config.placeholder_prefix ) - if not placeholder_text: - return False - self.widget_enhancer.apply_placeholder_with_value(widget, resolved_value, placeholder_text) + self.widget_enhancer.apply_placeholder_with_value(widget, resolved.stored, placeholder_text) return True def clear_widget_to_default_state(self, widget: QWidget) -> None: diff --git a/src/pyqt_reactive/widgets/shared/config_hierarchy_tree.py b/src/pyqt_reactive/widgets/shared/config_hierarchy_tree.py index 67721537..adf3e112 100644 --- a/src/pyqt_reactive/widgets/shared/config_hierarchy_tree.py +++ b/src/pyqt_reactive/widgets/shared/config_hierarchy_tree.py @@ -9,6 +9,8 @@ from __future__ import annotations +from objectstate.ui_visibility import UIParameterVisibilityPolicy + import logging from abc import ABC, abstractmethod from dataclasses import dataclass, fields, is_dataclass @@ -21,7 +23,7 @@ from PyQt6.QtWidgets import QTreeWidget, QTreeWidgetItem, QStyledItemDelegate, QStyleOptionViewItem, QStyle from metaclass_registry import AutoRegisterMeta -from objectstate.lazy_factory import get_base_type_for_lazy, is_lazy_dataclass +from python_introspect import resolve_declared_type from objectstate import is_ui_hidden_config_type from pyqt_reactive.widgets.shared.config_tree_contracts import ( ConfigTreeFlashManager, @@ -581,7 +583,7 @@ def populate_from_mapping( """Populate the tree given a dict of field_name -> dataclass type.""" self._current_tree = tree for field_name, obj_type in dataclass_mapping.items(): - base_type = self.get_base_type(obj_type) + base_type = resolve_declared_type(obj_type) label = self._format_label(base_type.__name__) path = field_name alt_path = self._to_snake_case(base_type.__name__) @@ -624,7 +626,7 @@ def _add_ui_visible_dataclasses_to_tree( if not is_dataclass(field_type): continue - base_type = self.get_base_type(field_type) + base_type = resolve_declared_type(field_type) display_name = self._format_label(base_type.__name__) ui_hidden = self.is_field_ui_hidden(obj_type, field.name, field_type) @@ -706,32 +708,12 @@ def is_field_ui_hidden( field_type: Type, ) -> bool: """Return True if a field should be hidden in the tree.""" - try: - field_obj = next(f for f in fields(obj_type) if f.name == field_name) - if "ui_hidden" in field_obj.metadata and bool( - field_obj.metadata["ui_hidden"] - ): - return True - except (StopIteration, TypeError): - pass - - base_type = self.get_base_type(field_type) - if is_ui_hidden_config_type(base_type): + field_obj = {item.name: item for item in fields(obj_type)}[field_name] + if UIParameterVisibilityPolicy.field_hidden(field_obj): return True - return False - - def get_base_type(self, obj_type: Type) -> Type: - """Return the non-lazy base type for a dataclass. - - Lazy dataclass identity is provided by ObjectState's LazyDataclass base - and registry, not by class-name shape. - """ - if is_lazy_dataclass(obj_type): - base_type = get_base_type_for_lazy(obj_type) - if base_type is not None: - return base_type - return obj_type + base_type = resolve_declared_type(field_type) + return is_ui_hidden_config_type(base_type) def activate_item( self, @@ -798,7 +780,7 @@ def annotation_matches_class(self, annotation, target_class: Type) -> bool: for candidate_type in self._annotation_candidate_types(annotation): if candidate_type is target_class: return True - if self.get_base_type(candidate_type) is target_class: + if resolve_declared_type(candidate_type) is target_class: return True return False @@ -826,7 +808,7 @@ def add_inheritance_info( if not is_dataclass(cls): continue - base_type = self.get_base_type(cls) + base_type = resolve_declared_type(cls) direct_bases.append(base_type) for base_class in direct_bases: From da2449ab7c1d55c7fcda4388d7562d7b4b8cd15e Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 16 Oct 2026 23:31:51 -0400 Subject: [PATCH 7/7] Let admitted members own widget inheritance decisions --- src/pyqt_reactive/forms/parameter_form_chrome_sync.py | 7 ++++--- src/pyqt_reactive/protocols/widget_adapters.py | 4 ++-- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/src/pyqt_reactive/forms/parameter_form_chrome_sync.py b/src/pyqt_reactive/forms/parameter_form_chrome_sync.py index 96a759d0..1beff78e 100644 --- a/src/pyqt_reactive/forms/parameter_form_chrome_sync.py +++ b/src/pyqt_reactive/forms/parameter_form_chrome_sync.py @@ -364,12 +364,13 @@ def refresh_widgets_for_paths(self, paths: Set[str]) -> set[str]: if path_prefix == manager.field_id: widget = manager.widgets.get(leaf_field) if isinstance(widget, ValueSettable): - value = DottedFieldPath(path).bind(manager.state).raw - skip_context_behavior = FieldValue.is_inherited(value) + member = DottedFieldPath(path).bind(manager.state).raw_member + value = member.stored + skip_context_behavior = member.inherits() manager._widget_service.update_widget_value( widget, value, leaf_field, skip_context_behavior, manager, ) - if FieldValue.is_inherited(value): + if member.inherits(): manager._parameter_ops_service.refresh_single_placeholder(manager, leaf_field) with TimeTravelProfiler.phase( diff --git a/src/pyqt_reactive/protocols/widget_adapters.py b/src/pyqt_reactive/protocols/widget_adapters.py index 893fd193..7b841886 100644 --- a/src/pyqt_reactive/protocols/widget_adapters.py +++ b/src/pyqt_reactive/protocols/widget_adapters.py @@ -161,13 +161,13 @@ def disconnect_change_signal(self, callback): self._input_callbacks.remove(callback) def has_placeholder_state(self): - return FieldValue.is_inherited(self._draft) + return self._draft.inherits() def mark_placeholder_state(self): self._draft = Inherit() def clear_placeholder_state(self): - if FieldValue.is_inherited(self._draft): + if self._draft.inherits(): self.setStyleSheet("") self._draft = WidgetValue(self._read_concrete_value) self.clear_placeholder_cache()