GUI Performance Patterns
========================

pyqt-reactive GUI implements several performance optimization patterns to maintain responsiveness when editing complex pipelines with many steps and cross-window dependencies.

Cross-Window Preview System
---------------------------

The cross-window preview system enables real-time preview updates in list widgets (like pipeline editor step lists) when users edit configuration values in other windows (like step editor dialogs).

**Problem**

Traditional approach: When a user edits a step's configuration in a dialog, the pipeline editor must refresh its entire step list to show updated preview text. With 20+ steps, this causes:

- Redundant context collection (gathering live values from all open forms)
- Redundant context resolution (building context stacks 20+ times)
- Full widget list rebuilds (destroying and recreating all list items)
- Measured latency: 60ms per keystroke

**Solution Architecture**

The cross-window preview system uses three components:

1. **Token-based caching**: Global token counter invalidates all caches when any value changes
2. **Scope-based routing**: Changes routed to specific items via hierarchical scope IDs
3. **Incremental updates**: Only affected items refresh, not entire lists

**CrossWindowPreviewMixin**

Reusable mixin for widgets that consume cross-window updates. The mixin provides:

1. Scope-based routing for targeted updates
2. Debounced preview updates (100ms trailing debounce)
3. Incremental updates (only affected items refresh)
4. **Configurable preview fields** (per-widget control over which fields show previews)

.. code-block:: python

   from pyqt_reactive.widgets.mixins import CrossWindowPreviewMixin

   class PipelineEditorWidget(QWidget, CrossWindowPreviewMixin):
       def __init__(self):
           super().__init__()
           self._init_cross_window_preview_mixin()

           # Map editing objects to hierarchical scope ids
           self.register_preview_scope(
               root_name='step',
               editing_types=(FunctionStep,),
               scope_resolver=lambda step, ctx: self._build_step_scope_id(step),
               aliases=('FunctionStep',),
           )
           self.register_preview_scope(
               root_name='global_config',
               editing_types=(GlobalPipelineConfig,),
               scope_resolver=lambda obj, ctx: self.ALL_ITEMS_SCOPE,
               aliases=('GlobalPipelineConfig',),
               process_all_fields=True,
           )

           # Configure which fields to show in previews
           self.enable_preview_for_field(
               'napari_streaming_config.enabled',
               lambda v: 'N:✓' if v else 'N:✗',
               scope_root='step'
           )
           self.enable_preview_for_field(
               'fiji_streaming_config.enabled',
               lambda v: 'F:✓' if v else 'F:✗',
               scope_root='step'
           )
           self.enable_preview_for_field(
               'roi_streaming_config.enabled',
               lambda v: 'R:✓' if v else 'R:✗',
               scope_root='step'
           )

**Configurable Preview Fields**

The mixin provides methods to control which configuration fields are shown in preview labels:

.. code-block:: python

   # Enable preview for a field with custom formatter
   self.enable_preview_for_field(
       'global_config.num_workers',
       lambda v: f'Workers: {v}',
       scope_root='global_config'
   )

   # Enable preview with default str() formatter
   self.enable_preview_for_field(
       'pipeline_config.well_filter',
       scope_root='pipeline_config'
   )

   # Disable preview for a field
   self.disable_preview_for_field('global_config.num_workers')

   # Check if preview is enabled
   if self.is_preview_enabled('napari_streaming_config.enabled'):
       # ...

   # Format a value using registered formatter
   formatted = self.format_preview_value('napari_streaming_config.enabled', True)
   # Returns: 'N:✓'

   # Get all enabled preview fields
   enabled_fields = self.get_enabled_preview_fields()
   # Returns: {'napari_streaming_config.enabled', 'fiji_streaming_config.enabled', ...}

**Scope registration**

``register_preview_scope`` wires editing objects to scope ids used for incremental updates:

.. code-block:: python

   self.register_preview_scope(
       root_name='step',
       editing_types=(FunctionStep,),
       scope_resolver=lambda step, ctx: self._build_step_scope_id(step),
       aliases=('FunctionStep', 'step'),
   )

   self.register_preview_scope(
       root_name='global_config',
       editing_types=(GlobalPipelineConfig,),
       scope_resolver=lambda obj, ctx: self.ALL_ITEMS_SCOPE,
       aliases=('global_config', 'GlobalPipelineConfig'),
       process_all_fields=True,  # Refresh even if field not explicitly registered
   )

