6.5. Widget Synchronization

6.5.1. Overview

The widget_sync module provides a simple, powerful way to synchronize widget properties across multiple Qt widgets. It eliminates the need for manual signal management, feedback loop prevention, and connection cleanup.

6.5.1.1. Why Use Widget Sync?

The Problem: Manual signal/slot management becomes complex quickly:

# Manual approach - repetitive and error-prone ❌
def __init__(self):
    self.slider1.valueChanged.connect(self._on_slider1_changed)
    self.slider2.valueChanged.connect(self._on_slider2_changed)
    self.slider3.valueChanged.connect(self._on_slider3_changed)

def _on_slider1_changed(self, value):
    # Block signals to prevent feedback loop
    self.slider2.blockSignals(True)
    self.slider3.blockSignals(True)
    self.slider2.setValue(value)
    self.slider3.setValue(value)
    self.slider2.blockSignals(False)
    self.slider3.blockSignals(False)

# ... repeat for slider2 and slider3 ...

The Solution: Widget sync handles everything automatically:

# With widget_sync - simple and automatic ✅
def __init__(self):
    self.sync = WidgetSync.for_slider(self.slider1, initial=50)
    self.sync.add(self.slider2)
    self.sync.add(self.slider3)
    # Done! All three stay in sync, no feedback loops, automatic cleanup

Key features:

  • Automatic bidirectional synchronization

  • Built-in feedback loop prevention

  • Multiple sync modes (bidirectional, to_sync, from_sync)

  • Value transformations between widgets

  • Automatic cleanup when widgets are deleted

  • No memory leaks (uses weak references)

  • Easy extension with custom factory methods

6.5.2. Quick Start Guide

Choose your approach based on what you’re synchronizing:

Single Value (Most Common)

Use factory methods for common widget types:

from pymodaq_gui.utils.widget_sync import WidgetSync

# Checkboxes
sync = WidgetSync.for_checkbox(checkbox1, initial=True)
sync.add(checkbox2)
sync.add(checkbox3)

# Sliders / SpinBoxes
sync = WidgetSync.for_spinbox(spinbox1, initial=50)
sync.add(spinbox2, match='property')  # Works with different compatible types

# ComboBoxes
sync = WidgetSync.for_combobox(combo1, initial=0)  # By index
sync = WidgetSync.for_combobox(combo1, initial="Option A", use_text=True)  # By text

Multiple Related Values (Dictionary)

Use dict-based sync for related properties:

# RGB color with separate sliders
color_sync = WidgetSync(initial_value={'r': 128, 'g': 64, 'b': 192})

color_sync.bind_dict({
    'r': {'widget': red_slider, 'property': 'value'},
    'g': {'widget': green_slider, 'property': 'value'},
    'b': {'widget': blue_slider, 'property': 'value'}
})

Custom Widgets

Use generic property binding:

# Any Qt property
sync = WidgetSync.for_property(
    widget,
    property_name='myProperty',
    signal_name='myPropertyChanged',  # Optional, auto-detected
    initial=100
)

6.5.2.1. Understanding WidgetSync Classes

The widget_sync system has three main classes:

  • WidgetSync - Smart factory that auto-detects what you need (use this!)

    • Pass a single value → Uses ValueSync internally

    • Pass a dict → Uses DictSync internally

    • Includes all factory methods (for_checkbox, for_spinbox, etc.)

  • ValueSync - For single values (int, str, bool, float, custom objects)

    • Used automatically when you call WidgetSync(initial_value=42)

  • DictSync - For dictionary values with multiple keys

    • Used automatically when you call WidgetSync(initial_value={'a': 1})

  • BaseWidgetSync - Base class for advanced extensions (rarely needed)

Most users only need WidgetSync - it automatically chooses the right implementation.

6.5.3. Basic Usage

6.5.3.1. Simple Synchronization

Keep multiple checkboxes in sync:

from pymodaq_gui.utils.widget_sync import WidgetSync

# Create sync from first checkbox
sync = WidgetSync.for_checkbox(toolbar_checkbox, initial=True)

# Add more checkboxes - they stay in sync automatically
sync.add(menu_checkbox)
sync.add(settings_checkbox)

# Change value programmatically - all update
sync.value = False  # All three checkboxes uncheck

Tip

Example: examples/widget_sync/1_basic_sync_example.py demonstrates:

  • Checkbox synchronization across multiple views (Tab 1)

  • Different widget types for the same value (Tab 2)

  • Value transforms for unit conversions and inverted checkboxes (Tabs 3 & 5)

  • Enable/disable patterns and many widget types (Tabs 4, 6, 7)

6.5.3.2. Different Widget Types

Sync different widget types representing the same value:

# Slider and spinbox for same value
sync = WidgetSync.for_slider(slider, initial=50)
# Use match='property' to allow different widget types with same property/signal
sync.add(spinbox, match='property')  # Works because both use 'value'/'valueChanged'

# Add read-only display
sync.bind(
    progress_bar,
    setter=lambda v: progress_bar.setValue(v),
    mode=SyncMode.FROM_SYNC  # Read-only
)

6.5.4. Understanding add() vs bind()

Important: The add() method is only available for single-value sync (ValueSync), not for dictionary sync (DictSync).

6.5.4.1. Why the Difference?

ValueSync (single values):
  • All widgets represent the same logical property (e.g., a checkbox state)

  • Can auto-detect property/signal from first widget and reuse for others

  • add() provides convenience: “add another widget just like the first one”

DictSync (multiple properties):
  • Different dict keys map to different properties or widgets

  • Each binding needs explicit configuration (which key? which property?)

  • Must use bind(), bind_properties(), or bind_dict()

6.5.4.2. Quick Reference

# ✅ ValueSync - add() works
sync = WidgetSync.for_checkbox(checkbox1, initial=True)
sync.add(checkbox2)  # Reuses 'checked' property from checkbox1
sync.add(checkbox3)  # Works!

