diff --git a/CHANGELOG.md b/CHANGELOG.md index 1910737..61a6bbc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,19 @@ # Changelog +## [0.1.29] - 2026-07-30 + +### Changed + +- Apply placeholder and enabled-state chrome as form fields materialize. +- Preserve and project annotated dataclass widget types, including dedicated + key-sequence capture and finite system-monitor color choices. +- Make reset operations discard invalid transient editor text safely. +- Support functions without an image-memory backend in generic selectors. + +### Dependencies + +- python-introspect >= 0.1.8 + ## [0.1.28] - 2026-07-30 ### Changed diff --git a/docs/conf.py b/docs/conf.py index 5f65a7b..7a8ffd4 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -11,7 +11,7 @@ copyright = "2024, Tristan Simas" author = "Tristan Simas" version = "0.1" -release = "0.1.22" +release = "0.1.29" # General configuration extensions = [ diff --git a/pyproject.toml b/pyproject.toml index e956c7f..0f5f5a8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "pyqt-reactive" -version = "0.1.28" +version = "0.1.29" description = "React-quality reactive form generation framework for PyQt6" authors = [{name = "Tristan Simas", email = "tristan.simas@mail.mcgill.ca"}] license = {text = "MIT"} @@ -33,7 +33,7 @@ dependencies = [ "psutil>=5.9", "Pygments>=2.15", "pyzmq>=22.0", - "python-introspect>=0.1.6", + "python-introspect>=0.1.8", "zmqruntime>=0.1.19", ] diff --git a/src/pyqt_reactive/__init__.py b/src/pyqt_reactive/__init__.py index e05e120..1c983a3 100644 --- a/src/pyqt_reactive/__init__.py +++ b/src/pyqt_reactive/__init__.py @@ -19,7 +19,7 @@ - Cross-window reactive updates """ -__version__ = "0.1.28" +__version__ = "0.1.29" # Public API will be populated as modules are added __all__ = [ diff --git a/src/pyqt_reactive/forms/form_init_service.py b/src/pyqt_reactive/forms/form_init_service.py index b9d3fd9..b36a1c2 100644 --- a/src/pyqt_reactive/forms/form_init_service.py +++ b/src/pyqt_reactive/forms/form_init_service.py @@ -615,8 +615,8 @@ def build_widgets( ) widget = manager._create_widget_for_param(param_info) content_layout.addWidget(widget) - manager._enabled_field_styling_service.invalidate_widget_cache( - manager + manager.chrome_sync.fields_materialized( + param_info.name for param_info in sync_params ) def on_batch_complete(batch_widgets): @@ -626,8 +626,8 @@ def on_batch_complete(batch_widgets): len(batch_widgets), manager._pfm_seq, ) - manager._enabled_field_styling_service.invalidate_widget_cache( - manager + manager.chrome_sync.fields_materialized( + param_name for param_name, _widget in batch_widgets ) if async_params: diff --git a/src/pyqt_reactive/forms/parameter_form_chrome_sync.py b/src/pyqt_reactive/forms/parameter_form_chrome_sync.py index 7e1cb96..12e300d 100644 --- a/src/pyqt_reactive/forms/parameter_form_chrome_sync.py +++ b/src/pyqt_reactive/forms/parameter_form_chrome_sync.py @@ -2,6 +2,7 @@ from __future__ import annotations +from collections.abc import Iterable from typing import Any, Set from objectstate import DottedFieldPath @@ -84,6 +85,28 @@ def enabled_field_visuals(self, value: Any) -> None: self.manager, Enableable.require_parameter_name(), value ) + def fields_materialized(self, field_names: Iterable[str]) -> None: + """Apply existing chrome authorities to newly visible form fields.""" + manager = self.manager + materialized_names = tuple( + field_name + for field_name in field_names + if field_name in manager.widgets + ) + if not materialized_names: + return + + for field_name in materialized_names: + manager._parameter_ops_service.refresh_single_placeholder( + manager, + field_name, + ) + + manager._enabled_field_styling_service.apply_materialized_enabled_styling( + manager, + (manager.widgets[field_name] for field_name in materialized_names), + ) + def update_owning_groupbox_dirty_marker(self) -> None: from pyqt_reactive.protocols import DirtyMarkerSettable diff --git a/src/pyqt_reactive/forms/parameter_form_service.py b/src/pyqt_reactive/forms/parameter_form_service.py index 4fee8c9..c48d06e 100644 --- a/src/pyqt_reactive/forms/parameter_form_service.py +++ b/src/pyqt_reactive/forms/parameter_form_service.py @@ -21,7 +21,7 @@ from enum import Enum from typing import Any, Dict, Type, Optional, List, get_args, get_origin, get_type_hints -from python_introspect import is_union_type +from python_introspect import is_union_type, resolve_annotated from pyqt_reactive.forms.parameter_form_constants import CONSTANTS from .parameter_type_utils import ParameterTypeUtils from pyqt_reactive.forms.ui_utils import FieldDisplayText, debug_param @@ -282,38 +282,40 @@ def convert_value_to_type(self, value: ParameterValue, param_type: Type, param_n if isinstance(value, str) and value == CONSTANTS.NONE_STRING_LITERAL: return None + resolved_type = resolve_annotated(param_type) structured_value = self._convert_value_by_annotation( value, - param_type, + resolved_type, param_name, ) if structured_value is not _NO_CONVERSION: return structured_value # Handle enum types - if self._type_utils.is_enum_type(param_type): - return param_type(value) + if self._type_utils.is_enum_type(resolved_type): + return resolved_type(value) # Handle list of enums - if self._type_utils.is_list_of_enums(param_type): + if self._type_utils.is_list_of_enums(resolved_type): # If value is already a list (from checkbox group widget), return as-is if isinstance(value, list): return value - enum_type = self._type_utils.get_enum_from_list_type(param_type) + enum_type = self._type_utils.get_enum_from_list_type(resolved_type) if enum_type: return [enum_type(value)] # Handle basic types - if param_type == bool and isinstance(value, str): + if resolved_type is bool and isinstance(value, str): return self._type_utils.convert_string_to_bool(value) - if param_type in (int, float) and isinstance(value, str): + if resolved_type in (int, float) and isinstance(value, str): if value == CONSTANTS.EMPTY_STRING: return None try: - return param_type(value) + return resolved_type(value) except (ValueError, TypeError) as exc: raise ValueError( - f"Invalid {param_type.__name__} value for parameter {param_name!r}: {value!r}" + f"Invalid {resolved_type.__name__} value for parameter " + f"{param_name!r}: {value!r}" ) from exc # Handle empty strings in lazy context - convert to None for all parameter types @@ -322,7 +324,11 @@ def convert_value_to_type(self, value: ParameterValue, param_type: Type, param_n return None # Handle string types - also convert empty strings to None for consistency - if param_type == str and isinstance(value, str) and value == CONSTANTS.EMPTY_STRING: + if ( + resolved_type is str + and isinstance(value, str) + and value == CONSTANTS.EMPTY_STRING + ): return None return value @@ -334,6 +340,7 @@ def _convert_value_by_annotation( param_name: str, ) -> ParameterValue | object: """Recursively rebuild structured values from JSON-like containers.""" + param_type = resolve_annotated(param_type) origin = get_origin(param_type) if is_union_type(param_type): @@ -368,6 +375,7 @@ def _convert_union_value( for candidate_type in get_args(param_type): if candidate_type is type(None): continue + candidate_type = resolve_annotated(candidate_type) try: converted = self._convert_value_by_annotation( value, @@ -404,7 +412,7 @@ def _convert_dataclass_value( return _NO_CONVERSION try: - type_hints = get_type_hints(dataclass_type) + type_hints = get_type_hints(dataclass_type, include_extras=True) except Exception: type_hints = {} @@ -504,6 +512,7 @@ def _converted_container_item( item_type: Type, param_name: str, ) -> ParameterValue: + item_type = resolve_annotated(item_type) converted = self._convert_value_by_annotation( value, item_type, diff --git a/src/pyqt_reactive/forms/widget_strategies.py b/src/pyqt_reactive/forms/widget_strategies.py index 2f63539..aa836a1 100644 --- a/src/pyqt_reactive/forms/widget_strategies.py +++ b/src/pyqt_reactive/forms/widget_strategies.py @@ -17,6 +17,7 @@ is_enum_type, is_list_of_enums, is_union_type, + resolve_annotated, resolve_optional, ) from pyqt_reactive.forms.parameter_info_types import ParameterInfo @@ -32,6 +33,7 @@ from pyqt_reactive.protocols import ( ChangeSignalEmitter, CurrentValueValidatable, + KeySequenceEditAdapter, PlaceholderStateMixin, PlaceholderStateTrackable, PyQtWidgetMeta, @@ -41,6 +43,7 @@ WidgetCapability, widget_supports_capability, ) +from pyqt_reactive.qt_types import QtKeySequenceText from pyqt_reactive.protocols.widget_adapters import CheckboxGroupAdapter from pyqt_reactive.widgets.enhanced_path_widget import EnhancedPathWidget from pyqt_reactive.theming.color_scheme import ColorScheme as PyQt6ColorScheme @@ -501,6 +504,14 @@ def create_string(self, current_value: ParameterValue | None = None) -> QLineEdi widget.set_value(current_value) return widget + def create_key_sequence( + self, + current_value: ParameterValue | None = None, + ) -> KeySequenceEditAdapter: + widget = KeySequenceEditAdapter() + widget.set_value(current_value) + return widget + DIRECT_WIDGET_FACTORY = DirectWidgetFactory() @@ -739,6 +750,7 @@ class PyQt6WidgetCreationAuthority: float: DIRECT_WIDGET_FACTORY.create_float, bool: DIRECT_WIDGET_FACTORY.create_bool, str: DIRECT_WIDGET_FACTORY.create_string, + QtKeySequenceText: DIRECT_WIDGET_FACTORY.create_key_sequence, } def create(self, request: WidgetCreationRequest) -> QWidget: @@ -782,7 +794,17 @@ def create(self, request: WidgetCreationRequest) -> QWidget: return MAGICGUI_WIDGET_FACTORY.create(resolved) def _resolve_request(self, request: WidgetCreationRequest) -> ResolvedWidgetRequest: - resolved_type = resolve_optional(request.param_type) + resolved_type = request.param_type + while resolved_type not in self.direct_factories: + owned_type = resolve_annotated(resolved_type) + if owned_type != resolved_type: + resolved_type = owned_type + continue + required_type = resolve_optional(resolved_type) + if required_type == resolved_type: + break + resolved_type = required_type + enum_type = enum_member_type(resolved_type) current_value = request.current_value if enum_type is not None: diff --git a/src/pyqt_reactive/protocols/__init__.py b/src/pyqt_reactive/protocols/__init__.py index b013e98..3010c99 100644 --- a/src/pyqt_reactive/protocols/__init__.py +++ b/src/pyqt_reactive/protocols/__init__.py @@ -31,6 +31,7 @@ ) from .widget_adapters import ( LineEditAdapter, + KeySequenceEditAdapter, SpinBoxAdapter, DoubleSpinBoxAdapter, ComboBoxAdapter, @@ -85,6 +86,7 @@ "EnumSelectable", "ChangeSignalEmitter", "LineEditAdapter", + "KeySequenceEditAdapter", "SpinBoxAdapter", "DoubleSpinBoxAdapter", "ComboBoxAdapter", diff --git a/src/pyqt_reactive/protocols/widget_adapters.py b/src/pyqt_reactive/protocols/widget_adapters.py index ae56b4b..d9c08f2 100644 --- a/src/pyqt_reactive/protocols/widget_adapters.py +++ b/src/pyqt_reactive/protocols/widget_adapters.py @@ -20,9 +20,17 @@ try: from PyQt6.QtWidgets import ( - QLineEdit, QSpinBox, QDoubleSpinBox, QComboBox, QCheckBox, QWidget, QGroupBox + QLineEdit, + QSpinBox, + QDoubleSpinBox, + QComboBox, + QCheckBox, + QKeySequenceEdit, + QWidget, + QGroupBox, ) from PyQt6.QtCore import Qt, QObject + from PyQt6.QtGui import QKeySequence PYQT6_AVAILABLE = True # PyQt-specific metaclass that combines ABCMeta with Qt's metaclass # Order matters: ABCMeta first (it's the "primary" metaclass for ABC functionality) @@ -33,7 +41,8 @@ class PyQtWidgetMeta(_QtMetaclass, ABCMeta): except ImportError: PYQT6_AVAILABLE = False # Create dummy base classes for type hints - QLineEdit = QSpinBox = QDoubleSpinBox = QComboBox = QCheckBox = QWidget = object + QLineEdit = QSpinBox = QDoubleSpinBox = QComboBox = object + QCheckBox = QKeySequenceEdit = QWidget = object PyQtWidgetMeta = ABCMeta from .widget_protocols import ( @@ -121,8 +130,60 @@ def disconnect_change_signal(self, callback: Callable[[Any], None]) -> None: except TypeError: # Signal not connected - ignore pass - - + class KeySequenceEditAdapter( + PlaceholderStateMixin, + QKeySequenceEdit, + ValueGettable, + ValueSettable, + ChangeSignalEmitter, + metaclass=PyQtWidgetMeta, + ): + """Capture complete portable Qt key sequences before committing them.""" + + _widget_id = "key_sequence_edit" + + def __init__(self, *args: Any, **kwargs: Any) -> None: + super().__init__(*args, **kwargs) + self._change_signal_wrappers: dict[ + Callable[[Any], None], + Callable[[], None], + ] = {} + + def get_value(self) -> str: + """Return the complete key sequence in portable text form.""" + + return self.keySequence().toString( + QKeySequence.SequenceFormat.PortableText + ) + + def set_value(self, value: Any) -> None: + """Assign a portable key sequence.""" + + self.setKeySequence(QKeySequence("" if value is None else str(value))) + + def connect_change_signal(self, callback: Callable[[Any], None]) -> None: + """Commit only after Qt has finished capturing the sequence.""" + + if callback in self._change_signal_wrappers: + return + def wrapper() -> None: + callback(self.get_value()) + + self._change_signal_wrappers[callback] = wrapper + self.editingFinished.connect(wrapper) + + def disconnect_change_signal(self, callback: Callable[[Any], None]) -> None: + """Disconnect callbacks registered at this semantic boundary.""" + + wrapper = self._change_signal_wrappers.pop(callback, None) + if wrapper is None: + return + try: + self.editingFinished.disconnect(wrapper) + except TypeError: + pass + + class SpinBoxAdapter(PlaceholderStateMixin, QSpinBox, ValueGettable, ValueSettable, PlaceholderCapable, RangeConfigurable, ChangeSignalEmitter, metaclass=PyQtWidgetMeta): """ diff --git a/src/pyqt_reactive/services/enabled_field_styling_service.py b/src/pyqt_reactive/services/enabled_field_styling_service.py index ad8713c..e3c25e4 100644 --- a/src/pyqt_reactive/services/enabled_field_styling_service.py +++ b/src/pyqt_reactive/services/enabled_field_styling_service.py @@ -1,14 +1,15 @@ """Enabled Field Styling Service - Visual styling for enabled/disabled states.""" import logging +from collections.abc import Iterable from typing import Any from weakref import WeakKeyDictionary from objectstate.time_travel_profile import TimeTravelProfiler from PyQt6 import sip -from PyQt6.QtWidgets import QCheckBox, QGraphicsOpacityEffect, QLabel +from PyQt6.QtWidgets import QCheckBox, QGraphicsOpacityEffect, QLabel, QWidget -from pyqt_reactive.protocols import PlaceholderStateTrackable +from pyqt_reactive.protocols import PlaceholderStateTrackable, ValueGettable logger = logging.getLogger(__name__) @@ -37,6 +38,31 @@ def invalidate_widget_cache(self, manager) -> None: group_box = manager.form_tree.owning_groupbox(manager) if group_box is not None: self._value_widgets_by_container.pop(group_box, None) + + def apply_materialized_enabled_styling( + self, + manager, + materialized_widgets: Iterable[QWidget], + ) -> None: + """Style exact new value widgets before their form tree is attached.""" + direct_widgets = list(self._direct_widgets_by_manager.get(manager, ())) + direct_widget_ids = {id(widget) for widget in direct_widgets} + for widget in materialized_widgets: + if ( + not isinstance(widget, ValueGettable) + or sip.isdeleted(widget) + or id(widget) in direct_widget_ids + ): + continue + direct_widgets.append(widget) + direct_widget_ids.add(id(widget)) + + self._direct_widgets_by_manager[manager] = direct_widgets + group_box = manager.form_tree.owning_groupbox(manager) + if group_box is not None: + self._value_widgets_by_container.pop(group_box, None) + self._last_enabled_values.pop(manager, None) + self.apply_initial_enabled_styling(manager) def apply_initial_enabled_styling(self, manager) -> None: """ diff --git a/src/pyqt_reactive/services/help_document.py b/src/pyqt_reactive/services/help_document.py index 7edbe3e..e14772c 100644 --- a/src/pyqt_reactive/services/help_document.py +++ b/src/pyqt_reactive/services/help_document.py @@ -116,7 +116,7 @@ def rendered_html(self) -> str: ) parts = publish_parts( self.content, - writer="html5", + writer_name="html5", settings_overrides={ "raw_enabled": False, "file_insertion_enabled": False, diff --git a/src/pyqt_reactive/services/parameter_ops_service.py b/src/pyqt_reactive/services/parameter_ops_service.py index a7ba62d..da19cef 100644 --- a/src/pyqt_reactive/services/parameter_ops_service.py +++ b/src/pyqt_reactive/services/parameter_ops_service.py @@ -113,7 +113,7 @@ def reset_parameter(self, manager, param_name: str) -> None: widget = manager.widgets[param_name] from .signal_service import SignalService with SignalService.block_signals(widget): - manager._widget_service.update_widget_value( + manager._widget_service.overwrite_widget_value( widget, reset_value, param_name, skip_context_behavior=False, manager=manager ) # Emit event for consistency @@ -193,7 +193,7 @@ def _reset_value_widget_parameter(self, param_name: str, manager) -> None: widget = manager.widgets[param_name] from .signal_service import SignalService with SignalService.block_signals(widget): - manager._widget_service.update_widget_value( + manager._widget_service.overwrite_widget_value( widget, reset_value, param_name, diff --git a/src/pyqt_reactive/services/system_monitor_config.py b/src/pyqt_reactive/services/system_monitor_config.py index 03dc03c..116b292 100644 --- a/src/pyqt_reactive/services/system_monitor_config.py +++ b/src/pyqt_reactive/services/system_monitor_config.py @@ -3,28 +3,52 @@ from __future__ import annotations from dataclasses import dataclass, field +from enum import StrEnum from python_introspect import validate_annotated_dataclass -from pyqt_reactive.qt_types import QtColorText from pyqt_reactive.services.system_metrics_sampler import SystemMetricsSamplerConfig from zmqruntime.config import PositiveFloat, PositiveInteger +class PerformanceGraphColor(StrEnum): + """Closed high-contrast palette offered for system-monitor graph series.""" + + BLACK = "black" + WHITE = "white" + LIGHT_GRAY = "lightgray" + GRAY = "gray" + DARK_GRAY = "darkgray" + RED = "red" + DARK_RED = "darkred" + ORANGE = "orange" + YELLOW = "yellow" + LIME = "lime" + GREEN = "green" + DARK_GREEN = "darkgreen" + CYAN = "cyan" + DARK_CYAN = "darkcyan" + BLUE = "blue" + DARK_BLUE = "darkblue" + MAGENTA = "magenta" + PURPLE = "purple" + PINK = "pink" + + @dataclass(frozen=True, slots=True) class PerformanceMonitorColors: """Colors for the declared system-monitor series.""" - cpu: QtColorText = "cyan" - """Qt color name or literal used for the CPU utilization series.""" + cpu: PerformanceGraphColor = PerformanceGraphColor.CYAN + """Color used for the CPU utilization series.""" - ram: QtColorText = "lime" - """Qt color name or literal used for the system-memory series.""" + ram: PerformanceGraphColor = PerformanceGraphColor.LIME + """Color used for the system-memory series.""" - gpu: QtColorText = "orange" - """Qt color name or literal used for the GPU utilization series.""" + gpu: PerformanceGraphColor = PerformanceGraphColor.ORANGE + """Color used for the GPU utilization series.""" - vram: QtColorText = "magenta" - """Qt color name or literal used for the GPU-memory series.""" + vram: PerformanceGraphColor = PerformanceGraphColor.MAGENTA + """Color used for the GPU-memory series.""" def __post_init__(self) -> None: validate_annotated_dataclass(self) diff --git a/src/pyqt_reactive/services/widget_service.py b/src/pyqt_reactive/services/widget_service.py index 45c3803..9137812 100644 --- a/src/pyqt_reactive/services/widget_service.py +++ b/src/pyqt_reactive/services/widget_service.py @@ -231,7 +231,7 @@ def update_widget_value( self, widget: QWidget, value: Any, - param_name: Optional[str] = None, + param_name: str | None = None, skip_context_behavior: bool = False, manager=None ) -> None: @@ -242,6 +242,29 @@ def update_widget_value( if not skip_context_behavior and manager: self._apply_context_behavior(widget, value, param_name, manager) + def overwrite_widget_value( + self, + widget: QWidget, + value: Any, + param_name: str | None = None, + skip_context_behavior: bool = False, + manager=None, + ) -> None: + """Authoritatively replace a widget value without reading editor state. + + Reset and restore operations discard any transient text in the editor, + including syntactically incomplete text that cannot be extracted + through ``ValueGettable``. Ordinary reactive refreshes should continue + to use :meth:`update_widget_value` so equal assignments are skipped. + """ + 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.""" diff --git a/src/pyqt_reactive/widgets/shared/function_table_browser.py b/src/pyqt_reactive/widgets/shared/function_table_browser.py index 0cc4b3c..8a0bd48 100644 --- a/src/pyqt_reactive/widgets/shared/function_table_browser.py +++ b/src/pyqt_reactive/widgets/shared/function_table_browser.py @@ -5,8 +5,9 @@ Used as the table portion of FunctionSelectorDialog. """ +from collections.abc import Sequence from enum import Enum -from typing import ClassVar, List, Optional, Protocol, Sequence, cast +from typing import ClassVar, Protocol, cast from pyqt_reactive.theming import ColorScheme from pyqt_reactive.widgets.shared.abstract_table_browser import ( @@ -26,7 +27,7 @@ class FunctionTableRow(Protocol): doc: str display_name: str - def get_memory_type(self) -> str: ... + def get_memory_type(self) -> str | None: ... def get_registry_name(self) -> str: ... @@ -39,11 +40,11 @@ def _function_tags(item: object) -> Sequence[str]: class FunctionTableBrowser(AbstractTableBrowser[FunctionTableRow]): """ Table browser for function metadata. - + Static columns: Name, Module, Backend, Registry, Contract, Tags, Description Single-select mode. """ - + # Column widths MODULE_WIDTH = 250 DESCRIPTION_WIDTH = 300 @@ -57,7 +58,7 @@ class FunctionTableBrowser(AbstractTableBrowser[FunctionTableRow]): ColumnDef("Description", "doc", DESCRIPTION_WIDTH), ) - def __init__(self, color_scheme: Optional[ColorScheme] = None, parent=None): + def __init__(self, color_scheme: ColorScheme | None = None, parent=None): super().__init__( color_scheme=color_scheme, selection_mode=TableSelectionMode.SINGLE, @@ -71,15 +72,16 @@ def _contract_display_name(contract: object, *, unknown_label: str) -> str: if isinstance(contract, Enum): return contract.name return str(contract) - - def get_columns(self) -> List[ColumnDef]: + + def get_columns(self) -> list[ColumnDef]: """Static column definitions for function table.""" return list(self.COLUMNS) - - def extract_row_data(self, item: FunctionTableRow) -> List[str]: + + def extract_row_data(self, item: FunctionTableRow) -> list[str]: """Extract display values from function metadata.""" # Get contract name contract_name = self._contract_display_name(item.contract, unknown_label="unknown") + memory_type = item.get_memory_type() # Format tags tags_str = ", ".join(item.tags) if item.tags else "" @@ -90,13 +92,13 @@ def extract_row_data(self, item: FunctionTableRow) -> List[str]: return [ item.display_name, item.module, - item.get_memory_type().title(), + (memory_type or "").title(), item.get_registry_name().title(), contract_name, tags_str, description, ] - + def get_searchable_text(self, item: FunctionTableRow) -> str: """Return searchable text for function metadata.""" contract_name = self._contract_display_name(item.contract, unknown_label="") @@ -109,6 +111,6 @@ def get_searchable_text(self, item: FunctionTableRow) -> str: " ".join(item.tags), item.doc, ]) - + def get_search_placeholder(self) -> str: return "Search functions by name, module, contract, or tags..." diff --git a/tests/test_form_build_transaction.py b/tests/test_form_build_transaction.py index 654f573..72b551c 100644 --- a/tests/test_form_build_transaction.py +++ b/tests/test_form_build_transaction.py @@ -2,11 +2,12 @@ from __future__ import annotations -from dataclasses import dataclass, field +from dataclasses import dataclass, field, make_dataclass from time import monotonic from types import SimpleNamespace import pytest +from python_introspect import Enableable @dataclass @@ -35,6 +36,16 @@ class _FlatRoot: value_6: int = 6 +@dataclass(frozen=True) +class _ProgressiveChromeConfig(Enableable): + value_1: int = 1 + value_2: int = 2 + value_3: int = 3 + value_4: int = 4 + value_5: int = 5 + value_6: int = 6 + + @dataclass class _OptionalNestedRoot: optional_leaf: _TransactionLeaf | None = None @@ -135,6 +146,92 @@ def counted_refresh(service, manager, defer=False): ObjectStateRegistry.clear() +def test_visible_progressive_rows_receive_chrome_before_finalization(qapp): + """Materialized lazy fields are styled before the form tree completes.""" + from objectstate import ( + LazyDataclassFactory, + ObjectState, + ObjectStateRegistry, + set_base_config_type, + ) + + from pyqt_reactive.forms.parameter_form_manager import ( + FormManagerConfig, + ParameterFormManager, + ) + from pyqt_reactive.protocols import PlaceholderStateTrackable + from pyqt_reactive.theming import ColorScheme + + set_base_config_type(_ProgressiveChromeConfig) + ObjectStateRegistry.clear() + lazy_config_type = LazyDataclassFactory.make_lazy_simple( + _ProgressiveChromeConfig, + ) + progressive_root_type = make_dataclass( + "_ProgressiveChromeRoot", + [ + ( + "config", + lazy_config_type, + field( + default_factory=lambda: lazy_config_type( + enabled=False, + value_1=101, + ), + ), + ), + ], + frozen=True, + ) + set_base_config_type(progressive_root_type) + state = ObjectState( + progressive_root_type(), + scope_id="progressive-chrome-child", + ) + manager = ParameterFormManager( + state, + FormManagerConfig( + color_scheme=ColorScheme(), + use_scroll_area=False, + ), + ) + transaction = manager._form_build_transaction + + try: + assert transaction.finalization_count == 0 + assert tuple(manager.widgets) == ("config",) + nested_manager = manager.nested_managers["config"] + assert tuple(nested_manager.widgets) == ( + "enabled", + "value_1", + "value_2", + "value_3", + ) + + enabled_widget = nested_manager.widgets["enabled"] + inherited_value_widget = nested_manager.widgets["value_2"] + concrete_value_widget = nested_manager.widgets["value_1"] + assert isinstance(enabled_widget, PlaceholderStateTrackable) + assert isinstance(inherited_value_widget, PlaceholderStateTrackable) + assert not enabled_widget.has_placeholder_state() + assert enabled_widget.isChecked() is False + assert inherited_value_widget.has_placeholder_state() + assert concrete_value_widget.property("enabled_field_dimmed") is True + assert concrete_value_widget.graphicsEffect() is not None + + _wait_until( + qapp, + lambda: transaction.finalization_count == 1, + ) + + assert manager._form_build_transaction.failure is None + assert nested_manager.widgets["value_6"].has_placeholder_state() + finally: + manager.deleteLater() + qapp.processEvents() + ObjectStateRegistry.clear() + + def test_deleting_progressive_root_cancels_unfinished_generation(qapp): """Manager-owned timers cannot finalize a deleted progressive form.""" from objectstate import ObjectState, ObjectStateRegistry, set_base_config_type @@ -296,10 +393,12 @@ def test_batch_callback_failure_is_published_without_finalizing(qapp): failures = [] manager.form_build_failed.connect(failures.append) - def fail_batch(_manager): + def fail_batch(_manager, _materialized_widgets): raise ValueError("deliberate batch callback failure") - manager._enabled_field_styling_service.invalidate_widget_cache = fail_batch + manager._enabled_field_styling_service.apply_materialized_enabled_styling = ( + fail_batch + ) try: _wait_until(qapp, lambda: bool(failures)) diff --git a/tests/test_key_sequence_adapter_lifecycle.py b/tests/test_key_sequence_adapter_lifecycle.py new file mode 100644 index 0000000..cd40579 --- /dev/null +++ b/tests/test_key_sequence_adapter_lifecycle.py @@ -0,0 +1,39 @@ +"""Lifecycle regressions for the nominal Qt key-sequence editor.""" + +from pyqt_reactive.protocols import KeySequenceEditAdapter + + +def test_key_sequence_disconnect_preserves_unrelated_callback(qapp) -> None: + widget = KeySequenceEditAdapter() + first_values: list[str] = [] + second_values: list[str] = [] + + def first_callback(value: str) -> None: + first_values.append(value) + + def second_callback(value: str) -> None: + second_values.append(value) + + widget.connect_change_signal(first_callback) + widget.connect_change_signal(second_callback) + widget.disconnect_change_signal(first_callback) + widget.editingFinished.emit() + + assert first_values == [] + assert second_values == [""] + + +def test_key_sequence_duplicate_connect_is_idempotent(qapp) -> None: + widget = KeySequenceEditAdapter() + values: list[str] = [] + + def callback(value: str) -> None: + values.append(value) + + widget.connect_change_signal(callback) + widget.connect_change_signal(callback) + widget.editingFinished.emit() + widget.disconnect_change_signal(callback) + widget.editingFinished.emit() + + assert values == [""] diff --git a/tests/test_parameter_form_service.py b/tests/test_parameter_form_service.py index 4a02aa4..bb4b774 100644 --- a/tests/test_parameter_form_service.py +++ b/tests/test_parameter_form_service.py @@ -2,6 +2,7 @@ from dataclasses import dataclass from enum import Enum +from typing import Annotated import pytest @@ -111,6 +112,19 @@ def test_convert_value_to_type_rejects_bool_as_int_container_item() -> None: ) +def test_convert_value_to_type_resolves_annotated_union_members() -> None: + annotation = Annotated[bool, "enabled-field"] | None + + assert ( + ParameterFormService().convert_value_to_type( + True, + annotation, + "enabled", + ) + is True + ) + + def test_pep604_optional_dataclass_uses_shared_optional_authority() -> None: annotation = NestedConfig | None diff --git a/tests/test_system_monitor_config.py b/tests/test_system_monitor_config.py index 81f90f5..6953900 100644 --- a/tests/test_system_monitor_config.py +++ b/tests/test_system_monitor_config.py @@ -9,6 +9,7 @@ import pyqt_reactive.widgets.system_monitor as system_monitor_module from pyqt_reactive.services.system_metrics_sampler import SystemMetricsSamplerConfig from pyqt_reactive.services.system_monitor_config import ( + PerformanceGraphColor, PerformanceMonitorColors, PerformanceMonitorConfig, ) @@ -228,10 +229,10 @@ def test_each_sampler_leaf_rebuilds_monitor_with_exact_nominal_policy( ( "colors", PerformanceMonitorColors( - cpu="red", - ram="blue", - gpu="yellow", - vram="white", + cpu=PerformanceGraphColor.RED, + ram=PerformanceGraphColor.BLUE, + gpu=PerformanceGraphColor.YELLOW, + vram=PerformanceGraphColor.WHITE, ), ), ), @@ -315,10 +316,10 @@ def test_retained_config_leaves_have_one_field_behavior_cases() -> None: def test_monitor_colors_are_a_declared_nested_type() -> None: colors = PerformanceMonitorColors( - cpu="red", - ram="blue", - gpu="yellow", - vram="white", + cpu=PerformanceGraphColor.RED, + ram=PerformanceGraphColor.BLUE, + gpu=PerformanceGraphColor.YELLOW, + vram=PerformanceGraphColor.WHITE, ) config = PerformanceMonitorConfig(colors=colors) @@ -332,6 +333,8 @@ def test_monitor_colors_are_a_declared_nested_type() -> None: ) -def test_monitor_colors_reject_values_qt_cannot_render() -> None: - with pytest.raises(ValueError): +def test_monitor_colors_are_closed_nominal_choices() -> None: + with pytest.raises(TypeError): PerformanceMonitorColors(cpu="not-a-qt-color") + + assert all(isinstance(color, str) for color in PerformanceGraphColor) diff --git a/tests/test_widget_projection_safety.py b/tests/test_widget_projection_safety.py index 777bdf1..d02e97c 100644 --- a/tests/test_widget_projection_safety.py +++ b/tests/test_widget_projection_safety.py @@ -6,7 +6,7 @@ from dataclasses import dataclass, field from enum import Enum from pathlib import Path -from typing import Any +from typing import Annotated, Any import pytest @@ -335,7 +335,7 @@ class Mode(Enum): enum_widget = create_pyqt6_widget("mode", Mode, Mode.B, "mode") path_widget = create_pyqt6_widget( "output_path", - Path, + Annotated[Path | None, "environment-owned"], Path("/tmp/output"), "output_path", ) @@ -348,6 +348,35 @@ class Mode(Enum): ) +def test_qt_key_sequence_uses_complete_sequence_capture_widget(qapp): + """Shortcut edits commit complete portable sequences, never partial text.""" + + from PyQt6.QtGui import QKeySequence + + from pyqt_reactive.forms.widget_strategies import create_pyqt6_widget + from pyqt_reactive.protocols import KeySequenceEditAdapter + from pyqt_reactive.qt_types import QtKeySequenceText + + widget = create_pyqt6_widget( + "show_help", + QtKeySequenceText, + "F1", + "show_help", + ) + committed: list[str] = [] + widget.connect_change_signal(committed.append) + + assert isinstance(widget, KeySequenceEditAdapter) + assert widget.get_value() == "F1" + + widget.setKeySequence(QKeySequence("Ctrl+Shift+H")) + qapp.processEvents() + assert committed == [] + + widget.editingFinished.emit() + assert committed == ["Ctrl+Shift+H"] + + def test_unsupported_annotations_fail_loud_without_string_fallback( qapp, monkeypatch, diff --git a/tests/test_widgets.py b/tests/test_widgets.py index fcba5f4..9b9c81c 100644 --- a/tests/test_widgets.py +++ b/tests/test_widgets.py @@ -2869,6 +2869,59 @@ def set_value(self, value): assert widget.set_calls == ["new"] +def test_parameter_reset_overwrites_invalid_typed_literal_editor(qapp): + """Reset discards invalid transient text without weakening commit parsing.""" + from types import SimpleNamespace + + import pytest + + from pyqt_reactive.forms.parameter_info_types import GenericInfo + from pyqt_reactive.forms.widget_strategies import TypedLiteralContainerEdit + from pyqt_reactive.services.parameter_ops_service import ParameterOpsService + from pyqt_reactive.services.widget_service import WidgetService + + reset_value = (10, 50, 90) + widget = TypedLiteralContainerEdit(tuple) + widget.configure_annotation("percentiles", tuple[int, ...]) + widget.set_value(reset_value) + widget.setText("wwwwe21") + + widget_service = WidgetService() + with pytest.raises(ValueError, match="valid tuple literal"): + widget_service.update_widget_value(widget, reset_value) + + class ResetState: + parameters = {"percentiles": (1, 2, 3)} + + def reset_parameter(self, dotted_path): + assert dotted_path == "percentiles" + self.parameters[dotted_path] = reset_value + + info = GenericInfo( + name="percentiles", + type=tuple[int, ...], + current_value=(1, 2, 3), + default_value=reset_value, + ) + manager = SimpleNamespace( + field_id="", + form_structure=SimpleNamespace( + get_parameter_info=lambda param_name: ( + info if param_name == "percentiles" else None + ), + ), + state=ResetState(), + widgets={"percentiles": widget}, + _widget_service=widget_service, + object_instance=None, + ) + + ParameterOpsService().reset_parameter(manager, "percentiles") + + assert widget.text() == repr(reset_value) + assert widget.get_value() == reset_value + + def test_resolved_preview_placeholder_uses_cached_placeholder_text(qapp): """Inherited preview widgets are not repainted when placeholder text is unchanged.""" from pyqt_reactive.forms.widget_strategies import (