Key details:

- ``aliases`` lets you support both lowercase and class-name prefixes in ``field_path``.
- ``scope_root`` in ``enable_preview_for_field`` links a field to the corresponding scope registration.
- ``process_all_fields=True`` tells the mixin to refresh items for any change under that root, even if the field is not explicitly registered (useful for pipeline/global configs that affect everything).
- ``ALL_ITEMS_SCOPE`` refreshes every registered item; ``FULL_REFRESH_SCOPE`` triggers ``_handle_full_preview_refresh``; ``ROOTLESS_SCOPE`` tracks fields without a declared root.

**Centralized Config Formatters**

For consistency across widgets, use the centralized formatters in ``config_preview_formatters.py``:

.. code-block:: python

   from pyqt_reactive.utils.preview_formatters import (
       CONFIG_INDICATORS,
       format_config_indicator
   )

   # Use centralized indicators (single source of truth)
   # CONFIG_INDICATORS = {
   #     'step_materialization_config': 'MAT',
   #     'napari_streaming_config': 'NAP',
   #     'fiji_streaming_config': 'FIJI',
   # }

   # Format config using centralized formatter
   indicator = format_config_indicator('napari_streaming_config', config, resolve_attr)
   # Returns: 'NAP' (if enabled) or None (if disabled)

   # Both PipelineEditor and PlateManager use these formatters
   # to ensure consistent preview labels (e.g., 'NAP', 'FIJI', 'MAT')

**Enabled Field Checking Rule**

**ARCHITECTURAL RULE**: Any config with an ``enabled: bool`` parameter should only display its preview label if the value resolves to ``True``.

This rule is enforced by the centralized formatters:

.. code-block:: python

   def _check_enabled_field(config: Any, resolve_attr: Optional[Callable] = None) -> bool:
       """Check if a config object is enabled.

       GENERAL RULE: Any config with an 'enabled: bool' parameter should only show
       if it resolves to True.
       """
       import dataclasses

       # Check if config has 'enabled' field
       has_enabled = dataclasses.is_dataclass(config) and 'enabled' in {f.name for f in dataclasses.fields(config)}

       if has_enabled:
           # Resolve enabled field if resolver provided
           if resolve_attr:
               enabled = resolve_attr(None, config, 'enabled', None)
           else:
               enabled = getattr(config, 'enabled', False)

           return bool(enabled)

       # No enabled field - assume enabled
       return True

**Examples**:

- ``NapariStreamingConfig(enabled=True)`` → Shows ``'NAP'`` label
- ``NapariStreamingConfig(enabled=False)`` → Shows nothing (returns ``None``)
- ``FijiStreamingConfig(enabled=True)`` → Shows ``'FIJI'`` label
- ``StepMaterializationConfig(enabled=False)`` → Shows nothing (returns ``None``)

This ensures that disabled configs don't clutter the UI with misleading preview labels.

**Well Filter Handling**:

The formatters correctly handle ``None`` values for ``well_filter`` fields. When a config
has a specific indicator (e.g., ``'NAP'``, ``'FIJI'``, ``'MAT'``) and the ``enabled`` field
is ``True``, the indicator is shown even if ``well_filter`` is ``None``. This preserves
visual consistency in preview labels across different config states.

**Reset Button Refresh Behavior**

``CrossWindowPreviewMixin`` automatically responds to reset button clicks via the ``refresh_handler``:

.. code-block:: python

   def _init_cross_window_preview_mixin(self):
       """Initialize cross-window preview mixin."""
       # ...

       # CRITICAL: Register as external listener for cross-window refresh signals
       # This makes preview labels reactive to live context changes
       # Listen to both value changes AND refresh events (e.g., reset button clicks)
       from pyqt_reactive.forms.parameter_form_manager import ParameterFormManager
       ParameterFormManager.register_external_listener(
           self,
           value_changed_handler=self.handle_cross_window_preview_change,
           refresh_handler=self.handle_cross_window_preview_refresh  # Listen to refresh events
       )

   def handle_cross_window_preview_refresh(
       self,
       editing_object: Any,
       context_object: Any,
   ) -> None:
       """Handle cross-window refresh events (e.g., reset button clicks).

       This is called when a ParameterFormManager emits context_refreshed signal,
       which happens when:
       - User clicks Reset button (reset_all_parameters or reset_parameter)
       - User cancels a config editor window (trigger_global_cross_window_refresh)

       Unlike handle_cross_window_preview_change which does incremental updates,
       this triggers a full refresh since reset can affect multiple fields.
       """
       # Extract scope ID and refresh affected items
       # Same logic as handle_cross_window_preview_change