# ✅ DictSync - must use bind_dict() or bind_properties()
color_sync = WidgetSync(initial_value={'r': 128, 'g': 64, 'b': 192})
# color_sync.add(red_slider)  # ❌ Won't work - no add() method!
color_sync.bind_dict({  # ✅ Must specify which property goes to which key
    'r': {'widget': red_slider, 'property': 'value'},
    'g': {'widget': green_slider, 'property': 'value'}
})

Tip

Rule of thumb:

  • Single value → Use add()

  • Dictionary → Use bind_dict() or bind_properties()

6.5.4.3. Method Availability by Sync Type

Method

ValueSync (single value)

DictSync (dictionary)

add()

✅ Available

❌ Not available

bind()

✅ Available

✅ Available

bind_properties()

❌ Not available

✅ Available

bind_dict()

❌ Not available

✅ Available

Factory methods

for_checkbox(), etc.

❌ (use WidgetSync(initial_value={...}))

6.5.5. Factory Methods

6.5.5.1. Built-in Factories

Convenient factories for common widget types:

# Checkboxes
sync = WidgetSync.for_checkbox(checkbox, initial=True)

# SpinBoxes / DoubleSpinBoxes
sync = WidgetSync.for_spinbox(spinbox, initial=50)

# Sliders
sync = WidgetSync.for_slider(slider, initial=75)

# ComboBoxes
sync = WidgetSync.for_combobox(combo, initial=0)  # By index
sync = WidgetSync.for_combobox(combo, initial="Option A", use_text=True)  # By text

# LineEdits
sync = WidgetSync.for_lineedit(edit, initial="Hello")

6.5.5.2. Generic Factory

For any Qt property:

sync = WidgetSync.for_property(
    widget,
    property_name='value',  # Qt property name
    signal_name='valueChanged',  # Change signal (optional, auto-detected)
    initial=50,
    data_type=int  # Optional: explicit type checking
)

6.5.5.3. Adding Widgets: Type vs Property Matching

When adding widgets with add(), you can control the matching strategy:

# Type matching (default) - only same widget types
sync = WidgetSync.for_slider(slider1)
sync.add(slider2)  # OK - both QSlider
# sync.add(spinbox)  # Would raise TypeError

# Property matching - different widget types with compatible property/signal
sync = WidgetSync.for_slider(slider1)
sync.add(spinbox, match='property')  # OK - both use 'value'/'valueChanged'
sync.add(dial, match='property')     # OK - also uses 'value'/'valueChanged'

When to use:

  • match='type' (default): Safest, ensures identical widget behavior

  • match='property': Flexible, allows mixing compatible widget types (e.g., QSlider + QSpinBox)

6.5.5.4. Data Type Safety

WidgetSync supports explicit data type checking:

# Type inferred from initial value
sync = WidgetSync(initial_value=True)  # data_type = bool
sync = WidgetSync(initial_value=50)    # data_type = int

# Explicit type checking
sync = WidgetSync(initial_value=0, data_type=int)

# Using factories with data_type
sync = WidgetSync.for_property(
    widget, 'value', initial=50, data_type=int
)

# Type checking validates values and transforms
sync.value = 100  # OK
# sync.value = "text"  # Raises TypeError

6.5.6. Sync Modes

Three synchronization modes:

from pymodaq_gui.utils.widget_sync import SyncMode

# BIDIRECTIONAL (default): Widget ↔ Sync
sync.add(widget, mode=SyncMode.BIDIRECTIONAL)

# TO_SYNC: Widget → Sync only
sync.add(widget, mode=SyncMode.TO_SYNC)

# FROM_SYNC: Sync → Widget only (read-only display)
sync.bind(label, setter=lambda v: label.setText(str(v)),
          mode=SyncMode.FROM_SYNC)

6.5.7. Initialization Control (init_from)

The init_from parameter controls what happens when you connect a widget.

6.5.7.1. Understanding init_from

When you bind a widget to a sync, there are three possibilities for initial values:

sync = WidgetSync(initial_value=100)
widget.setValue(50)  # Widget has different value

# What should happen when we connect them?

Three Options:

  1. init_from=’sync’ (default) - Widget gets sync’s value

  2. init_from=’widget’ - Sync gets widget’s value (and propagates to other widgets)

  3. init_from=None - No initialization, keep current values

6.5.7.2. Examples

Option 1: init_from=’sync’ (Default Behavior)

sync = WidgetSync(initial_value=100)

spinbox.setValue(50)  # Widget starts at 50

# Default: widget immediately updates to sync's value
sync.bind(
    spinbox,
    signal=spinbox.valueChanged,
    getter=spinbox.value,
    setter=spinbox.setValue,
    init_from='sync'  # Default, can be omitted
)

print(spinbox.value())  # 100 - widget updated to sync's value!

Option 2: init_from=’widget’ (Sync Takes Widget’s Value)

sync = WidgetSync(initial_value=100)
spinbox1.setValue(50)
spinbox2.setValue(75)

# Already connected
sync.bind(spinbox1, ..., init_from='sync')
print(spinbox1.value())  # 100

# Connect spinbox2 with init_from='widget'
sync.bind(spinbox2, ..., init_from='widget')

# Sync AND spinbox1 update to spinbox2's value!
print(sync.value)         # 75
print(spinbox1.value())   # 75
print(spinbox2.value())   # 75

Option 3: init_from=None (No Initialization)

sync = WidgetSync(initial_value=100)
spinbox.setValue(50)

# No initialization - values stay different
sync.bind(spinbox, ..., init_from=None)

print(sync.value)         # 100 - unchanged
print(spinbox.value())    # 50  - unchanged

# But future changes do sync!
sync.value = 75
print(spinbox.value())    # 75  - now they sync

