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
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
2 changes: 1 addition & 1 deletion docs/conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Expand Down
4 changes: 2 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"}
Expand Down Expand Up @@ -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",
]

Expand Down
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.1.28"
__version__ = "0.1.29"

# Public API will be populated as modules are added
__all__ = [
Expand Down
8 changes: 4 additions & 4 deletions src/pyqt_reactive/forms/form_init_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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:
Expand Down
23 changes: 23 additions & 0 deletions src/pyqt_reactive/forms/parameter_form_chrome_sync.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

from __future__ import annotations

from collections.abc import Iterable
from typing import Any, Set

from objectstate import DottedFieldPath
Expand Down Expand Up @@ -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

Expand Down
33 changes: 21 additions & 12 deletions src/pyqt_reactive/forms/parameter_form_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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):
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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 = {}

Expand Down Expand Up @@ -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,
Expand Down
24 changes: 23 additions & 1 deletion src/pyqt_reactive/forms/widget_strategies.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -32,6 +33,7 @@
from pyqt_reactive.protocols import (
ChangeSignalEmitter,
CurrentValueValidatable,
KeySequenceEditAdapter,
PlaceholderStateMixin,
PlaceholderStateTrackable,
PyQtWidgetMeta,
Expand All @@ -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
Expand Down Expand Up @@ -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()

Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Resolving Annotated wrappers here lets annotated Path declarations use EnhancedPathWidget, but the value-conversion path still receives the original annotation. Since convert_widget_value_to_type only converts when param_type is Path, EnhancedPathWidget.get_value() emits a str that is either stored as the wrong type or rejected for Path | None, so edited annotated path fields regress.

Severity: medium


🤖 Was this useful? React with 👍 or 👎

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:
Expand Down
2 changes: 2 additions & 0 deletions src/pyqt_reactive/protocols/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
)
from .widget_adapters import (
LineEditAdapter,
KeySequenceEditAdapter,
SpinBoxAdapter,
DoubleSpinBoxAdapter,
ComboBoxAdapter,
Expand Down Expand Up @@ -85,6 +86,7 @@
"EnumSelectable",
"ChangeSignalEmitter",
"LineEditAdapter",
"KeySequenceEditAdapter",
"SpinBoxAdapter",
"DoubleSpinBoxAdapter",
"ComboBoxAdapter",
Expand Down
69 changes: 65 additions & 4 deletions src/pyqt_reactive/protocols/widget_adapters.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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 (
Expand Down Expand Up @@ -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):
"""
Expand Down
Loading
Loading