Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,28 @@
# Changelog

## 0.7.1

Requires ObjectState 3 (`objectstate>=3.0,<4`) and `formbridge>=0.1.2,<0.2`.
No public name is removed.

Changed:

- An unset field's placeholder is one read: `WidgetService.
show_resolved_value_as_placeholder` shows `state.get_resolved_value(path)`.
The value-update path used to build its own context stack
(`build_context_stack(ancestor_objects=...)`, removed in ObjectState 3) and
read the object's attribute, and it used the default prefix instead of the
form's `placeholder_prefix`; the two refresh paths in `ParameterOpsService`
each repeated the resolve-and-format step. All three call the one method.
- Provenance navigation reads `state.get_provenance(path)` once per action
(`ProvenanceNavigationMixin._provenance_source`). When another config of the
same object supplies the value, the source state is the widget's own state.
ObjectState 3 reports that source as `(None, source_type)` for a state
without a scope id; the target path used to be looked up in the global
state for that scope.
- `AbstractManagerWidget` no longer builds a `LiveContextResolver` it never
called.

## 0.7.0

Generic machinery moved here from three OpenHCS GUI classes. Needs
Expand Down
4 changes: 2 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -25,11 +25,11 @@ keywords = ["pyqt6", "forms", "reactive", "gui", "widgets", "dataclass"]

dependencies = [
"annotated-types>=0.7.0",
"formbridge>=0.1.1,<0.2",
"formbridge>=0.1.2,<0.2",
"magicgui>=0.7.0",
"metaclass-registry>=0.1.5",
"PyQt6>=6.4.0",
"objectstate>=2.0,<3",
"objectstate>=3.0,<4",
"psutil>=5.9",
"Pygments>=2.15",
"python-introspect>=0.2.6,<0.3",
Expand Down
2 changes: 1 addition & 1 deletion requirements-ci.txt
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
# Source candidates needed to validate this checkout before coordinated publication.
# Empty: every first-party dependency is released (formbridge 0.1.1, objectstate 2.1.1).
# Empty: every first-party dependency is released (objectstate 3.0.0, formbridge 0.1.2).
2 changes: 1 addition & 1 deletion src/pyqt_reactive/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
- Cross-window reactive updates
"""

__version__ = "0.7.0"
__version__ = "0.7.1"

# Public API will be populated as modules are added
__all__ = [
Expand Down
39 changes: 4 additions & 35 deletions src/pyqt_reactive/services/parameter_ops_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -220,9 +220,6 @@ def refresh_single_placeholder(self, manager, field_name: str) -> None:
if not PyQt6WidgetEnhancer.supports_placeholder(widget):
return

# Compute full dotted path for nested PFMs
full_path = f"{manager.field_id}.{field_name}" if manager.field_id else field_name

# Only refresh if value is None (needs placeholder)
# Use manager.parameters (scoped) not state.parameters (full paths)
current_value = manager.parameters.get(field_name)
Expand All @@ -231,26 +228,7 @@ def refresh_single_placeholder(self, manager, field_name: str) -> None:
logger.debug(f"🔬 RESET_TRACE: value is not None, no placeholder needed")
return

logger.debug(f"🔬 RESET_TRACE: value is None, calling get_resolved_value...")

from objectstate import LazyDefaultPlaceholderService

# Get raw resolved value from ObjectState (handles context building internally)
# Use full_path for nested PFMs
resolved_value = manager.state.get_resolved_value(full_path)
logger.debug(f"🔬 RESET_TRACE: resolved_value={repr(resolved_value)[:50]}")

# Format for display (VIEW responsibility)
placeholder_text = LazyDefaultPlaceholderService._format_placeholder_text(
resolved_value, manager.config.placeholder_prefix
)
logger.debug(f" 📝 Formatted placeholder: {repr(placeholder_text)[:50]}")

if placeholder_text:
# Use type-safe method that passes actual value for checkbox groups
PyQt6WidgetEnhancer.apply_placeholder_with_value(widget, resolved_value, placeholder_text)
logger.debug(f" ✅ Applied placeholder to widget")

if manager._widget_service.show_resolved_value_as_placeholder(widget, field_name, manager):
# Keep enabled-field styling in sync when placeholder changes the visual state
if field_name == 'enabled':
try:
Expand Down Expand Up @@ -321,7 +299,6 @@ def refresh_all_placeholders(self, manager) -> None:
return

from pyqt_reactive.forms.widget_strategies import PyQt6WidgetEnhancer
from objectstate import LazyDefaultPlaceholderService
logger.debug(f"[PLACEHOLDER] {manager.field_id}: Refreshing placeholders via ObjectState")

monitor = get_monitor("Placeholder resolution per field")
Expand All @@ -339,18 +316,10 @@ def refresh_all_placeholders(self, manager) -> None:
continue

full_path = f"{manager.field_id}.{param_name}" if manager.field_id else param_name
current_value = manager.state.parameters.get(full_path)
should_apply_placeholder = current_value is None
resolved_value = manager.state.get_resolved_value(full_path)

if should_apply_placeholder:
if manager.state.parameters.get(full_path) is None:
with monitor.measure():
# Format for display (VIEW responsibility)
placeholder_text = LazyDefaultPlaceholderService._format_placeholder_text(
resolved_value, manager.config.placeholder_prefix
manager._widget_service.show_resolved_value_as_placeholder(
widget, param_name, manager
)
if placeholder_text:
# Use type-safe method that passes actual value for checkbox groups
PyQt6WidgetEnhancer.apply_placeholder_with_value(widget, resolved_value, placeholder_text)
elif PyQt6WidgetEnhancer.has_placeholder_state(widget):
PyQt6WidgetEnhancer._clear_placeholder_state(widget)
45 changes: 20 additions & 25 deletions src/pyqt_reactive/services/widget_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -302,36 +302,31 @@ def _apply_context_behavior(
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")
self.show_resolved_value_as_placeholder(widget, param_name, manager)
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)

def show_resolved_value_as_placeholder(self, widget: QWidget, param_name: str, manager) -> bool:
"""Preview an unset field: show the value it resolves to; return whether one was shown.

The preview is the state's resolved read of the field. The field's own
value stays unset, and the source of the shown value is the same state's
``get_provenance`` answer for the same path.
"""
from objectstate import LazyDefaultPlaceholderService

full_path = f"{manager.field_id}.{param_name}" if manager.field_id else param_name
resolved_value = manager.state.get_resolved_value(full_path)
placeholder_text = LazyDefaultPlaceholderService._format_placeholder_text(
resolved_value, manager.config.placeholder_prefix
)
if not placeholder_text:
return False
self.widget_enhancer.apply_placeholder_with_value(widget, resolved_value, placeholder_text)
return True

def clear_widget_to_default_state(self, widget: QWidget) -> None:
"""Clear widget to its default/empty state for reset operations."""
if isinstance(widget, QLineEdit):
Expand Down
4 changes: 0 additions & 4 deletions src/pyqt_reactive/widgets/shared/abstract_manager_widget.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,6 @@
FormattingConfig,
ObjectStatePreviewFormattingService,
)
from objectstate import LiveContextResolver
from pyqt_reactive.animation import FlashMixin
from pyqt_reactive.widgets.shared.manager_ui_scaffold import setup_manager_widget_ui
from formbridge.managers.item_hooks import ManagerItemHooks
Expand Down Expand Up @@ -693,9 +692,6 @@ def __init__(self, service_adapter: UiHostServices, color_scheme=None, parent=No
self.manager_header = None
self.item_list: Optional[ReorderableListWidget] = None

# Live context resolver for config attribute resolution
self._live_context_resolver = LiveContextResolver()

# Per-update-cycle scope cache: item_id -> scope_id (cleared at start of each update)
self._item_scope_cache: Dict[int, str] = {}
self._pending_list_visual_scope_ids: dict[str, set[str]] = {}
Expand Down
126 changes: 55 additions & 71 deletions src/pyqt_reactive/widgets/shared/clickable_help_components.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
from dataclasses import dataclass
from typing import Optional, Union

from objectstate import ObjectState
from objectstate import ObjectState, ObjectStateRegistry
from PyQt6.QtCore import QRect, QRectF, QSize, Qt, QTimer, pyqtSignal
from PyQt6.QtGui import QColor, QCursor, QFont, QPainter
from PyQt6.QtWidgets import (
Expand Down Expand Up @@ -521,60 +521,58 @@ def set_provenance_info(self, state, dotted_path: str) -> None:
self._state = state
self._dotted_path = dotted_path

def _has_provenance(self) -> bool:
"""Check if this field has a provenance source (inherited from ancestor).
def _provenance_source(self) -> tuple[str | None, type] | None:
"""The scope and type that supply this field's inherited value.

Returns True if the field inherits from an ancestor, even if that ancestor
is the root scope (GlobalPipelineConfig, scope_id='').
``None`` when the field is set on this field itself. The scope is ``''``
for the global config and this state's own scope id (``None`` for a state
without one) when another config of the same object supplies the value.
"""
if not self._state or not self._dotted_path:
logger.debug(f"ProvenanceMixin._has_provenance: NO - state={self._state}, dotted_path={self._dotted_path}")
return False
try:
result = self._state.get_provenance(self._dotted_path)
logger.debug(f"ProvenanceMixin._has_provenance: result={result}, dotted_path={self._dotted_path}")
# Check if result is a valid tuple with a type class
# Empty scope_id ('') means GlobalPipelineConfig (root scope)
if not result or not isinstance(result, tuple) or len(result) != 2:
return False
scope_id, source_type = result
# Empty scope_id is valid (represents root/GlobalPipelineConfig)
# Just need a valid type class
return source_type is not None
except Exception as e:
logger.debug(f"ProvenanceMixin._has_provenance: EXCEPTION - {e}")
return None
return self._state.get_provenance(self._dotted_path)

def _has_provenance(self) -> bool:
"""Whether this field's value is inherited from another field."""
return self._provenance_source() is not None

def _navigate_to_provenance_source(self) -> bool:
"""Go to the field that supplies this field's value; False when it is set here."""
source = self._provenance_source()
if source is None:
return False
self._navigate_to_source(*source)
return True

def _navigate_to_source(self, source_scope_id: str, source_type: type) -> None:
def _navigate_to_source(self, source_scope_id: str | None, source_type: type) -> None:
"""Open the source window (creating if needed) and scroll to the field.

Args:
source_scope_id: The scope where the concrete value exists
source_type: The TYPE that has the concrete value (may differ from local
container type due to MRO inheritance)
"""
# Extract field_name from our local dotted path
field_name = self._dotted_path.split('.')[-1] if '.' in self._dotted_path else self._dotted_path

logger.debug(f"_navigate_to_source: local_path={self._dotted_path}, field_name={field_name}, "
f"source_scope_id={source_scope_id}, source_type={source_type.__name__}")
field_name = self._dotted_path.rsplit(".", 1)[-1]

# Compute the target path in the source window:
# We need to find what path source_type is at in the target window's ObjectState
# Another config of this same object supplies the value: the source state is
# this state, whether or not it is registered under a scope id.
same_window = (
isinstance(self._state, ObjectState) and self._state.scope_id == source_scope_id
)
source_state = (
self._state if same_window else ObjectStateRegistry.get_by_scope(source_scope_id)
)
try:
target_path = self._compute_target_path(source_scope_id, source_type, field_name)
target_path = self._compute_target_path(
source_state, source_scope_id, source_type, field_name
)
except ProvenanceNavigationUnavailable as exc:
logger.warning("Provenance navigation unavailable: %s", exc)
return

logger.info("Navigate: %s -> %s field %s", self._dotted_path, source_scope_id, target_path)

# Check if source is in the SAME window (sibling MRO inheritance within same scope)
if (
isinstance(self._state, ObjectState)
and self._state.scope_id == source_scope_id
):
logger.info(f"🔄 Same-window navigation: scrolling to {target_path}")
if same_window:
self._scroll_within_current_window(target_path)
return

Expand Down Expand Up @@ -618,43 +616,34 @@ def _scroll_within_current_window(self, target_path: str) -> None:

logger.warning(f"Could not find scrollable parent to navigate to {target_path}")

def _compute_target_path(self, source_scope_id: str, source_type: type, field_name: str) -> str:
"""Compute the dotted path for source_type.field_name in the target window.

The source_type might be at a different path in the target window than in our window.
For example:
- Local: WellFilterConfig at 'well_filter_config'
- Source: PathPlanningConfig at 'path_planning_config'

We need to find what path source_type is at in the target window's ObjectState.

IMPORTANT: If source_type is ui_hidden (not shown in the UI), we find the closest
visible subclass in the MRO that also has this field. For example:
- NapariDisplayConfig is ui_hidden=True
- NapariStreamingConfig inherits from NapariDisplayConfig and IS visible
- So we navigate to napari_streaming_config.field instead of napari_display_config.field
@staticmethod
def _compute_target_path(
source_state: ObjectState | None,
source_scope_id: str | None,
source_type: type,
field_name: str,
) -> str:
"""The dotted path of ``source_type.field_name`` in the source state's window.

The source type can sit at another path there than in this window (local
``well_filter_config``, source ``path_planning_config``). A source type that
is hidden in the UI answers with its closest visible subclass holding the
field, which ``project_ui_visible_field_path`` decides.
"""
from objectstate import ObjectStateRegistry

logger.debug(f"_compute_target_path: scope={source_scope_id}, source_type={source_type.__name__}, field={field_name}")

# Get the target window's ObjectState
target_state = ObjectStateRegistry.get_by_scope(source_scope_id)
if target_state is None:
if source_state is None:
raise ProvenanceNavigationUnavailable(
f"no ObjectState registered for scope {source_scope_id!r}"
)

target_path = target_state.project_ui_visible_field_path(source_type, field_name)
target_path = source_state.project_ui_visible_field_path(source_type, field_name)
if target_path is None:
raise ProvenanceNavigationUnavailable(
f"no visible UI path for type {source_type.__name__}.{field_name} "
f"in scope {source_scope_id!r}"
)

logger.info(f"Computed target path: {target_path} (source_type={source_type.__name__}, field={field_name})")
return target_path


class ProvenanceLabel(QLabel, ProvenanceNavigationMixin):
"""QLabel that supports provenance navigation on hover/click.

Expand Down Expand Up @@ -703,12 +692,11 @@ def leaveEvent(self, event) -> None:

def mousePressEvent(self, event) -> None:
"""On click: navigate to provenance source window and field."""
if event.button() == Qt.MouseButton.LeftButton and self._has_provenance():
result = self._state.get_provenance(self._dotted_path)
if result is not None:
source_scope_id, source_type = result
self._navigate_to_source(source_scope_id, source_type)
return
if (
event.button() == Qt.MouseButton.LeftButton
and self._navigate_to_provenance_source()
):
return
super().mousePressEvent(event)


Expand All @@ -731,11 +719,7 @@ def __init__(self, text="^", color_scheme=None, parent=None):

def _on_click(self):
"""Handle button click - navigate to provenance source."""
if self._has_provenance():
result = self._state.get_provenance(self._dotted_path)
if result:
source_scope_id, source_type = result
self._navigate_to_source(source_scope_id, source_type)
self._navigate_to_provenance_source()

def enterEvent(self, event):
"""Show pointer cursor if field has provenance."""
Expand Down
Loading
Loading