6.5.7.3. When to Use Each Option

init_from

Use When

Example

'sync' (default)

Sync holds the “source of truth”

Loading settings into UI widgets

'widget'

Widget holds current state you want to propagate

Connecting to an already-configured widget

None

Values are temporarily different but should sync going forward

Testing, debugging, or complex initialization

6.5.7.4. Requirements and Validation

init_from=’widget’ Requirements:

  • Mode must have TO_SYNC capability (TO_SYNC or BIDIRECTIONAL)

  • Must provide a getter to read widget’s value

  • Will raise ValueError if these requirements aren’t met

init_from=’sync’ Requirements:

  • Mode must have FROM_SYNC capability (FROM_SYNC or BIDIRECTIONAL)

  • Must provide a setter to update widget

  • With TO_SYNC mode, silently skips initialization (no setter available)

6.5.7.5. Per-Property init_from (DictSync)

For bind_dict() and bind_properties(), you can override init_from per property:

# Global default: init_from='sync'
sync = WidgetSync(initial_value={'host': 'localhost', 'port': 8080})

sync.bind_dict(
    property_map={
        'host': {
            'widget': host_edit,
            'property': 'text',
            # Uses global default 'sync'
        },
        'port': {
            'widget': port_spin,
            'property': 'value',
            'init_from': 'widget'  # Override: use widget's current value
        }
    },
    init_from='sync'  # Global default
)

# Result: 'host' uses sync's value, 'port' uses widget's value

6.5.8. Value Transformations

Transform values between widgets:

# Temperature: Celsius ↔ Fahrenheit
celsius_sync = WidgetSync.for_spinbox(celsius_spin, initial=0)

celsius_sync.add(
    fahrenheit_spin,
    to_sync_transform=lambda f: round((f - 32) * 5/9),  # F → C
    from_sync_transform=lambda c: round(c * 9/5 + 32)   # C → F
)

# Opposite/Inverted Checkboxes
# Perfect for "Enable/Disable" vs "Lock/Unlock" scenarios
enable_sync = WidgetSync.for_checkbox(enable_checkbox, initial=True)

enable_sync.add(
    disable_checkbox,
    match='property',  # Both are checkboxes with 'checked' property
    to_sync_transform=lambda checked: not checked,  # Invert: checked → not checked
    from_sync_transform=lambda checked: not checked  # Invert: checked → not checked
)
# Now: enable_checkbox=True ↔ disable_checkbox=False

# Boolean ↔ ComboBox index
bool_sync = WidgetSync.for_checkbox(checkbox, initial=True)

bool_sync.bind(
    combobox,
    signal=combobox.currentIndexChanged,
    getter=lambda: combobox.currentIndex(),
    setter=lambda i: combobox.setCurrentIndex(i),
    to_sync_transform=lambda i: i == 1,  # Index → Bool
    from_sync_transform=lambda b: 1 if b else 0  # Bool → Index
)

6.5.9. Advanced Usage

6.5.9.1. Manual Connection

For complete control:

sync = WidgetSync(initial_value=50)

sync.bind(
    widget,
    signal=widget.valueChanged,
    getter=lambda: widget.value(),
    setter=lambda v: widget.setValue(v),
    mode=SyncMode.BIDIRECTIONAL
)

6.5.9.2. Temporarily Disable Connections

Temporarily pause syncing without disconnecting:

sync = WidgetSync.for_slider(slider1, initial=50)
sync.add(slider2)
sync.add(slider3)

# Disable one slider temporarily
sync.disable(slider2)
slider1.setValue(75)  # slider2 doesn't update, slider3 does

# Re-enable it
sync.enable(slider2)
# IMPORTANT: slider2 still has its old value (50)
# enable() does NOT auto-update the widget

slider1.setValue(60)  # Now slider2 updates to 60

# Check if enabled
if sync.is_enabled(slider2):
    print("Slider 2 is syncing")

Use cases:

  • Temporarily pause sync during batch operations

  • Conditional syncing based on application state

  • Prevent feedback loops during complex updates

  • Testing and debugging

Key differences:

  • disable() - Temporarily stops syncing, connection remains, widget keeps old value

  • unbind() - Removes connection entirely, needs reconnection

  • enable() - Resumes syncing but widget keeps old value (no auto-update)

  • bind() - Creates connection and immediately updates widget to current sync value

Critical Behavior Note:

When you re-bind a widget with bind(), it automatically updates to the current sync value. When you re-enable a widget with enable(), it keeps its old value until the next sync event.

# Demonstrate the difference
sync = WidgetSync.for_slider(master, initial=50)
sync.add(slaveA)
sync.add(slaveB)

master.setValue(70)  # All at 70

sync.disable(slaveA)  # Disable A
sync.unbind(slaveB)   # Unbind B

master.setValue(30)  # A and B don't update

sync.enable(slaveA)  # A stays at 70 (no auto-update!)
sync.bind(slaveB, ...)  # B jumps to 30 (auto-updates!)

master.setValue(50)  # Now all three update to 50

6.5.9.3. Conditional Widget Enable/Disable

Enable/disable widgets based on another widget’s state:

# Create sync for master checkbox
enable_sync = WidgetSync.for_checkbox(master_checkbox, initial=False)

# Connect to enable/disable other widgets
enable_sync.value_changed.connect(
    lambda enabled: advanced_spinbox.setEnabled(enabled)
)
enable_sync.value_changed.connect(
    lambda enabled: advanced_slider.setEnabled(enabled)
)

6.5.9.4. Introspection

Check sync state:

# Get connected widgets
widgets = sync.connected_widgets  # List of active widgets

# Get connection count
count = sync.connection_count  # Number of connections

# Get current value
value = sync.value

6.5.9.5. Connection Management

