Source code for pyqt_reactive.services.widget_service
"""
Consolidated Widget Service.
Merges:
- WidgetFinderService: Finding widgets in ParameterFormManager
- WidgetStylingService: Read-only styling, dimming, visual state management
- WidgetUpdateService: Low-level widget value update operations
Key features:
1. Centralized widget finding, styling, and update logic
2. Type-safe widget retrieval with fail-loud behavior
3. Signal blocking during value updates
4. Read-only styling that maintains normal appearance
"""
from typing import Any, Optional, List, Type, Callable, Dict
from PyQt6.QtWidgets import (
QWidget, QCheckBox, QLineEdit, QSpinBox, QDoubleSpinBox,
QComboBox, QTextEdit, QAbstractSpinBox
)
from PyQt6.QtCore import Qt
from pyqt_reactive.protocols import ValueGettable
import logging
logger = logging.getLogger(__name__)
[docs]
class WidgetService:
"""
Consolidated service for widget finding, styling, and value updates.
Examples:
service = WidgetService()
# Find widgets:
checkbox = WidgetService.find_optional_checkbox(manager, param_name)
widget = WidgetService.get_widget_safe(manager, param_name)
# Style widgets:
WidgetService.make_readonly(widget, color_scheme)
WidgetService.apply_dimming(widget, opacity=0.5)
# Update widget values:
service.update_widget_value(widget, value, param_name, manager=manager)
"""
[docs]
def __init__(self):
"""Initialize widget service with dependencies."""
from pyqt_reactive.forms.widget_operations import WidgetOperations
from pyqt_reactive.forms.widget_strategies import PyQt6WidgetEnhancer
self.widget_ops = WidgetOperations
self.widget_enhancer = PyQt6WidgetEnhancer
# ========== WIDGET FINDING (from WidgetFinderService) ==========
[docs]
@staticmethod
def find_optional_checkbox(manager, param_name: str) -> Optional[QCheckBox]:
"""Find optional checkbox for a parameter."""
if param_name not in manager.widgets:
logger.debug(f"No widget for param_name={param_name}")
return None
container = manager.widgets[param_name]
ids = manager.service.generate_field_ids_direct(manager.config.field_id, param_name)
checkbox = container.findChild(QCheckBox, ids['optional_checkbox_id'])
if checkbox:
logger.debug(f"Found optional checkbox for param_name={param_name}")
return checkbox
[docs]
@staticmethod
def find_nested_checkbox(manager, param_name: str) -> Optional[QCheckBox]:
"""Find checkbox in nested manager's container."""
if param_name not in manager.widgets:
return None
container = manager.widgets[param_name]
ids = manager.service.generate_field_ids_direct(manager.config.field_id, param_name)
return container.findChild(QCheckBox, ids['optional_checkbox_id'])
[docs]
@staticmethod
def find_group_box(container: QWidget, group_box_type: Type = None) -> Optional[QWidget]:
"""Find group box within container."""
if group_box_type is None:
try:
from pyqt_reactive.widgets.shared.clickable_help_components import GroupBoxWithHelp
group_box_type = GroupBoxWithHelp
except ImportError:
logger.warning("Could not import GroupBoxWithHelp")
return None
return container.findChild(group_box_type)
[docs]
@staticmethod
def get_widget_safe(manager, param_name: str) -> Optional[QWidget]:
"""Safely get a widget from manager's widgets dict."""
widget = manager.widgets.get(param_name)
if widget:
logger.debug(f"Found widget for param_name={param_name}, type={type(widget).__name__}")
return widget
[docs]
@staticmethod
def find_all_input_widgets(container: QWidget, widget_ops) -> List[QWidget]:
"""Find all input widgets within a container."""
widgets = widget_ops.get_all_value_widgets(container)
logger.debug(f"Found {len(widgets)} input widgets in container")
return widgets
# ========== PARAMETER DISPLAY INFO ==========
[docs]
@staticmethod
def get_parameter_display_info(param_name: str, param_type: Type,
manager=None, description: Optional[str] = None) -> Dict[str, Any]:
"""
Get parameter display information including description from ObjectState._parameter_descriptions.
Args:
param_name: Name of the parameter
param_type: Type of the parameter
manager: Optional ParameterFormManager instance for context
description: Optional parameter description to use
Returns:
Dictionary with display information (field_label, description, etc.)
"""
# Get manager from context if not provided
if manager is None:
return {}
resolved_description = description
param_info = manager.form_structure.get_parameter_info(param_name)
if resolved_description is None and param_info is not None:
resolved_description = param_info.description
if resolved_description is None:
dotted = f"{manager.field_id}.{param_name}" if manager.field_id else param_name
raw = manager.state.parameter_descriptions.get(dotted)
if raw is not None:
resolved_description = raw if isinstance(raw, str) else raw.description
display_info = manager.service.get_parameter_display_info(
param_name,
param_type,
description=resolved_description,
)
logger.debug(
"get_parameter_display_info: param_name=%s, description=%s",
param_name,
display_info.get("description"),
)
return display_info
# ========== WIDGET STYLING (from WidgetStylingService) ==========
[docs]
@staticmethod
def make_readonly(widget: QWidget, color_scheme) -> None:
"""Make a widget read-only without greying it out."""
if isinstance(widget, (QLineEdit, QTextEdit)):
widget.setReadOnly(True)
widget.setStyleSheet(f"color: {color_scheme.to_hex(color_scheme.text_primary)};")
elif isinstance(widget, (QSpinBox, QDoubleSpinBox)):
widget.setReadOnly(True)
widget.setButtonSymbols(QAbstractSpinBox.ButtonSymbols.NoButtons)
widget.setStyleSheet(f"color: {color_scheme.to_hex(color_scheme.text_primary)};")
elif isinstance(widget, QComboBox):
widget.setEnabled(False)
widget.setStyleSheet(f"""
QComboBox:disabled {{
color: {color_scheme.to_hex(color_scheme.text_primary)};
background-color: {color_scheme.to_hex(color_scheme.input_bg)};
}}
""")
elif isinstance(widget, QCheckBox):
widget.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents)
widget.setFocusPolicy(Qt.FocusPolicy.NoFocus)
else:
logger.warning(f"No read-only styling for {type(widget).__name__}")
[docs]
@staticmethod
def apply_dimming(widget: QWidget, opacity: float = 0.5) -> None:
"""Apply visual dimming to a widget."""
if not (0.0 <= opacity <= 1.0):
raise ValueError(f"Opacity must be 0.0-1.0, got {opacity}")
widget.setWindowOpacity(opacity)
[docs]
@staticmethod
def remove_dimming(widget: QWidget) -> None:
"""Remove visual dimming from a widget."""
widget.setWindowOpacity(1.0)
[docs]
@staticmethod
def set_enabled_with_styling(widget: QWidget, enabled: bool, color_scheme=None) -> None:
"""Set widget enabled state with appropriate styling."""
if enabled:
widget.setEnabled(True)
if isinstance(widget, (QLineEdit, QTextEdit)):
widget.setReadOnly(False)
widget.setStyleSheet("")
elif isinstance(widget, (QSpinBox, QDoubleSpinBox)):
widget.setReadOnly(False)
widget.setButtonSymbols(QAbstractSpinBox.ButtonSymbols.UpDownArrows)
widget.setStyleSheet("")
elif isinstance(widget, QCheckBox):
widget.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents, False)
widget.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
else:
if color_scheme:
WidgetService.make_readonly(widget, color_scheme)
else:
widget.setEnabled(False)
[docs]
@staticmethod
def clear_stylesheet(widget: QWidget) -> None:
"""Clear widget's stylesheet."""
widget.setStyleSheet("")
[docs]
@staticmethod
def apply_error_styling(widget: QWidget, color_scheme) -> None:
"""Apply error styling to a widget."""
error_color = color_scheme.to_hex(color_scheme.status_error)
widget.setStyleSheet(f"border: 2px solid {error_color};")
[docs]
@staticmethod
def remove_error_styling(widget: QWidget) -> None:
"""Remove error styling from a widget."""
WidgetService.clear_stylesheet(widget)
# ========== WIDGET VALUE UPDATES (from WidgetUpdateService) ==========
[docs]
def update_widget_value(
self,
widget: QWidget,
value: Any,
param_name: Optional[str] = None,
skip_context_behavior: bool = False,
manager=None
) -> None:
"""Update widget value with signal blocking and optional placeholder application."""
if self._widget_value_needs_update(widget, value):
self._execute_with_signal_blocking(widget, lambda: self._dispatch_widget_update(widget, value))
if not skip_context_behavior and manager:
self._apply_context_behavior(widget, value, param_name, manager)
@staticmethod
def _widget_value_needs_update(widget: QWidget, value: Any) -> bool:
"""Return whether a widget assignment would change its nominal value."""
if not isinstance(widget, ValueGettable):
return True
return widget.get_value() != value
def _execute_with_signal_blocking(self, widget: QWidget, operation: Callable) -> None:
"""Execute operation with widget signals blocked."""
widget.blockSignals(True)
operation()
widget.blockSignals(False)
def _dispatch_widget_update(self, widget: QWidget, value: Any) -> None:
"""Dispatch widget update using ABC-based operations."""
self.widget_ops.set_value(widget, value)
def _apply_context_behavior(
self,
widget: QWidget,
value: Any,
param_name: str,
manager
) -> None:
"""Apply placeholder behavior based on value."""
logger.debug(
"Applying context behavior for %s.%s, value=%r",
manager.field_id,
param_name,
value,
)
if not param_name or not manager.object_instance:
logger.debug("Skipping context behavior without param_name or object_instance")
return
if value is None:
logger.debug("Computing placeholder for None value")
from pyqt_reactive.forms.parameter_form_manager import ParameterFormManager
from objectstate import ObjectStateRegistry, build_context_stack
# Get ancestor objects for context stack
ancestor_objects = ObjectStateRegistry.get_ancestor_objects(manager.scope_id or "")
# Build context stack
stack = build_context_stack(
object_instance=manager.object_instance,
ancestor_objects=ancestor_objects,
)
with stack:
placeholder_text = manager.service.get_placeholder_text(param_name, type(manager.object_instance))
logger.debug("Resolved placeholder text: %r", placeholder_text)
if placeholder_text:
# Get the resolved value directly for type-safe placeholder application
try:
resolved_value = getattr(manager.object_instance, param_name)
except AttributeError:
resolved_value = None
self.widget_enhancer.apply_placeholder_with_value(widget, resolved_value, placeholder_text)
else:
logger.debug("No placeholder text returned")
elif value is not None:
if self.widget_enhancer.has_placeholder_state(widget):
logger.debug("Clearing placeholder state for concrete value")
self.widget_enhancer._clear_placeholder_state(widget)
[docs]
def clear_widget_to_default_state(self, widget: QWidget) -> None:
"""Clear widget to its default/empty state for reset operations."""
if isinstance(widget, QLineEdit):
widget.clear()
elif isinstance(widget, (QSpinBox, QDoubleSpinBox)):
widget.setValue(widget.minimum())
elif isinstance(widget, QComboBox):
widget.setCurrentIndex(-1)
elif isinstance(widget, QCheckBox):
widget.setChecked(False)
elif isinstance(widget, QTextEdit):
widget.clear()
else:
widget.clear()
[docs]
def update_combo_box(self, widget: QComboBox, value: Any) -> None:
"""Update combo box with value matching."""
widget.setCurrentIndex(
-1 if value is None else
next((i for i in range(widget.count()) if widget.itemData(i) == value), -1)
)
[docs]
def update_checkbox_group(self, widget: QWidget, value: Any) -> None:
"""Update checkbox group using functional operations."""
if isinstance(value, list):
[cb.setChecked(False) for cb in widget._checkboxes.values()]
[widget._checkboxes[v].setChecked(True) for v in value if v in widget._checkboxes]
[docs]
def get_widget_value(self, widget: QWidget) -> Any:
"""Get widget value using ABC-based polymorphism."""
from pyqt_reactive.protocols.widget_protocols import (
PlaceholderStateTrackable,
ValueGettable,
WidgetCapability,
widget_supports_capability,
)
if (
widget_supports_capability(widget, WidgetCapability.PLACEHOLDER_STATE)
and isinstance(widget, PlaceholderStateTrackable)
and widget.has_placeholder_state()
):
return None
if isinstance(widget, ValueGettable):
return widget.get_value()
return None