Field Change Dispatcher Architecture
Unified event-driven architecture for parameter form field changes.
Overview
The FieldChangeDispatcher centralizes all field change handling in parameter forms,
replacing scattered callback connections with a single event-driven dispatch point.
This architecture eliminates “callback spaghetti” and provides consistent behavior
for sibling inheritance, cross-window updates, and nested form propagation.
Problem Statement
Prior to the dispatcher, field changes were handled through multiple overlapping paths:
_emit_parameter_change()- Local signal emission_on_nested_parameter_changed()- Parent notification for nested changes_emit_cross_window_change()- Cross-window context updatesVarious signal connections in
SignalService
This caused several bugs:
First keystroke missed: Sibling placeholders didn’t update on first input because parent chain wasn’t marked modified before sibling refresh
Reset broke inheritance: Individual field reset cleared sibling-inherited placeholders because it bypassed proper context building
Non-dataclass roots excluded:
FunctionStepand other non-dataclass roots couldn’t participate in sibling inheritance
Solution: Event-Driven Dispatch
All field changes now flow through a single FieldChangeEvent:
@dataclass
class FieldChangeEvent:
field_name: str # Leaf field name
value: Any # New value
source_manager: ParameterFormManager # Where change originated
is_reset: bool = False # True if reset operation
The FieldChangeDispatcher (singleton, stateless) handles all events:
from pyqt_reactive.services.field_change_dispatcher import (
FieldChangeDispatcher, FieldChangeEvent
)
# Widget change handler
def on_widget_change(param_name, value, manager):
converted_value = manager._convert_widget_value(value, param_name)
event = FieldChangeEvent(param_name, converted_value, manager)
FieldChangeDispatcher.instance().dispatch(event)
# Reset operation
event = FieldChangeEvent(param_name, reset_value, manager, is_reset=True)
FieldChangeDispatcher.instance().dispatch(event)
Dispatch Flow
When dispatch(event) is called:
Update Source Data Model: -
source.parameters[field_name] = value- Add/remove from_user_set_fieldsbased onis_resetMark Parent Chain Modified: - Walk up
_parent_managerchain - Update each parent’sparameterswith collected nested value - Add nested field name to parent’s_user_set_fields- This ensuresroot.get_user_modified_values()includes the new valueRefresh Sibling Placeholders: - Find siblings via
parent.nested_managers- For each sibling with same field name, callrefresh_single_placeholder()- Skip if field is in sibling’s_user_set_fields(user-set value preserved)Apply Enabled Styling: - If
field_name == 'enabled', apply visual stylingUpdate Visual Indicators: - Reset button
*and_styling for all fields in the manager - Provenance button visibility (show/hide based on provenance availability) - See Field Styling and Visual Indicators Architecture for detailed semanticsEmit Local Signal: -
source.parameter_changed.emit(field_name, value)
Emit Cross-Window Signal: - Build full path:
"Root.nested.field_name"- Update thread-local global config if editing global config -root.context_value_changed.emit(full_path, value, ...)
Sibling Inheritance via Root Form
The dispatcher enables sibling inheritance through the build_context_stack()
function in context_manager.py:
# Find root manager (walk up parent chain)
root_manager = manager
while root_manager._parent_manager is not None:
root_manager = root_manager._parent_manager
# Get root's values (contains all sibling configs)
root_values = root_manager.get_user_modified_values()
# Build context stack with root form values
stack = build_context_stack(
context_obj=manager.context_obj,
overlay=manager.parameters,
root_form_values=root_values,
root_form_type=root_manager.dataclass_type,
...
)
For non-dataclass roots (e.g., FunctionStep), the stack builder wraps values
in a SimpleNamespace to maintain a unified code path:
if root_form_type and is_dataclass(root_form_type):
root_instance = root_form_type(**root_form_values)
else:
# Non-dataclass root - wrap in SimpleNamespace
root_instance = SimpleNamespace(**root_form_values)
stack.enter_context(config_context(root_instance))
This allows FunctionStep parameters (like step_well_filter_config) to
participate in sibling inheritance just like dataclass-based configurations.
Integration Points
Widget Creation (widget_creation_config.py):
def on_widget_change(pname, value, mgr=manager):
converted_value = mgr._convert_widget_value(value, pname)
event = FieldChangeEvent(pname, converted_value, mgr)
FieldChangeDispatcher.instance().dispatch(event)
PyQt6WidgetEnhancer.connect_change_signal(widget, param_name, on_widget_change)
Parameter Updates (parameter_form_manager.py):
def update_parameter(self, param_name: str, value: Any) -> None:
# ... convert value, update widget ...
event = FieldChangeEvent(param_name, converted_value, self)
FieldChangeDispatcher.instance().dispatch(event)
Reset Operations (parameter_ops_service.py):
def _reset_GenericInfo(self, info, manager) -> None:
# ... update parameters, tracking ...
if reset_value is None:
self.refresh_single_placeholder(manager, param_name)
Benefits
Single Entry Point: All changes flow through one dispatcher
Consistent Ordering: Parent marking always before sibling refresh
Reentrancy Safe: Guard prevents recursive dispatch
Debug Friendly: Centralized logging with
DEBUG_DISPATCHERflagFramework Agnostic: Core logic in
build_context_stack()works with any UI
See Also
Field Styling and Visual Indicators Architecture - Visual indicators (*, _, ^) and reset button styling
UI Services Architecture - UI service layer overview
Parameter Form Lifecycle Management - Form lifecycle management
context_system - Configuration context and inheritance