Cleanup is automatic via weak reference callbacks - when a widget is deleted, its connection is automatically removed. Manual management is available when needed:

# Temporarily pause syncing (connection remains)
sync.disable(widget)
sync.enable(widget)  # Resume syncing

# Check connection state
is_syncing = sync.is_enabled(widget)

# Permanently unbind widget
sync.unbind(widget)

# Unbind all (useful when deleting the sync itself)
sync.unbind_all()

When to use what:

  • disable() - Temporary pause, keeps connection setup, fast to re-enable

  • unbind() - Permanent removal, requires full reconnection

  • Automatic cleanup - Widget deletion triggers cleanup automatically

6.5.10. Dictionary Synchronization (DictSync)

When you need to synchronize multiple related properties or map widgets to different keys, use dictionary-based synchronization.

Important

DictSync does NOT have an ``add()`` method.

You must use:

  • bind_dict() - For different widgets mapped to dict keys

  • bind_properties() - For multiple properties of ONE widget

  • bind() - For custom dict handling

This is because each dict key needs explicit configuration - there’s no “pattern” to auto-detect like with single-value sync.

Tip

Example: examples/widget_sync/2_dict_sync_example.py demonstrates:

  • bind_properties(): Multiple properties of ONE widget (Tab 1 - ComboBox items + selection)

  • bind_dict(): Different widgets to dict keys (Tab 2 - RGB color sliders)

  • Validators for dict values (Tab 3 - Clamping, auto-swapping min/max)

  • Custom dict widgets with bind() (Tab 4 - JSON editor)

6.5.10.1. When to Use DictSync vs ValueSync

Use ValueSync (default) when:

  • Synchronizing a single value across multiple widgets

  • All widgets represent the same logical property

  • Example: Multiple checkboxes for the same “enabled” state

Use DictSync when:

  • Synchronizing multiple related properties (e.g., RGB color components)

  • Mapping different widgets to different dictionary keys

  • Syncing multiple properties of a single widget

  • Example: Color picker with separate R, G, B sliders

6.5.10.2. Creating DictSync

WidgetSync automatically detects when to use DictSync:

from pymodaq_gui.utils.widget_sync import WidgetSync, SyncMode

# Auto-detects DictSync because initial_value is a dict
color_sync = WidgetSync(initial_value={'r': 128, 'g': 64, 'b': 192})

# Access/modify the dict value
print(color_sync.value)  # {'r': 128, 'g': 64, 'b': 192}
color_sync.value = {'r': 255, 'g': 0, 'b': 0}  # Red

6.5.10.3. DictSync Binding Methods

DictSync provides four binding methods:

  1. bind() - For widgets that work with entire dict (JSON editors, displays)

  2. bind_properties() - For multiple properties of ONE widget (regular Qt widgets)

  3. bind_parameter() - For pyqtgraph Parameters (avoids blockSignals issue)

  4. bind_dict() - For DIFFERENT widgets mapped to dict keys

6.5.10.3.1. Method 1: bind() - Entire Dict

For widgets that need the entire dictionary:

config_sync = WidgetSync(initial_value={'host': 'localhost', 'port': 8080})

# Custom widget that displays JSON
config_sync.bind(
    json_display,
    signal=json_display.contentChanged,
    getter=lambda: json_display.get_dict(),
    setter=lambda d: json_display.set_dict(d),
    mode=SyncMode.BIDIRECTIONAL
)

# Read-only display
config_sync.bind(
    status_label,
    setter=lambda d: status_label.setText(f"Config: {d}"),
    mode=SyncMode.FROM_SYNC
)

6.5.10.3.2. Method 2: bind_properties() - Multiple Properties of ONE Widget

For synchronizing multiple properties of a single widget to dict keys:

# ComboBox with both items and selection
combo_sync = WidgetSync(initial_value={
    'items': ["Red", "Green", "Blue"],
    'selection': "Red"
})

# Bind multiple properties of ONE combobox
combo_sync.bind_properties(
    my_combobox,
    property_map={
        'items': {
            'signal': None,  # FROM_SYNC only
            'getter': lambda: [my_combobox.itemText(i)
                               for i in range(my_combobox.count())],
            'setter': lambda items: (my_combobox.clear(),
                                    my_combobox.addItems(items)),
            'mode': SyncMode.FROM_SYNC
        },
        'selection': {
            'signal': my_combobox.currentTextChanged,
            'getter': lambda: my_combobox.currentText(),
            'setter': lambda text: my_combobox.setCurrentText(text),
            'mode': SyncMode.BIDIRECTIONAL
        }
    }
)

# Update items programmatically
combo_sync.value = {
    'items': ["Apple", "Banana", "Orange"],
    'selection': "Apple"
}

Using Qt property names (auto-generation):

# Shorter syntax using 'property' key
widget_sync = WidgetSync(initial_value={'width': 100, 'height': 50})

widget_sync.bind_properties(
    my_widget,
    property_map={
        'width': {'property': 'minimumWidth'},   # Auto-generates getter/setter
        'height': {'property': 'minimumHeight'}  # Signal auto-detected
    }
)

6.5.10.3.3. Method 3: bind_dict() - DIFFERENT Widgets to Dict Keys

For mapping different widgets to different dictionary keys:

# RGB color with separate sliders
color_sync = WidgetSync(initial_value={'r': 128, 'g': 64, 'b': 192})

# Each slider controls one color component
color_sync.bind_dict(
    property_map={
        'r': {
            'widget': red_slider,
            'property': 'value'  # Auto-generates getter/setter/signal
        },
        'g': {
            'widget': green_slider,
            'property': 'value'
        },
        'b': {
            'widget': blue_slider,
            'property': 'value'
        }
    }
)

# Now changing any slider updates color_sync.value['r'/'g'/'b']
# And changing color_sync.value updates all sliders

Manual getter/setter (full control):

