Skip to content

Commit c4d7d2c

Browse files
committed
Improve form rendering and scoped flash handling
1 parent 7c0e741 commit c4d7d2c

11 files changed

Lines changed: 384 additions & 130 deletions

src/pyqt_reactive/animation/flash_mixin.py

Lines changed: 64 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,27 @@
3535
from weakref import WeakValueDictionary
3636
import re
3737
from PyQt6.QtCore import QTimer, Qt, QRect, QRectF, QSize
38-
from PyQt6.QtWidgets import QWidget, QMainWindow, QDialog, QScrollArea, QCheckBox, QLabel, QStyle, QStyleOptionButton, QLineEdit, QSpinBox, QDoubleSpinBox, QComboBox, QPushButton, QToolButton, QTextEdit, QPlainTextEdit, QTreeWidget, QListWidget, QTableWidget
38+
from PyQt6.QtWidgets import (
39+
QCheckBox,
40+
QComboBox,
41+
QDialog,
42+
QDoubleSpinBox,
43+
QLabel,
44+
QLineEdit,
45+
QListWidget,
46+
QMainWindow,
47+
QPlainTextEdit,
48+
QPushButton,
49+
QScrollArea,
50+
QSpinBox,
51+
QStyle,
52+
QStyleOptionButton,
53+
QTableWidget,
54+
QTextEdit,
55+
QToolButton,
56+
QTreeWidget,
57+
QWidget,
58+
)
3959
from PyQt6.QtGui import QColor, QPainter, QRegion, QPainterPath
4060
from PyQt6 import sip
4161

@@ -322,7 +342,8 @@ class FlashElement:
322342
source_id: Optional[str] = None # Unique identifier for deduplication (e.g., "groupbox:123", "list_item:scope_id")
323343
corner_radius: float = 0.0 # Rounded corners (0 = sharp, >0 = rounded)
324344
skip_overlay_paint: bool = False # If True, overlay skips painting (element handles its own paint, e.g., list item delegate)
325-
delegate_widget: Optional[QWidget] = None # Widget whose viewport needs updating when skip_overlay_paint=True (e.g., QListWidget, QTreeWidget)
345+
# Widget whose viewport needs updating when skip_overlay_paint=True.
346+
delegate_widget: Optional[QWidget] = None
326347
get_model_index: Optional[Callable[[], Any]] = None # Returns QModelIndex for targeted item updates (avoids full viewport repaint)
327348

328349

@@ -506,6 +527,43 @@ def _get_function_pane_title_widgets(groupbox: QWidget) -> List[QWidget]:
506527
return unique_widgets
507528

508529

