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 SimpleNamespace for dynamic attribute assignment

  • Registry 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_id per tab)

  • exclude_params prevents other configs from appearing in each tab

  • TabbedFormWidget creates ParameterFormManager for each tab automatically

  • All tabs share the same root ObjectState

  • header_widgets feature places widgets in tab bar corner using Qt’s setCornerWidget()

Benefits:

  • Eliminates duplicated form creation code

  • Automatic scroll area wrapping per tab

  • Consistent styling via color_scheme

  • Single 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 dot

  • Must normalize with lstrip('.') before comparison

  • Uses get_resolved_value() to read live unsaved state from ObjectState

  • Widgets 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:

  • TabbedFormWidget uses QTabWidget.setCornerWidget() internally

  • Widgets 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:

  1. User Action: User changes a parameter in the form

  2. ObjectState Update: ParameterFormManager updates ObjectState with new value

  3. Signal Emission: Root PFM emits parameter_changed signal with dotted path

  4. Signal Handler: Handler receives signal, normalizes path

  5. Widget Update: Handler queries ObjectState and updates dependent widgets

  6. 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

  1. Create config class

  2. Add to container dataclass

  3. Create form panel method

  4. Add tab to QTabWidget

  5. Create action methods

  6. Add widget state management

  7. Update all type checks

After: Required changes in 1 place

  1. 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 TabConfig list

  • Button 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.