position_sync = WidgetSync(initial_value={'x': 0, 'y': 0, 'z': 0})

position_sync.bind_dict(
    property_map={
        'x': {
            'widget': x_spinbox,
            'signal': x_spinbox.valueChanged,
            'getter': lambda: x_spinbox.value(),
            'setter': lambda v: x_spinbox.setValue(v)
        },
        'y': {
            'widget': y_spinbox,
            'signal': y_spinbox.valueChanged,
            'getter': lambda: y_spinbox.value(),
            'setter': lambda v: y_spinbox.setValue(v)
        },
        'z': {
            'widget': z_spinbox,
            'signal': z_spinbox.valueChanged,
            'getter': lambda: z_spinbox.value(),
            'setter': lambda v: z_spinbox.setValue(v)
        }
    }
)

6.5.10.4. Binding Parameters with bind_parameter()

The bind_parameter() method is specifically designed for binding pyqtgraph Parameters to DictSync. Unlike bind_properties(), it avoids the blockSignals() issue that prevents parameter tree widgets from updating visually.

When to Use Which Method:

  • Use bind_properties() for regular Qt widgets (QSpinBox, QComboBox, etc.)

  • Use bind_parameter() for pyqtgraph Parameters

Why bind_parameter() Exists:

The blockSignals() mechanism used by bind_properties() prevents feedback loops, but it also prevents parameter tree widgets from updating. Parameters manage their own internal widgets, so we need callback disconnection instead of signal blocking.

6.5.10.4.1. Basic Parameter Binding

from pymodaq_gui.parameter import Parameter, ParameterTree
from pymodaq_gui.utils.widget_sync import WidgetSync, SyncMode

# Create parameters
params = [
    {'title': 'Threshold:', 'name': 'threshold', 'type': 'float',
     'value': 0.5, 'limits': (0.0, 1.0)},
    {'title': 'Buffer Size:', 'name': 'buffer_size', 'type': 'int',
     'value': 1024, 'limits': (128, 8192)},
]

settings = Parameter.create(name='settings', type='group',
                           children=params)
tree = ParameterTree()
tree.setParameters(settings)

# Create sync with toolbar widgets
sync = WidgetSync(initial_value={
    'threshold': 0.5,
    'buffer_size': 1024
})

# Bind toolbar widgets (regular Qt widgets)
sync.bind_properties(
    threshold_spinbox,
    property_map={'threshold': {'property': 'value'}}
)

sync.bind_properties(
    buffer_spinbox,
    property_map={'buffer_size': {'property': 'value'}}
)

# Bind parameters (use bind_parameter - NOT bind_properties!)
sync.bind_parameter(
    settings.child('threshold'),
    property_map={
        'threshold': {
            'signal': settings.child('threshold').sigValueChanged,
            'getter': settings.child('threshold').value,
            'setter': settings.child('threshold').setValue,
        }
    }
)

sync.bind_parameter(
    settings.child('buffer_size'),
    property_map={
        'buffer_size': {
            'signal': settings.child('buffer_size').sigValueChanged,
            'getter': settings.child('buffer_size').value,
            'setter': settings.child('buffer_size').setValue,
        }
    }
)

Now changing values in the toolbar spinboxes updates the parameter tree, and vice versa!

6.5.10.4.2. Shortcut Syntax for Simple Parameters

For simple parameter value synchronization, use the shortcut syntax:

# Shortcut: Auto-generates sigValueChanged, value(), setValue()
sync.bind_parameter(
    threshold_param,
    property_map={
        'threshold': {'param': threshold_param}
    }
)

# Equivalent to:
sync.bind_parameter(
    threshold_param,
    property_map={
        'threshold': {
            'signal': threshold_param.sigValueChanged,
            'getter': threshold_param.value,
            'setter': threshold_param.setValue,
            'mode': SyncMode.BIDIRECTIONAL
        }
    }
)

The shortcut syntax is ideal for simple cases where you just want to sync the parameter value.

6.5.10.4.3. Binding List Parameters (ComboBox)

List parameters require syncing both the options (limits) and the selected value:

# Create a list parameter
params = [
    {'title': 'Algorithm:', 'name': 'algorithm', 'type': 'list',
     'limits': ['FFT', 'Wavelet', 'Correlation'], 'value': 'FFT'},
]

settings = Parameter.create(name='settings', type='group',
                           children=params)
algorithm_param = settings.child('algorithm')

# Create sync for both limits and value
sync = WidgetSync(initial_value={
    'algorithms': ['FFT', 'Wavelet', 'Correlation'],
    'algorithm': 'FFT'
})

# Bind toolbar combobox (regular Qt widget)
sync.bind_properties(
    algorithm_combo,
    property_map={
        'algorithms': {
            'signal': None,  # FROM_SYNC only
            'getter': lambda: [algorithm_combo.itemText(i)
                              for i in range(algorithm_combo.count())],
            'setter': lambda items: (algorithm_combo.clear(),
                                    algorithm_combo.addItems(items)),
            'mode': SyncMode.FROM_SYNC
        },
        'algorithm': {
            'signal': algorithm_combo.currentTextChanged,
            'getter': lambda: algorithm_combo.currentText(),
            'setter': lambda text: algorithm_combo.setCurrentText(text),
        }
    }
)

# Bind parameter (use bind_parameter!)
sync.bind_parameter(
    algorithm_param,
    property_map={
        'algorithms': {
            'getter': lambda: algorithm_param.opts['limits'],
            'setter': algorithm_param.setLimits,
            'mode': SyncMode.FROM_SYNC,
        },
        'algorithm': {
            'signal': algorithm_param.sigValueChanged,
            'getter': algorithm_param.value,
            'setter': algorithm_param.setValue,
        }
    }
)

Now the combobox, parameter value, and parameter tree all stay synchronized!