**When refresh_handler is called**:

1. **Reset All button**: User clicks "Reset All" in a config window → all preview labels refresh
2. **Reset Field button**: User clicks reset icon next to a field → affected preview labels refresh
3. **Cancel button**: User cancels a config editor → preview labels revert to saved values

This ensures that preview labels stay synchronized with the actual config state, even when users reset values to defaults.

**Scope IDs**

Hierarchical scope identifiers enable targeted updates:

.. code-block:: python

   # Format: "plate_path::step_token"
   scope_id = f"{orchestrator.plate_path}::{step._pipeline_scope_token}"
   
   # Example: "/path/to/plate::step_001"
   # Enables routing changes to specific step in specific plate

**Scope Mapping**

Map scope IDs to item keys for incremental updates:

.. code-block:: python

   def _build_scope_index_map(self) -> Dict[str, int]:
       """Map scope IDs to step indices."""
       scope_map = {}
       for idx, step in enumerate(self.pipeline_steps):
           token = getattr(step, '_pipeline_scope_token', None)
           if token:
               scope_id = f"{self.current_plate}::{token}"
               scope_map[scope_id] = idx
       return scope_map

**Implementing Mixin Hooks**

After registering scopes/fields, subclasses still implement the operational hooks:

.. code-block:: python

   def _process_pending_preview_updates(self) -> None:
       """Apply incremental updates for pending keys."""
       # Collect live context ONCE
       # Refresh only items in self._pending_preview_keys
       
   def _handle_full_preview_refresh(self) -> None:
       """Fallback when incremental updates not possible."""
       # Call update_step_list() or equivalent

   def _merge_with_live_values(...):
       """Merge live overrides into objects returned by _get_preview_instance."""
       # Widget-specific merge logic

**Performance Impact**

- Context collection: 20+ calls → 1 call (cached via token)
- Context resolution: 20+ operations → 1 operation (incremental update)
- Widget updates: Full rebuild → Text-only update on existing widgets
- Measured latency: 60ms → 1ms per keystroke

Dispatch Cycle Caching System
------------------------------

**Problem**: When a user types a single character in a form field, the system triggers multiple expensive operations:

1. Collect live context from all open forms (~2ms)
2. Build context stack with GLOBAL layer resolution (~2ms)
3. Refresh sibling placeholders (5-10 siblings × ~2ms each)
4. Cross-window updates to other windows

With 6 sibling refreshes per keystroke, this totals ~20-30ms per keystroke, making typing feel sluggish.

**Solution**: Dispatch Cycle Caching

The dispatch cycle caching system uses ``contextvars`` to cache expensive computations within a single keystroke's dispatch cycle:

.. code-block:: python

   from your_app.context_manager import dispatch_cycle

   # In FieldChangeDispatcher.dispatch():
   with dispatch_cycle():
       # All operations within this cycle share the same cache
       # First sibling refresh: computes and caches live_context + GLOBAL layer
       # Subsequent siblings: get cache hits (O(1) lookup)
       for sibling_manager in sibling_managers:
           sibling_manager.refresh_with_live_context()

**How It Works**

1. **Context Variable Storage**: ``dispatch_cycle()`` creates a thread-local cache dict
2. **Cache Keys**: Operations use deterministic keys like ``('live_context', scope, type)``
3. **Automatic Invalidation**: Token increments on next keystroke, invalidating all caches
4. **Zero Overhead**: Cache lookups are O(1) dict operations

**Cache Layers**

The system caches at multiple levels:

1. **Live Context Cache** (``collect_live_context()``)
   - Key: ``('live_context', scope_filter, for_type_name)``
   - Value: Dict of all form values for the given scope/type
   - Hit rate: ~90% (same scope/type queried multiple times per keystroke)

2. **GLOBAL Layer Cache** (``build_context_stack()``)
   - Key: ``('global_layer', is_global_config_editing, global_config_type)``
   - Value: Resolved GLOBAL layer for lazy placeholder resolution
   - Hit rate: ~95% (GLOBAL layer same for all siblings)

