TabbedFormWidget Registry Pattern
Dynamic tabbed forms with registry-based config discovery and reactive state management.
Status: STABLE Module: pyqt_reactive.widgets.shared
Overview
The TabbedFormWidget provides a registry-based, dynamic architecture for creating tabbed configuration interfaces that automatically discover and adapt to registered config types. This pattern eliminates hardcoded config types and enables extensibility without UI code changes.
The Hardcoding Problem: Traditional tabbed UI implementations hardcode config types throughout the codebase, requiring code changes in multiple files for each new config type. This creates maintenance burden and tight coupling between config implementations and UI code.
The pyqt-reactive Solution: A registry-driven architecture where configs self-register via metaclass, and UI components dynamically discover and adapt to available configs. Adding a new config type requires only registering the config class—zero UI code changes.
Key Innovation: The config registry serves as the single source of truth, with display names, tab creation, button generation, and signal handling all derived dynamically from registry keys.
Core Architectural Patterns
Dynamic Config Discovery Pattern
Problem: Hardcoded dataclasses require code changes for each new config type.
Solution: Dynamic config container built from a config registry
def _create_config_container(registry):
"""Create config container with configs from registry."""
from types import SimpleNamespace
config = SimpleNamespace()
# Auto-discover configs from registry
for field_name in registry.keys():
config_class = registry[field_name]
instance = config_class() # May use lazy resolution
setattr(config, field_name, instance)
return config
Key Points:
Uses
SimpleNamespacefor dynamic attribute assignmentRegistry keys are snake_case field names (e.g.,
'napari_streaming_config')Configs may use lazy resolution through ObjectState hierarchy
Adding new config = just register the class, zero UI code changes
Before:
@dataclass
class AppConfig:
napari_config: NapariConfig = field(default_factory=...)
fiji_config: FijiConfig = field(default_factory=...)
After:
self.config = _create_config_container(ConfigRegistry) # Dynamic discovery
TabbedFormWidget Integration Pattern
Problem: Manual QTabWidget creation requires duplicated code for each config.
Solution: Reusable TabbedFormWidget abstraction with dynamic tab generation
# Create a tab for each config type
tabs = []
for field_name in registry.keys():
display_name = _get_display_name(field_name)
tabs.append(TabConfig(
label=display_name,
field_id=field_name,
exclude_params=[other for other in registry.keys() if other != field_name]
))
tabbed_config = TabbedFormConfig(
tabs=tabs,
color_scheme=color_scheme,
use_scroll_area=True,
header_widgets=header_widgets # Buttons on same row as tabs
)
tabbed_form = TabbedFormWidget(state=state, config=tabbed_config)
Key Points:
Each tab shows a single config (one
field_idper tab)exclude_paramsprevents other configs from appearing in each tabTabbedFormWidgetcreates ParameterFormManager for each tab automaticallyAll tabs share the same root
ObjectStateheader_widgetsfeature places widgets in tab bar corner using Qt’ssetCornerWidget()
Benefits:
Eliminates duplicated form creation code
Automatic scroll area wrapping per tab
Consistent styling via
color_schemeSingle signal connection point for all parameter changes
Registry-Based Type System
Problem: Mixing short names, config keys, and registry keys caused type mismatches.
Solution: Use registry keys as canonical identifiers throughout
Registry Key Format: {type_name}_config (snake_case)
Example:
'napari_streaming_config','fiji_streaming_config'
Display Name Derivation:
def _get_display_name(field_name: str) -> str:
\"\"\"Convert snake_case field name to display name.\"\"\"
# Remove common suffixes
name = field_name.replace('_config', '').replace('_streaming_config', '')
return name.replace('_', ' ').title()
# 'napari_streaming_config' -> 'Napari Streaming'
Key Points:
Registry keys are the canonical identifier
Display names derived from registry keys for UI
Single source of truth prevents type mismatches
Type-safe lookups via registry
Reactive State Management Pattern
Problem: UI widgets didn’t update when user changed related parameters.
Solution: Connect parameter_changed signal to update widget states in real-time
# Signal connection
tabbed_form.parameter_changed.connect(self._on_parameter_changed)
# Handler
def _on_parameter_changed(self, param_name: str, value: object):
# Strip leading dot (root PFM emits paths like \".config_type.enabled\")
normalized_param = param_name.lstrip('.')
for config_type in self.buttons.keys():
enabled_path = f\"{config_type}.enabled\"
if normalized_param == enabled_path:
self._update_button_state(config_type)
break
def _update_button_state(self, config_type: str):
\"\"\"Update button based on conditions AND enabled state.\"\"\"
meets_conditions = self._check_conditions()
is_enabled = self._is_config_enabled(config_type)
self.buttons[config_type].setEnabled(meets_conditions and is_enabled)
Key Points:
Root PFM with
field_id=''emits parameter names with leading dotMust normalize with
lstrip('.')before comparisonUses
get_resolved_value()to read live unsaved state from ObjectStateWidgets update immediately on parameter changes (no manual refresh needed)
ObjectState-Driven Enable State Pattern
Problem: Widget state was separate from config data, causing inconsistency.
Solution: Store state fields in ObjectState, query it for widget state
def _is_config_enabled(self, config_type: str) -> bool:
"""Check if config is enabled by querying ObjectState."""
enabled_path = f"{config_type}.enabled"
# Get resolved value (respects inheritance from parent_state)
return self.state.get_resolved_value(enabled_path) is True
Key Points:
State fields are regular fields in the config dataclass
get_resolved_value()returns live UI state (unsaved changes)Respects lazy resolution from parent_state
Single source of truth for all state
Before: Separate widget state
enabled = self.enable_checkbox.isChecked()
After: Query ObjectState
is_enabled = self._is_config_enabled('config_type')
Header Widgets Pattern
Problem: Action buttons were in a separate row, wasting vertical space.
Solution: Use Qt’s setCornerWidget() to place widgets in tab bar corner
# Create action buttons for each config type
header_widgets = []
for field_name in registry.keys():
display_name = _get_display_name(field_name)
btn = QPushButton(f"Action for {display_name}")
btn.clicked.connect(
lambda checked, fn=field_name: self._on_action(fn)
)
btn.setEnabled(False)
self.buttons[field_name] = btn
header_widgets.append(btn)
# Pass to TabbedFormConfig - they'll be placed in tab bar corner
tabbed_config = TabbedFormConfig(
tabs=tabs,
header_widgets=header_widgets # Qt places these in tab bar corner
)
Key Points:
TabbedFormWidgetusesQTabWidget.setCornerWidget()internallyWidgets appear right-aligned on same row as tab labels
Saves vertical space in UI
Cleaner, more professional appearance
Implementation Details
Signal Flow
The reactive UI updates follow this signal flow:
User Action: User changes a parameter in the form
ObjectState Update: ParameterFormManager updates ObjectState with new value
Signal Emission: Root PFM emits
parameter_changedsignal with dotted pathSignal Handler: Handler receives signal, normalizes path
Widget Update: Handler queries ObjectState and updates dependent widgets
UI Refresh: Widgets update immediately
Key Insight: The signal flow uses ObjectState as the single source of truth. The UI never stores state in widgets—it always queries ObjectState for current values.
ObjectState Integration
The tabbed form pattern integrates deeply with ObjectState:
Hierarchy:
ParentState (parent_state)
└── ChildState (child_state)
├── config_type_a.param1
├── config_type_a.param2
├── config_type_b.param1
└── config_type_b.param2
Lazy Resolution: Configs use lazy resolution to inherit values from parent_state:
# If child_state doesn't have a value, it resolves from parent_state
value = self.state.get_resolved_value('config_type.param')
# Returns: parent_state.config_type.param if not overridden
Benefits:
Configs inherit global defaults from parent state
Local overrides possible per instance
Changes to parent_state automatically propagate to children
Single source of truth for all configuration values
Benefits and Impact
Code Reduction
Eliminated Code:
Duplicated form creation code
Duplicated action methods
Separate widget state management
Typical Reduction: 200-400 lines per implementation
Added Code:
Registry-based infrastructure (one-time cost)
Net Reduction: Significant, especially with multiple config types
Type Safety
Before: String literals scattered throughout
if config_type == "type_a":
config = TypeAConfig(**values)
elif config_type == "type_b":
config = TypeBConfig(**values)
After: Registry-based lookup with type safety
ConfigClass = registry.get(config_type)
config = ConfigClass(**values)
Benefits:
Eliminates bugs from typos
IDE autocomplete works for registry keys
Type checker can verify registry key usage
Refactoring tools can find all usages
Extensibility
Adding a New Config Type:
Before: Required changes in multiple files
Create config class
Add to container dataclass
Create form panel method
Add tab to QTabWidget
Create action methods
Add widget state management
Update all type checks
After: Required changes in 1 place
Create config class with auto-registration
That’s it! The UI automatically:
Discovers the new config from registry
Creates a tab for it
Generates action buttons
Connects signals
Handles state management
Maintainability
Single Source of Truth: Registry keys are the canonical identifier
No more mixing different naming conventions
Display names derived consistently from registry keys
Type selection uses consistent lookups
Declarative Configuration: UI structure defined by data, not code
Tab structure defined by
TabConfiglistButton creation driven by registry keys
Signal handling generic across all config types
Testability: Generic methods easier to test
Single action method to test
Parameterized tests can cover all config types
Mock registry for isolated testing
Usage Example
Complete Example
from pyqt_reactive.widgets.shared import TabbedFormWidget, TabbedFormConfig, TabConfig
from pyqt_reactive.core import ObjectState
from types import SimpleNamespace
# 1. Define your config registry (using AutoRegisterMeta or manual)
class ConfigRegistry:
__registry__ = {
'viewer_a_config': ViewerAConfig,
'viewer_b_config': ViewerBConfig,
}
# 2. Create dynamic config container
def create_config():
config = SimpleNamespace()
for field_name, config_class in ConfigRegistry.__registry__.items():
setattr(config, field_name, config_class())
return config
# 3. Create ObjectState with configs
config = create_config()
state = ObjectState(config)
# 4. Create tabs dynamically
tabs = [
TabConfig(
label=field_name.replace('_config', '').title(),
field_id=field_name,
exclude_params=[k for k in ConfigRegistry.__registry__.keys() if k != field_name]
)
for field_name in ConfigRegistry.__registry__.keys()
]
# 5. Create action buttons
buttons = {}
header_widgets = []
for field_name in ConfigRegistry.__registry__.keys():
btn = QPushButton(f"Action: {field_name}")
btn.clicked.connect(lambda checked, fn=field_name: handle_action(fn))
buttons[field_name] = btn
header_widgets.append(btn)
# 6. Create tabbed form
tabbed_config = TabbedFormConfig(
tabs=tabs,
header_widgets=header_widgets,
use_scroll_area=True
)
tabbed_form = TabbedFormWidget(state=state, config=tabbed_config)
# 7. Connect reactive updates
tabbed_form.parameter_changed.connect(
lambda param, val: update_buttons(param, val, buttons)
)
Summary
The TabbedFormWidget registry pattern demonstrates how registry-based patterns enable extensibility without sacrificing type safety or maintainability. By using a config registry as the single source of truth, the system achieves:
Zero-code config addition: New configs require only registration
Type safety: Registry keys prevent typos and enable IDE support
Code reduction: Eliminates duplicated form and action code
Reactive UI: Widgets update immediately via ObjectState signals
Single source of truth: ObjectState stores all config values
Testability: Generic methods easier to test and maintain
This pattern is applicable to any PyQt application that needs dynamic, extensible tabbed configuration interfaces.