6.5.10.4.4. Parameter Signal Details

Important: Parameter signals emit (param, value) tuples, not just values:

# Parameter signals emit (param, value)
def on_param_changed(param, value):
    print(f"{param.name()} changed to {value}")

my_param.sigValueChanged.connect(on_param_changed)

The bind_parameter() method automatically handles this by extracting the value from the (param, value) tuple before passing it to the sync system.

6.5.10.4.5. Complete Example

See pymodaq_gui/examples/widget_sync/6_parameter_binding_example.py for a complete working example showing:

  • Syncing multiple parameter types (list, int, float, bool)

  • Toolbar + parameter tree staying synchronized

  • Both shortcut and manual syntax

  • Programmatic value changes

python -m pymodaq_gui.examples.widget_sync.6_parameter_binding_example

6.5.10.5. Validation with DictSync

Add validators to ensure dict values stay within constraints:

def validate_rgb(color):
    """Clamp RGB values to 0-255"""
    return {
        'r': max(0, min(255, color.get('r', 0))),
        'g': max(0, min(255, color.get('g', 0))),
        'b': max(0, min(255, color.get('b', 0)))
    }

color_sync = WidgetSync(
    initial_value={'r': 128, 'g': 64, 'b': 192},
    validator=validate_rgb
)

# Bind sliders
color_sync.bind_dict(property_map={
    'r': {'widget': r_slider, 'property': 'value'},
    'g': {'widget': g_slider, 'property': 'value'},
    'b': {'widget': b_slider, 'property': 'value'}
})

# Values automatically clamped
color_sync.value = {'r': 300, 'g': -50, 'b': 100}
print(color_sync.value)  # {'r': 255, 'g': 0, 'b': 100}

6.5.10.6. Common DictSync Patterns

Pattern 1: ComboBox Items + Selection

class DeviceSelector(QWidget):
    def __init__(self):
        super().__init__()
        self.combo = QComboBox()

        # Sync both items and current selection
        self.device_sync = WidgetSync(initial_value={
            'devices': ["Device A", "Device B"],
            'current': "Device A"
        })

        self.device_sync.bind_properties(
            self.combo,
            property_map={
                'devices': {
                    'setter': lambda items: (self.combo.clear(),
                                            self.combo.addItems(items)),
                    'mode': SyncMode.FROM_SYNC
                },
                'current': {
                    'signal': self.combo.currentTextChanged,
                    'getter': lambda: self.combo.currentText(),
                    'setter': lambda t: self.combo.setCurrentText(t)
                }
            }
        )

Pattern 2: Multi-Widget Configuration

class ServerConfig(QWidget):
    def __init__(self):
        super().__init__()
        self.host_edit = QLineEdit()
        self.port_spin = QSpinBox()
        self.ssl_check = QCheckBox()

        # All settings in one dict
        self.config_sync = WidgetSync(initial_value={
            'host': 'localhost',
            'port': 8080,
            'ssl': False
        })

        # Map each widget to a config key
        self.config_sync.bind_dict(property_map={
            'host': {'widget': self.host_edit, 'property': 'text'},
            'port': {'widget': self.port_spin, 'property': 'value'},
            'ssl': {'widget': self.ssl_check, 'property': 'checked'}
        })

        # Access full config
        config = self.config_sync.value
        # Save/load entire config at once
        self.config_sync.value = load_config_from_file()

Pattern 3: Separate Syncs vs Single Dict

Sometimes you need separate syncs for items and selection:

# Approach A: Separate syncs (items change independently of selection)
self.items_sync = WidgetSync(initial_value=["A", "B", "C"])
self.items_sync.bind(combo, setter=lambda items: combo.addItems(items),
                     mode=SyncMode.FROM_SYNC)

self.selection_sync = WidgetSync.for_combobox(combo, initial="A")

# Approach B: Single dict sync (items and selection always change together)
self.combo_sync = WidgetSync(initial_value={'items': ["A", "B", "C"],
                                              'current': "A"})
self.combo_sync.bind_properties(combo, property_map={...})

When to use which:

  • Separate syncs: Items can change without affecting selection

  • Single dict: Atomic state updates (items + selection always consistent)

6.5.11. Extending Widget Sync

For most users: Use ValueSync or DictSync directly with validators and transforms. Subclassing is rarely needed.

For library developers: This section explains when and how to extend the sync system.

Tip

Example: examples/widget_sync/3_advanced_sync_example.py demonstrates:

  • Multiple syncs on the same widget (Tab 1 - ListWidget with items + selection)

  • Dynamic property addition/removal (Tab 2 - Adding properties at runtime)

  • Custom sync classes extending BaseWidgetSync (Tab 3 - RangeSync example)

6.5.11.1. Understanding the Architecture

The widget sync system has four main classes:

  1. BaseWidgetSync - Internal base class providing connection management infrastructure

  2. ValueSync - Synchronizes single values (int, str, bool, custom objects)

  3. DictSync - Synchronizes dictionary values with multiple keys

  4. WidgetSync - Smart factory that auto-selects ValueSync or DictSync based on initial_value

Internal Design Note:

To maximize code reuse (~900 lines of shared infrastructure), both ValueSync and DictSync use dict storage internally:

  • ValueSync(42) internally stores: {"__value__": 42}

  • DictSync({'r': 255}) stores: {'r': 255}

This allows unified callback routing at the cost of wrapping overhead for ValueSync. This is an implementation detail - users work with the unwrapped values.

6.5.11.2. When to Extend

Don’t subclass if you can use:

  • DictSync with a validator parameter (handles 90% of custom validation logic)

  • Transform functions (to_sync_transform / from_sync_transform)

  • Custom factory methods (see below)

