Source code for pyqt_reactive.widgets.shared.scrollable_form_mixin
"""
Mixin for widgets that manage a ParameterFormManager with a scroll area.
Provides common functionality for scrolling to sections in the form.
Used by ConfigWindow and StepParameterEditorWidget.
"""
import logging
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any, Optional
from PyQt6.QtWidgets import QScrollArea, QWidget
from pyqt_reactive.services.window_navigation import (
FormFieldWindowNavigationDriver,
NavigationWaitReason,
RegisteredWindowNavigationReadiness,
RegisteredWindowNavigationRequest,
WindowNavigationDriver,
)
if TYPE_CHECKING:
from pyqt_reactive.widgets.structural_table import StructuralFlashTarget
logger = logging.getLogger(__name__)
[docs]
@dataclass(frozen=True)
class ScrollTarget:
"""Resolved form navigation target."""
field_name: str
leaf_name: str
section_path: str
target_widget: QWidget
groupbox_widget: Optional[QWidget]
current_manager: Any
is_field: bool
structural_flash_target: Optional["StructuralFlashTarget"] = None
[docs]
@dataclass(frozen=True)
class ScrollViewport:
"""Snapshot of scroll-area geometry used to compute navigation."""
content_widget: QWidget
viewport_height: int
viewport_top: int
viewport_bottom: int
vertical_scroll_bar: Any
[docs]
class ScrollableFormMixin:
"""
Mixin for widgets that have:
- self.scroll_area: QScrollArea containing the form
- self.form_manager: ParameterFormManager with nested_managers
Provides scroll-to-section functionality.
Optionally triggers flash animation on the target groupbox.
"""
# Type hints for attributes that must be provided by the implementing class
scroll_area: QScrollArea
form_manager: 'ParameterFormManager' # Forward reference
def _scroll_to_section(self, field_name: str, flash: bool = True):
"""Scroll to a specific section or field in the form.
Supports both:
- Section names (e.g., 'path_planning_config') - scrolls to groupbox
- Dotted paths (e.g., 'path_planning_config.well_filter') - scrolls to specific widget
Args:
field_name: The field name or dotted path to scroll to
flash: If True, flash the target groupbox after scrolling
"""
logger.info(f"🔍 Scrolling to section: {field_name}")
if self.scroll_area is None:
logger.warning("Scroll area not initialized; cannot navigate to section")
return
target = self._resolve_scroll_target(field_name, warn_missing=False)
if target is None:
target = self._resolve_nearest_ancestor_scroll_target(field_name)
if target is None:
self._resolve_scroll_target(field_name, warn_missing=True)
return
viewport = self._scroll_viewport()
if self._target_is_fully_visible(target, viewport):
logger.info(f"✅ Target {field_name} already visible, skipping scroll")
else:
target_scroll = self._target_scroll_position(target, viewport)
viewport.vertical_scroll_bar.setValue(target_scroll)
logger.info(f"✅ Scrolled to {field_name} (target_scroll={target_scroll})")
# Invalidate flash overlay geometry cache after programmatic scroll
from pyqt_reactive.animation import WindowFlashOverlay
WindowFlashOverlay.invalidate_cache_for_widget(self) # type: ignore[arg-type]
if flash:
self._flash_scroll_target(target)
def _resolve_scroll_target(
self,
field_name: str,
*,
warn_missing: bool = True,
) -> Optional[ScrollTarget]:
"""Resolve a dotted form path to a concrete widget and section path."""
parts = field_name.split('.')
current_manager = self.form_manager
section_parts = []
for i, part in enumerate(parts[:-1]):
if part not in current_manager.nested_managers:
inline_target = self._resolve_inline_dataclass_child_target(
field_name=field_name,
current_manager=current_manager,
section_parts=section_parts,
inline_field_name=part,
child_field_name=parts[i + 1],
)
if inline_target is not None:
return inline_target
if warn_missing:
logger.warning(f"❌ Part '{part}' not in nested_managers at depth {i}")
return None
current_manager = current_manager.nested_managers[part]
section_parts.append(part)
leaf_name = parts[-1]
section_path = ".".join(section_parts)
groupbox_widget = None
target_widget = None
if leaf_name in current_manager.nested_managers:
nested_manager = current_manager.nested_managers[leaf_name]
section_parts.append(leaf_name)
section_path = ".".join(section_parts)
groupbox_widget = self.form_manager.form_tree.groupbox_for_prefix(section_path)
if nested_manager.widgets:
first_param_name = next(iter(nested_manager.widgets.keys()))
target_widget = nested_manager.widgets[first_param_name]
elif leaf_name in current_manager.widgets:
target_widget = current_manager.widgets[leaf_name]
inline_owner_target = self._resolve_inline_dataclass_owner_target(
field_name=field_name,
current_manager=current_manager,
section_parts=section_parts,
inline_field_name=leaf_name,
)
if inline_owner_target is not None:
return inline_owner_target
else:
if warn_missing:
logger.warning(f"❌ Leaf '{leaf_name}' not found in widgets or nested_managers")
return None
if target_widget is None:
if warn_missing:
logger.warning(f"⚠️ No target widget found for {field_name}")
return None
is_field = leaf_name in current_manager.widgets and leaf_name not in current_manager.nested_managers
return ScrollTarget(
field_name=field_name,
leaf_name=leaf_name,
section_path=section_path,
target_widget=target_widget,
groupbox_widget=groupbox_widget,
current_manager=current_manager,
is_field=is_field,
)
def _resolve_inline_dataclass_owner_target(
self,
*,
field_name: str,
current_manager: Any,
section_parts: list[str],
inline_field_name: str,
) -> Optional[ScrollTarget]:
"""Resolve an inline dataclass owner path to its structural container."""
from pyqt_reactive.widgets.structural_table import (
resolve_inline_dataclass_structural_target,
)
inline_widget = current_manager.widgets.get(inline_field_name)
if not isinstance(inline_widget, QWidget):
return None
inline_prefix = tuple(section_parts + [inline_field_name])
structural_result = resolve_inline_dataclass_structural_target(
inline_widget=inline_widget,
inline_field_path=inline_prefix,
display_path=field_name,
)
if structural_result is None:
return None
structural_target = structural_result.target
return ScrollTarget(
field_name=field_name,
leaf_name=structural_result.child_field_name,
section_path=".".join(inline_prefix),
target_widget=structural_target.scroll_widget(),
groupbox_widget=None,
current_manager=current_manager,
is_field=False,
structural_flash_target=structural_target,
)
def _resolve_inline_dataclass_child_target(
self,
*,
field_name: str,
current_manager: Any,
section_parts: list[str],
inline_field_name: str,
child_field_name: str,
) -> Optional[ScrollTarget]:
"""Resolve a child field rendered inside an inline dataclass widget."""
from pyqt_reactive.protocols import (
ChildFieldNavigationTargetProvider,
)
from pyqt_reactive.widgets.structural_table import (
resolve_inline_dataclass_structural_target,
)
from pyqt_reactive.widgets.shared.clickable_help_components import (
InlineDataclassGroupBox,
)
inline_widget = current_manager.widgets.get(inline_field_name)
if not isinstance(inline_widget, InlineDataclassGroupBox):
return None
inline_prefix = tuple(section_parts + [inline_field_name])
structural_result = resolve_inline_dataclass_structural_target(
inline_widget=inline_widget,
inline_field_path=inline_prefix,
display_path=field_name,
owner_child_field_name=child_field_name,
)
if structural_result is not None:
structural_target = structural_result.target
return ScrollTarget(
field_name=field_name,
leaf_name=structural_result.child_field_name,
section_path=".".join(section_parts + [inline_field_name]),
target_widget=structural_target.scroll_widget(),
groupbox_widget=None,
current_manager=current_manager,
is_field=False,
structural_flash_target=structural_target,
)
target_widget: QWidget
if isinstance(inline_widget, ChildFieldNavigationTargetProvider):
child_widget = inline_widget.child_field_navigation_target(child_field_name)
if not isinstance(child_widget, QWidget):
return None
target_widget = child_widget
else:
target_widget = inline_widget
section_path = ".".join(section_parts + [inline_field_name])
return ScrollTarget(
field_name=field_name,
leaf_name=child_field_name,
section_path=section_path,
target_widget=target_widget,
groupbox_widget=None,
current_manager=current_manager,
is_field=False,
)
def _resolve_nearest_ancestor_scroll_target(self, field_name: str) -> Optional[ScrollTarget]:
"""Resolve the nearest visible ancestor for a missing deep field path."""
parts = tuple(part for part in field_name.split(".") if part)
if len(parts) <= 1:
return None
for prefix_length in range(len(parts) - 1, 0, -1):
ancestor_path = ".".join(parts[:prefix_length])
target = self._resolve_scroll_target(
ancestor_path,
warn_missing=False,
)
if target is not None:
return target
return None
def _scroll_viewport(self) -> ScrollViewport:
"""Capture scroll-area geometry once for a navigation operation."""
content_widget = self.scroll_area.widget()
v_scroll_bar = self.scroll_area.verticalScrollBar()
viewport_height = self.scroll_area.viewport().height()
viewport_top = v_scroll_bar.value()
return ScrollViewport(
content_widget=content_widget,
viewport_height=viewport_height,
viewport_top=viewport_top,
viewport_bottom=viewport_top + viewport_height,
vertical_scroll_bar=v_scroll_bar,
)
def _target_sizing_widget(self, target: ScrollTarget) -> QWidget:
"""Use section groupboxes for section navigation and leaf widgets for fields."""
if target.groupbox_widget is not None and not target.is_field:
return target.groupbox_widget
return target.target_widget
def _target_bounds(self, widget: QWidget, viewport: ScrollViewport) -> tuple[int, int, int]:
widget_pos = widget.mapTo(viewport.content_widget, widget.rect().topLeft())
widget_height = widget.height()
widget_top = widget_pos.y()
return widget_top, widget_height, widget_top + widget_height
def _target_visual_bounds(
self,
target: ScrollTarget,
viewport: ScrollViewport,
) -> tuple[int, int, int]:
if target.structural_flash_target is not None:
rect = target.structural_flash_target.scroll_rect_in(viewport.content_widget)
if rect is not None:
return rect.y(), rect.height(), rect.y() + rect.height()
return self._target_bounds(self._target_sizing_widget(target), viewport)
def _target_is_fully_visible(self, target: ScrollTarget, viewport: ScrollViewport) -> bool:
widget_top, _, widget_bottom = self._target_visual_bounds(target, viewport)
return widget_top >= viewport.viewport_top and widget_bottom <= viewport.viewport_bottom
def _target_scroll_position(self, target: ScrollTarget, viewport: ScrollViewport) -> int:
if target.is_field:
return self._field_scroll_position(target, viewport)
return self._section_scroll_position(target, viewport)
def _field_scroll_position(self, target: ScrollTarget, viewport: ScrollViewport) -> int:
field_center = self._widget_center(target.target_widget, viewport)
return max(0, field_center - viewport.viewport_height // 2)
def _section_scroll_position(self, target: ScrollTarget, viewport: ScrollViewport) -> int:
widget_top, widget_height, _ = self._target_visual_bounds(target, viewport)
if widget_height >= viewport.viewport_height:
logger.debug(
f"📜 SCROLL: {target.field_name} taller than viewport, top-aligning: "
f"widget_height={widget_height}, viewport_height={viewport.viewport_height}"
)
return widget_top
widget_center = widget_top + widget_height // 2
target_scroll = max(0, widget_center - viewport.viewport_height // 2)
logger.debug(
f"📜 SCROLL: {target.field_name} centering: widget_top={widget_top}, "
f"widget_height={widget_height}, widget_center={widget_center}, "
f"viewport_height={viewport.viewport_height}, target_scroll={target_scroll}"
)
return target_scroll
def _widget_center(self, widget: QWidget, viewport: ScrollViewport) -> int:
widget_top, widget_height, _ = self._target_bounds(widget, viewport)
return widget_top + widget_height // 2
def _flash_scroll_target(self, target: ScrollTarget) -> None:
"""Flash the resolved target locally after navigation."""
if target.structural_flash_target is not None:
target_key = target.field_name
target.structural_flash_target.register_flash(self.form_manager, target_key)
logger.info(
f"⚡ FLASH_DEBUG: Calling queue_flash_local({target_key}) "
f"on form_manager scope_id={self.form_manager.scope_id}"
)
self.form_manager.queue_flash_local(target_key)
return
if target.is_field:
self.form_manager._queue_leaf_flash_for_path(target.field_name)
elif target.section_path and target.field_name != target.section_path:
self.form_manager._queue_leaf_flash_for_path(target.field_name)
elif target.section_path:
logger.info(
f"⚡ FLASH_DEBUG: Calling queue_flash_local({target.section_path}) "
f"on form_manager scope_id={self.form_manager.scope_id}"
)
self.form_manager.queue_flash_local(target.section_path)
logger.debug(f"⚡ Flashed for {target.field_name} (local)")
[docs]
def select_and_scroll_to_field(self, field_path: str) -> None:
"""Public API for WindowManager navigation protocol.
Scrolls to and highlights the specified field.
This method name matches the protocol expected by WindowManager.focus_and_navigate().
"""
self._scroll_to_section(field_path, flash=True)