3. **Placeholder Text Cache** (``apply_placeholder_text()``)
   - Key: Widget instance + placeholder text
   - Value: Cached placeholder text
   - Hit rate: ~80% (same placeholder text for unchanged fields)

**Usage Example**

.. code-block:: python

   from your_app.context_manager import dispatch_cycle, get_dispatch_cache

   def my_operation():
       # Check if we're in a dispatch cycle
       cache = get_dispatch_cache()
       if cache is not None:
           # We're in a dispatch cycle - use the cache
           cache_key = ('my_operation', param1, param2)
           if cache_key in cache:
               return cache[cache_key]  # Cache hit!

           # Cache miss - compute and store
           result = expensive_computation()
           cache[cache_key] = result
           return result
       else:
           # Not in a dispatch cycle - compute directly
           return expensive_computation()

**Performance Impact**

Before dispatch cycle caching:
- 94 keystrokes → 163 ``collect_live_context`` COMPUTING calls
- Each keystroke: ~20-30ms (6 siblings × ~3-5ms each)

After dispatch cycle caching:
- 94 keystrokes → 47 ``collect_live_context`` COMPUTING calls (369 cache hits)
- Each keystroke: ~3-5ms (6 siblings × ~0.5-1ms each)
- **Improvement: 4-6x faster typing**

**Implementation Details**

The dispatch cycle is implemented in ``pyqt_reactive/services/flag_context_manager.py``:

.. code-block:: python

   from contextvars import ContextVar

   _dispatch_cycle_cache: ContextVar[Optional[dict]] = ContextVar(
       'dispatch_cycle_cache', default=None
   )

   @contextmanager
   def dispatch_cycle():
       """Context manager for a dispatch cycle. Enables caching of computed values."""
       cache: dict = {}
       token = _dispatch_cycle_cache.set(cache)
       try:
           yield cache
       finally:
           _dispatch_cycle_cache.reset(token)

   def get_dispatch_cache() -> Optional[dict]:
       """Get the current dispatch cycle cache, or None if not in a cycle."""
       return _dispatch_cycle_cache.get()

**Integration Points**

The dispatch cycle is automatically entered at the top level:

1. **FieldChangeDispatcher.dispatch()** - Wraps entire field change handling
2. **LiveContextService.collect_live_context()** - Checks cache before computing
3. **build_context_stack()** - Caches GLOBAL layer resolution
4. **apply_placeholder_text()** - Caches placeholder text by string comparison

**Thread Safety**

``contextvars`` are thread-safe by design:

- Each thread has its own context variable values
- No locks needed
- Safe to use in async code (each async task gets its own context)

**When NOT to Use Dispatch Cycle Caching**

Don't use dispatch cycle caching for:

- Operations that must always reflect current state (e.g., file I/O)
- Operations with side effects (e.g., database writes)
- Long-running operations (cache should be short-lived)

**Debugging Dispatch Cycle Issues**

Enable debug logging to see cache hits/misses:

.. code-block:: python

   import logging
   logging.getLogger('your_app.context_manager').setLevel(logging.DEBUG)
   logging.getLogger('your_app.object_state').setLevel(logging.DEBUG)

Log output will show:

.. code-block:: text

   📦 collect_live_context: DISPATCH CACHE HIT (token=76, scope=None, for_type=GlobalPipelineConfig)
   📦 collect_live_context: COMPUTING (token=76, scope=/path/to/scope, for_type=PipelineConfig)
   🚀 GLOBAL layer CACHE HIT

Eliminating Redundant Cross-Window Refreshes
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

**Problem**: The ``config_window.py`` was calling ``trigger_global_cross_window_refresh()`` on every keystroke, which:

1. Called ``refresh_with_live_context()`` for ALL active form managers
2. Triggered full placeholder refresh for every manager
3. Caused O(n) work where n = number of open windows

This was completely redundant because ``FieldChangeDispatcher`` already handles cross-window updates via:

- Sibling refresh (nested managers with same field name)
- Cross-window signals (``context_value_changed``)
- Listener notification (``LiveContextService._notify_change()``)

**Solution**: Remove the redundant ``trigger_global_cross_window_refresh()`` call