Do subclass ``BaseWidgetSync`` only for:

  • Computed/derived values - Storage format differs from exposed format (e.g., HSV ↔ RGB, center/width ↔ min/max)

  • Custom propagation strategies - Debouncing, throttling, batched updates

  • Persistent sync - Auto-save to file/database, network synchronization

  • Complex behaviors - Undo/redo history, conditional propagation

6.5.11.3. Extension Options

There are three ways to extend the widget sync system:

  1. Custom Factory Methods (Easiest) - Add convenience methods for your widget types

  2. Custom Validators (Common) - Use DictSync/ValueSync with validation functions

  3. Custom Sync Classes (Advanced) - Subclass BaseWidgetSync for computed values or custom behavior

6.5.11.3.1. Option 1: Custom Factory Methods (Easiest)

Extend WidgetSyncFactories to add convenience methods for your custom widgets:

from pymodaq_gui.utils.widget_sync import WidgetSync, WidgetSyncFactories

class MyWidgetSync(WidgetSync, WidgetSyncFactories):
    """Enhanced WidgetSync with custom factory methods"""

    @classmethod
    def for_my_custom_widget(cls, widget, initial=None):
        """Factory for my custom widget type"""
        return cls.for_property(
            widget,
            property_name='customValue',
            signal_name='customValueChanged',
            initial=initial
        )

# Usage
sync = MyWidgetSync.for_my_custom_widget(my_widget, initial=100)
sync.add(another_custom_widget)

6.5.11.3.2. Option 2: Custom Validators (Common)

For validation logic, use DictSync or ValueSync with a validator function:

from pymodaq_gui.utils.widget_sync import DictSync

# Example: Range validation with auto-swap
def range_validator(value):
    """Ensure min <= max, swap if needed"""
    min_val, max_val = value['min'], value['max']
    if min_val > max_val:
        return {'min': max_val, 'max': min_val}
    return value

sync = DictSync({'min': 20, 'max': 80}, validator=range_validator)
sync.bind_dict({
    'min': {'widget': min_spinbox, 'property': 'value'},
    'max': {'widget': max_spinbox, 'property': 'value'}
})

# No subclassing needed! Much simpler than creating a custom class.

6.5.11.3.3. Option 3: Custom Sync Classes (Advanced)

Only subclass BaseWidgetSync when you need computed/derived values or custom propagation logic that can’t be achieved with validators.

Example: When NOT to subclass

Don’t create a custom RangeSync class just for validation - use DictSync + validator as shown above.

Example: When TO subclass - ColorSync

Subclass when storage format differs from exposed format:

from pymodaq_gui.utils.widget_sync import BaseWidgetSync

class ColorSync(BaseWidgetSync):
    """
    Sync colors with HSV ↔ RGB conversion.

    Stores: HSV internally (easier for brightness/saturation)
    Exposes: RGB for display widgets
    """

    def __init__(self, h=0, s=100, v=100):
        super().__init__()
        self._h = h  # Hue: 0-360
        self._s = s  # Saturation: 0-100
        self._v = v  # Value/Brightness: 0-100
        self._data_type = dict
        self._value = self._hsv_to_rgb_dict()
        self._previous_value = self._value.copy()

    @property
    def value(self):
        """Return as RGB dict for widgets"""
        return self._hsv_to_rgb_dict()

    @value.setter
    def value(self, rgb_dict):
        """Accept RGB, store as HSV"""
        self._rgb_to_hsv(rgb_dict['r'], rgb_dict['g'], rgb_dict['b'])
        new_value = self._hsv_to_rgb_dict()
        if self._value != new_value:
            self._previous_value = self._value.copy()
            self._value = new_value
            self.value_changed.emit(self._value)

    def set_value(self, rgb_dict, emit=True):
        """Set RGB value"""
        self._rgb_to_hsv(rgb_dict['r'], rgb_dict['g'], rgb_dict['b'])
        new_value = self._hsv_to_rgb_dict()
        if self._value != new_value:
            self._previous_value = self._value.copy()
            self._value = new_value
            if emit:
                self.value_changed.emit(self._value)

    def _get_internal_storage_key(self):
        """Return None for dict-like binding"""
        return None

    def _get_user_facing_value(self):
        """Return current RGB dict"""
        return self.value

    # Convenience methods leveraging HSV storage
    def adjust_brightness(self, delta):
        """Adjust brightness (much easier in HSV than RGB!)"""
        self._v = max(0, min(100, self._v + delta))
        new_value = self._hsv_to_rgb_dict()
        if self._value != new_value:
            self._previous_value = self._value.copy()
            self._value = new_value
            self.value_changed.emit(self._value)

    def _hsv_to_rgb_dict(self):
        """Convert HSV to RGB dict"""
        # ... conversion logic ...
        return {'r': r, 'g': g, 'b': b}

    def _rgb_to_hsv(self, r, g, b):
        """Convert RGB to HSV and store"""
        # ... conversion logic ...
        self._h, self._s, self._v = h, s, v

# Usage
color_sync = ColorSync(h=0, s=100, v=100)  # Red
color_sync.bind_dict({
    'r': {'widget': r_slider, 'property': 'value'},
    'g': {'widget': g_slider, 'property': 'value'},
    'b': {'widget': b_slider, 'property': 'value'}
})

# Brightness adjustment is easier in HSV!
color_sync.adjust_brightness(10)  # Can't do this with DictSync alone

6.5.11.4. Required Methods

When subclassing BaseWidgetSync, you must implement these five methods:

  1. __init__() - Initialize _value, _previous_value, _data_type, call super().__init__()

  2. set_value(new_value, emit=True) - Update value and optionally emit value_changed signal

  3. value property - Get/set the synchronized value (property with getter and setter)

  4. _get_internal_storage_key() - Return "__value__" for single-value sync, None for dict sync

  5. _get_user_facing_value() - Return value for widget initialization (unwrapped for ValueSync, direct for DictSync)