530+
def _get_groupbox_title_mask_rects(groupbox: QWidget, window: QWidget) -> List[Tuple[QRect, bool]]:
531+
"""Return mask rects for visible QGroupBox title text painted by Qt styles."""
532+
from PyQt6.QtWidgets import QGroupBox
533+
from PyQt6.QtCore import QPoint
534+
535+
rects: List[Tuple[QRect, bool]] = []
536+
for titled_group in groupbox.findChildren(QGroupBox):
537+
title = titled_group.title()
538+
if not title or not titled_group.isVisible() or not titled_group.isVisibleTo(window):
539+
continue
540+
541+
metrics = titled_group.fontMetrics()
542+
group_window_pos = window.mapFromGlobal(titled_group.mapToGlobal(QPoint(0, 0)))
543+
stylesheet = titled_group.styleSheet()
544+
left_padding = 6
545+
extra_width = 8
546+
if stylesheet:
547+
import re
548+
left_match = re.search(r"left\s*:\s*(\d+)", stylesheet)
549+
if left_match:
550+
left_padding = int(left_match.group(1))
551+
padding_match = re.search(r"padding\s*:\s*0\s+(\d+)", stylesheet)
552+
if padding_match:
553+
extra_width = int(padding_match.group(1)) * 2
554+
555+
rects.append((
556+
QRect(
557+
group_window_pos.x() + left_padding,
558+
group_window_pos.y(),
559+
metrics.horizontalAdvance(title) + extra_width,
560+
metrics.height() + 4,
561+
),
562+
False,
563+
))
564+
return rects
565+
566+
509567
def create_groupbox_element(
510568
key: str,
511569
groupbox: 'QGroupBox',
@@ -670,6 +728,8 @@ def get_child_rects(window: QWidget) -> List[Tuple[QRect, bool]]:
670728
except Exception as e:
671729
logger.warning(f"[FLASH INVERSE] Failed to mask function pane title widget: {e}")
672730

731+
exclusions.extend(_get_groupbox_title_mask_rects(groupbox, window))
732+
673733
logger.debug(f"[FLASH INVERSE] Total exclusions: {len(exclusions)}")
674734
except Exception as e:
675735
logger.error(f"[FLASH INVERSE] Outer exception: {e}", exc_info=True)
@@ -725,6 +785,8 @@ def get_child_rects(window: QWidget) -> List[Tuple[QRect, bool]]:
725785
child_rect = get_child_mask_rect(child, window)
726786
child_rects.append((child_rect, _needs_square_checkbox_mask(child)))
727787

788+
child_rects.extend(_get_groupbox_title_mask_rects(groupbox, window))
789+
728790
# DEBUG: Log groupbox position and first 2 child positions
729791
if child_rects:
730792
first_children = [f"({r.x()},{r.y()})" for r, _ in child_rects[:2]]

src/pyqt_reactive/forms/parameter_form_manager.py

Lines changed: 94 additions & 74 deletions
Large diffs are not rendered by default.

src/pyqt_reactive/forms/parameter_form_service.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -109,7 +109,11 @@ def analyze_parameters(self, input: ParameterAnalysisInput) -> FormStructure:
109109

110110
import logging
111111
logger = logging.getLogger(__name__)
112-
logger.info(f"🔍 analyze_parameters: field_id={input.field_id}, param_type.keys()={list(input.param_type.keys())}")
112+
logger.debug(
113+
"analyze_parameters: field_id=%s param_type.keys()=%s",
114+
input.field_id,
115+
list(input.param_type.keys()),
116+
)
113117

114118
param_infos = []
115119
nested_forms = {}
@@ -359,7 +363,7 @@ def validate_field_path_mapping(self):
359363
context_fields = {f.name for f in dataclasses.fields(GlobalPipelineConfig)
360364
if dataclasses.is_dataclass(f.type)}
361365

362-
print("Context fields:", context_fields)
366+
logger.debug("Context fields: %s", context_fields)
363367
# Should include: well_filter_config, zarr_config, step_materialization_config, etc.
364368

365369
# Verify form managers use these exact field names (no "nested_" prefix)

src/pyqt_reactive/forms/widget_creation_config.py

Lines changed: 65 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -497,6 +497,42 @@ def _create_nested_container(manager: ParameterFormManager, param_info: Paramete
497497
return container
498498

499499

500+
def _create_inline_dataclass_container(
501+
manager: ParameterFormManager,
502+
param_info: ParameterInfo,
503+
display_info: DisplayInfo,
504+
field_ids: FieldIds,
505+
current_value: Any,
506+
unwrapped_type: Optional[Type],
507+
layout=None,
508+
CURRENT_LAYOUT=None,
509+
QWidget=None,
510+
GroupBoxWithHelp=None,
511+
PyQt6ColorScheme=None,
512+
) -> Any:
513+
"""Create dataclass chrome for a registered inline dataclass editor."""
514+
from pyqt_reactive.widgets.shared.clickable_help_components import (
515+
InlineDataclassGroupBox,
516+
)
517+
from pyqt_reactive.theming.color_scheme import ColorScheme as PCS
518+
519+
color_scheme = manager.config.color_scheme or PCS()
520+
root_manager = manager
521+
while getattr(root_manager, '_parent_manager', None) is not None:
522+
root_manager = root_manager._parent_manager
523+
flash_key = f"{manager.field_id}.{param_info.name}" if manager.field_id else param_info.name
524+
scope_accent_color = getattr(manager, '_scope_accent_color', None)
525+
526+
return InlineDataclassGroupBox(
527+
title=display_info['field_label'],
528+
help_target=unwrapped_type,
529+
color_scheme=color_scheme,
530+
scope_accent_color=scope_accent_color,
531+
flash_key=flash_key,
532+
flash_manager=root_manager,
533+
)
534+
535+
500536
def _create_inline_dataclass_widget(
501537
manager: ParameterFormManager,
502538
param_info: ParameterInfo,
@@ -612,12 +648,12 @@ def _setup_optional_nested_layout(manager: ParameterFormManager, param_info: Par
612648
WidgetCreationType.INLINE_DATACLASS: WidgetCreationConfig(
613649
layout_type='GroupBoxWithHelp',
614650
is_nested=True,
615-
create_container=_create_nested_container,
651+
create_container=_create_inline_dataclass_container,
616652
setup_layout=None,
617653
create_main_widget=_create_inline_dataclass_widget,
618654
needs_label=False,
619655
needs_reset_button=False,
620-
needs_unwrap_type=False,
656+
needs_unwrap_type=True,
621657
is_optional=False,
622658
),
623659

@@ -982,29 +1018,39 @@ def create_widget_parametric(manager: ParameterFormManager, param_info: Paramete
9821018
unwrapped_type
9831019
)
9841020

1021+
def on_widget_change(pname, value, mgr: ParameterFormManager = manager):
1022+
converted_value = mgr._convert_widget_value(value, pname)
1023+
event = FieldChangeEvent(pname, converted_value, mgr)
1024+
from objectstate import ObjectStateRegistry
1025+
1026+
if mgr.state and mgr.state._parent_state is not None:
1027+
with ObjectStateRegistry.atomic("edit func parameter"):
1028+
FieldChangeDispatcher.instance().dispatch(event)
1029+
else:
1030+
FieldChangeDispatcher.instance().dispatch(event)
1031+
9851032
# Store widget and connect signals
9861033
if config.is_nested:
987-
# For nested, store the GroupBox/container
988-
manager.widgets[param_info.name] = container
989-
logger.debug(f"[CREATE_NESTED_DATACLASS] param_info.name={param_info.name}, stored container in manager.widgets")
1034+
if creation_type is WidgetCreationType.INLINE_DATACLASS:
1035+
container.set_value_widget(main_widget)
1036+
manager.widgets[param_info.name] = container
1037+
PyQt6WidgetEnhancer.connect_change_signal(
1038+
container,
1039+
param_info.name,
1040+
on_widget_change,
1041+
)
1042+
logger.debug(
1043+
"[CREATE_INLINE_DATACLASS] param_info.name=%s, stored inline container in manager.widgets",
1044+
param_info.name,
1045+
)
1046+
else:
1047+
# For nested, store the GroupBox/container
1048+
manager.widgets[param_info.name] = container
1049+
logger.debug(f"[CREATE_NESTED_DATACLASS] param_info.name={param_info.name}, stored container in manager.widgets")
9901050
else:
9911051
# For regular, store the main widget
9921052
manager.widgets[param_info.name] = main_widget
9931053

994-
# Connect widget changes to dispatcher
995-
# NOTE: connect_change_signal calls callback(param_name, value)
996-
def on_widget_change(pname, value, mgr: ParameterFormManager = manager):
997-
converted_value = mgr._convert_widget_value(value, pname)
998-
event = FieldChangeEvent(pname, converted_value, mgr)
999-
# ATOMIC: If this manager's state has a parent (e.g., function in step),
1000-
# wrap dispatch in atomic to coalesce with parent step update
1001-
from objectstate import ObjectStateRegistry
1002-
if mgr.state and mgr.state._parent_state is not None:
1003-
with ObjectStateRegistry.atomic("edit func parameter"):
1004-
FieldChangeDispatcher.instance().dispatch(event)
1005-
else:
1006-
FieldChangeDispatcher.instance().dispatch(event)
1007-
10081054
PyQt6WidgetEnhancer.connect_change_signal(main_widget, param_info.name, on_widget_change)
10091055

10101056
if manager.read_only:

src/pyqt_reactive/forms/widget_creation_registry.py

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,19 @@
55
eliminating class-based abstractions while maintaining clean extensibility.
66
"""
77

8+
from types import UnionType
89
from typing import Type, get_origin, get_args, Union
910
from enum import Enum
1011

1112

13+
def is_union_type(param_type: Type) -> bool:
14+
"""Check whether a type annotation is a typing or PEP 604 union."""
15+
return get_origin(param_type) in (Union, UnionType)
16+
17+
1218
def resolve_optional(param_type: Type) -> Type:
1319
"""Resolve Optional[T] to T."""
14-
if get_origin(param_type) is Union:
20+
if is_union_type(param_type):
1521
args = get_args(param_type)
1622
if len(args) == 2 and type(None) in args:
1723
return next(arg for arg in args if arg is not type(None))
@@ -23,6 +29,18 @@ def is_enum(param_type: Type) -> bool:
2329
return isinstance(param_type, type) and issubclass(param_type, Enum)
2430

2531

32+
def enum_member_type(param_type: Type) -> Type | None:
33+
"""Return the enum member from a union annotation, if one is present."""
34+
if is_enum(param_type):
35+
return param_type
36+
if not is_union_type(param_type):
37+
return None
38+
enum_args = [arg for arg in get_args(param_type) if is_enum(arg)]
39+
if len(enum_args) == 1:
40+
return enum_args[0]
41+
return None
42+
43+
2644
def is_list_of_enums(param_type: Type) -> bool:
2745
"""Check if type is List[Enum]."""
2846
return (get_origin(param_type) is list and

src/pyqt_reactive/forms/widget_strategies.py

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,13 @@
1616
)
1717
from pyqt_reactive.widgets.enhanced_path_widget import EnhancedPathWidget
1818
from pyqt_reactive.theming.color_scheme import ColorScheme as PyQt6ColorScheme
19-
from pyqt_reactive.forms.widget_creation_registry import resolve_optional, is_enum, is_list_of_enums, get_enum_from_list
19+
from pyqt_reactive.forms.widget_creation_registry import (
20+
enum_member_type,
21+
resolve_optional,
22+
is_enum,
23+
is_list_of_enums,
24+
get_enum_from_list,
25+
)
2026
from contextlib import contextmanager
2127

2228
try:
@@ -280,6 +286,9 @@ def create_widget(self, param_name: str, param_type: Type, current_value: Any,
280286

281287
with timer(" resolve_optional", threshold_ms=0.1):
282288
resolved_type = resolve_optional(param_type)
289+
enum_type = enum_member_type(resolved_type)
290+
if enum_type is not None:
291+
resolved_type = enum_type
283292

284293
# Handle direct List[Enum] types - create multi-selection checkbox group
285294
if is_list_of_enums(resolved_type):
@@ -981,6 +990,15 @@ def wrapped():
981990
PyQt6WidgetEnhancer._connect_checkbox_group_signals(widget, param_name, placeholder_aware_callback)
982991
return
983992

993+
from pyqt_reactive.protocols.widget_protocols import ChangeSignalEmitter
994+
if isinstance(widget, ChangeSignalEmitter):
995+
def emit_contract_value(value):
996+
PyQt6WidgetEnhancer._clear_placeholder_state(widget)
997+
callback(param_name, value)
998+
999+
widget.connect_change_signal(emit_contract_value)
1000+
return
1001+
9841002
# Fallback to native PyQt6 signals
9851003
connector = next(
9861004
(connector for signal_name, connector in SIGNAL_CONNECTION_REGISTRY.items()
@@ -1030,7 +1048,12 @@ def handler(state):
10301048
selected = widget.get_value()
10311049
# Handle None (placeholder state) in logging
10321050
selected_str = "None (inherit from parent)" if selected is None else [v.name for v in selected]
1033-
logger.info(f"🔘 Checkbox {cb.text()} changed to {state}, selected values: {selected_str}")
1051+
logger.debug(
1052+
"Checkbox %s changed to %s, selected values: %s",
1053+
cb.text(),
1054+
state,
1055+
selected_str,
1056+
)
10341057

10351058
callback(param_name, selected)
10361059
return handler

src/pyqt_reactive/services/field_change_dispatcher.py

Lines changed: 2 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -68,37 +68,13 @@ def dispatch(self, event: FieldChangeEvent) -> None:
6868
# CRITICAL: Compute full dotted path for nested PFMs
6969
full_path = f"{source.field_id}.{event.field_name}" if source.field_id else event.field_name
7070
source.state.update_parameter(full_path, event.value)
71+
source.sync_after_model_field_change(event.field_name, full_path)
7172
if DEBUG_DISPATCHER:
7273
reset_note = " (reset to None)" if event.is_reset else ""
7374
logger.info(f" ✅ Updated state.parameters[{full_path}]{reset_note}")
7475

75-
# Update ProvenanceButton visibility after state change
76-
# The button is in the groupbox title, not in source.widgets
77-
from pyqt_reactive.widgets.shared.clickable_help_components import ProvenanceButton
78-
from PyQt6.QtWidgets import QWidget
79-
8076
# 3. Refresh siblings that have the same field
8177
parent = source._parent_manager
82-
83-
# Find the groupbox widget for this manager
84-
groupbox = None
85-
if parent:
86-
for name, nested in parent.nested_managers.items():
87-
if nested is source:
88-
groupbox = parent.widgets.get(name)
89-
break
90-
91-
# Update provenance button visibility
92-
if source._parent_manager:
93-
source._update_provenance_button_visibility()
94-
95-
# Update groupbox dirty markers (title and Reset All button)
96-
source._update_groupbox_dirty_markers()
97-
98-
# Update reset button styling for ALL reset buttons in this manager
99-
from pyqt_reactive.utils.styling_utils import update_reset_button_styling
100-
for field_name, reset_button in source.reset_buttons.items():
101-
update_reset_button_styling(reset_button, source.state, source.field_id, field_name)
10278
if parent:
10379
if DEBUG_DISPATCHER:
10480
logger.info(f" 🔍 Looking for siblings with field '{event.field_name}' in {parent.field_id}")
@@ -143,9 +119,7 @@ def dispatch(self, event: FieldChangeEvent) -> None:
143119

144120
# 3. Handle 'enabled' field styling
145121
if event.field_name == 'enabled':
146-
source._enabled_field_styling_service.on_enabled_field_changed(
147-
source, 'enabled', event.value
148-
)
122+
source.sync_enabled_field_visuals(event.value)
149123
if DEBUG_DISPATCHER:
150124
logger.info(f" ✅ Applied enabled styling")
151125

@@ -231,4 +205,3 @@ def _refresh_single_field(self, manager: 'ParameterFormManager', field_name: str
231205
logger.info(f" ✅ Refreshing placeholder for {manager.field_id}.{field_name}")
232206

233207
manager._parameter_ops_service.refresh_single_placeholder(manager, field_name)
234-

0 commit comments

Comments
 (0)