.. code-block:: python

   # BEFORE (slow):
   def _sync_global_context_with_current_values(self, source_param: str = None):
       current_values = self.form_manager.get_current_values()
       updated_config = self.config_class(**current_values)
       self.current_config = updated_config
       set_global_config_for_editing(self.config_class, updated_config)
       self._global_context_dirty = True
       ParameterFormManager.trigger_global_cross_window_refresh()  # ❌ REDUNDANT!

   # AFTER (fast):
   def _sync_global_context_with_current_values(self, source_param: str = None):
       current_values = self.form_manager.get_current_values()
       updated_config = self.config_class(**current_values)
       self.current_config = updated_config
       set_global_config_for_editing(self.config_class, updated_config)
       self._global_context_dirty = True
       # FieldChangeDispatcher already handles cross-window updates

**Performance Impact**

- Removed O(n) refresh of all managers per keystroke
- Measured improvement: ~10-15ms per keystroke

Optimizing get_user_modified_values()
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

**Problem**: ``get_user_modified_values()`` was calling ``get_current_values()`` which:

1. Reads ALL widget values (expensive)
2. Recursively collects nested manager values
3. Happens on every keystroke during ``collect_live_context()``

But for lazy dataclasses, we only need values for fields in ``_user_set_fields``, not all fields.

**Solution**: Read directly from ``self.parameters`` instead of calling ``get_current_values()``

.. code-block:: python

   # BEFORE (slow):
   def get_user_modified_values(self) -> Dict[str, Any]:
       if not is_lazy_dataclass(self.object_instance):
           return self.get_current_values()  # ❌ Reads ALL widgets

       user_modified = {}
       current_values = self.get_current_values()  # ❌ Expensive!

       for field_name in self._user_set_fields:
           value = current_values.get(field_name)
           # ...

   # AFTER (fast):
   def get_user_modified_values(self) -> Dict[str, Any]:
       if not is_lazy_dataclass(self.object_instance):
           return self.get_current_values()

       user_modified = {}

       # Fast path: if no user-set fields, return empty dict
       if not self._user_set_fields:
           return user_modified

       for field_name in self._user_set_fields:
           # ✅ Read directly from self.parameters (already updated by FieldChangeDispatcher)
           value = self.parameters.get(field_name)
           # ...

**Why This Works**

- ``FieldChangeDispatcher`` updates ``self.parameters`` BEFORE calling any refresh
- For user-set fields, ``self.parameters`` is always the source of truth
- We only need values for fields in ``_user_set_fields``, not all fields
- No need to read widgets or recursively collect nested values

**Performance Impact**

- Eliminated expensive ``get_current_values()`` calls from ``collect_live_context()`` path
- Measured improvement: ~5-10ms per keystroke
- Reduced from 109 ``get_current_values`` calls to ~20 calls per typing session

Live Context Collection
-----------------------

``ParameterFormManager.collect_live_context()`` provides cached access to live form values:

.. code-block:: python

   from pyqt_reactive.forms.parameter_form_manager import (
       ParameterFormManager
   )
   
   # Collect live context with scope filtering
   snapshot = ParameterFormManager.collect_live_context(
       scope_filter=self.current_plate
   )
   
   # Use snapshot for resolution
   for step_index in indices_to_refresh:
       display_text = self.format_item_for_display(
           step, 
           live_context_snapshot=snapshot
       )

**Caching Behavior**

- Token-based: Snapshot cached until token changes
- Scope-filtered: Separate cache entries per scope filter
- Automatic invalidation: Token increments on any form value change
- Type aliasing: Maps lazy/base types for flexible matching

**Token Lifecycle**

1. User edits form field → ``_emit_cross_window_context_changed()``
2. Token incremented → ``_live_context_token_counter += 1``
3. All caches invalidated globally
4. Next ``collect_live_context()`` call recomputes snapshot
5. Subsequent calls with same token return cached snapshot

Async Operations in GUI
----------------------

Heavy operations (file I/O, network requests, blocking waits) must run in background threads to prevent UI freezes.

**Problem**

Blocking operations on the UI thread cause:

- Frozen interface (no repaints, no event processing)
- Unresponsive buttons and menus
- Poor user experience (appears crashed)
- Cannot cancel long-running operations

**Solution: Background Workers**

Move heavy operations to daemon threads:

.. code-block:: python

   import threading

   def on_user_action(self):
       """UI thread: Lightweight checks only."""
       # Check preconditions (cheap)
       if not self.is_valid():
           return

       # Spawn background worker
       threading.Thread(
           target=self._heavy_operation_async,
           args=(param1, param2),
           daemon=True
       ).start()

   def _heavy_operation_async(self, param1, param2):
       """Background thread: Heavy operations."""
       try:
           # Load from disk (blocking I/O)
           data = load_from_file(path)

           # Wait for external service (blocking)
           if not service.wait_for_ready(timeout=15.0):
               raise RuntimeError("Service not ready")

           # Process data (CPU-intensive)
           result = process_data(data)

           # Update UI via signal (thread-safe)
           self._status_update_signal.emit(f"Completed: {result}")

       except Exception as e:
           # Show error dialog on UI thread
           QTimer.singleShot(0, lambda: QMessageBox.warning(
               self, "Error", str(e)
           ))

**Thread-Safe UI Updates**

Never call UI methods directly from background threads. Use Qt signals or QTimer:

.. code-block:: python

   class MyWidget(QWidget):
       # Define signal for cross-thread communication
       _status_update_signal = pyqtSignal(str)

       def __init__(self):
           super().__init__()
           # Connect signal to UI update method
           self._status_update_signal.connect(self._update_status_label)

       def _update_status_label(self, text: str):
           """UI thread: Safe to update widgets."""
           self.status_label.setText(text)

       def _background_worker(self):
           """Background thread: Emit signal instead of direct update."""
           # ❌ WRONG: self.status_label.setText("Loading...")
           # ✅ CORRECT: Emit signal
           self._status_update_signal.emit("Loading...")

**QTimer for One-Shot UI Operations**

Use ``QTimer.singleShot()`` to schedule UI operations from background threads:

.. code-block:: python

   def _background_worker(self):
       """Background thread."""
       try:
           result = expensive_operation()
       except Exception as e:
           # Schedule dialog on UI thread
           QTimer.singleShot(0, lambda: QMessageBox.warning(
               self, "Error", f"Operation failed: {e}"
           ))
           return

       # Schedule success dialog on UI thread
       QTimer.singleShot(0, lambda: QMessageBox.information(
           self, "Success", f"Result: {result}"
       ))

**Daemon Threads**

Always use ``daemon=True`` for background workers:

- Daemon threads automatically terminate when app exits
- Non-daemon threads prevent app from closing
- User doesn't have to wait for background operations to finish

**Example: Async ROI Streaming**

From ``image_browser.py``:

.. code-block:: python

   def _stream_roi_file(self, roi_zip_path: Path):
       """UI thread: Lightweight checks only."""
       # Check which viewers are enabled (cheap)
       napari_enabled = self.napari_enable_checkbox.isChecked()
       fiji_enabled = self.fiji_enable_checkbox.isChecked()

       if not napari_enabled and not fiji_enabled:
           QMessageBox.information(self, "No Viewers", "Enable at least one viewer")
           return

       # Resolve configs on UI thread (cheap)
       napari_config = self._resolve_napari_config()
       fiji_config = self._resolve_fiji_config()

       # Spawn background workers
       if napari_enabled:
           threading.Thread(
               target=self._stream_single_roi_async,
               args=(napari_viewer, roi_zip_path, napari_config),
               daemon=True
           ).start()

       if fiji_enabled:
           threading.Thread(
               target=self._stream_single_roi_async,
               args=(fiji_viewer, roi_zip_path, fiji_config),
               daemon=True
           ).start()

   def _stream_single_roi_async(self, viewer, roi_zip_path, config):
       """Background thread: Heavy operations."""
       try:
           # Load ROIs from disk (blocking I/O)
           self._status_update_signal.emit(f"Loading {roi_zip_path.name}...")
           rois = load_rois_from_zip(roi_zip_path)

           # Wait for viewer (blocking, up to 15s)
           if not viewer.wait_for_ready(timeout=15.0):
               raise RuntimeError("Viewer not ready")

           # Stream to viewer (blocking I/O)
           self._status_update_signal.emit(f"Streaming to viewer...")
           filemanager.save(rois, roi_zip_path, backend, **metadata)

           # Success message on UI thread
           msg = f"Streamed {len(rois)} ROIs"
           self._status_update_signal.emit(msg)

       except Exception as e:
           # Error dialog on UI thread
           QTimer.singleShot(0, lambda: QMessageBox.warning(
               self, "Error", str(e)
           ))

Best Practices
-------------

**When to Use Incremental Updates**

Use incremental updates when:

- List has many items (10+)
- Updates are frequent (per-keystroke)
- Items have stable identities (indices, IDs)
- Preview computation is expensive

**When to Use Full Refresh**

Use full refresh when:

- List structure changes (items added/removed/reordered)
- Scope mapping is invalid or stale
- Incremental update complexity outweighs benefits

**When to Use Background Threads**

Use background threads when:

- Operation blocks for >100ms
- File I/O or network requests
- Waiting for external services
- CPU-intensive processing

**Threading Safety Checklist**

1. ✅ Use ``daemon=True`` for all background threads
2. ✅ Never call UI methods from background threads
3. ✅ Use Qt signals for cross-thread communication
4. ✅ Use ``QTimer.singleShot()`` for one-shot UI operations
5. ✅ Handle exceptions in background threads
6. ✅ Show errors via dialogs on UI thread

**Optimization Checklist**

1. ✅ Collect live context ONCE per refresh cycle
2. ✅ Use token caching for expensive operations
3. ✅ Update existing widgets instead of rebuilding
4. ✅ Batch multiple changes before processing
5. ✅ Use scope filtering to limit context collection
6. ✅ Implement incremental updates for large lists
7. ✅ Move blocking operations to background threads

Log Viewer Performance Optimizations
-------------------------------------

The log viewer implements several performance patterns to minimize UI impact when running in the background while users work in other windows.

Background Syntax Highlighting
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

**Problem**: Regex-based syntax highlighting is expensive (~1-2ms per line). Running it on the UI thread during paint events causes lag when scrolling or when new log lines arrive.

**Solution**: Move regex parsing to background thread pool, cache results, paint plain text as fallback.

**Architecture**:

.. code-block:: python

   class LogItemDelegate(QStyledItemDelegate):
       def __init__(self):
           self._thread_pool = QThreadPool.globalInstance()
           self._segment_cache: Dict[Tuple[str, str, int], List[HighlightedSegment]] = {}
           self._pending_highlights: Set[Tuple[str, str, int]] = set()

       def paint(self, painter, option, index):
           text = index.data(Qt.DisplayRole)

           # Try to get cached formatting segments (async, may return None)
           segments = self._get_or_request_segments(text, option.font)

           # Create document on main thread (fast)
           doc = QTextDocument()
           doc.setPlainText(text)

           if segments is not None:
               # Formatting ready - apply it (fast)
               self._apply_segments_to_document(doc, segments)
           # else: Paint plain text (still readable while parsing)

           # Paint the document
           doc.drawContents(painter)

       def _get_or_request_segments(self, text, font):
           cache_key = (text, font.family(), font.pointSize())

           # Check cache
           if cache_key in self._segment_cache:
               return self._segment_cache[cache_key]

           # Not in cache - start async parsing if not already pending
           if cache_key not in self._pending_highlights:
               self._pending_highlights.add(cache_key)
               worker = HighlightWorker(text, cache_key, self._color_scheme, self._signals)
               self._thread_pool.start(worker)

           return None  # Caller will paint plain text

**Benefits**:

- UI thread never blocks on regex parsing
- Progressive enhancement: plain text → highlighted text
- Cache provides instant highlighting on subsequent paints
- Scrolling remains smooth even with complex highlighting rules

**Performance**:

- Regex parsing: 1-2ms per line (background thread)
- Format application: <1ms per line (main thread)
- Cache hit: <0.1ms per line
- UI impact: 0ms (async)

Update Throttling
~~~~~~~~~~~~~~~~~

**Problem**: Log tailer checks for new content every 50ms. When new lines arrive, they immediately trigger model updates which cause the entire QListView to repaint. When typing rapidly in pipeline config, these frequent repaints compete for UI thread time.

**Solution**: Buffer incoming log lines and flush at most every 50ms, defer updates when window is hidden.

**Architecture**:

.. code-block:: python

   class LogViewerWindow(QMainWindow):
       def __init__(self):
           self._pending_lines: List[str] = []
           self._update_timer = QTimer()
           self._update_timer.setSingleShot(True)
           self._update_timer.timeout.connect(self._flush_pending_lines)
           self._update_throttle_ms = 50

       def _on_new_content(self, new_content: str, new_file_position: int):
           # Defer updates if window is hidden
           if self.isMinimized() or not self.isVisible():
               self.current_file_position = new_file_position
               return

           lines = new_content.splitlines()

           # Add to pending buffer
           self._pending_lines.extend(lines)

           # Start throttle timer if not already running
           if not self._update_timer.isActive():
               self._update_timer.start(self._update_throttle_ms)

       def _flush_pending_lines(self):
           """Flush pending lines to UI (called by throttle timer)."""
           if not self._pending_lines:
               return

           lines = self._pending_lines
           self._pending_lines = []

           # Insert lines into model
           self.log_model.append_lines(lines)