Note on methods 4 and 5: These are internal plumbing methods for the base class infrastructure. They handle the difference between ValueSync (which wraps values in {"__value__": x}) and DictSync (which stores dicts directly). For most custom syncs with dict-like behavior, return None from _get_internal_storage_key() and return self.value from _get_user_facing_value().

6.5.11.5. Available Helper Methods

BaseWidgetSync provides many helper methods you can use:

Connection Management:

# Create callbacks for widget ↔ sync communication
widget_to_sync_cb = self._create_widget_to_sync_callback(
    connection_key, widget_ref, getter, mode, to_sync_transform, property_key
)
sync_to_widget_cb = self._create_sync_to_widget_callback(
    connection_key, widget_ref, setter, mode, from_sync_transform, property_key
)

# Store connection info
self._set_connection(widget_id, property_key, connection_info)

# Retrieve connection
connection = self._get_connection(widget_id, property_key)

# Setup automatic cleanup when widget is destroyed
self._setup_widget_destruction_callback(widget, connection_keys)

Widget Control:

# Temporarily disable/enable syncing
self.disable(widget)
self.enable(widget)
is_syncing = self.is_enabled(widget)

# Remove widget connection
self.unbind(widget)
self.unbind_all()

Property Binding (for DictSync-like behavior):

# Setup binding for a single property
connection_info = self._setup_property_binding(
    widget, widget_ref, property_key, config
)

6.5.12. Common Patterns

6.5.12.1. Toolbar and Menu Sync

Keep toolbar and menu items synchronized:

class MyWindow(QMainWindow):
    def __init__(self):
        super().__init__()

        # Create toolbar and menu actions
        self.toolbar_auto = QCheckBox("Auto")
        self.menu_auto = QAction("Auto Mode", self)
        self.menu_auto.setCheckable(True)

        # Sync them
        self.auto_sync = WidgetSync.for_checkbox(self.toolbar_auto)

        # Bind menu action
        self.auto_sync.bind(
            self.menu_auto,
            signal=self.menu_auto.toggled,
            getter=lambda: self.menu_auto.isChecked(),
            setter=lambda v: self.menu_auto.setChecked(v)
        )

6.5.12.2. Multi-View Synchronization

Keep multiple views of the same data synchronized:

# Main view slider
main_sync = WidgetSync.for_slider(main_slider, initial=50)

# Add compact view
main_sync.add(compact_slider)

# Add detailed view with transforms
main_sync.bind(
    detailed_label,
    setter=lambda v: detailed_label.setText(
        f"Value: {v} ({v/100:.0%})"
    ),
    mode=SyncMode.FROM_SYNC
)

6.5.12.3. Dynamic Widget Management

Manage widgets that are created and destroyed dynamically:

class DynamicPanel(QWidget):
    def __init__(self):
        super().__init__()
        self.sync = WidgetSync(initial_value=50)
        self.widgets = []

    def add_widget(self):
        """Add a new widget to the panel"""
        slider = QSlider(Qt.Horizontal)
        slider.setRange(0, 100)

        # Bind to sync
        self.sync.bind(
            slider,
            signal=slider.valueChanged,
            getter=lambda: slider.value(),
            setter=lambda v: slider.setValue(v)
        )

        self.widgets.append(slider)
        self.layout().addWidget(slider)

Tip

Example: examples/widget_sync/4_dynamic_widgets_example.py demonstrates:

  • Dynamic add/remove of synchronized sliders (Tab 1 - Audio mixer)

  • Widget cloning with automatic sync (Tab 2 - Control panel duplication)

  • Automatic cleanup when widgets are destroyed

    def remove_widget(self, slider):

    “””Remove a widget from the panel””” self.sync.unbind(slider) self.widgets.remove(slider) slider.deleteLater()

    def pause_widget(self, slider):

    “””Temporarily pause syncing for a widget””” self.sync.disable(slider)

    def resume_widget(self, slider):

    “””Resume syncing for a widget””” self.sync.enable(slider)

    def get_connection_info(self):

    “””Get info about active connections””” return {

    ‘count’: self.sync.connection_count, ‘widgets’: self.sync.connected_widgets

    }

6.5.13. Extending Widget Sync (Advanced)

For library developers who need to extend the widget sync system.

Tip

Example: examples/widget_sync/5_extending_sync_example.py demonstrates all three extension approaches:

  • Tab 1: Custom factory methods (range spinboxes, slider with label)

  • Tab 2: Custom validators (value clamping, range auto-swap)

  • Tab 3: Custom sync class (CoordinateSync for image coordinate systems)

See the “Extending Widget Sync” section above for detailed documentation on:

  • Option 1: Custom Factory Methods (Easiest)

  • Option 2: Custom Validators (Common)

  • Option 3: Custom Sync Classes (Advanced)

6.5.14. API Reference

6.5.15. Examples

Complete examples demonstrating widget synchronization are available:

# Level 1: Basic synchronization (checkboxes, sliders, spinboxes)
python -m pymodaq_gui.examples.widget_sync.1_basic_sync_example

# Level 2: Dictionary synchronization (RGB colors, coordinates)
python -m pymodaq_gui.examples.widget_sync.2_dict_sync_example

# Level 3: Advanced patterns (transforms, conditional syncing)
python -m pymodaq_gui.examples.widget_sync.3_advanced_sync_example

# Level 4: Dynamic widget management
python -m pymodaq_gui.examples.widget_sync.4_dynamic_widgets_example

# Level 5: Extending widget_sync (custom factories, validators, sync classes)
python -m pymodaq_gui.examples.widget_sync.5_extending_sync_example

# Level 6: Parameter binding (pyqtgraph Parameters with bind_parameter())
python -m pymodaq_gui.examples.widget_sync.6_parameter_binding_example

6.5.16. See Also

  • contributing - Contributing guidelines

  • api - Full API reference