**Benefits**:

- Multiple log lines arriving within 50ms are batched into single UI update
- Reduces number of model updates and QListView repaints
- UI thread has more time to handle user input in other windows
- Hidden windows don't consume UI resources

**Performance**:

- Before throttling: 10 updates/second = 10 repaints/second
- After throttling: 1 update per 50ms burst = 1 repaint per burst
- Typing latency improvement: ~40ms (measured in pipeline config)

Type-Based Inheritance Filtering
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

**Problem**: When typing in a nested config field (e.g., ``WellFilterConfig.well_filter``), the cross-window update system was refreshing ALL sibling nested configs (e.g., ``VFSConfig``, ``NapariStreamingConfig``) even though only configs inheriting from ``WellFilterConfig`` could be affected.

**Solution**: Use ``isinstance()`` checks to only refresh sibling configs whose object instances inherit from the changed config type.

**Architecture**:

.. code-block:: python

   def _on_nested_parameter_changed(self, emitting_manager_name: str):
       # Get the emitting manager's type
       emitting_manager = self.nested_managers.get(emitting_manager_name)
       emitting_type = emitting_manager.dataclass_type if emitting_manager else None

       def should_refresh_sibling(name: str, manager) -> bool:
           if name == emitting_manager_name:
               return False  # Don't refresh the emitting manager itself
           if not emitting_type:
               return True  # Conservative: refresh if we can't determine
           # Check if sibling's object instance inherits from emitting type
           return isinstance(manager.object_instance, emitting_type)

       # Only refresh affected siblings
       self._apply_to_nested_managers(
           lambda name, manager: (
               manager._refresh_all_placeholders(live_context=live_context)
               if should_refresh_sibling(name, manager)
               else None
           )
       )

**Example**:

When editing ``WellFilterConfig.well_filter`` in ``PipelineConfig``:

- ✅ Refresh ``NapariStreamingConfig`` (inherits from ``WellFilterConfig`` via ``StreamingDefaults`` → ``StepWellFilterConfig``)
- ❌ Skip ``VFSConfig`` (doesn't inherit from ``WellFilterConfig``)

**Benefits**:

- Eliminates unnecessary placeholder refreshes
- Reduces cross-window update overhead
- Cleaner logs (no more "Skipping cross-window update" spam)

**Performance**:

- Before: 3-5 sibling refreshes per keystroke (all siblings)
- After: 0-2 sibling refreshes per keystroke (only affected siblings)
- Measured improvement: ~5-10ms per keystroke in complex configs

Performance Monitoring
---------------------

The system includes a performance monitor for tracking widget creation and form operations.

**Performance Logger**:

.. code-block:: python

    from pyqt_reactive.core.performance_monitor import perf_logger

    perf_logger.debug(f"Operation took {duration_ms:.2f}ms")

**Logging Level**:

Performance logger uses ``WARNING`` level by default to reduce log noise:

.. code-block:: python

    perf_logger.setLevel(logging.WARNING)

This suppresses routine performance measurements in normal operation while still logging performance issues when they occur.

**Timer Context Manager**:

.. code-block:: python

    from pyqt_reactive.core.performance_monitor import timer

    with timer("Widget creation", threshold_ms=5.0):
        widget = create_complex_widget()

Operations slower than ``threshold_ms`` are logged to ``perf_logger``.

**Usage Guidelines**:

- Use ``timer`` for operations that may be slow (>5ms)
- Set appropriate ``threshold_ms`` for each context
- Only log operations that are likely to be performance bottlenecks
- Avoid excessive logging in hot paths (like paint events)

**Performance Metrics**:

Common operation timings:

- Widget creation: 1-50ms (depends on complexity)
- Form initialization: 10-200ms (depends on parameter count)
- Placeholder refresh: 1-10ms (per field)
- Cross-window update: 5-20ms (per affected window)

Operations exceeding these thresholds are flagged for investigation.
