From d0e3f4e7c6175684b73bacd2fcee5a125130c6c7 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Sun, 11 Oct 2026 03:15:36 -0400 Subject: [PATCH 01/16] Path cache contexts and path-name roles are open class families PathCacheKey was a closed enum that restated one application's file kinds and was copied verbatim into that application to add its own key. Each context is now a PathCacheKey subclass whose stored key is its snake-case class name, so applications declare contexts where they use them and existing cache files load unchanged. PathNameRole moves its tokens and behaviour onto subclasses; the pipeline, step and function roles named file formats the library does not own. The backward-compatibility alias functions and the unused keys are deleted. Co-Authored-By: Claude Opus 5.5 --- src/pyqt_reactive/core/path_cache.py | 265 ++++++------------ .../widgets/editors/simple_code_editor.py | 12 +- .../widgets/enhanced_path_widget.py | 151 ++++------ tests/test_path_name_roles.py | 46 ++- 4 files changed, 176 insertions(+), 298 deletions(-) diff --git a/src/pyqt_reactive/core/path_cache.py b/src/pyqt_reactive/core/path_cache.py index 7488330d..33395c3e 100644 --- a/src/pyqt_reactive/core/path_cache.py +++ b/src/pyqt_reactive/core/path_cache.py @@ -1,250 +1,141 @@ -""" -Unified Path Cache System +"""Last-used directories remembered per dialog context across sessions. -Provides shared path caching functionality for both TUI and PyQt GUI implementations. -Persists last used paths across application runs for improved user experience. +A context is a :class:`PathCacheKey` subclass. The family is open: applications +declare their own contexts by subclassing, and each context's key in the cache +file is derived from its class name. """ import json import logging +import re from pathlib import Path -from typing import Dict, Optional -from enum import Enum + +from metaclass_registry import AutoRegisterMeta logger = logging.getLogger(__name__) -class PathCacheKey(Enum): - """ - Enumeration of path cache keys for different UI contexts. - - Used to maintain separate cached paths for different file operations - across both TUI and PyQt GUI implementations. +def _stored_key(name: str, cls: type) -> str | None: + if "__registry__" in vars(cls): + return None + return re.sub(r"(? None: - """Load cache from disk.""" try: if self.cache_file.exists(): - with open(self.cache_file, 'r') as f: + with open(self.cache_file) as f: self._cache = json.load(f) - logger.debug(f"Loaded path cache with {len(self._cache)} entries") - else: - logger.debug("No existing path cache found, starting fresh") except (json.JSONDecodeError, OSError) as e: logger.warning(f"Failed to load path cache: {e}") self._cache = {} - + def _save_cache(self) -> None: - """Save cache to disk.""" try: - # Ensure cache directory exists self.cache_file.parent.mkdir(parents=True, exist_ok=True) - - with open(self.cache_file, 'w') as f: + with open(self.cache_file, "w") as f: json.dump(self._cache, f, indent=2) - logger.debug(f"Saved path cache with {len(self._cache)} entries") except OSError as e: logger.warning(f"Failed to save path cache: {e}") - - def get_cached_path(self, key: PathCacheKey) -> Optional[Path]: - """ - Get cached path for a specific key. - - Args: - key: PathCacheKey identifying the context - - Returns: - Cached Path if exists and valid, None otherwise - """ - cached_str = self._cache.get(key.value) - if cached_str: - cached_path = Path(cached_str) - if cached_path.exists(): - logger.debug(f"Retrieved cached path for {key.value}: {cached_path}") - return cached_path - else: - # Remove invalid cached path - logger.debug(f"Removing invalid cached path for {key.value}: {cached_path}") - del self._cache[key.value] - self._save_cache() - + + def get_cached_path(self, key: type[PathCacheKey]) -> Path | None: + """Return the remembered directory for ``key`` if it still exists.""" + + cached_str = self._cache.get(key.stored_key) + if not cached_str: + return None + cached_path = Path(cached_str) + if cached_path.exists(): + return cached_path + del self._cache[key.stored_key] + self._save_cache() return None - - def set_cached_path(self, key: PathCacheKey, path: Path) -> None: - """ - Set cached path for a specific key. - - Args: - key: PathCacheKey identifying the context - path: Path to cache - """ + + def set_cached_path(self, key: type[PathCacheKey], path: Path) -> None: + """Remember ``path`` for ``key``; a path that does not exist is ignored.""" + if path and path.exists(): - self._cache[key.value] = str(path) + self._cache[key.stored_key] = str(path) self._save_cache() - logger.debug(f"Cached path for {key.value}: {path}") else: - logger.warning(f"Attempted to cache non-existent path for {key.value}: {path}") - - def get_initial_path(self, key: PathCacheKey, fallback: Optional[Path] = None) -> Path: - """ - Get initial path with intelligent fallback hierarchy. - - Args: - key: PathCacheKey identifying the context - fallback: Optional fallback path if cached path unavailable - - Returns: - Best available path (cached > fallback > home directory) - """ - # Try cached path first + logger.warning(f"Attempted to cache non-existent path for {key.stored_key}: {path}") + + def get_initial_path(self, key: type[PathCacheKey], fallback: Path | None = None) -> Path: + """Return the remembered directory, else ``fallback``, else the home directory.""" + cached = self.get_cached_path(key) if cached: return cached - - # Try fallback if fallback and fallback.exists(): return fallback - - # Ultimate fallback to home directory return Path.home() - - def clear_cache(self) -> None: - """Clear all cached paths.""" - self._cache.clear() - self._save_cache() - logger.info("Cleared all cached paths") - - def remove_cached_path(self, key: PathCacheKey) -> None: - """ - Remove specific cached path. - - Args: - key: PathCacheKey to remove - """ - if key.value in self._cache: - del self._cache[key.value] - self._save_cache() - logger.debug(f"Removed cached path for {key.value}") -# Global cache instance -_global_path_cache: Optional[UnifiedPathCache] = None +_global_path_cache: UnifiedPathCache | None = None def get_path_cache() -> UnifiedPathCache: - """Get global path cache instance.""" + """Return the process-wide path cache.""" + global _global_path_cache if _global_path_cache is None: _global_path_cache = UnifiedPathCache() return _global_path_cache -def cache_path(key: PathCacheKey, path: Path) -> None: - """ - Convenience function to cache a path. - - Args: - key: PathCacheKey identifying the context - path: Path to cache - """ +def cache_path(key: type[PathCacheKey], path: Path) -> None: + """Remember ``path`` for ``key`` in the process-wide cache.""" + get_path_cache().set_cached_path(key, path) -def get_cached_path(key: PathCacheKey) -> Optional[Path]: - """ - Convenience function to get cached path. - - Args: - key: PathCacheKey identifying the context - - Returns: - Cached Path if exists and valid, None otherwise - """ - return get_path_cache().get_cached_path(key) - +def get_initial_path(key: type[PathCacheKey], fallback: Path | None = None) -> Path: + """Return the starting directory for a dialog opened in context ``key``.""" -def get_initial_path(key: PathCacheKey, fallback: Optional[Path] = None) -> Path: - """ - Convenience function to get initial path with fallback. - - Args: - key: PathCacheKey identifying the context - fallback: Optional fallback path - - Returns: - Best available path (cached > fallback > home directory) - """ return get_path_cache().get_initial_path(key, fallback) - - -# Backward compatibility aliases for existing code -def cache_browser_path(key: PathCacheKey, path: Path) -> None: - """Backward compatibility alias for TUI code.""" - cache_path(key, path) - - -def cache_dialog_path(key: PathCacheKey, path: Path) -> None: - """Backward compatibility alias for PyQt code.""" - cache_path(key, path) - - -def get_cached_browser_path(key: PathCacheKey, fallback: Optional[Path] = None) -> Path: - """Backward compatibility alias for TUI code.""" - return get_initial_path(key, fallback) - - -def get_cached_dialog_path(key: PathCacheKey, fallback: Optional[Path] = None) -> Path: - """Backward compatibility alias for PyQt code.""" - return get_initial_path(key, fallback) diff --git a/src/pyqt_reactive/widgets/editors/simple_code_editor.py b/src/pyqt_reactive/widgets/editors/simple_code_editor.py index 1e35166a..cbe61127 100644 --- a/src/pyqt_reactive/widgets/editors/simple_code_editor.py +++ b/src/pyqt_reactive/widgets/editors/simple_code_editor.py @@ -822,10 +822,10 @@ def _new_file(self): def _open_file(self): """Open file dialog and load content.""" - from pyqt_reactive.core.path_cache import PathCacheKey, get_cached_dialog_path, cache_dialog_path + from pyqt_reactive.core.path_cache import CodeEditor, cache_path, get_initial_path # Get cached initial directory - initial_dir = str(get_cached_dialog_path(PathCacheKey.CODE_EDITOR, fallback=Path.home())) + initial_dir = str(get_initial_path(CodeEditor, fallback=Path.home())) file_path, _ = QFileDialog.getOpenFileName( self, "Open Python File", initial_dir, "Python Files (*.py);;All Files (*)" @@ -834,7 +834,7 @@ def _open_file(self): try: selected_path = Path(file_path) # Cache the parent directory for future dialogs - cache_dialog_path(PathCacheKey.CODE_EDITOR, selected_path.parent) + cache_path(CodeEditor, selected_path.parent) with open(file_path, 'r', encoding='utf-8') as f: content = f.read() @@ -844,10 +844,10 @@ def _open_file(self): def _save_as(self): """Save content to file.""" - from pyqt_reactive.core.path_cache import PathCacheKey, get_cached_dialog_path, cache_dialog_path + from pyqt_reactive.core.path_cache import CodeEditor, cache_path, get_initial_path # Get cached initial directory - initial_dir = str(get_cached_dialog_path(PathCacheKey.CODE_EDITOR, fallback=Path.home())) + initial_dir = str(get_initial_path(CodeEditor, fallback=Path.home())) file_path, _ = QFileDialog.getSaveFileName( self, "Save Python File", initial_dir, "Python Files (*.py);;All Files (*)" @@ -862,7 +862,7 @@ def _save_as(self): file_path = str(selected_path) # Cache the parent directory for future dialogs - cache_dialog_path(PathCacheKey.CODE_EDITOR, selected_path.parent) + cache_path(CodeEditor, selected_path.parent) with open(file_path, 'w', encoding='utf-8') as f: f.write(self.editor.text()) diff --git a/src/pyqt_reactive/widgets/enhanced_path_widget.py b/src/pyqt_reactive/widgets/enhanced_path_widget.py index 80c9b88b..815c4c5c 100644 --- a/src/pyqt_reactive/widgets/enhanced_path_widget.py +++ b/src/pyqt_reactive/widgets/enhanced_path_widget.py @@ -9,17 +9,21 @@ import re from collections.abc import Callable, Sequence from dataclasses import dataclass -from enum import Enum from pathlib import Path +from typing import ClassVar +from metaclass_registry import AutoRegisterMeta from PyQt6.QtCore import QSize, pyqtSignal from PyQt6.QtWidgets import QFileDialog, QHBoxLayout, QLineEdit, QPushButton, QWidget from python_introspect import ParameterInfo from pyqt_reactive.core.path_cache import ( + DirectorySelection, + FileSelection, + General, PathCacheKey, - cache_dialog_path, - get_cached_dialog_path, + cache_path, + get_initial_path, ) from pyqt_reactive.protocols import ( ChangeSignalEmitter, @@ -33,13 +37,17 @@ logger = logging.getLogger(__name__) +def _role_name(name: str, cls: type) -> str | None: + return None if "__registry__" in vars(cls) else name + + @dataclass(frozen=True, slots=True) class PathBehavior: """Defines behavior for path widget based on parameter analysis.""" is_directory: bool = False extensions: tuple[str, ...] | None = None - cache_key: PathCacheKey = PathCacheKey.GENERAL + cache_key: type[PathCacheKey] = General description: str = "path" @classmethod @@ -53,11 +61,11 @@ def from_extensions(cls, extensions: Sequence[str]) -> "PathBehavior": return cls( is_directory=False, extensions=declared_extensions, - cache_key=PathCacheKey.FILE_SELECTION, + cache_key=FileSelection, description=description, ) - def with_cache_key(self, cache_key: PathCacheKey) -> "PathBehavior": + def with_cache_key(self, cache_key: type[PathCacheKey]) -> "PathBehavior": return PathBehavior( is_directory=self.is_directory, extensions=self.extensions, @@ -118,7 +126,7 @@ def detect_behavior( if docstring_behavior: if base_behavior: return docstring_behavior.with_cache_key(base_behavior.cache_key) - return docstring_behavior.with_cache_key(PathCacheKey.GENERAL) + return docstring_behavior.with_cache_key(General) # Fall back to base behavior or smart default if base_behavior: @@ -131,10 +139,8 @@ def _parse_docstring_hints(description: str) -> PathBehavior | None: desc_lower = description.lower() # Directory specification derives its accepted words from the role owner. - if any( - f"{token} only" in desc_lower for token in PathNameRole.DIRECTORY.tokens - ): - return PathNameRole.DIRECTORY.behavior + if any(f"{token} only" in desc_lower for token in DirectoryPathRole.tokens): + return DirectoryPathRole.behavior # Extension patterns: (.ext only), (.ext1, .ext2), (.ext1/.ext2), etc. patterns = [ @@ -161,89 +167,55 @@ def _parse_docstring_hints(description: str) -> PathBehavior | None: @staticmethod def _detect_from_parameter_name(param_name: str) -> PathBehavior | None: """Detect behavior from parameter name patterns.""" - tokens = PathParameterNameTokens.parse(param_name) - role = PATH_NAME_ROLE_CLASSIFIER.classify(tokens) + role = PathNameRole.for_tokens(PathParameterNameTokens.parse(param_name)) if role is None: return None return role.behavior -class PathNameRole(Enum): - """Nominal path behavior roles inferred from parameter-name tokens.""" +class PathNameRole(metaclass=AutoRegisterMeta): + """A path-parameter role declared by words in the parameter's name. - DIRECTORY = ( - "directory", - frozenset({"dir", "directory", "folder"}), - PathBehavior( - is_directory=True, - cache_key=PathCacheKey.DIRECTORY_SELECTION, - description="directory", - ), - ) - FILE = ( - "file", - frozenset({"file", "filepath", "filename"}), - PathBehavior( - is_directory=False, - cache_key=PathCacheKey.FILE_SELECTION, - description="file", - ), - ) - PIPELINE = ( - "pipeline", - frozenset({"pipeline"}), - PathBehavior( - is_directory=False, - cache_key=PathCacheKey.PIPELINE_FILES, - description="pipeline file", - ), - ) - STEP = ( - "step", - frozenset({"step"}), - PathBehavior( - is_directory=False, - cache_key=PathCacheKey.STEP_SETTINGS, - description="step file", - ), - ) - FUNCTION = ( - "function", - frozenset({"function", "func"}), - PathBehavior( - is_directory=False, - cache_key=PathCacheKey.FUNCTION_PATTERNS, - description="function file", - ), - ) + Roles are tried in declaration order; applications add roles by subclassing. + """ - def __new__( - cls, - value: str, - tokens: frozenset[str], - behavior: PathBehavior, - ) -> "PathNameRole": - member = object.__new__(cls) - member._value_ = value - member._tokens = tokens - member._behavior = behavior - return member + __registry_key__ = "role_name" + __key_extractor__ = _role_name + __skip_if_no_key__ = True - @property - def tokens(self) -> frozenset[str]: - """Parameter-name tokens that declare this role.""" - return self._tokens + tokens: ClassVar[frozenset[str]] + behavior: ClassVar[PathBehavior] - @property - def behavior(self) -> PathBehavior: - """Path selection behavior owned by this role.""" - return self._behavior + @classmethod + def for_tokens(cls, tokens: "PathParameterNameTokens") -> "type[PathNameRole] | None": + for role in cls.__registry__.values(): + if tokens.contains_any(role.tokens): + return role + return None + + +class DirectoryPathRole(PathNameRole): + tokens = frozenset({"dir", "directory", "folder"}) + behavior = PathBehavior( + is_directory=True, + cache_key=DirectorySelection, + description="directory", + ) + + +class FilePathRole(PathNameRole): + tokens = frozenset({"file", "filepath", "filename"}) + behavior = PathBehavior( + is_directory=False, + cache_key=FileSelection, + description="file", + ) DEFAULT_PATH_BEHAVIOR = PathBehavior( is_directory=False, extensions=None, - cache_key=PathCacheKey.GENERAL, + cache_key=General, description="file or directory", ) @@ -263,19 +235,6 @@ def contains_any(self, candidates: frozenset[str]) -> bool: return bool(self.values.intersection(candidates)) -class PathNameRoleClassifier: - """Classify parsed path-name tokens into a nominal behavior role.""" - - def classify(self, tokens: PathParameterNameTokens) -> PathNameRole | None: - for role in PathNameRole: - if tokens.contains_any(role.tokens): - return role - return None - - -PATH_NAME_ROLE_CLASSIFIER = PathNameRoleClassifier() - - class EnhancedPathWidget( PlaceholderStateMixin, QWidget, @@ -420,7 +379,7 @@ def _open_dialog(self): """Open appropriate Qt dialog based on behavior.""" try: # Get cached initial directory - initial_dir = str(get_cached_dialog_path(self.behavior.cache_key, fallback=Path.home())) + initial_dir = str(get_initial_path(self.behavior.cache_key, fallback=Path.home())) # Use None as parent to create a clean, top-level dialog # This prevents inheriting the dark styling from nested containers @@ -444,8 +403,8 @@ def _open_dialog(self): self.path_changed.emit(str(path_obj)) # Cache the selection (directory for files, path itself for directories) - cache_path = path_obj.parent if path_obj.is_file() else path_obj - cache_dialog_path(self.behavior.cache_key, cache_path) + selected_directory = path_obj.parent if path_obj.is_file() else path_obj + cache_path(self.behavior.cache_key, selected_directory) except Exception as e: logger.error(f"Failed to open dialog: {e}") diff --git a/tests/test_path_name_roles.py b/tests/test_path_name_roles.py index 051b2e38..fe175cdb 100644 --- a/tests/test_path_name_roles.py +++ b/tests/test_path_name_roles.py @@ -1,9 +1,17 @@ -"""Declaration-owned path-name inference behavior.""" +"""Path-name roles and dialog contexts are open, registered families.""" import pytest +from pyqt_reactive.core.path_cache import ( + DirectorySelection, + FileSelection, + PathCacheKey, + UnifiedPathCache, +) from pyqt_reactive.widgets.enhanced_path_widget import ( - PATH_NAME_ROLE_CLASSIFIER, + DEFAULT_PATH_BEHAVIOR, + DirectoryPathRole, + FilePathRole, PathBehaviorDetector, PathNameRole, PathParameterNameTokens, @@ -13,18 +21,38 @@ @pytest.mark.parametrize( ("parameter_name", "expected_role"), ( - ("output_directory", PathNameRole.DIRECTORY), - ("inputFile", PathNameRole.FILE), - ("pipeline_path", PathNameRole.PIPELINE), - ("step_path", PathNameRole.STEP), - ("custom_func_path", PathNameRole.FUNCTION), + ("output_directory", DirectoryPathRole), + ("inputFile", FilePathRole), + ("source_folder", DirectoryPathRole), ), ) def test_path_role_owns_classification_tokens_and_behavior( parameter_name: str, - expected_role: PathNameRole, + expected_role: type[PathNameRole], ) -> None: - role = PATH_NAME_ROLE_CLASSIFIER.classify(PathParameterNameTokens.parse(parameter_name)) + role = PathNameRole.for_tokens(PathParameterNameTokens.parse(parameter_name)) assert role is expected_role assert PathBehaviorDetector._detect_from_parameter_name(parameter_name) == role.behavior + + +def test_name_without_a_role_word_gets_the_default_behavior() -> None: + assert PathBehaviorDetector.detect_behavior("pipeline_path") == DEFAULT_PATH_BEHAVIOR + + +def test_application_declares_its_own_dialog_context(tmp_path) -> None: + class SampleImport(PathCacheKey): + """A context declared outside the library.""" + + try: + cache = UnifiedPathCache(tmp_path / "paths.json") + cache.set_cached_path(SampleImport, tmp_path) + cache.set_cached_path(DirectorySelection, tmp_path) + + reloaded = UnifiedPathCache(tmp_path / "paths.json") + assert SampleImport.stored_key == "sample_import" + assert PathCacheKey.__registry__["sample_import"] is SampleImport + assert reloaded.get_initial_path(SampleImport) == tmp_path + assert reloaded.get_cached_path(FileSelection) is None + finally: + del PathCacheKey.__registry__["sample_import"] From 4ed52b81eb5470e59b24108dd631121e8f10a948 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Sun, 11 Oct 2026 03:15:36 -0400 Subject: [PATCH 02/16] Manager header status marquee owns one timer for its lifetime Each status message too wide for the header created a new QTimer parented to the manager widget and only stopped the previous one, so the widget collected one live timer per long message. The controller now creates the timer once and restarts it, and measures the message with font metrics instead of a throwaway label. Co-Authored-By: Claude Opus 5.5 --- .../shared/manager_status_controller.py | 41 +++++++++---------- tests/test_manager_ui_scaffold.py | 36 ++++++++++++++++ 2 files changed, 56 insertions(+), 21 deletions(-) diff --git a/src/pyqt_reactive/widgets/shared/manager_status_controller.py b/src/pyqt_reactive/widgets/shared/manager_status_controller.py index 4eefb353..2d212f4e 100644 --- a/src/pyqt_reactive/widgets/shared/manager_status_controller.py +++ b/src/pyqt_reactive/widgets/shared/manager_status_controller.py @@ -17,11 +17,16 @@ @dataclass class ManagerStatusController: - """Owns manager status text rendering and optional marquee scrolling.""" + """Owns manager status text rendering and optional marquee scrolling. + + One marquee timer, parented to the manager widget, lives as long as the + widget; each status message restarts it. + """ enable_scrolling: bool presentation_strategy: Any = None scroll_timer: QTimer | None = None + scroll_area: Any = None scroll_position: int = 0 single_message_width: int = 0 current_message: str = "Ready" @@ -89,10 +94,9 @@ def _render_scrolling_message( status_label.adjustSize() separator = " " - temp_label = QLabel(f"{rendered_message}{separator}") - temp_label.setFont(status_label.font()) - temp_label.adjustSize() - self.single_message_width = temp_label.width() + self.single_message_width = status_label.fontMetrics().horizontalAdvance( + f"{rendered_message}{separator}" + ) label_width = status_label.width() scroll_width = status_scroll.viewport().width() @@ -116,32 +120,27 @@ def _restart_scrolling( status_scroll: Any, timer_parent: Any, ) -> None: - if self.scroll_timer: - self.scroll_timer.stop() - self.scroll_timer = None - status_scroll.horizontalScrollBar().setValue(0) self.scroll_position = 0 + self.scroll_area = status_scroll - label_width = status_label.width() - scroll_width = status_scroll.viewport().width() + if status_label.width() <= status_scroll.viewport().width(): + if self.scroll_timer is not None: + self.scroll_timer.stop() + return - if label_width > scroll_width: + if self.scroll_timer is None: self.scroll_timer = QTimer(timer_parent) self.scroll_timer.setTimerType(Qt.TimerType.PreciseTimer) - self.scroll_timer.timeout.connect( - lambda: self._auto_scroll(status_label=status_label, status_scroll=status_scroll) - ) - self.scroll_timer.start(50) + self.scroll_timer.timeout.connect(self._auto_scroll) + self.scroll_timer.start(50) - def _auto_scroll(self, *, status_label: QLabel, status_scroll: Any) -> None: - del status_label - scrollbar = status_scroll.horizontalScrollBar() + def _auto_scroll(self) -> None: + scrollbar = self.scroll_area.horizontalScrollBar() max_scroll = scrollbar.maximum() if max_scroll == 0: - if self.scroll_timer: - self.scroll_timer.stop() + self.scroll_timer.stop() return self.scroll_position += 2 diff --git a/tests/test_manager_ui_scaffold.py b/tests/test_manager_ui_scaffold.py index 3562687b..72d09a91 100644 --- a/tests/test_manager_ui_scaffold.py +++ b/tests/test_manager_ui_scaffold.py @@ -110,3 +110,39 @@ def test_scrolling_manager_header_preserves_usable_status_viewport( assert parts.status_scroll.horizontalScrollBar().maximum() == 0 finally: parts.header.close() + + +def test_status_marquee_keeps_one_timer_across_messages(qapp) -> None: + from PyQt6.QtCore import Qt, QTimer + + from pyqt_reactive.widgets.shared.manager_status_controller import ManagerStatusController + + owner = QWidget() + parts = create_manager_header( + title="Manager", + color_scheme=ColorScheme(), + enable_status_scrolling=True, + ) + parts.header.setParent(owner) + parts.header.resize(120, 40) + owner.show() + controller = ManagerStatusController(enable_scrolling=True) + try: + for index in range(20): + controller.update( + message=f"message {index} " + "too wide for the header " * 6, + context=owner, + manager_header=parts, + ) + qapp.processEvents() + + direct = Qt.FindChildOption.FindDirectChildrenOnly + timers = owner.findChildren(QTimer, options=direct) + assert timers == [controller.scroll_timer] + assert controller.scroll_timer.isActive() + + controller.update(message="ok", context=owner, manager_header=parts) + assert owner.findChildren(QTimer, options=direct) == timers + assert not controller.scroll_timer.isActive() + finally: + owner.close() From 9e951d256de6d92efeda3804391dc1262bd842ad Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Sun, 11 Oct 2026 03:16:15 -0400 Subject: [PATCH 03/16] Refuse malformed function-pattern entries instead of dropping them extract_func_and_kwargs answered (None, {}) for anything that was not a callable or a (callable, kwargs) pair, and six callers skipped such entries, so a malformed entry vanished from the pattern on the next save. It now raises TypeError; validate_pattern_structure reports it as invalid. Co-Authored-By: Claude Opus 5.5 --- .../services/pattern_data_manager.py | 30 ++++++++----------- .../widgets/function_list_editor.py | 13 -------- tests/test_pattern_data_manager.py | 19 ++++++++++++ 3 files changed, 32 insertions(+), 30 deletions(-) create mode 100644 tests/test_pattern_data_manager.py diff --git a/src/pyqt_reactive/services/pattern_data_manager.py b/src/pyqt_reactive/services/pattern_data_manager.py index c1d99e7e..05c7a2af 100644 --- a/src/pyqt_reactive/services/pattern_data_manager.py +++ b/src/pyqt_reactive/services/pattern_data_manager.py @@ -105,26 +105,23 @@ def convert_dict_to_list(pattern: dict) -> list | dict: return pattern @staticmethod - def extract_func_and_kwargs(func_item) -> tuple[Callable | None, dict]: - """ - Parse (func, kwargs) tuples and bare callables. - - Handles both tuple format and bare callable format exactly as current logic. - - Args: - func_item: Either (callable, kwargs) tuple or bare callable + def extract_func_and_kwargs(func_item) -> tuple[Callable, dict]: + """Return ``(callable, kwargs)`` for a ``(callable, kwargs)`` tuple or a bare callable. - Returns: - Tuple of (callable, kwargs_dict) + Raises: + TypeError: for any other item, so a malformed entry is never dropped silently. """ if isinstance(func_item, tuple) and len(func_item) == 2 and callable(func_item[0]): - _func, kwargs = func_item + func, kwargs = func_item if not isinstance(kwargs, Mapping): raise TypeError("Function-pattern tuple entries must carry a kwargs mapping.") - return func_item[0], dict(kwargs) + return func, dict(kwargs) if callable(func_item): return func_item, {} - return None, {} + raise TypeError( + "Function-pattern entries are callables or (callable, kwargs) pairs, " + f"got {func_item!r}." + ) @staticmethod def validate_pattern_structure(pattern: list | dict) -> bool: @@ -143,10 +140,9 @@ def validate_pattern_structure(pattern: list | dict) -> bool: if isinstance(pattern, list): # Validate list items are callables or (callable, dict) tuples for item in pattern: - func, kwargs = PatternDataManager.extract_func_and_kwargs(item) - if func is None: - return False - if not isinstance(kwargs, dict): + try: + PatternDataManager.extract_func_and_kwargs(item) + except TypeError: return False return True diff --git a/src/pyqt_reactive/widgets/function_list_editor.py b/src/pyqt_reactive/widgets/function_list_editor.py index c62c043b..af125c9d 100644 --- a/src/pyqt_reactive/widgets/function_list_editor.py +++ b/src/pyqt_reactive/widgets/function_list_editor.py @@ -485,9 +485,6 @@ def _refresh_pattern_from_context_state(self) -> None: new_tokens = [] for index, item in enumerate(self.functions): func, _kwargs = PatternDataManager.extract_func_and_kwargs(item) - if func is None: - new_tokens = [] - break token = str(new_tokens[index]) if index < len(new_tokens) else "" if not token: new_tokens = [] @@ -686,8 +683,6 @@ def _unregister_function_states_for_functions( for idx, item in enumerate(func_items): func, kwargs = PatternDataManager.extract_func_and_kwargs(item) - if func is None: - continue token = None if tokens is not None and idx < len(tokens): @@ -864,8 +859,6 @@ def _invocation_badge_text(self, index: int, func_item) -> Optional[str]: if self.invocation_badge_provider is None: return None func, _kwargs = self.data_manager.extract_func_and_kwargs(func_item) - if func is None: - return None group_key = ( str(self.selected_pattern_key) if self.is_dict_mode and self.selected_pattern_key is not None @@ -1348,8 +1341,6 @@ def _prune_kwargs( normalized_list = [] for item in value: func, kwargs = PatternDataManager.extract_func_and_kwargs(item) - if func is None: - continue pruned_kwargs = _prune_kwargs(func, kwargs) normalized_list.append(func if not pruned_kwargs else (func, pruned_kwargs)) migrated_pattern[str_key] = normalized_list @@ -1359,8 +1350,6 @@ def _prune_kwargs( normalized_list = [] for item in pattern: func, kwargs = PatternDataManager.extract_func_and_kwargs(item) - if func is None: - continue pruned_kwargs = _prune_kwargs(func, kwargs) normalized_list.append(func if not pruned_kwargs else (func, pruned_kwargs)) @@ -1897,8 +1886,6 @@ def _update_pattern_data(self): sanitized_functions = [] for item in self.functions: func, kwargs = PatternDataManager.extract_func_and_kwargs(item) - if func is None: - continue clean_kwargs = self._sanitize_pattern_kwargs(kwargs if isinstance(kwargs, dict) else {}) sanitized_functions.append((func, clean_kwargs)) self.functions = sanitized_functions diff --git a/tests/test_pattern_data_manager.py b/tests/test_pattern_data_manager.py new file mode 100644 index 00000000..65af2033 --- /dev/null +++ b/tests/test_pattern_data_manager.py @@ -0,0 +1,19 @@ +import pytest + +from pyqt_reactive.services.pattern_data_manager import PatternDataManager + + +def _func(image): + return image + + +def test_pattern_entries_are_callables_or_callable_kwargs_pairs(): + assert PatternDataManager.extract_func_and_kwargs(_func) == (_func, {}) + assert PatternDataManager.extract_func_and_kwargs((_func, {"a": 1})) == (_func, {"a": 1}) + + +@pytest.mark.parametrize("entry", ((_func, {}, "extra"), ("name", {}), None)) +def test_malformed_pattern_entry_is_refused_not_dropped(entry): + with pytest.raises(TypeError): + PatternDataManager.extract_func_and_kwargs(entry) + assert PatternDataManager.validate_pattern_structure([entry]) is False From 096934678b37126f9946f64296e7fd6cec654e16 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Sun, 11 Oct 2026 03:19:17 -0400 Subject: [PATCH 04/16] Editable tables whose columns derive from a row dataclass Moved from OpenHCS's source-binding editor, where it was generic code waiting for its library: the CellEditor family keyed by field annotation, columns derived from dataclass fields (labels from python-introspect's FieldLabel, tooltips from field docstrings), the typed record-list picker, and the table controller with its ObjectState semantic chrome and placeholder styling. Requires python-introspect 0.2.6 for FieldLabel. Co-Authored-By: Claude Opus 5.5 --- pyproject.toml | 2 +- requirements-ci.txt | 1 + src/pyqt_reactive/widgets/editable_table.py | 1349 +++++++++++++++++++ tests/test_editable_table.py | 156 +++ 4 files changed, 1507 insertions(+), 1 deletion(-) create mode 100644 src/pyqt_reactive/widgets/editable_table.py create mode 100644 tests/test_editable_table.py diff --git a/pyproject.toml b/pyproject.toml index a03c060e..e3df2ece 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -33,7 +33,7 @@ dependencies = [ "psutil>=5.9", "Pygments>=2.15", "pyzmq>=22.0", - "python-introspect>=0.2.5", + "python-introspect>=0.2.6", "zmqruntime>=0.6,<0.7", ] diff --git a/requirements-ci.txt b/requirements-ci.txt index 60bef0bd..9c3704fd 100644 --- a/requirements-ci.txt +++ b/requirements-ci.txt @@ -1,2 +1,3 @@ # Source candidates needed to validate this checkout before coordinated publication. objectstate @ git+https://github.com/OpenHCSDev/ObjectState.git@44379b0c75fb5bb69c27b4c6a3f4ca6eb28712ca +python-introspect @ git+https://github.com/OpenHCSDev/python-introspect.git@d8071f0f14471416c3ee27b5dadd7b463382c3ff diff --git a/src/pyqt_reactive/widgets/editable_table.py b/src/pyqt_reactive/widgets/editable_table.py new file mode 100644 index 00000000..c001bb44 --- /dev/null +++ b/src/pyqt_reactive/widgets/editable_table.py @@ -0,0 +1,1349 @@ +"""Editable Qt tables whose columns and cell editors derive from a row dataclass. + +``DataclassFieldColumns.of(Row)`` gives one column per field a cell editor +accepts (nested dataclass fields contribute their own fields). Cell editors are +an ``AutoRegisterMeta`` family keyed by the annotation they edit; cells hold +typed values, never a text encoding of several fields. ``EditableTableController`` +owns the Qt mechanics, the ObjectState semantic chrome and placeholder styling. +""" + +from __future__ import annotations + +import re +from abc import ABC, abstractmethod +from collections.abc import Callable, Mapping +from dataclasses import MISSING, dataclass, field, fields, is_dataclass, replace +from enum import Enum +from functools import cache +from types import MappingProxyType, UnionType +from typing import ( + Annotated, + Generic, + TypeVar, + Union, + cast, + get_args, + get_origin, + get_type_hints, +) + +from metaclass_registry import AutoRegisterMeta +from objectstate import ObjectStateSubfieldSemanticIndex, StructuralValuePath +from PyQt6.QtCore import Qt, QTimer +from PyQt6.QtGui import QBrush, QColor +from PyQt6.QtWidgets import ( + QCheckBox, + QComboBox, + QDialog, + QDialogButtonBox, + QHBoxLayout, + QHeaderView, + QLabel, + QLineEdit, + QListWidget, + QListWidgetItem, + QPushButton, + QSizePolicy, + QStyle, + QTableWidget, + QTableWidgetItem, + QVBoxLayout, + QWidget, +) +from python_introspect import ( + AnnotationChoices, + SignatureAnalyzer, + declared_annotation_choices, + declared_field_label, +) + +from pyqt_reactive.forms.widget_strategies import PlaceholderConfig +from pyqt_reactive.widgets.no_scroll_spinbox import NoScrollComboBox +from pyqt_reactive.widgets.structural_table import ( + EditableTableSemanticBinding, + StructuralTableCellTarget, +) + +EditableRowT = TypeVar("EditableRowT") +INVALID_CELL_VALUE_ERRORS = (ValueError, TypeError, re.error) +"""Errors a row constructor raises for a value the user has not finished editing.""" + + +@dataclass(frozen=True, slots=True) +class RowSuggestion: + """Prefilled field values offered for a new record row in a picker dialog.""" + + values: tuple[tuple[str, object], ...] + label: str + + @classmethod + def of(cls, row_type: type, **values: object) -> RowSuggestion: + columns = DataclassFieldColumns.of(row_type) + return cls( + values=tuple(values.items()), + label=":".join( + columns.named(name).editor.display(value) for name, value in values.items() + ), + ) + + +RowSuggestionMap = Mapping[type, tuple[RowSuggestion, ...]] + + +@dataclass(frozen=True, slots=True) +class CellEditContext: + """What a cell editor needs from its table besides the cell value.""" + + on_change: Callable[[], None] + suggestions: RowSuggestionMap = field(default_factory=lambda: MappingProxyType({})) + + +def cell_editor_key(name: str, cls: type) -> str: + return name + + +class CellEditor(ABC, metaclass=AutoRegisterMeta): + """Cell editor family keyed by the field annotation it edits. + + A cell holds a *raw* value (the widget's own state, e.g. item text); the + editor converts it to the field's typed value. No raw value is ever a + serialized encoding of several fields. + """ + + __registry_key__ = "editor_key" + __key_extractor__ = cell_editor_key + __skip_if_no_key__ = True + + def __init__(self, annotation: object) -> None: + self.annotation = annotation + + @classmethod + def for_annotation(cls, annotation: object) -> CellEditor | None: + for editor_type in cls.__registry__.values(): + if editor_type.accepts(annotation): + return editor_type(annotation) + return None + + @classmethod + @abstractmethod + def accepts(cls, annotation: object) -> bool: ... + + @abstractmethod + def raw_for(self, value: object) -> object: + """Return the raw cell state that renders ``value``.""" + + @abstractmethod + def empty_raw(self) -> object: + """Raw state of a new cell whose field has no default.""" + + @abstractmethod + def value_from_raw(self, raw: object) -> object: + """Return the typed field value; raise ``ValueError`` while incomplete.""" + + @abstractmethod + def display(self, value: object) -> str: ... + + @abstractmethod + def create( + self, + table: QTableWidget, + row: int, + column: int, + raw: object, + context: CellEditContext, + ) -> None: ... + + @abstractmethod + def update(self, table: QTableWidget, row: int, column: int, raw: object) -> bool: + """Update an existing cell in place; return False when it must be rebuilt.""" + + @abstractmethod + def raw(self, table: QTableWidget, row: int, column: int) -> object: ... + + @staticmethod + def union_members(annotation: object) -> tuple[object, ...]: + if get_origin(annotation) in (Union, UnionType): + return get_args(annotation) + return (annotation,) + + +class ScalarTextCellEditor(CellEditor): + """Text item for ``str``/``int``/``float`` scalars, optionally ``None``.""" + + SCALAR_TYPES = (str, int, float, bool) + + @classmethod + def accepts(cls, annotation: object) -> bool: + if annotation is bool: + return False + members = tuple( + member for member in cls.union_members(annotation) if member is not type(None) + ) + return bool(members) and all(member in cls.SCALAR_TYPES for member in members) + + @property + def optional(self) -> bool: + return type(None) in self.union_members(self.annotation) + + @property + def scalar_type(self) -> type: + members = tuple( + member for member in self.union_members(self.annotation) if member is not type(None) + ) + return members[0] if len(members) == 1 else str + + def raw_for(self, value: object) -> str: + return "" if value is None else str(value) + + def empty_raw(self) -> str: + return "" + + def value_from_raw(self, raw: object) -> object: + text = str(raw).strip() + if not text and self.optional: + return None + return self.scalar_type(text) + + def display(self, value: object) -> str: + return self.raw_for(value) + + def create(self, table, row, column, raw, context) -> None: + table.setItem(row, column, EditableTableItem(str(raw))) + + def update(self, table, row, column, raw) -> bool: + item = table.item(row, column) + if not isinstance(item, EditableTableItem): + return False + item.set_logical_text(str(raw)) + return True + + def raw(self, table, row, column) -> str: + item = table.item(row, column) + if item is None: + return "" + return EditableTableController._editable_item(item).data( + EditableTableItem.LOGICAL_VALUE_ROLE + ) + + +class BooleanCellEditor(CellEditor): + """Check box for ``bool`` fields.""" + + @classmethod + def accepts(cls, annotation: object) -> bool: + return annotation is bool + + def raw_for(self, value: object) -> bool: + return bool(value) + + def empty_raw(self) -> bool: + return False + + def value_from_raw(self, raw: object) -> bool: + return bool(raw) + + def display(self, value: object) -> str: + return str(bool(value)) + + def create(self, table, row, column, raw, context) -> None: + checkbox = QCheckBox(table) + checkbox.setChecked(bool(raw)) + checkbox.toggled.connect(lambda _checked: context.on_change()) + table.setCellWidget(row, column, checkbox) + + def update(self, table, row, column, raw) -> bool: + checkbox = table.cellWidget(row, column) + if not isinstance(checkbox, QCheckBox): + return False + blocked = checkbox.blockSignals(True) + try: + checkbox.setChecked(bool(raw)) + finally: + checkbox.blockSignals(blocked) + return True + + def raw(self, table, row, column) -> bool: + checkbox = table.cellWidget(row, column) + return isinstance(checkbox, QCheckBox) and checkbox.isChecked() + + +class ChoiceCellEditor(CellEditor): + """Combo box whose choices are the field type's members. + + Declared ``AnnotationChoices`` supply their choices and labels; an ``Enum`` + type supplies its members; ``type[F]`` for an ``AutoRegisterMeta`` family + supplies the family registry, labelled by each member's registry key. + """ + + COMBO_LOGICAL_TEXT_ROLE = Qt.ItemDataRole.UserRole + 1 + + @classmethod + def accepts(cls, annotation: object) -> bool: + return ( + cls.declared_choices(annotation) is not None + or cls.family(annotation) is not None + or (isinstance(annotation, type) and issubclass(annotation, Enum)) + ) + + @staticmethod + def declared_choices(annotation: object) -> AnnotationChoices | None: + if get_origin(annotation) is not Annotated: + return None + return declared_annotation_choices(annotation) + + @staticmethod + def family(annotation: object) -> type | None: + if get_origin(annotation) is not type: + return None + (member_type,) = get_args(annotation) + return member_type if isinstance(member_type, AutoRegisterMeta) else None + + @property + def choices(self) -> tuple[object, ...]: + declared = self.declared_choices(self.annotation) + if declared is not None: + return declared.choices() + family = self.family(self.annotation) + if family is not None: + return tuple(family.__registry__.values()) + return tuple(cast(type[Enum], self.annotation)) + + def raw_for(self, value: object) -> object: + return value + + def empty_raw(self) -> object: + return self.choices[0] + + def value_from_raw(self, raw: object) -> object: + return raw + + def display(self, value: object) -> str: + declared = self.declared_choices(self.annotation) + if declared is not None: + return declared.label(value) + family = self.family(self.annotation) + if family is not None: + return str(getattr(value, family.__registry_key__)) + return str(cast(Enum, value).value) + + def create(self, table, row, column, raw, context) -> None: + combo = NoScrollComboBox(table) + for choice in self.choices: + text = self.display(choice) + combo.addItem(text, choice) + combo.setItemData(combo.count() - 1, text, self.COMBO_LOGICAL_TEXT_ROLE) + combo.setCurrentIndex(self.index_of(combo, raw)) + combo.currentIndexChanged.connect(lambda _: context.on_change()) + combo.activated.connect(lambda _: context.on_change()) + table.setCellWidget(row, column, combo) + + def update(self, table, row, column, raw) -> bool: + combo = table.cellWidget(row, column) + if not isinstance(combo, QComboBox): + return False + index = self.index_of(combo, raw) + if index < 0: + return False + blocked = combo.blockSignals(True) + try: + combo.setCurrentIndex(index) + finally: + combo.blockSignals(blocked) + return True + + def raw(self, table, row, column) -> object: + combo = table.cellWidget(row, column) + if not isinstance(combo, QComboBox): + raise TypeError("Choice cells are rendered as combo boxes.") + return combo.currentData() + + @staticmethod + def index_of(combo: QComboBox, value: object) -> int: + for index in range(combo.count()): + if combo.itemData(index) == value: + return index + return -1 + + +class RecordTupleCellEditor(CellEditor): + """Picker cell for ``tuple[Record, ...]`` with columns derived from ``Record``.""" + + @classmethod + def accepts(cls, annotation: object) -> bool: + if get_origin(annotation) is not tuple: + return False + arguments = get_args(annotation) + return ( + len(arguments) == 2 + and arguments[1] is Ellipsis + and isinstance(arguments[0], type) + and is_dataclass(arguments[0]) + ) + + @property + def element_type(self) -> type: + return get_args(self.annotation)[0] + + def raw_for(self, value: object) -> tuple[object, ...]: + return tuple(cast(tuple, value)) + + def empty_raw(self) -> tuple[object, ...]: + return () + + def value_from_raw(self, raw: object) -> tuple[object, ...]: + return tuple(cast(tuple, raw)) + + def display(self, value: object) -> str: + columns = DataclassFieldColumns.of(self.element_type) + return "; ".join( + ":".join( + text + for column in columns.columns + if (text := column.editor.display(column.value_of(element))) + ) + for element in cast(tuple, value) + ) + + def create(self, table, row, column, raw, context) -> None: + table.setCellWidget( + row, + column, + StructuredSelectorCellWidget( + editor=self, + value=self.value_from_raw(raw), + suggestions=context.suggestions.get(self.element_type, ()), + apply_changes=context.on_change, + parent=table, + ), + ) + + def update(self, table, row, column, raw) -> bool: + widget = table.cellWidget(row, column) + if not isinstance(widget, StructuredSelectorCellWidget): + return False + widget.set_value_silently(self.value_from_raw(raw)) + return True + + def raw(self, table, row, column) -> tuple[object, ...]: + widget = table.cellWidget(row, column) + if not isinstance(widget, StructuredSelectorCellWidget): + raise TypeError("Record-list cells are rendered as selector widgets.") + return widget.value() + + +@dataclass(frozen=True, slots=True) +class DataclassFieldColumn: + """One editable leaf field of a row dataclass, at ``path`` from the row.""" + + index: int + path: tuple[str, ...] + declaring_type: type + editor: CellEditor + label: str + tooltip: str | None + default: object = MISSING + + @property + def name(self) -> str: + return self.path[-1] + + def value_of(self, row: object) -> object: + value = row + for name in self.path: + value = getattr(value, name) + return value + + def replaced(self, row: object, value: object) -> object: + """Return ``row`` with this field replaced; every other field is kept.""" + + return self._replaced(row, self.path, value) + + @classmethod + def _replaced(cls, row: object, path: tuple[str, ...], value: object) -> object: + name, *rest = path + if rest: + value = cls._replaced(getattr(row, name), tuple(rest), value) + return replace(row, **{name: value}) + + +@dataclass(frozen=True, slots=True) +class DataclassFieldColumns: + """Columns derived from a row dataclass: one per field a cell editor accepts. + + Nested dataclass fields contribute their own fields. Fields no editor accepts + have no column; editing a row through ``replaced`` keeps them unchanged. + """ + + row_type: type + columns: tuple[DataclassFieldColumn, ...] + + @classmethod + @cache + def of(cls, row_type: type) -> DataclassFieldColumns: + columns: list[DataclassFieldColumn] = [] + cls._collect(row_type, (), columns) + return cls(row_type=row_type, columns=tuple(columns)) + + @classmethod + def _collect( + cls, + row_type: type, + prefix: tuple[str, ...], + columns: list[DataclassFieldColumn], + ) -> None: + hints = get_type_hints(row_type, include_extras=True) + for row_field in fields(row_type): + annotation, metadata = cls._split_annotated(hints[row_field.name]) + path = (*prefix, row_field.name) + if isinstance(annotation, type) and is_dataclass(annotation): + cls._collect(annotation, path, columns) + continue + editor = CellEditor.for_annotation( + hints[row_field.name] + if any(isinstance(item, AnnotationChoices) for item in metadata) + else annotation + ) + + if editor is None: + continue + columns.append( + DataclassFieldColumn( + index=len(columns), + path=path, + declaring_type=row_type, + editor=editor, + label=declared_field_label(hints[row_field.name]) + or row_field.name.replace("_", " ").title(), + tooltip=SignatureAnalyzer.extract_field_documentation( + row_type, + row_field.name, + ), + default=row_field.default, + ) + ) + + @staticmethod + def _split_annotated(annotation: object) -> tuple[object, tuple[object, ...]]: + if get_origin(annotation) is Annotated: + base, *metadata = get_args(annotation) + return base, tuple(metadata) + return annotation, () + + def __len__(self) -> int: + return len(self.columns) + + def __iter__(self): + return iter(self.columns) + + def named(self, *path: str) -> DataclassFieldColumn: + for column in self.columns: + if column.path[-len(path) :] == path: + return column + raise KeyError(f"{self.row_type.__name__} has no column {'.'.join(path)}.") + + @property + def labels(self) -> tuple[str, ...]: + return tuple(column.label for column in self.columns) + + def apply_header_items(self, header_item: Callable[[int], QTableWidgetItem | None]): + for column in self.columns: + item = header_item(column.index) + if item is not None and column.tooltip is not None: + item.setToolTip(column.tooltip) + + @property + def is_isomorphic(self) -> bool: + return len(self.columns) == len(fields(self.row_type)) and all( + len(column.path) == 1 for column in self.columns + ) + + def construct(self, values: tuple[object, ...]) -> object: + """Build an isomorphic row from one typed value per field.""" + + return self.row_type(**{column.name: value for column, value in zip(self.columns, values)}) + + def draft_raw(self, values: Mapping[str, object]) -> tuple[object, ...]: + """Raw cells for a new row: given values, then field defaults, then empty.""" + + return tuple( + column.editor.raw_for(values[column.name]) + if column.name in values + else ( + column.editor.raw_for(column.default) + if column.default is not MISSING + else column.editor.empty_raw() + ) + for column in self.columns + ) + + +class StructuredSelectorCellWidget(QWidget): + """Read-only summary of a record list with a typed picker dialog.""" + + def __init__( + self, + *, + editor: RecordTupleCellEditor, + value: tuple[object, ...], + suggestions: tuple[RowSuggestion, ...], + apply_changes: Callable[[], None], + parent: QWidget | None = None, + ) -> None: + super().__init__(parent) + self.editor = editor + self.suggestions = suggestions + self._value = value + self._apply_changes = apply_changes + self.semantic_marker_label = QLabel("", self) + self.semantic_marker_label.setVisible(False) + self.line_edit = QLineEdit(editor.display(value), self) + self.line_edit.setReadOnly(True) + picker_button = QPushButton("...", self) + picker_button.setFixedWidth(28) + picker_button.clicked.connect(self._open_picker) + + layout = QHBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(2) + layout.addWidget(self.semantic_marker_label) + layout.addWidget(self.line_edit, 1) + layout.addWidget(picker_button) + + def value(self) -> tuple[object, ...]: + return self._value + + def set_value(self, value: tuple[object, ...]) -> None: + self.set_value_silently(value) + self._apply_changes() + + def set_value_silently(self, value: tuple[object, ...]) -> None: + self._value = tuple(value) + self.line_edit.setText(self.editor.display(self._value)) + + def set_semantic_markers(self, markers: tuple[str, ...]) -> None: + """Render ObjectState-owned markers without changing the edit value.""" + + self.semantic_marker_label.setText("".join(markers)) + self.semantic_marker_label.setVisible(bool(markers)) + + def create_dialog(self) -> StructuredSelectorDialog: + return StructuredSelectorDialog( + element_type=self.editor.element_type, + suggestions=self.suggestions, + value=self._value, + parent=self, + ) + + def _open_picker(self) -> None: + dialog = self.create_dialog() + if dialog.exec() != QDialog.DialogCode.Accepted: + return + self.set_value(dialog.value()) + + +class StructuredSelectorDialog(QDialog): + """Typed editor for a record list; its columns derive from the record type.""" + + def __init__( + self, + *, + element_type: type, + suggestions: tuple[RowSuggestion, ...], + value: tuple[object, ...], + parent: QWidget | None = None, + ) -> None: + super().__init__(parent) + columns = DataclassFieldColumns.of(element_type) + self.setWindowTitle(f"Edit {element_type.__name__} list") + self.table = QTableWidget(0, len(columns), self) + self.table.setHorizontalHeaderLabels(columns.labels) + columns.apply_header_items(self.table.horizontalHeaderItem) + self.validation_label = QLabel(self) + self.controller: EditableTableController[object] = EditableTableController( + table=self.table, + columns=columns, + apply_changes=self._update_validation_hint, + ) + for element in value: + self.controller.append(element) + self.table.itemChanged.connect(lambda _item: self.controller.request_apply_changes()) + self.suggestions = QListWidget(self) + for suggestion in suggestions: + item = QListWidgetItem(suggestion.label) + item.setData(Qt.ItemDataRole.UserRole, suggestion) + self.suggestions.addItem(item) + self.suggestions.itemDoubleClicked.connect( + lambda item: self._append_suggestion(item.data(Qt.ItemDataRole.UserRole)) + ) + add_button = QPushButton("Add selected", self) + add_button.clicked.connect(self._append_selected) + add_row_button = QPushButton("Add row", self) + add_row_button.clicked.connect(lambda: self.append_draft({})) + buttons = QDialogButtonBox( + QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel, + self, + ) + buttons.accepted.connect(self.accept) + buttons.rejected.connect(self.reject) + + layout = QVBoxLayout(self) + layout.addWidget(self.table) + layout.addWidget(self.validation_label) + layout.addWidget(add_row_button) + layout.addWidget(QLabel("Suggestions", self)) + layout.addWidget(self.suggestions) + layout.addWidget(add_button) + layout.addWidget(buttons) + self._update_validation_hint() + + def value(self) -> tuple[object, ...]: + return self.controller.rows() + + def append_draft(self, values: Mapping[str, object]) -> None: + self.controller.append_draft(values) + self._update_validation_hint() + + def _append_selected(self) -> None: + for item in self.suggestions.selectedItems(): + self._append_suggestion(item.data(Qt.ItemDataRole.UserRole)) + + def _append_suggestion(self, suggestion: RowSuggestion) -> None: + self.append_draft(dict(suggestion.values)) + + def _update_validation_hint(self) -> None: + invalid_rows = self.controller.incomplete_row_numbers() + if invalid_rows: + joined_rows = ", ".join(str(row) for row in invalid_rows) + self.validation_label.setText(f"Incomplete rows ignored: {joined_rows}") + return + self.validation_label.setText("All rows are structurally valid.") + + +RawRow = tuple[object, ...] + + +@dataclass(slots=True, weakref_slot=True) +class EditableTableProgrammaticUpdateGuard: + """Suppress table-driven change callbacks while the model updates the view.""" + + depth: int = 0 + pending_release: bool = False + programmatic_row_values: tuple[RawRow, ...] | None = None + + def __enter__(self) -> EditableTableProgrammaticUpdateGuard: + self.depth += 1 + self.pending_release = False + self.programmatic_row_values = None + return self + + def __exit__(self, exc_type, exc_value, traceback) -> None: + if self.depth > 0: + self.depth -= 1 + self.pending_release = True + QTimer.singleShot(0, lambda: self._release_pending()) + + @property + def active(self) -> bool: + return self.depth > 0 + + def remember_rows(self, row_values: tuple[RawRow, ...]) -> None: + """Record the table rows produced by the current programmatic update.""" + + self.programmatic_row_values = row_values + + def suppress_pending_rows( + self, + row_values: tuple[RawRow, ...], + ) -> None: + """Suppress delayed callbacks that still reflect programmatic rows.""" + + self.pending_release = True + self.remember_rows(row_values) + QTimer.singleShot(0, lambda: self._release_pending()) + + def should_suppress_pending_rows( + self, + row_values: tuple[RawRow, ...], + ) -> bool: + """Whether a pending callback still reflects the programmatic rows.""" + + return self.pending_release and self.programmatic_row_values == row_values + + def release(self) -> None: + self.pending_release = False + self.programmatic_row_values = None + + def _release_pending(self) -> None: + if self.depth == 0: + self.release() + + +class EditableTableItem(QTableWidgetItem): + """QTableWidgetItem with separate logical/edit value and rendered chrome.""" + + LOGICAL_VALUE_ROLE = Qt.ItemDataRole.UserRole + RENDERED_TEXT_ROLE = Qt.ItemDataRole.UserRole + 1 + + def __init__(self, value: str) -> None: + super().__init__() + self._logical_value = value + self._rendered_text = value + super().setData(Qt.ItemDataRole.DisplayRole, value) + + def data(self, role): + if role == Qt.ItemDataRole.DisplayRole: + return self._rendered_text + if role == Qt.ItemDataRole.EditRole: + return self._logical_value + if role == self.LOGICAL_VALUE_ROLE: + return self._logical_value + if role == self.RENDERED_TEXT_ROLE: + return self._rendered_text + return super().data(role) + + def setData(self, role, value) -> None: # noqa: N802 - Qt virtual method name + text = "" if value is None else str(value) + if role == Qt.ItemDataRole.EditRole: + self.set_logical_text(text) + return + if role == Qt.ItemDataRole.DisplayRole: + self.set_rendered_text(text) + return + if role == self.LOGICAL_VALUE_ROLE: + self._logical_value = text + super().setData(self.LOGICAL_VALUE_ROLE, text) + return + if role == self.RENDERED_TEXT_ROLE: + self.set_rendered_text(text) + return + super().setData(role, value) + + def setText(self, text: str) -> None: # noqa: N802 - Qt virtual method name + self.set_logical_text(text) + + def set_logical_text(self, value: str) -> None: + self._logical_value = value + self._rendered_text = value + super().setData(self.LOGICAL_VALUE_ROLE, value) + super().setData(self.RENDERED_TEXT_ROLE, value) + super().setData(Qt.ItemDataRole.DisplayRole, value) + + def set_rendered_text(self, value: str) -> None: + self._rendered_text = value + super().setData(self.RENDERED_TEXT_ROLE, value) + super().setData(Qt.ItemDataRole.DisplayRole, value) + + +@dataclass(frozen=True) +class EditableTableController(Generic[EditableRowT]): + """Own editable Qt table mechanics for one isomorphic row dataclass. + + Every field of the row type has a column, so a row is constructed from its + typed cell values without losing anything. + """ + + LOGICAL_VALUE_ROLE = EditableTableItem.LOGICAL_VALUE_ROLE + RENDERED_TEXT_ROLE = EditableTableItem.RENDERED_TEXT_ROLE + COMBO_LOGICAL_TEXT_ROLE = ChoiceCellEditor.COMBO_LOGICAL_TEXT_ROLE + + table: QTableWidget + columns: DataclassFieldColumns + apply_changes: Callable[[], None] + suggestions: RowSuggestionMap = field(default_factory=lambda: MappingProxyType({})) + semantic_binding: EditableTableSemanticBinding | None = None + update_guard: EditableTableProgrammaticUpdateGuard = field( + default_factory=EditableTableProgrammaticUpdateGuard, + ) + + def __post_init__(self) -> None: + if not self.columns.is_isomorphic: + raise TypeError( + f"{self.columns.row_type.__name__} has fields without an editable " + "column; edit it through replace-based typed rows instead." + ) + + @property + def cell_context(self) -> CellEditContext: + return CellEditContext( + on_change=self.request_apply_changes, + suggestions=self.suggestions, + ) + + def append(self, row_model: EditableRowT) -> None: + self._append_raw( + tuple(column.editor.raw_for(column.value_of(row_model)) for column in self.columns) + ) + + def append_draft(self, values: Mapping[str, object]) -> None: + """Append a row prefilled with ``values`` that may not be complete yet.""" + + self._append_raw(self.columns.draft_raw(values)) + + def _append_raw(self, raw_row: RawRow) -> None: + table_signals_blocked = self.table.blockSignals(True) + try: + row_index = self.table.rowCount() + self.table.insertRow(row_index) + for column, raw in zip(self.columns, raw_row, strict=True): + self._set_cell(row_index, column, raw) + finally: + self.table.blockSignals(table_signals_blocked) + + def replace_all(self, row_models: tuple[EditableRowT, ...]) -> bool: + """Replace table contents; return whether table structure changed.""" + + with self.update_guard: + table_signals_blocked = self.table.blockSignals(True) + try: + if self.table.rowCount() == len(row_models): + for row_index, row_model in enumerate(row_models): + for column in self.columns: + raw = column.editor.raw_for(column.value_of(row_model)) + if not column.editor.update(self.table, row_index, column.index, raw): + self._set_cell(row_index, column, raw) + return False + + self.table.setRowCount(0) + for row_model in row_models: + self.append(row_model) + return True + finally: + self.table.blockSignals(table_signals_blocked) + self.update_guard.remember_rows(self.row_values()) + + def request_apply_changes(self) -> None: + """Apply edits unless they are fallout from a programmatic table update.""" + + if self.update_guard.active: + return + self._sync_item_logical_values_from_edit_role() + if self.update_guard.pending_release: + if self.update_guard.should_suppress_pending_rows(self.row_values()): + return + self.update_guard.release() + self.apply_changes() + + def rows(self) -> tuple[EditableRowT, ...]: + """Return the complete rows; incomplete rows are reported separately.""" + + return tuple( + row + for row in (self._row_or_none(values) for values in self.row_values()) + if row is not None + ) + + def row_values(self) -> tuple[RawRow, ...]: + """Return the current raw cell state for every row.""" + + return tuple( + tuple(column.editor.raw(self.table, row_index, column.index) for column in self.columns) + for row_index in range(self.table.rowCount()) + ) + + def incomplete_row_numbers(self) -> tuple[int, ...]: + """One-based numbers of rows whose cells do not yet form a valid row.""" + + return tuple( + row_index + 1 + for row_index, values in enumerate(self.row_values()) + if self._row_or_none(values) is None + ) + + def has_incomplete_rows(self) -> bool: + return bool(self.incomplete_row_numbers()) + + def _row_or_none(self, raw_row: RawRow) -> EditableRowT | None: + try: + return cast( + EditableRowT, + self.columns.construct( + tuple( + column.editor.value_from_raw(raw) + for column, raw in zip(self.columns, raw_row, strict=True) + ) + ), + ) + except INVALID_CELL_VALUE_ERRORS: + return None + + def suppress_current_rows_until_idle(self) -> None: + """Suppress delayed callbacks for the currently rendered row values.""" + + self.update_guard.suppress_pending_rows(self.row_values()) + + def _sync_item_logical_values_from_edit_role(self) -> None: + """Update item backing values for a real table edit without re-emitting.""" + + table_signals_blocked = self.table.blockSignals(True) + try: + for row_index in range(self.table.rowCount()): + for column in self.columns: + if self.table.cellWidget(row_index, column.index) is not None: + continue + item = self.table.item(row_index, column.index) + if item is None: + continue + edit_value = item.data(Qt.ItemDataRole.EditRole) + if not isinstance(edit_value, str): + edit_value = item.text() + if item.data(self.LOGICAL_VALUE_ROLE) == edit_value: + continue + self._set_item_logical_text(item, edit_value) + finally: + self.table.blockSignals(table_signals_blocked) + + def remove_selected(self) -> bool: + selected_rows = {index.row() for index in self.table.selectedIndexes()} + if not selected_rows: + return False + table_signals_blocked = self.table.blockSignals(True) + try: + for row_index in sorted(selected_rows, reverse=True): + self.table.removeRow(row_index) + finally: + self.table.blockSignals(table_signals_blocked) + return True + + def _set_cell( + self, + row_index: int, + column: DataclassFieldColumn, + raw: object, + ) -> None: + column.editor.create( + self.table, + row_index, + column.index, + raw, + self.cell_context, + ) + self._apply_current_placeholder_text_style_to_cell(row_index, column) + + @classmethod + def _set_item_logical_text(cls, item: EditableTableItem, value: str) -> None: + cls._editable_item(item) + item.set_logical_text(value) + + @staticmethod + def _editable_item(item: QTableWidgetItem) -> EditableTableItem: + if not isinstance(item, EditableTableItem): + raise TypeError( + "Editable table text cells must be EditableTableItem instances " + f"for semantic chrome isolation, got {type(item).__name__}." + ) + return item + + def _apply_current_placeholder_text_style_to_cell( + self, + row_index: int, + column: DataclassFieldColumn, + ) -> None: + """Apply the table's current inherited-preview style to a newly built cell.""" + + active = self.table.property("placeholder_text_style_active") is True + widget = self.table.cellWidget(row_index, column.index) + if widget is not None: + self._apply_widget_placeholder_text_style(widget, active) + return + item = self.table.item(row_index, column.index) + if item is not None: + self._apply_item_placeholder_text_style(item, active) + + def apply_semantic_index( + self, + semantic_index: ObjectStateSubfieldSemanticIndex, + ) -> None: + """Apply ObjectState structural semantics to table cell chrome.""" + + from objectstate.time_travel_profile import TimeTravelProfiler + + with TimeTravelProfiler.phase( + "pyqt_reactive.editable_table.apply_semantic_index", + rows=self.table.rowCount(), + columns=self.table.columnCount(), + ): + with self.update_guard: + table_signals_blocked = self.table.blockSignals(True) + try: + for ( + row_index, + column_index, + ), relative_path in self.semantic_paths().items(): + semantic = semantic_index.leaf_for(relative_path) + if semantic is None: + continue + widget = self.table.cellWidget(row_index, column_index) + if widget is not None: + self._apply_widget_semantic(widget, semantic) + continue + item = self.table.item(row_index, column_index) + if item is not None: + self._apply_item_semantic(item, semantic) + finally: + self.table.blockSignals(table_signals_blocked) + self.update_guard.remember_rows(self.row_values()) + + def set_placeholder_text_style(self, active: bool) -> None: + """Apply inherited-preview text styling to every rendered table cell.""" + + with self.update_guard: + self.apply_placeholder_text_style_to_table(self.table, active) + self.update_guard.remember_rows(self.row_values()) + + @classmethod + def apply_placeholder_text_style_to_table( + cls, + table: QTableWidget, + active: bool, + ) -> None: + """Apply or clear placeholder text styling without dimming table controls.""" + + table_signals_blocked = table.blockSignals(True) + try: + table.setProperty("placeholder_text_style_active", active) + for row_index in range(table.rowCount()): + for column_index in range(table.columnCount()): + widget = table.cellWidget(row_index, column_index) + if widget is not None: + cls._apply_widget_placeholder_text_style(widget, active) + continue + item = table.item(row_index, column_index) + if item is not None: + cls._apply_item_placeholder_text_style(item, active) + finally: + table.blockSignals(table_signals_blocked) + + def semantic_paths(self) -> Mapping[tuple[int, int], StructuralValuePath]: + """Return structural paths for all currently rendered cells.""" + + if self.semantic_binding is None: + return MappingProxyType({}) + return MappingProxyType( + { + (row_index, column.index): self.semantic_binding.relative_path_for_cell( + row_index, + column.index, + ) + for row_index in range(self.table.rowCount()) + for column in self.columns + } + ) + + def cell_target_for_semantic_path( + self, + relative_path: StructuralValuePath, + ) -> StructuralTableCellTarget | None: + """Return a concrete target for a structural cell path.""" + + for (row_index, column_index), candidate in self.semantic_paths().items(): + if candidate != relative_path: + continue + return StructuralTableCellTarget( + table=self.table, + row_index=row_index, + column_index=column_index, + cell_widget=self.table.cellWidget(row_index, column_index), + ) + return None + + @staticmethod + def _semantic_tooltip(semantic) -> str: + reasons: list[str] = [semantic.display_path] + if semantic.dirty: + reasons.append("* differs from saved value") + if semantic.signature_diff: + reasons.append("_ differs from signature default") + if semantic.inherited_value: + reasons.append("inherited resolved value") + return "\n".join(reasons) + + @classmethod + def _apply_item_semantic(cls, item: QTableWidgetItem, semantic) -> None: + item = cls._editable_item(item) + logical_value = item.data(cls.LOGICAL_VALUE_ROLE) + if not isinstance(logical_value, str): + edit_value = item.data(Qt.ItemDataRole.EditRole) + logical_value = edit_value if isinstance(edit_value, str) else item.text() + item.setData(cls.LOGICAL_VALUE_ROLE, logical_value) + target_text = f"{''.join(semantic.semantic_markers)}{logical_value}" + target_tooltip = cls._semantic_tooltip(semantic) + font = item.font() + if ( + item.text() == target_text + and item.data(Qt.ItemDataRole.EditRole) == logical_value + and item.data(cls.RENDERED_TEXT_ROLE) == target_text + and font.underline() == semantic.signature_diff + and item.toolTip() == target_tooltip + ): + return + item.set_rendered_text(target_text) + font.setUnderline(semantic.signature_diff) + item.setFont(font) + item.setToolTip(target_tooltip) + + @classmethod + def _apply_widget_semantic(cls, widget: QWidget, semantic) -> None: + target_tooltip = cls._semantic_tooltip(semantic) + font = widget.font() + if isinstance(widget, QComboBox): + widget_marker_matches = cls._combo_current_marker_matches( + widget, + semantic.semantic_markers, + ) + elif isinstance(widget, StructuredSelectorCellWidget): + marker_text = "".join(semantic.semantic_markers) + widget_marker_matches = ( + widget.semantic_marker_label.text() == marker_text + and widget.semantic_marker_label.isVisible() == bool(marker_text) + ) + else: + widget_marker_matches = True + if ( + widget.property("objectstate_dirty") == semantic.dirty + and widget.property("objectstate_signature_diff") == semantic.signature_diff + and widget.property("objectstate_inherited") == semantic.inherited_value + and widget.toolTip() == target_tooltip + and font.underline() == semantic.signature_diff + and widget_marker_matches + ): + return + if isinstance(widget, QComboBox): + cls._apply_combo_semantic_markers(widget, semantic.semantic_markers) + elif isinstance(widget, StructuredSelectorCellWidget): + widget.set_semantic_markers(semantic.semantic_markers) + widget.setProperty("objectstate_dirty", semantic.dirty) + widget.setProperty("objectstate_signature_diff", semantic.signature_diff) + widget.setProperty("objectstate_inherited", semantic.inherited_value) + widget.setToolTip(target_tooltip) + font.setUnderline(semantic.signature_diff) + widget.setFont(font) + widget.style().unpolish(widget) + widget.style().polish(widget) + widget.update() + + @staticmethod + def _combo_current_marker_matches( + combo: QComboBox, + markers: tuple[str, ...], + ) -> bool: + rendered_index = combo.currentIndex() if markers else -1 + return ( + combo.property("objectstate_rendered_markers") == markers + and combo.property("objectstate_rendered_marker_index") == rendered_index + ) + + @classmethod + def _apply_combo_semantic_markers( + cls, + combo: QComboBox, + markers: tuple[str, ...], + ) -> None: + was_blocked = combo.blockSignals(True) + try: + cls._restore_combo_item_texts(combo) + if markers and combo.currentIndex() >= 0: + current_index = combo.currentIndex() + combo.setItemText( + current_index, + f"{''.join(markers)}{cls._combo_logical_text(combo, current_index)}", + ) + combo.setProperty("objectstate_rendered_marker_index", current_index) + else: + combo.setProperty("objectstate_rendered_marker_index", -1) + combo.setProperty("objectstate_rendered_markers", markers) + finally: + combo.blockSignals(was_blocked) + + @classmethod + def _restore_combo_item_texts(cls, combo: QComboBox) -> None: + for index in range(combo.count()): + combo.setItemText(index, cls._combo_logical_text(combo, index)) + + @classmethod + def _combo_logical_text(cls, combo: QComboBox, index: int) -> str: + logical_text = combo.itemData(index, cls.COMBO_LOGICAL_TEXT_ROLE) + if not isinstance(logical_text, str): + raise TypeError("Editable table choice cells require logical text data.") + return logical_text + + @classmethod + def _apply_item_placeholder_text_style( + cls, + item: QTableWidgetItem, + active: bool, + ) -> None: + font = item.font() + font.setItalic(active) + item.setFont(font) + item.setForeground( + QBrush(QColor(PlaceholderConfig.text_color_name())) if active else QBrush() + ) + + @classmethod + def _apply_widget_placeholder_text_style( + cls, + widget: QWidget, + active: bool, + ) -> None: + if isinstance(widget, StructuredSelectorCellWidget): + cls._apply_line_edit_placeholder_text_style(widget.line_edit, active) + return + if isinstance(widget, QLineEdit): + cls._apply_line_edit_placeholder_text_style(widget, active) + return + if isinstance(widget, QComboBox): + widget.setStyleSheet( + f"QComboBox {{ {PlaceholderConfig.text_style(include_opacity=False)} }}" + if active + else "" + ) + + @staticmethod + def _apply_line_edit_placeholder_text_style( + line_edit: QLineEdit, + active: bool, + ) -> None: + line_edit.setStyleSheet( + f"QLineEdit {{ {PlaceholderConfig.text_style(include_opacity=False)} }}" + if active + else "" + ) + + +class EditableTableLayout: + """Shared layout policy for compact editable tables.""" + + @staticmethod + def configure(table: QTableWidget) -> None: + table.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) + table.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded) + table.setSizePolicy( + QSizePolicy.Policy.Expanding, + QSizePolicy.Policy.Fixed, + ) + table.verticalHeader().setVisible(False) + table.verticalHeader().setDefaultSectionSize(22) + table.verticalHeader().setMinimumSectionSize(18) + table.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeMode.ResizeToContents) + table.verticalHeader().setSectionResizeMode(QHeaderView.ResizeMode.ResizeToContents) + + @staticmethod + def fit_to_rows(table: QTableWidget) -> None: + table.ensurePolished() + table.resizeRowsToContents() + header = table.horizontalHeader() + header_height = max(header.height(), header.sizeHint().height()) + row_height = sum(table.rowHeight(row) for row in range(table.rowCount())) + if table.rowCount() == 0: + row_height = table.verticalHeader().defaultSectionSize() + scrollbar_height = table.horizontalScrollBar().sizeHint().height() + frame = table.frameWidth() * 2 + viewport_vertical_margin = 2 * table.style().pixelMetric( + QStyle.PixelMetric.PM_FocusFrameVMargin, + None, + table, + ) + table.viewport().setMinimumHeight(row_height) + table.setFixedHeight( + header_height + row_height + scrollbar_height + frame + viewport_vertical_margin + ) diff --git a/tests/test_editable_table.py b/tests/test_editable_table.py new file mode 100644 index 00000000..64c33563 --- /dev/null +++ b/tests/test_editable_table.py @@ -0,0 +1,156 @@ +"""Editable tables derive columns, editors and rows from a row dataclass.""" + +from __future__ import annotations + +from dataclasses import dataclass +from enum import Enum +from typing import Annotated + +import pytest + +from PyQt6.QtCore import QRect, Qt +from PyQt6.QtWidgets import QCheckBox, QComboBox, QTableWidget, QTableWidgetItem +from python_introspect import FieldLabel + +from pyqt_reactive.widgets.editable_table import ( + BooleanCellEditor, + ChoiceCellEditor, + DataclassFieldColumns, + EditableTableController, + EditableTableLayout, + RecordTupleCellEditor, + RowSuggestion, + ScalarTextCellEditor, + StructuredSelectorDialog, +) + + +class Subject(Enum): + FILE = "file" + DIRECTORY = "directory" + + +@dataclass(frozen=True) +class Clause: + subject: Subject + """What the clause matches.""" + value: str + enabled: bool = True + + +@dataclass(frozen=True) +class Count: + amount: int + + +@dataclass(frozen=True) +class Rule: + name: Annotated[str, FieldLabel("Rule Name")] + clauses: tuple[Clause, ...] = () + opaque: object = None + + +def test_columns_derive_from_fields_that_have_an_editor() -> None: + columns = DataclassFieldColumns.of(Rule) + + assert [column.path for column in columns] == [("name",), ("clauses",)] + assert columns.labels == ("Rule Name", "Clauses") + assert not columns.is_isomorphic + assert [type(column.editor) for column in DataclassFieldColumns.of(Clause)] == [ + ChoiceCellEditor, + ScalarTextCellEditor, + BooleanCellEditor, + ] + assert isinstance(columns.named("clauses").editor, RecordTupleCellEditor) + assert DataclassFieldColumns.of(Clause).named("subject").tooltip == "What the clause matches." + + +def test_replacing_a_column_keeps_fields_without_a_column() -> None: + marker = object() + row = Rule(name="a", opaque=marker) + + edited = DataclassFieldColumns.of(Rule).named("name").replaced(row, "b") + + assert edited == Rule(name="b", opaque=marker) + + +def test_controller_round_trips_typed_rows(qapp) -> None: + table = QTableWidget(0, 3) + changes: list[int] = [] + controller = EditableTableController( + table=table, + columns=DataclassFieldColumns.of(Clause), + apply_changes=lambda: changes.append(1), + ) + rows = (Clause(Subject.FILE, "DNA"), Clause(Subject.DIRECTORY, "plate", enabled=False)) + + controller.replace_all(rows) + + assert controller.rows() == rows + assert isinstance(table.cellWidget(0, 0), QComboBox) + assert isinstance(table.cellWidget(1, 2), QCheckBox) + table.cellWidget(1, 2).setChecked(True) + assert controller.rows()[1].enabled is True + + +def test_selector_dialog_edits_typed_rows_and_reports_incomplete_ones(qapp) -> None: + existing = Clause(Subject.FILE, "DNA") + dialog = StructuredSelectorDialog( + element_type=Clause, + suggestions=(RowSuggestion.of(Clause, subject=Subject.DIRECTORY, value="x"),), + value=(existing,), + ) + + dialog._append_suggestion(dialog.suggestions.item(0).data(Qt.ItemDataRole.UserRole)) + assert dialog.value() == (existing, Clause(Subject.DIRECTORY, "x")) + + assert dialog.validation_label.text() == "All rows are structurally valid." + + count_dialog = StructuredSelectorDialog(element_type=Count, suggestions=(), value=()) + count_dialog.append_draft({}) + assert count_dialog.validation_label.text() == "Incomplete rows ignored: 1" + assert count_dialog.value() == () + + with pytest.raises(TypeError, match="fields without an editable column"): + StructuredSelectorDialog(element_type=Rule, suggestions=(), value=()) + + +def test_layout_keeps_final_row_visible_with_or_without_scrollbar(qapp) -> None: + table = QTableWidget(2, 2) + table.setItem(0, 0, QTableWidgetItem("field")) + table.setItem(0, 1, QTableWidgetItem("short")) + table.setItem(1, 0, QTableWidgetItem("imported metadata tables")) + table.setItem(1, 1, QTableWidgetItem("a value wide enough to require scrolling")) + EditableTableLayout.configure(table) + EditableTableLayout.fit_to_rows(table) + table.resize(180, table.height()) + table.show() + try: + for _ in range(4): + qapp.processEvents() + EditableTableLayout.fit_to_rows(table) + qapp.processEvents() + + expected_rows_height = sum(table.rowHeight(row) for row in range(table.rowCount())) + final_index = table.model().index(table.rowCount() - 1, 0) + assert table.horizontalScrollBar().isVisible() + assert table.viewport().height() >= expected_rows_height + assert table.visualRect(final_index).bottom() <= table.viewport().rect().bottom() + viewport_rect = QRect( + table.viewport().mapTo(table, table.viewport().rect().topLeft()), + table.viewport().size(), + ) + bar = table.horizontalScrollBar() + bar_rect = QRect(bar.mapTo(table, bar.rect().topLeft()), bar.size()) + assert not viewport_rect.intersects(bar_rect) + + table.resize(1200, table.height()) + for _ in range(4): + qapp.processEvents() + EditableTableLayout.fit_to_rows(table) + qapp.processEvents() + + assert not table.horizontalScrollBar().isVisible() + assert table.visualRect(final_index).bottom() <= table.viewport().rect().bottom() + finally: + table.close() From 0e5f5216f843d0fed8604b086eb68832427f2732 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Sun, 11 Oct 2026 03:28:14 -0400 Subject: [PATCH 05/16] Scope hierarchy operations call objectstate.ScopeKey Root, parent, last segment, child joins and descendant tests were written as '::' string operations in eleven places. They call ScopeKey, so containment is by whole segments: ScopeTokenService.clear_scope no longer clears /plate10's generators when /plate1 is cleared. Two copies of the root-scope extraction and the never-produced 'tree::' flash namespace are deleted. Co-Authored-By: Claude Opus 5.5 --- src/pyqt_reactive/animation/flash_mixin.py | 78 ++++++------------- .../function_pattern_code_document.py | 30 +++---- .../services/scope_color_service.py | 14 ++-- .../services/scope_token_service.py | 11 ++- src/pyqt_reactive/services/window_manager.py | 2 +- .../widgets/function_list_editor.py | 5 +- src/pyqt_reactive/widgets/function_pane.py | 3 +- .../widgets/shared/scope_color_strategy.py | 7 +- .../widgets/shared/scope_color_utils.py | 21 ++--- .../widgets/shared/scoped_border_mixin.py | 5 +- tests/test_editable_table.py | 1 - 11 files changed, 72 insertions(+), 105 deletions(-) diff --git a/src/pyqt_reactive/animation/flash_mixin.py b/src/pyqt_reactive/animation/flash_mixin.py index 90059830..8bc5e194 100644 --- a/src/pyqt_reactive/animation/flash_mixin.py +++ b/src/pyqt_reactive/animation/flash_mixin.py @@ -84,6 +84,7 @@ from pyqt_reactive.services.window_snapshot import FlashPaintElement, FlashPaintFrame from ..flash_trace import flash_trace +from objectstate import ScopeKey # Cache for extracted widget corner radii (widget_id -> radius) @@ -191,15 +192,6 @@ def _ensure_wcag_compliant( return color_rgb -def _extract_orchestrator_scope(scope_id: Optional[str]) -> Optional[str]: - """Extract orchestrator portion from scope_id.""" - if scope_id is None: - return None - if "::" in scope_id: - return scope_id.split("::", 1)[0] - return scope_id - - def _generate_flash_palette() -> List[Tuple[int, int, int]]: """Generate WCAG AA compliant flash color palette. @@ -251,9 +243,9 @@ def get_flash_color_from_palette(scope_id: str, alpha: int = 255, use_parent_sco # Extract parent scope (plate path) if requested # This ensures all elements in same plate get same color - scope_to_hash = _extract_orchestrator_scope(scope_id) if use_parent_scope else scope_id - if scope_to_hash is None: - scope_to_hash = scope_id + scope_to_hash = ( + ScopeKey(scope_id).root if use_parent_scope and scope_id is not None else scope_id + ) # Hash scope to deterministic index in palette hash_bytes = hashlib.md5(scope_to_hash.encode()).digest() @@ -1834,7 +1826,7 @@ def _rebuild_geometry_cache( if debug_enabled and first_children is not None and i < 3: first_children.append(f"({child_rect.x()},{child_rect.y()} {child_rect.width()}x{child_rect.height()})") if debug_enabled: - short_key = key.split('::')[-1] if '::' in key else key[-30:] + short_key = ScopeKey(key).last logger.debug( "[FLASH] CACHE_BUILD key=%s groupbox_rect=(%d,%d) first_child_rects=%s subtracted=%d/%d", short_key, @@ -2548,12 +2540,8 @@ def _start_timer_in_owner_thread(self) -> None: ) def _extract_scope_from_key(self, key: str) -> Optional[str]: - """Extract orchestrator/parent scope from flash key using canonical parser.""" - try: - return _extract_orchestrator_scope(key) - except Exception: - # Fallback to original key if parsing fails - return key + """Return the root scope of a flash key.""" + return ScopeKey(key).root def _get_base_color_for_key(self, key: str) -> QColor: """Get base color for flash rendering, using cache for performance. @@ -2575,31 +2563,18 @@ def _compute_base_color_for_key(self, key: str) -> QColor: # Empty/None → neutral if not key or key == "": return self._get_neutral_flash_color() - # Strip tree:: namespace prefix (used to avoid groupbox key collision) - if key.startswith("tree::"): - key = key[6:] # len("tree::") == 6 - # Heuristic: non-scope keys (no "::" and not path-like) → neutral - if "::" not in key and not key.startswith("/"): + scope_key = ScopeKey(key) + # Non-scope keys (a single segment that is not path-like) → neutral + if scope_key.depth < 2 and not key.startswith("/"): return self._get_neutral_flash_color() try: - # Try to use OpenHCS scope coloring if available - from pyqt_reactive.widgets.shared.scope_color_utils import ( - extract_orchestrator_scope, - get_scope_color_scheme, - ) + from pyqt_reactive.widgets.shared.scope_color_utils import get_scope_color_scheme - # Determine if this is a step key or config field key - parts = key.split("::") - if len(parts) >= 2: - token = parts[-1] - # Use pre-compiled regex (module-level _STEP_TOKEN_RE) - is_step_token = bool(_STEP_TOKEN_RE.match(token)) - if is_step_token: - scope_for_color = key - else: - # Config field - use orchestrator scope so all fields match plate color - scope_for_color = extract_orchestrator_scope(key) or key + # A step key colors as its own scope; a config field key colors as its root + # so all fields match the root's color. + if scope_key.depth >= 2 and not _STEP_TOKEN_RE.match(scope_key.last): + scope_for_color = scope_key.root else: scope_for_color = key @@ -2609,9 +2584,6 @@ def _compute_base_color_for_key(self, key: str) -> QColor: return self._get_neutral_flash_color() return scheme.accent_qcolor() - except ImportError: - # OpenHCS scope coloring not available - use palette-based coloring - return get_flash_color_from_palette(key) except Exception as exc: logger.debug("Failed to get scope color for key %s: %s", key, exc) return self._get_neutral_flash_color() @@ -2920,7 +2892,7 @@ def _prepare_visible_recipients(self, keys: Set[str]) -> Dict[str, Set[tuple[int @staticmethod def _key_matches_hierarchical_prefix(key: str, prefix: str) -> bool: """Return whether an ObjectState flash key is at or below a prefix.""" - if key == prefix or key.startswith(f"{prefix}::"): + if key == prefix or (prefix and ScopeKey(prefix).contains(key)): return True key_scope, key_path = _GlobalFlashCoordinator._split_scoped_object_state_key(key) prefix_scope, prefix_path = _GlobalFlashCoordinator._split_scoped_object_state_key(prefix) @@ -2934,18 +2906,18 @@ def _key_matches_hierarchical_prefix(key: str, prefix: str) -> bool: def _split_scoped_object_state_key(key: str) -> tuple[str | None, str]: """Split an optional scope prefix from an ObjectState flash path.""" - scope, separator, path = key.rpartition("::") - if not separator: + scope_key = ScopeKey(key) + if scope_key.depth < 2: return None, key - return scope, path + return scope_key.parent, scope_key.last @staticmethod def _hierarchical_prefix_candidates(key: str) -> tuple[str, ...]: """Return possible ObjectState ancestor keys for one scoped flash key.""" - scope, separator, path = key.rpartition("::") - scoped = bool(separator) - if not scoped: - path = key + scope_key = ScopeKey(key) + scoped = scope_key.depth >= 2 + scope = scope_key.parent + path = scope_key.last if scoped else key if not path: return (key,) @@ -2959,7 +2931,7 @@ def _hierarchical_prefix_candidates(key: str) -> tuple[str, ...]: if char not in ".[" or index == 0: continue candidate_path = path[:index] - candidate = f"{scope}::{candidate_path}" if scoped else candidate_path + candidate = scope.child(candidate_path) if scoped else candidate_path if candidate not in seen: candidates.append(candidate) seen.add(candidate) @@ -3375,7 +3347,7 @@ def _get_scoped_flash_key(self, key: str) -> str: """ scope_id = self.flash_scope_id() if scope_id: - return f"{scope_id}::{key}" + return ScopeKey(scope_id).child(key) return key def _register_flash_element_internal( diff --git a/src/pyqt_reactive/services/function_pattern_code_document.py b/src/pyqt_reactive/services/function_pattern_code_document.py index 2a0bbd46..ae09cc1e 100644 --- a/src/pyqt_reactive/services/function_pattern_code_document.py +++ b/src/pyqt_reactive/services/function_pattern_code_document.py @@ -41,6 +41,7 @@ WindowCodeDocument, WindowCodeDocumentDriver, ) +from objectstate import ScopeKey logger = logging.getLogger(__name__) FUNCTION_PATTERN_AUTHORITY_ATTR = "__function_pattern_authority__" @@ -244,20 +245,18 @@ class FunctionPatternChildScopeAddress: parent_scope_id: str token: str - separator: ClassVar[str] = "::" - @classmethod def parse(cls, scope_id: str) -> FunctionPatternChildScopeAddress: - parent_scope_id, separator, token = scope_id.rpartition(cls.separator) - if separator != cls.separator or not parent_scope_id or not token: + scope = ScopeKey(scope_id) + if scope.depth < 2: raise FunctionPatternRoundTripError( "Function-pattern child scopes must have a parent scope and " - f"entry token separated by {cls.separator!r}; got {scope_id!r}." + f"an entry token; got {scope_id!r}." ) return cls( - scope_id=scope_id, - parent_scope_id=parent_scope_id, - token=token, + scope_id=scope, + parent_scope_id=scope.parent, + token=scope.last, ) @@ -523,17 +522,18 @@ def existing_function_scope_tokens( if not parent_scope_id: return [] + parent_scope = ScopeKey(parent_scope_id) candidate_states = [ state for state in ObjectStateRegistry.get_all() - if isinstance(state.scope_id, str) and state.scope_id.startswith(f"{parent_scope_id}::") + if isinstance(state.scope_id, str) and parent_scope.strictly_contains(state.scope_id) ] if not candidate_states: return [] available = sorted( candidate_states, - key=lambda state: state.scope_id.rsplit("::", 1)[-1], + key=lambda state: ScopeKey(state.scope_id).last, ) tokens: list[str] = [] used_scope_ids: set[str] = set() @@ -555,7 +555,7 @@ def existing_function_scope_tokens( if match is None: return [] used_scope_ids.add(match.scope_id) - tokens.append(match.scope_id.rsplit("::", 1)[-1]) + tokens.append(ScopeKey(match.scope_id).last) return tokens def function_scope_tokens_match( @@ -578,7 +578,7 @@ def function_scope_tokens_match( if entry is None: continue func, _kwargs = entry - state = ObjectStateRegistry.get_by_scope(f"{parent_scope_id}::{tokens[index]}") + state = ObjectStateRegistry.get_by_scope(ScopeKey(parent_scope_id).child(tokens[index])) if state is None: return False if not self.same_function_authority(state.object_instance, func): @@ -687,7 +687,7 @@ def update_function_object_states( for token in set(old_by_token) & set(new_by_token): new_value = new_by_token[token] - scope_id = f"{parent_scope_id}::{token}" + scope_id = ScopeKey(parent_scope_id).child(token) state = ObjectStateRegistry.get_by_scope(scope_id) if state is None: continue @@ -719,7 +719,7 @@ def resolve_pattern_values( return pattern resolved = pattern for entry in self.iter_tokenized_entries(pattern, tokens): - state = ObjectStateRegistry.get_by_scope(f"{parent_scope_id}::{entry.token}") + state = ObjectStateRegistry.get_by_scope(ScopeKey(parent_scope_id).child(entry.token)) if state is None: continue if not self.same_function_authority(entry.func, state.object_instance): @@ -863,7 +863,7 @@ def _state_roundtrips_entry( @staticmethod def unregister_function_state(parent_scope_id: str, token: str) -> None: """Unregister one function child ObjectState if it is present.""" - state = ObjectStateRegistry.get_by_scope(f"{parent_scope_id}::{token}") + state = ObjectStateRegistry.get_by_scope(ScopeKey(parent_scope_id).child(token)) if state is not None: ObjectStateRegistry.unregister(state, _skip_snapshot=True) diff --git a/src/pyqt_reactive/services/scope_color_service.py b/src/pyqt_reactive/services/scope_color_service.py index 3ba07ab0..a3ee022d 100644 --- a/src/pyqt_reactive/services/scope_color_service.py +++ b/src/pyqt_reactive/services/scope_color_service.py @@ -9,6 +9,7 @@ ManualColorStrategy, ColorStrategyType, ) +from objectstate import ScopeKey logger = logging.getLogger(__name__) @@ -75,7 +76,6 @@ def get_color_scheme( """ from pyqt_reactive.widgets.shared.scope_color_utils import ( build_color_scheme_from_rgb, - extract_orchestrator_scope, ) from pyqt_reactive.widgets.shared.scope_visual_config import ( RootScopeColorScheme, @@ -89,17 +89,17 @@ def get_color_scheme( self._scheme_cache[scope_id] = RootScopeColorScheme.create() return self._scheme_cache[scope_id] - # For orchestrator-level scopes (no "::"), ignore step_index in cache key - # since orchestrator-level uses fixed tint regardless of step_index. - # This ensures plate list items and their config windows share the same scheme. - is_orchestrator_level = "::" not in (scope_id or "") + # Root-level scopes ignore step_index in the cache key since they use a + # fixed tint regardless of step_index, so list items and their config + # windows share the same scheme. + is_orchestrator_level = ScopeKey(scope_id).depth < 2 if is_orchestrator_level: cache_key = scope_id else: cache_key = (scope_id, step_index) if step_index is not None else scope_id if cache_key not in self._scheme_cache: - orchestrator_scope = extract_orchestrator_scope(scope_id) or scope_id + orchestrator_scope = ScopeKey(scope_id).root # Pass step_index (used by some strategies, ignored by orchestrator-based) rgb = self.active_strategy.generate_color(orchestrator_scope, step_index=step_index) self._scheme_cache[cache_key] = build_color_scheme_from_rgb( @@ -161,7 +161,7 @@ def _cache_key_matches_scope(cache_key, scope_id: str) -> bool: return cached_scope == scope_id or ( scope_id != "" and isinstance(cached_scope, str) - and cached_scope.startswith(f"{scope_id}::") + and ScopeKey(scope_id).contains(cached_scope) ) def _invalidate_all(self) -> None: diff --git a/src/pyqt_reactive/services/scope_token_service.py b/src/pyqt_reactive/services/scope_token_service.py index 7b02e760..0b8e6561 100644 --- a/src/pyqt_reactive/services/scope_token_service.py +++ b/src/pyqt_reactive/services/scope_token_service.py @@ -13,6 +13,7 @@ from typing import Optional, TypeVar from objectstate import semantic_values_equal +from objectstate import ScopeKey logger = logging.getLogger(__name__) @@ -391,9 +392,9 @@ def same_object_token( _scope_id_cache: dict[tuple[str, int], str] = {} @staticmethod - def _scope_id_for_token(parent_scope: str, token: str) -> str: + def _scope_id_for_token(parent_scope: str, token: str) -> ScopeKey: """The one serialization projection of nominal object-token identity.""" - return f"{parent_scope}::{token}" + return ScopeKey(parent_scope).child(token) @classmethod def build_scope_id(cls, parent_scope, obj: ScopeTokenTarget) -> str: @@ -439,12 +440,14 @@ def seed_from_objects( def clear_scope(cls, parent_scope) -> None: """Clear all generators for a parent scope (and nested children).""" parent_scope = cls._normalize_scope(parent_scope) - keys_to_remove = [k for k in cls._generators if k[0].startswith(parent_scope)] + keys_to_remove = [k for k in cls._generators if ScopeKey(parent_scope).contains(k[0])] for key in keys_to_remove: del cls._generators[key] # PERFORMANCE: Also clear scope_id cache for this scope - cache_keys_to_remove = [k for k in cls._scope_id_cache if k[0].startswith(parent_scope)] + cache_keys_to_remove = [ + k for k in cls._scope_id_cache if ScopeKey(parent_scope).contains(k[0]) + ] for key in cache_keys_to_remove: del cls._scope_id_cache[key] diff --git a/src/pyqt_reactive/services/window_manager.py b/src/pyqt_reactive/services/window_manager.py index 298e7116..68fb0cef 100644 --- a/src/pyqt_reactive/services/window_manager.py +++ b/src/pyqt_reactive/services/window_manager.py @@ -717,7 +717,7 @@ def register( Example: class MyWindow(QDialog): def show(self): - scope_key = f"{self.scope_id}::{self.__class__.__name__}" + scope_key = ScopeKey(self.scope_id).child(type(self).__name__) if WindowManager.is_open(scope_key): WindowManager.focus_and_navigate(scope_key) return # Don't show duplicate diff --git a/src/pyqt_reactive/widgets/function_list_editor.py b/src/pyqt_reactive/widgets/function_list_editor.py index af125c9d..60e024c0 100644 --- a/src/pyqt_reactive/widgets/function_list_editor.py +++ b/src/pyqt_reactive/widgets/function_list_editor.py @@ -51,6 +51,7 @@ DetachableActionBarHost, ) from pyqt_reactive.widgets.shared.scope_visual_config import ScopeColorScheme +from objectstate import ScopeKey logger = logging.getLogger(__name__) @@ -221,14 +222,14 @@ def _subscribe_to_time_travel(self) -> None: return def _is_relevant_time_travel(dirty_states) -> bool: - scope = str(self.scope_id) + scope = ScopeKey(self.scope_id) for entry in dirty_states or []: if not isinstance(entry, (tuple, list)) or len(entry) < 1: continue scope_id = entry[0] if not isinstance(scope_id, str) or not scope_id: continue - if scope_id == scope or scope_id.startswith(scope + "::"): + if scope.contains(scope_id): return True return False diff --git a/src/pyqt_reactive/widgets/function_pane.py b/src/pyqt_reactive/widgets/function_pane.py index 85f8abd6..5a4bd7a5 100644 --- a/src/pyqt_reactive/widgets/function_pane.py +++ b/src/pyqt_reactive/widgets/function_pane.py @@ -38,6 +38,7 @@ ) from pyqt_reactive.services.function_navigation import FunctionPatternField from metaclass_registry import AutoRegisterMeta +from objectstate import ScopeKey logger = logging.getLogger(__name__) @@ -453,7 +454,7 @@ def create_parameter_form(self) -> QWidget: raise RuntimeError( "FunctionPaneWidget requires func_scope_token for deterministic ObjectState scope." ) - func_scope_id = f"{func_parent_scope}::{token}" + func_scope_id = ScopeKey(func_parent_scope).child(token) if self._flash_key == "": self._flash_key = func_scope_id self.register_flash_groupbox_full(self._flash_key, self) diff --git a/src/pyqt_reactive/widgets/shared/scope_color_strategy.py b/src/pyqt_reactive/widgets/shared/scope_color_strategy.py index 96839096..aa4cc0bd 100644 --- a/src/pyqt_reactive/widgets/shared/scope_color_strategy.py +++ b/src/pyqt_reactive/widgets/shared/scope_color_strategy.py @@ -13,6 +13,7 @@ import logging from metaclass_registry import AutoRegisterMeta +from objectstate import ScopeKey logger = logging.getLogger(__name__) @@ -100,10 +101,8 @@ def generate_color(self, scope_id: str, step_index: Optional[int] = None) -> Tup @staticmethod def _extract_orchestrator(scope_id: str) -> str: - """Extract orchestrator part (before ::) or return the whole scope_id.""" - if "::" in scope_id: - return scope_id.split("::", 1)[0] - return scope_id + """Return the root segment of ``scope_id``.""" + return ScopeKey(scope_id).root class MD5HashStrategy(ScopeColorStrategy): diff --git a/src/pyqt_reactive/widgets/shared/scope_color_utils.py b/src/pyqt_reactive/widgets/shared/scope_color_utils.py index 06cfa28e..bc91fba7 100644 --- a/src/pyqt_reactive/widgets/shared/scope_color_utils.py +++ b/src/pyqt_reactive/widgets/shared/scope_color_utils.py @@ -8,6 +8,7 @@ from PyQt6.QtGui import QColor from pyqt_reactive.widgets.shared.scope_visual_config import ScopeColorScheme +from objectstate import ScopeKey logger = logging.getLogger(__name__) @@ -120,15 +121,6 @@ def _ensure_wcag_compliant( return color_rgb -def extract_orchestrator_scope(scope_id: Optional[str]) -> Optional[str]: - """Extract orchestrator portion from scope_id.""" - if scope_id is None: - return None - if "::" in scope_id: - return scope_id.split("::", 1)[0] - return scope_id - - def extract_step_index(scope_id: str) -> int: """Extract per-orchestrator step index from scope_id. @@ -139,9 +131,10 @@ def extract_step_index(scope_id: str) -> int: """ import re - if "::" not in scope_id: + scope = ScopeKey(scope_id) + if scope.depth < 2: return 0 - step_part = scope_id.split("::")[1] + step_part = scope.parts[1] # Try @ notation first (legacy) if "@" in step_part: @@ -200,7 +193,7 @@ def build_color_scheme_from_rgb( step_index: Optional explicit step index. If provided, uses this for border pattern calculation instead of extracting from scope_id. """ - orchestrator_scope = extract_orchestrator_scope(scope_id) + orchestrator_scope = None if scope_id is None else ScopeKey(scope_id).root orch_bg_rgb = base_rgb # Use perceptual middle tint (index 1) for orchestrator border, with darker(120) @@ -209,8 +202,8 @@ def build_color_scheme_from_rgb( step_item_rgb = orch_bg_rgb - # Check if this is an orchestrator-level scope (no "::" = no step) - is_orchestrator_level = "::" not in (scope_id or "") + # A root-level scope has no step segment + is_orchestrator_level = ScopeKey(scope_id).depth < 2 if is_orchestrator_level: # Orchestrator-level: use fixed middle tint, solid pattern, single layer diff --git a/src/pyqt_reactive/widgets/shared/scoped_border_mixin.py b/src/pyqt_reactive/widgets/shared/scoped_border_mixin.py index f23ee6f2..852f9f0d 100644 --- a/src/pyqt_reactive/widgets/shared/scoped_border_mixin.py +++ b/src/pyqt_reactive/widgets/shared/scoped_border_mixin.py @@ -12,6 +12,7 @@ from pyqt_reactive.widgets.shared.scope_color_utils import ( get_scope_color_scheme, ) +from objectstate import ScopeKey logger = logging.getLogger(__name__) @@ -134,9 +135,7 @@ def _subscribe_to_color_changes(self) -> None: def _on_scope_color_changed(self, changed_scope_id: str) -> None: scope_id = self.scope_id - if scope_id is not None and ( - scope_id == changed_scope_id or scope_id.startswith(f"{changed_scope_id}::") - ): + if scope_id is not None and ScopeKey(changed_scope_id).contains(scope_id): self._refresh_scope_border() def _on_all_colors_reset(self) -> None: diff --git a/tests/test_editable_table.py b/tests/test_editable_table.py index 64c33563..b2e1aa42 100644 --- a/tests/test_editable_table.py +++ b/tests/test_editable_table.py @@ -7,7 +7,6 @@ from typing import Annotated import pytest - from PyQt6.QtCore import QRect, Qt from PyQt6.QtWidgets import QCheckBox, QComboBox, QTableWidget, QTableWidgetItem from python_introspect import FieldLabel From 2e4e6a6bee98c8772658ebd88512db06429f9e58 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Sun, 11 Oct 2026 03:34:57 -0400 Subject: [PATCH 06/16] AbstractManagerWidget no longer asks its service adapter for an event bus The widget stored service_adapter.get_event_bus() and never read it, which forced every host's service adapter to provide an event bus. Co-Authored-By: Claude Opus 5.5 --- src/pyqt_reactive/widgets/shared/abstract_manager_widget.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/pyqt_reactive/widgets/shared/abstract_manager_widget.py b/src/pyqt_reactive/widgets/shared/abstract_manager_widget.py index a25e936e..72c4565a 100644 --- a/src/pyqt_reactive/widgets/shared/abstract_manager_widget.py +++ b/src/pyqt_reactive/widgets/shared/abstract_manager_widget.py @@ -687,7 +687,6 @@ def __init__(self, service_adapter, color_scheme=None, parent=None): # Core dependencies (REQUIRED) self.service_adapter = service_adapter self.color_scheme = color_scheme - self.event_bus = service_adapter.get_event_bus() self.code_execution_workflow: ManagerCodeExecutionWorkflow = ( NullManagerCodeExecutionWorkflow() ) From f031bfd08163e1e6371199c4ffda9726257ee3a1 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Sun, 11 Oct 2026 03:40:04 -0400 Subject: [PATCH 07/16] Main-window dock panes and configurable application shortcuts Moved from OpenHCS's main window, where they knew nothing of the application: - widgets.shared.dock_layout: DockPane (a QDockWidget whose manager header is its title bar, with a float button), DockFloatController (float and redock to the exact pre-float workspace slot), DockPanes (panes by window id) and DockLayoutStore (native dock state in QSettings; the key and version are the application's). - services.application_shortcuts: ShortcutLifecycle, generic over the config type, projects key sequences onto menu actions and application-wide commands, validates them and rejects duplicates before rebinding; commands yield while a code editor has focus. Co-Authored-By: Claude Opus 5.5 --- .../services/application_shortcuts.py | 155 ++++++ .../widgets/shared/dock_layout.py | 328 +++++++++++++ tests/test_application_shortcuts.py | 66 +++ tests/test_dock_layout.py | 452 ++++++++++++++++++ 4 files changed, 1001 insertions(+) create mode 100644 src/pyqt_reactive/services/application_shortcuts.py create mode 100644 src/pyqt_reactive/widgets/shared/dock_layout.py create mode 100644 tests/test_application_shortcuts.py create mode 100644 tests/test_dock_layout.py diff --git a/src/pyqt_reactive/services/application_shortcuts.py b/src/pyqt_reactive/services/application_shortcuts.py new file mode 100644 index 00000000..20057566 --- /dev/null +++ b/src/pyqt_reactive/services/application_shortcuts.py @@ -0,0 +1,155 @@ +"""Configurable application keyboard shortcuts.""" + +from __future__ import annotations + +from collections.abc import Callable, Mapping +from typing import Generic, TypeVar + +from PyQt6.QtCore import QEvent, QObject +from PyQt6.QtGui import QAction, QKeySequence +from PyQt6.QtWidgets import QApplication + +ConfigT = TypeVar("ConfigT") + + +class KeyEventSequence: + """Convert Qt key events into portable key-sequence text.""" + + @classmethod + def from_event(cls, event) -> str | None: + if event.type() != QEvent.Type.KeyPress: + return None + sequence = QKeySequence(event.keyCombination()) + if sequence.isEmpty(): + return None + return sequence.toString(QKeySequence.SequenceFormat.PortableText) + + +class CodeEditorFocus: + """Application shortcuts yield while a code editor has keyboard focus.""" + + @staticmethod + def allows_application_shortcuts() -> bool: + from PyQt6.Qsci import QsciScintilla + + widget = QApplication.focusWidget() + while widget is not None: + if isinstance(widget, QsciScintilla): + return False + widget = widget.parentWidget() + return True + + +class ApplicationShortcutFilter(QObject): + """Application-wide event filter routing configured key sequences to commands.""" + + def __init__(self, actions: Mapping[str, Callable[[], None]]) -> None: + super().__init__() + self._actions = dict(actions) + + def replace_actions(self, actions: Mapping[str, Callable[[], None]]) -> None: + """Replace the complete configured key-to-command projection.""" + + self._actions = dict(actions) + + def eventFilter(self, obj, event): # noqa: N802 - Qt virtual method name + del obj + sequence = KeyEventSequence.from_event(event) + if sequence is None: + return False + if sequence not in self._actions: + return False + if not CodeEditorFocus.allows_application_shortcuts(): + return False + self._actions[sequence]() + return True + + +class ShortcutLifecycle(Generic[ConfigT]): + """Project key sequences from a config onto menu actions and application commands. + + Bindings read their key from the config through ``key_from_config``; ``apply`` + validates every sequence, rejects duplicates and then rebinds all of them. + """ + + def __init__(self, application: QApplication) -> None: + self._application = application + self._menu_actions: list[tuple[Callable[[ConfigT], str], QAction]] = [] + self._commands: list[ + tuple[ + Callable[[ConfigT], str], + str, + Callable[[], None], + ] + ] = [] + self._event_filter = ApplicationShortcutFilter({}) + self._application.installEventFilter(self._event_filter) + + def bind_menu_action( + self, + key_from_config: Callable[[ConfigT], str], + action: QAction, + ) -> None: + """Bind one concrete action to a typed configuration projection.""" + + self._menu_actions.append((key_from_config, action)) + + def bind_command( + self, + key_from_config: Callable[[ConfigT], str], + label: str, + command: Callable[[], None], + ) -> None: + """Bind one application-wide command to a typed config projection.""" + + self._commands.append((key_from_config, label, command)) + + def apply(self, config: ConfigT) -> None: + """Validate and atomically project all configured key sequences.""" + + projected_menu = tuple( + ( + action, + self._validated_sequence( + action.text().replace("&", ""), + key_from_config(config), + ), + ) + for key_from_config, action in self._menu_actions + ) + projected_commands = tuple( + ( + command, + self._validated_sequence(label, key_from_config(config)), + ) + for key_from_config, label, command in self._commands + ) + normalized = tuple( + sequence.toString(QKeySequence.SequenceFormat.PortableText) + for _consumer, sequence in (*projected_menu, *projected_commands) + ) + if len(set(normalized)) != len(normalized): + raise ValueError("Application keyboard shortcuts must be unique.") + + for action, sequence in projected_menu: + action.setShortcut(sequence) + self._event_filter.replace_actions( + { + sequence.toString(QKeySequence.SequenceFormat.PortableText): command + for command, sequence in projected_commands + } + ) + + def close(self) -> None: + self._application.removeEventFilter(self._event_filter) + self._menu_actions.clear() + self._commands.clear() + + @staticmethod + def _validated_sequence(field_name: str, value: str) -> QKeySequence: + if not isinstance(value, str) or not value.strip(): + raise ValueError(f"Shortcut {field_name} must be a non-empty key sequence.") + sequence = QKeySequence(value) + if sequence.isEmpty(): + raise ValueError(f"Shortcut {field_name} is not a valid Qt key sequence.") + return sequence diff --git a/src/pyqt_reactive/widgets/shared/dock_layout.py b/src/pyqt_reactive/widgets/shared/dock_layout.py new file mode 100644 index 00000000..6d9abb9f --- /dev/null +++ b/src/pyqt_reactive/widgets/shared/dock_layout.py @@ -0,0 +1,328 @@ +"""Main-window dock panes that float, redock and persist their layout.""" + +from __future__ import annotations + +from dataclasses import dataclass, field + +from PyQt6.QtCore import QByteArray, QSettings, QSize, Qt, QTimer +from PyQt6.QtWidgets import QDockWidget, QMainWindow, QStyle, QToolButton, QWidget + +from pyqt_reactive.widgets.shared.manager_ui_scaffold import ManagerHeaderParts + + +@dataclass(slots=True, weakref_slot=True) +class DockFloatController: + """Toggle one dock while preserving its complete pre-float workspace slot.""" + + main_window: QMainWindow + dock_widget: QDockWidget + docked_content_height: int | None = None + preferred_floating_size: QSize | None = None + _docked_state: QByteArray | None = None + _floating_size: QSize | None = field(init=False, repr=False) + _content_minimum_height: int = field(init=False, repr=False) + _content_maximum_height: int = field(init=False, repr=False) + _dock_transition_timer: QTimer = field(init=False, repr=False) + _top_level_sync_timer: QTimer = field(init=False, repr=False) + _pending_top_level_state: bool | None = field(default=None, init=False, repr=False) + _redock_pending: bool = field(default=False, init=False, repr=False) + + def __post_init__(self) -> None: + content = self.dock_widget.widget() + if content is None: + raise RuntimeError("Dock float controller requires a content widget") + if self.preferred_floating_size is not None and ( + self.preferred_floating_size.width() <= 0 or self.preferred_floating_size.height() <= 0 + ): + raise ValueError("Preferred floating size must be positive") + self._floating_size = ( + QSize(self.preferred_floating_size) + if self.preferred_floating_size is not None + else None + ) + self._content_minimum_height = content.minimumHeight() + self._content_maximum_height = content.maximumHeight() + self._dock_transition_timer = QTimer(self.main_window) + self._dock_transition_timer.setSingleShot(True) + self._dock_transition_timer.setTimerType(Qt.TimerType.PreciseTimer) + self._dock_transition_timer.setInterval(1) + self._dock_transition_timer.timeout.connect(self._restore_docked_workspace) + self._top_level_sync_timer = QTimer(self.main_window) + self._top_level_sync_timer.setSingleShot(True) + self._top_level_sync_timer.setInterval(0) + self._top_level_sync_timer.timeout.connect(self._apply_pending_top_level_state) + self.sync_top_level(self.dock_widget.isFloating()) + + def sync_top_level(self, is_floating: bool) -> None: + """Apply embedded-only sizing while leaving floating panes resizable.""" + + if self.docked_content_height is None: + return + content = self.dock_widget.widget() + if content is None: + return + if is_floating: + content.setMinimumHeight(self._content_minimum_height) + content.setMaximumHeight(self._content_maximum_height) + return + if self._redock_pending: + return + content.setFixedHeight(self.docked_content_height) + + def set_docked_content_height(self, height: int) -> None: + """Update the content owner's embedded height projection.""" + + if height <= 0: + raise ValueError("Docked content height must be positive") + if height == self.docked_content_height: + return + self.docked_content_height = height + self.sync_top_level(self.dock_widget.isFloating()) + + def schedule_top_level_sync(self, is_floating: bool) -> None: + """Apply dock constraints after Qt completes its native transition.""" + + self._pending_top_level_state = is_floating + self._top_level_sync_timer.start() + + def _apply_pending_top_level_state(self) -> None: + is_floating = self._pending_top_level_state + if is_floating is None: + return + self._pending_top_level_state = None + self.sync_top_level(is_floating) + + def toggle(self) -> None: + if not self.dock_widget.isFloating(): + self._docked_state = self.main_window.saveState() + self.dock_widget.setFloating(True) + self.dock_widget.show() + if self._floating_size is not None: + self.dock_widget.resize(self._floating_size) + QTimer.singleShot(0, self._reveal_after_layout) + return + + self._floating_size = self.dock_widget.size() + self._redock_pending = True + self.dock_widget.setFloating(False) + if self._docked_state is None: + self._redock_pending = False + self.sync_top_level(False) + self._reveal_after_layout() + return + + # A resized floating QDockWidget completes its native dock transition + # on the next event-loop turn. Restore the one authoritative workspace + # snapshot after that transition so the first click recovers the prior + # slot geometry instead of retaining the floating window dimensions. + # Qt emits topLevelChanged(False) before its dock layout has consumed + # a resized floating window. The first timed event-loop turn is the + # earliest boundary at which restoreState can recover the saved slot. + self._dock_transition_timer.start() + + def _restore_docked_workspace(self) -> None: + # Reapply embedded-only constraints before asking Qt to consume the + # saved layout. A resized floating pane otherwise contributes its + # top-level width to restoreState on slower event loops, leaving a + # fixed-height dock partially redocked at the floating geometry. + self._redock_pending = False + self.sync_top_level(False) + if self._docked_state is not None: + if not self.main_window.restoreState(self._docked_state): + self.dock_widget.setFloating(False) + QTimer.singleShot(0, self._reveal_after_layout) + + def _reveal_after_layout(self) -> None: + self.dock_widget.show() + self.dock_widget.raise_() + + +@dataclass(frozen=True, slots=True, weakref_slot=True) +class DockPane: + """One logical embedded pane and its native Qt geometry owner.""" + + window_id: str + title: str + widget: QWidget + dock_widget: QDockWidget + float_controller: DockFloatController | None = None + float_button: QToolButton | None = None + + @classmethod + def create( + cls, + *, + main_window: QMainWindow, + window_id: str, + title: str, + widget: QWidget, + manager_header: ManagerHeaderParts | None = None, + docked_content_height: int | None = None, + preferred_floating_size: QSize | None = None, + ) -> DockPane: + dock_widget = QDockWidget(title, main_window) + dock_widget.setObjectName(window_id) + dock_widget.setAllowedAreas(Qt.DockWidgetArea.AllDockWidgetAreas) + dock_widget.setFeatures( + QDockWidget.DockWidgetFeature.DockWidgetMovable + | QDockWidget.DockWidgetFeature.DockWidgetFloatable + ) + dock_widget.toggleViewAction().setVisible(False) + dock_widget.setWidget(widget) + float_button = None + float_controller = None + if manager_header is not None: + content_layout = widget.layout() + if content_layout is None: + raise RuntimeError( + f"Dock pane {window_id!r} has no content layout for its manager header" + ) + content_layout.removeWidget(manager_header.header) + manager_header.present_as_dock_title() + float_button = cls._title_button( + dock_widget=dock_widget, + object_name=f"{window_id}_dock_float_button", + tooltip="Float pane", + ) + float_controller = DockFloatController( + main_window=main_window, + dock_widget=dock_widget, + docked_content_height=docked_content_height, + preferred_floating_size=preferred_floating_size, + ) + + def sync_float_button(is_floating: bool) -> None: + standard_icon = ( + QStyle.StandardPixmap.SP_TitleBarNormalButton + if is_floating + else QStyle.StandardPixmap.SP_TitleBarMaxButton + ) + float_button.setIcon(dock_widget.style().standardIcon(standard_icon)) + float_button.setToolTip("Dock pane" if is_floating else "Float pane") + + float_button.clicked.connect(float_controller.toggle) + dock_widget.topLevelChanged.connect(sync_float_button) + dock_widget.topLevelChanged.connect(float_controller.schedule_top_level_sync) + sync_float_button(dock_widget.isFloating()) + manager_header.title_layout.add_right_widget(float_button) + dock_widget.setTitleBarWidget(manager_header.header) + return cls( + window_id=window_id, + title=title, + widget=widget, + dock_widget=dock_widget, + float_controller=float_controller, + float_button=float_button, + ) + + @staticmethod + def _title_button( + *, + dock_widget: QDockWidget, + object_name: str, + tooltip: str, + ) -> QToolButton: + button = QToolButton(dock_widget) + button.setObjectName(object_name) + button.setAutoRaise(True) + button.setIconSize(QSize(12, 12)) + button.setFixedSize(18, 18) + button.setToolTip(tooltip) + button.setAccessibleName(tooltip) + button.setFocusPolicy(Qt.FocusPolicy.NoFocus) + return button + + def set_docked_content_height(self, height: int) -> None: + """Delegate a content-owned embedded height to the dock controller.""" + + if self.float_controller is None: + raise RuntimeError(f"Dock pane {self.window_id!r} has no float controller") + self.float_controller.set_docked_content_height(height) + + def show(self) -> None: + """Reveal and focus this pane without changing its dock geometry.""" + + self.dock_widget.show() + self.dock_widget.raise_() + self.widget.setFocus(Qt.FocusReason.OtherFocusReason) + + +@dataclass(slots=True) +class DockPanes: + """The permanent dock panes of one main window, by window id.""" + + _panes: dict[str, DockPane] = field(default_factory=dict) + + def configure_host(self, main_window: QMainWindow) -> None: + """Configure stable native docking without reentrant drag animation.""" + + main_window.setDockNestingEnabled(True) + main_window.setDockOptions( + QMainWindow.DockOption.AllowNestedDocks | QMainWindow.DockOption.AllowTabbedDocks + ) + + def register(self, pane: DockPane) -> None: + if pane.window_id in self._panes: + raise ValueError(f"Duplicate dock pane id: {pane.window_id!r}") + self._panes[pane.window_id] = pane + + def panes(self) -> tuple[DockPane, ...]: + return tuple(self._panes.values()) + + def require_pane(self, window_id: str) -> DockPane: + try: + return self._panes[window_id] + except KeyError as exc: + raise RuntimeError(f"Dock pane {window_id!r} has not been initialized") from exc + + def ensure_all_visible(self) -> None: + """Keep every permanent workspace pane either docked or floating.""" + + for pane in self.panes(): + pane.dock_widget.show() + + +@dataclass(slots=True) +class DockLayoutStore: + """Persist the main window's opaque native dock geometry in ``QSettings``. + + A stored state that is not a byte array, or that Qt refuses, is discarded and + the default layout is kept. + """ + + settings: QSettings + state_key: str + state_version: int + + @classmethod + def for_application(cls, *, state_key: str, state_version: int) -> DockLayoutStore: + """Store in the running application's ``QSettings`` (its organization and name).""" + + return cls(settings=QSettings(), state_key=state_key, state_version=state_version) + + def restore(self, main_window: QMainWindow) -> bool: + stored_state = self.settings.value(self.state_key) + if stored_state is None: + return False + + default_state = main_window.saveState(self.state_version) + if not isinstance(stored_state, QByteArray): + self._discard_invalid_state() + return False + if main_window.restoreState(stored_state, self.state_version): + return True + + main_window.restoreState(default_state, self.state_version) + self._discard_invalid_state() + return False + + def save(self, main_window: QMainWindow) -> None: + self.settings.setValue( + self.state_key, + main_window.saveState(self.state_version), + ) + self.settings.sync() + + def _discard_invalid_state(self) -> None: + self.settings.remove(self.state_key) + self.settings.sync() diff --git a/tests/test_application_shortcuts.py b/tests/test_application_shortcuts.py new file mode 100644 index 00000000..9859a227 --- /dev/null +++ b/tests/test_application_shortcuts.py @@ -0,0 +1,66 @@ +"""Shortcut lifecycles project a config's key sequences onto actions and commands.""" + +from __future__ import annotations + +from dataclasses import dataclass, replace + +import pytest +from PyQt6.QtCore import QEvent, Qt +from PyQt6.QtGui import QAction, QKeyEvent + +from pyqt_reactive.services.application_shortcuts import ShortcutLifecycle + + +@dataclass(frozen=True) +class Keys: + open_list: str = "Ctrl+L" + open_editor: str = "Ctrl+E" + step_back: str = "Ctrl+Z" + + +def test_lifecycle_rebinds_live_qactions(qapp) -> None: + action = QAction("List") + lifecycle = ShortcutLifecycle[Keys](qapp) + lifecycle.bind_menu_action(lambda config: config.open_list, action) + + try: + lifecycle.apply(replace(Keys(), open_list="Ctrl+1")) + assert action.shortcut().toString() == "Ctrl+1" + + lifecycle.apply(replace(Keys(), open_list="Alt+2")) + assert action.shortcut().toString() == "Alt+2" + finally: + lifecycle.close() + + +def test_lifecycle_rejects_duplicates_before_rebinding(qapp) -> None: + first = QAction("First") + second = QAction("Second") + lifecycle = ShortcutLifecycle[Keys](qapp) + lifecycle.bind_menu_action(lambda config: config.open_list, first) + lifecycle.bind_menu_action(lambda config: config.open_editor, second) + lifecycle.apply(Keys()) + original = (first.shortcut().toString(), second.shortcut().toString()) + + try: + with pytest.raises(ValueError, match="must be unique"): + lifecycle.apply(replace(Keys(), open_list="Ctrl+1", open_editor="Ctrl+1")) + + assert (first.shortcut().toString(), second.shortcut().toString()) == original + finally: + lifecycle.close() + + +def test_application_command_accepts_any_configured_qt_key(qapp) -> None: + calls: list[str] = [] + lifecycle = ShortcutLifecycle[Keys](qapp) + lifecycle.bind_command(lambda config: config.step_back, "Back", lambda: calls.append("back")) + + try: + lifecycle.apply(replace(Keys(), step_back="Ctrl+B")) + event = QKeyEvent(QEvent.Type.KeyPress, Qt.Key.Key_B, Qt.KeyboardModifier.ControlModifier) + + assert lifecycle._event_filter.eventFilter(None, event) is True + assert calls == ["back"] + finally: + lifecycle.close() diff --git a/tests/test_dock_layout.py b/tests/test_dock_layout.py new file mode 100644 index 00000000..4dd2a9df --- /dev/null +++ b/tests/test_dock_layout.py @@ -0,0 +1,452 @@ +"""Dock panes float, redock and round-trip their layout through QSettings.""" + +from __future__ import annotations + +import inspect + +import pytest +from PyQt6.QtCore import QByteArray, QObject, QSettings, QSize, Qt, pyqtSignal +from PyQt6.QtTest import QTest +from PyQt6.QtWidgets import QDockWidget, QMainWindow, QVBoxLayout, QWidget + +from pyqt_reactive.theming import ColorScheme +from pyqt_reactive.widgets.shared.dock_layout import DockLayoutStore, DockPane, DockPanes +from pyqt_reactive.widgets.shared.manager_ui_scaffold import create_manager_header + +SYSTEM_MONITOR = "system_monitor" +PLATE_MANAGER = "plate_manager" +ZMQ_SERVER_MANAGER = "zmq_server_manager" +PIPELINE_EDITOR = "pipeline_editor" +STATE_KEY = "main_window/dock_layout_state" + +PANE_ROWS = ( + (SYSTEM_MONITOR, "System Monitor"), + (PLATE_MANAGER, "Plate Manager"), + (ZMQ_SERVER_MANAGER, "ZMQ Server Manager"), + (PIPELINE_EDITOR, "Pipeline Editor"), +) +TEST_DOCKED_CONTENT_HEIGHT = 90 +TEST_ZMQ_FLOATING_SIZE = QSize(900, 600) + + +class DockedHeightEmitter(QObject): + changed = pyqtSignal(int) + + +def _workspace(qapp) -> tuple[QMainWindow, DockPanes]: + main_window = QMainWindow() + embedded = DockPanes() + embedded.configure_host(main_window) + + panes = [] + for window_id, title in PANE_ROWS: + content = QWidget() + content_layout = QVBoxLayout(content) + manager_header = create_manager_header( + title=title, + color_scheme=ColorScheme(), + ) + content_layout.addWidget(manager_header.header) + content_layout.addWidget(QWidget()) + pane = DockPane.create( + main_window=main_window, + window_id=window_id, + title=title, + widget=content, + manager_header=manager_header, + docked_content_height=( + TEST_DOCKED_CONTENT_HEIGHT if window_id == SYSTEM_MONITOR else None + ), + preferred_floating_size=( + TEST_ZMQ_FLOATING_SIZE if window_id == ZMQ_SERVER_MANAGER else None + ), + ) + embedded.register(pane) + panes.append(pane) + + system_monitor, plate_manager, zmq_manager, pipeline_editor = panes + main_window.addDockWidget( + Qt.DockWidgetArea.TopDockWidgetArea, + system_monitor.dock_widget, + ) + main_window.splitDockWidget( + system_monitor.dock_widget, + plate_manager.dock_widget, + Qt.Orientation.Vertical, + ) + main_window.splitDockWidget( + plate_manager.dock_widget, + pipeline_editor.dock_widget, + Qt.Orientation.Horizontal, + ) + main_window.splitDockWidget( + plate_manager.dock_widget, + zmq_manager.dock_widget, + Qt.Orientation.Vertical, + ) + main_window.resize(1000, 700) + main_window.show() + qapp.processEvents() + return main_window, embedded + + +@pytest.mark.parametrize( + "window_id", + tuple(window_id for window_id, _title in PANE_ROWS), +) +def test_showing_dock_pane_preserves_user_geometry(qapp, window_id: str) -> None: + """Focus/show routes must not replace the user's pane proportions.""" + + main_window, embedded = _workspace(qapp) + pane_geometries_before = { + pane.window_id: pane.dock_widget.geometry() for pane in embedded.panes() + } + pane_areas_before = { + pane.window_id: main_window.dockWidgetArea(pane.dock_widget) for pane in embedded.panes() + } + + embedded.require_pane(window_id).show() + qapp.processEvents() + + assert { + pane.window_id: pane.dock_widget.geometry() for pane in embedded.panes() + } == pane_geometries_before + assert { + pane.window_id: main_window.dockWidgetArea(pane.dock_widget) for pane in embedded.panes() + } == pane_areas_before + main_window.close() + + +def test_dock_panes_expose_native_float_move_and_all_drop_areas(qapp) -> None: + main_window, embedded = _workspace(qapp) + required_features = ( + QDockWidget.DockWidgetFeature.DockWidgetMovable + | QDockWidget.DockWidgetFeature.DockWidgetFloatable + ) + + for pane in embedded.panes(): + assert pane.dock_widget.objectName() == pane.window_id + assert pane.dock_widget.features() == required_features + assert pane.dock_widget.allowedAreas() == Qt.DockWidgetArea.AllDockWidgetAreas + assert not pane.dock_widget.toggleViewAction().isVisible() + assert pane.float_button is not None + + pipeline_pane = embedded.require_pane(PIPELINE_EDITOR) + pipeline_pane.dock_widget.setFloating(True) + qapp.processEvents() + assert pipeline_pane.dock_widget.isFloating() + + pipeline_pane.show() + qapp.processEvents() + assert pipeline_pane.dock_widget.isFloating() + + main_window.close() + + +def test_workspace_keeps_nested_snap_docking_without_reentrant_animation( + qapp, +) -> None: + main_window, _embedded = _workspace(qapp) + + assert main_window.isDockNestingEnabled() + assert main_window.dockOptions() == ( + QMainWindow.DockOption.AllowNestedDocks | QMainWindow.DockOption.AllowTabbedDocks + ) + main_window.close() + + +def test_float_button_reflows_then_restores_exact_workspace_geometry(qapp, qtbot) -> None: + main_window, embedded = _workspace(qapp) + pipeline = embedded.require_pane(PIPELINE_EDITOR) + plate = embedded.require_pane(PLATE_MANAGER) + docked_geometries = {pane.window_id: pane.dock_widget.geometry() for pane in embedded.panes()} + docked_plate_width = plate.dock_widget.width() + + pipeline.float_button.click() + qapp.processEvents() + qapp.processEvents() + + assert pipeline.dock_widget.isFloating() + assert plate.dock_widget.width() > docked_plate_width + + pipeline.dock_widget.resize(720, 520) + qapp.processEvents() + assert pipeline.dock_widget.size().width() == 720 + + pipeline.float_button.click() + qtbot.waitUntil( + lambda: ( + {pane.window_id: pane.dock_widget.geometry() for pane in embedded.panes()} + == docked_geometries + ), + timeout=1000, + ) + + assert not pipeline.dock_widget.isFloating() + assert pipeline.dock_widget.isVisible() + assert { + pane.window_id: pane.dock_widget.geometry() for pane in embedded.panes() + } == docked_geometries + main_window.close() + + +def test_zmq_float_button_uses_large_size_and_remembers_user_resize(qapp) -> None: + main_window, embedded = _workspace(qapp) + zmq = embedded.require_pane(ZMQ_SERVER_MANAGER) + + zmq.float_button.click() + qapp.processEvents() + qapp.processEvents() + + assert zmq.dock_widget.isFloating() + assert zmq.dock_widget.size() == TEST_ZMQ_FLOATING_SIZE + + user_size = QSize(840, 540) + zmq.dock_widget.resize(user_size) + qapp.processEvents() + zmq.float_button.click() + QTest.qWait(25) + assert not zmq.dock_widget.isFloating() + + zmq.float_button.click() + qapp.processEvents() + qapp.processEvents() + assert zmq.dock_widget.isFloating() + assert zmq.dock_widget.size() == user_size + main_window.close() + + +def test_resized_system_monitor_redocks_without_corrupting_workspace(qapp, qtbot) -> None: + main_window, embedded = _workspace(qapp) + monitor = embedded.require_pane(SYSTEM_MONITOR) + docked_geometries = {pane.window_id: pane.dock_widget.geometry() for pane in embedded.panes()} + + monitor.float_button.click() + qapp.processEvents() + monitor.dock_widget.resize(900, 500) + qapp.processEvents() + assert monitor.dock_widget.isFloating() + + monitor.float_button.click() + qtbot.waitUntil( + lambda: ( + {pane.window_id: pane.dock_widget.geometry() for pane in embedded.panes()} + == docked_geometries + ), + timeout=1000, + ) + + assert not monitor.dock_widget.isFloating() + assert monitor.widget.minimumHeight() == TEST_DOCKED_CONTENT_HEIGHT + assert monitor.widget.maximumHeight() == TEST_DOCKED_CONTENT_HEIGHT + assert { + pane.window_id: pane.dock_widget.geometry() for pane in embedded.panes() + } == docked_geometries + main_window.close() + + +def test_manager_header_becomes_single_dock_title_row_with_owned_controls(qapp) -> None: + main_window = QMainWindow() + content = QWidget() + content_layout = QVBoxLayout(content) + manager_header = create_manager_header( + title="System Monitor", + color_scheme=ColorScheme(), + ) + content_layout.addWidget(manager_header.header) + content_layout.addWidget(QWidget()) + + pane = DockPane.create( + main_window=main_window, + window_id=SYSTEM_MONITOR, + title="System Monitor", + widget=content, + manager_header=manager_header, + ) + main_window.addDockWidget(Qt.DockWidgetArea.TopDockWidgetArea, pane.dock_widget) + main_window.show() + qapp.processEvents() + + assert pane.dock_widget.titleBarWidget() is manager_header.header + assert content_layout.indexOf(manager_header.header) == -1 + assert not manager_header.title_layout._staged_layout._row2_widget.isVisible() + assert manager_header.header.sizeHint().height() <= 32 + assert pane.float_button is not None + assert not (pane.dock_widget.features() & QDockWidget.DockWidgetFeature.DockWidgetClosable) + assert not pane.dock_widget.toggleViewAction().isVisible() + + pane.float_button.click() + qapp.processEvents() + assert pane.dock_widget.isFloating() + assert pane.float_button.toolTip() == "Dock pane" + + pane.float_button.click() + QTest.qWait(25) + assert not pane.dock_widget.isFloating() + assert pane.dock_widget.isVisible() + assert pane.float_button.toolTip() == "Float pane" + main_window.close() + + +def test_embedded_height_is_fixed_but_floating_pane_remains_resizable(qapp) -> None: + main_window = QMainWindow() + main_window.resize(800, 600) + content = QWidget() + content_layout = QVBoxLayout(content) + manager_header = create_manager_header( + title="System Monitor", + color_scheme=ColorScheme(), + ) + content_layout.addWidget(manager_header.header) + content_layout.addWidget(QWidget()) + original_minimum = content.minimumHeight() + original_maximum = content.maximumHeight() + + pane = DockPane.create( + main_window=main_window, + window_id=SYSTEM_MONITOR, + title="System Monitor", + widget=content, + manager_header=manager_header, + docked_content_height=TEST_DOCKED_CONTENT_HEIGHT, + ) + lower = QDockWidget("Lower", main_window) + lower.setObjectName("lower") + lower.setWidget(QWidget()) + main_window.addDockWidget(Qt.DockWidgetArea.TopDockWidgetArea, pane.dock_widget) + main_window.addDockWidget(Qt.DockWidgetArea.BottomDockWidgetArea, lower) + main_window.show() + qapp.processEvents() + + assert content.minimumHeight() == TEST_DOCKED_CONTENT_HEIGHT + assert content.maximumHeight() == TEST_DOCKED_CONTENT_HEIGHT + + height_emitter = DockedHeightEmitter() + height_emitter.changed.connect(pane.set_docked_content_height) + height_emitter.changed.emit(100) + qapp.processEvents() + assert content.minimumHeight() == 100 + assert content.maximumHeight() == 100 + + pane.dock_widget.topLevelChanged.emit(True) + assert content.minimumHeight() == 100 + assert content.maximumHeight() == 100 + qapp.processEvents() + assert content.minimumHeight() == original_minimum + assert content.maximumHeight() == original_maximum + + pane.dock_widget.topLevelChanged.emit(False) + assert content.minimumHeight() == original_minimum + assert content.maximumHeight() == original_maximum + qapp.processEvents() + assert content.minimumHeight() == 100 + assert content.maximumHeight() == 100 + docked_height = pane.dock_widget.height() + main_window.resizeDocks( + [pane.dock_widget, lower], + [400, 100], + Qt.Orientation.Vertical, + ) + qapp.processEvents() + assert pane.dock_widget.height() == docked_height + + pane.float_button.click() + qapp.processEvents() + assert pane.dock_widget.isFloating() + assert content.minimumHeight() == original_minimum + assert content.maximumHeight() == original_maximum + + pane.set_docked_content_height(110) + assert content.minimumHeight() == original_minimum + assert content.maximumHeight() == original_maximum + + pane.float_button.click() + QTest.qWait(25) + assert not pane.dock_widget.isFloating() + assert content.minimumHeight() == 110 + assert content.maximumHeight() == 110 + main_window.close() + + +def test_dock_layout_round_trips_tabs_and_floating_while_panes_remain_visible( + qapp, + tmp_path, +) -> None: + settings = QSettings( + str(tmp_path / "dock-layout.ini"), + QSettings.Format.IniFormat, + ) + store = DockLayoutStore(settings=settings, state_key=STATE_KEY, state_version=2) + source_window, source_embedded = _workspace(qapp) + source_plate = source_embedded.require_pane(PLATE_MANAGER) + source_pipeline = source_embedded.require_pane(PIPELINE_EDITOR) + source_zmq = source_embedded.require_pane(ZMQ_SERVER_MANAGER) + source_system = source_embedded.require_pane(SYSTEM_MONITOR) + + source_window.tabifyDockWidget( + source_plate.dock_widget, + source_pipeline.dock_widget, + ) + source_zmq.dock_widget.setFloating(True) + source_system.dock_widget.hide() + qapp.processEvents() + store.save(source_window) + + restored_store = DockLayoutStore( + settings=QSettings( + str(tmp_path / "dock-layout.ini"), + QSettings.Format.IniFormat, + ), + state_key=STATE_KEY, + state_version=2, + ) + restored_window, restored_embedded = _workspace(qapp) + assert restored_store.restore(restored_window) + qapp.processEvents() + + restored_plate = restored_embedded.require_pane(PLATE_MANAGER) + restored_pipeline = restored_embedded.require_pane(PIPELINE_EDITOR) + restored_zmq = restored_embedded.require_pane(ZMQ_SERVER_MANAGER) + restored_system = restored_embedded.require_pane(SYSTEM_MONITOR) + assert restored_pipeline.dock_widget in restored_window.tabifiedDockWidgets( + restored_plate.dock_widget + ) + assert restored_zmq.dock_widget.isFloating() + restored_embedded.ensure_all_visible() + assert restored_system.dock_widget.isVisible() + + source_window.close() + restored_window.close() + + +@pytest.mark.parametrize( + "invalid_state", + ("not Qt state", QByteArray(b"not Qt state")), +) +def test_invalid_dock_layout_is_discarded_without_changing_default( + qapp, + tmp_path, + invalid_state, +) -> None: + settings = QSettings( + str(tmp_path / "invalid-dock-layout.ini"), + QSettings.Format.IniFormat, + ) + settings.setValue(STATE_KEY, invalid_state) + store = DockLayoutStore(settings=settings, state_key=STATE_KEY, state_version=2) + main_window, embedded = _workspace(qapp) + default_areas = { + pane.window_id: main_window.dockWidgetArea(pane.dock_widget) for pane in embedded.panes() + } + + assert not store.restore(main_window) + assert not settings.contains(STATE_KEY) + assert { + pane.window_id: main_window.dockWidgetArea(pane.dock_widget) for pane in embedded.panes() + } == default_areas + main_window.close() + + +def test_dock_layout_store_does_not_depend_on_object_state() -> None: + source = inspect.getsource(DockLayoutStore) + assert "ObjectState" not in source From 6ead5753438adc2a10a453725c7765efce5e06fb Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Sun, 11 Oct 2026 03:45:43 -0400 Subject: [PATCH 08/16] Time-travel widget, snapshot browser and post-restore navigation Moved from OpenHCS, with the application's choices injected instead of named: - widgets.time_travel: TimeTravelCommands (guarded restore operations, the history listing with the application's hidden scopes) and TimeTravelWidget. - windows.snapshot_browser_window: the snapshot table; its history-changed subscription is now released through its handle. - services.time_travel_navigation: TimeTravelNavigator reopens the editor of the scope a restore changed and focuses the changed field or function. The application supplies which scopes are function-pattern children. Navigation targets are a class family (FieldPathTarget, FunctionTokenTarget) instead of a kind string. Form managers and the manager time-travel binding read the restore flag through ObjectStateRegistry.is_restoring_snapshot(). Co-Authored-By: Claude Opus 5.5 --- .../forms/parameter_form_manager.py | 4 +- .../services/time_travel_navigation.py | 216 ++++++++++ .../shared/manager_time_travel_binding.py | 2 +- src/pyqt_reactive/widgets/time_travel.py | 404 ++++++++++++++++++ .../windows/snapshot_browser_window.py | 257 +++++++++++ tests/test_time_travel.py | 112 +++++ 6 files changed, 992 insertions(+), 3 deletions(-) create mode 100644 src/pyqt_reactive/services/time_travel_navigation.py create mode 100644 src/pyqt_reactive/widgets/time_travel.py create mode 100644 src/pyqt_reactive/windows/snapshot_browser_window.py create mode 100644 tests/test_time_travel.py diff --git a/src/pyqt_reactive/forms/parameter_form_manager.py b/src/pyqt_reactive/forms/parameter_form_manager.py index fb5efdfe..775ca443 100644 --- a/src/pyqt_reactive/forms/parameter_form_manager.py +++ b/src/pyqt_reactive/forms/parameter_form_manager.py @@ -1078,7 +1078,7 @@ def _on_resolved_values_changed(self, changed_paths: Set[str]): INCLUDING all sibling fields, but mask out the specific changed widget. This highlights "all fields that inherited the change". - TIME-TRAVEL: When _in_time_travel flag is set, also refresh widget values + TIME-TRAVEL: While a snapshot restore is in progress, also refresh widget values for the changed paths (since user didn't type - we need to sync widgets). """ if self._parent_manager is not None: @@ -1130,7 +1130,7 @@ def _flush_resolved_values_changed(self) -> None: ) self._pending_path_scoped_state_refresh = None - if ObjectStateRegistry._in_time_travel: + if ObjectStateRegistry.is_restoring_snapshot(): # CRITICAL: Refresh enabled styling for all managers after time-travel # Widget updates bypass the FieldChangeDispatcher (signals blocked), so styling # isn't triggered automatically. We must manually sync styling to match restored state. diff --git a/src/pyqt_reactive/services/time_travel_navigation.py b/src/pyqt_reactive/services/time_travel_navigation.py new file mode 100644 index 00000000..dfc96343 --- /dev/null +++ b/src/pyqt_reactive/services/time_travel_navigation.py @@ -0,0 +1,216 @@ +"""Reopen and focus the editors a completed time-travel restore changed.""" + +from __future__ import annotations + +import logging +from abc import ABC, abstractmethod +from collections.abc import Callable, Iterable +from dataclasses import dataclass + +from objectstate import ObjectState, ObjectStateRegistry, ScopeKey +from objectstate.time_travel_profile import TimeTravelProfiler +from PyQt6.QtCore import QTimer + +from pyqt_reactive.services.function_navigation import ( + FUNCTION_FIELD_ROOT, + build_function_token_field_path, + is_function_field_path, +) +from pyqt_reactive.services.scope_window_navigation import ScopeWindowNavigationService +from pyqt_reactive.services.window_navigation import WindowNavigationRequest + +logger = logging.getLogger(__name__) + + +@dataclass(frozen=True, slots=True) +class NavigationTarget(ABC): + """Where in an editor window time travel should focus.""" + + value: str + + @property + @abstractmethod + def is_function_target(self) -> bool: ... + + @abstractmethod + def to_field_path(self) -> str: ... + + def replaces(self, existing: NavigationTarget | None) -> bool: + """Whether this target should replace ``existing`` for the same window. + + A plain field target replaces a function target: undoing a step-level edit + lands on the step field, not on a function the restore also touched. + """ + + if existing is None: + return True + return existing.is_function_target and not self.is_function_target + + +@dataclass(frozen=True, slots=True) +class FieldPathTarget(NavigationTarget): + """A dotted field path in the editor's form.""" + + @property + def is_function_target(self) -> bool: + return is_function_field_path(self.value) + + def to_field_path(self) -> str: + return self.value + + +@dataclass(frozen=True, slots=True) +class FunctionTokenTarget(NavigationTarget): + """One function of the editor's function pattern, by its scope token.""" + + @property + def is_function_target(self) -> bool: + return True + + def to_field_path(self) -> str: + return build_function_token_field_path(self.value) + + +@dataclass(frozen=True, slots=True) +class FunctionScopeRef: + """A function-pattern child scope and the editor scope that owns it.""" + + owner_scope_id: str + function_token: str + + +FunctionScopeResolver = Callable[[str], FunctionScopeRef | None] +"""The application's rule for which scopes are function-pattern children.""" + + +@dataclass(frozen=True, slots=True) +class TimeTravelWindowRequest: + """One editor window to reopen after a restore, and where to focus it.""" + + scope_id: str + object_state: ObjectState + target: NavigationTarget | None + + +def dirty_field_sort_key(field: str) -> tuple[int, int, str]: + """Order dirty fields: the function pattern root first, then deepest paths.""" + + return (0 if field == FUNCTION_FIELD_ROOT else 1, -field.count("."), field) + + +def fallback_field_path(last_changed_field: str | None, dirty_fields: Iterable[str]) -> str | None: + """The field to focus when no function scope names one.""" + + if last_changed_field: + return last_changed_field + candidates = [field for field in dirty_fields if isinstance(field, str)] + return min(candidates, key=dirty_field_sort_key) if candidates else None + + +@dataclass(frozen=True, slots=True) +class TimeTravelNavigator: + """Reopens the editors of the scopes a restore changed. + + Only the scope that triggered the restored snapshot (and, for a function + child, its owning editor) is followed: snapshots capture the whole registry, + so older dirty states would otherwise reopen unrelated editors. + ``window_shown`` lets the application adjust a window it navigated to. + """ + + function_scope: FunctionScopeResolver + window_shown: Callable[[str, NavigationTarget | None], None] = lambda _scope, _target: None + + def on_time_travel_complete(self, dirty_states, triggering_scope: str | None) -> None: + """Registry callback: navigate once the restore's own refreshes have run.""" + + with TimeTravelProfiler.phase( + "pyqt_reactive.time_travel.build_window_requests", + dirty_states=len(dirty_states), + ): + pending = self.window_requests(dirty_states, triggering_scope) + if pending: + self.defer(lambda: self.navigate(pending, triggering_scope)) + + @staticmethod + def defer(callback: Callable[[], None]) -> None: + """Run after the refreshes the restore queued in this event-loop turn.""" + + QTimer.singleShot(0, lambda: QTimer.singleShot(0, callback)) + + def includes(self, changed_scope_id: str, triggering_scope: str | None) -> bool: + """Whether a changed scope belongs to the edit the restore undid or redid.""" + + if triggering_scope is None: + return True + triggering_function = self.function_scope(triggering_scope) + if triggering_function is not None: + return ( + ScopeKey(triggering_scope).contains(changed_scope_id) + or changed_scope_id == triggering_function.owner_scope_id + ) + return changed_scope_id == triggering_scope + + def window_requests( + self, + dirty_states, + triggering_scope: str | None, + ) -> dict[str, TimeTravelWindowRequest]: + """Project changed ObjectStates onto one request per editor window.""" + + pending: dict[str, TimeTravelWindowRequest] = {} + for scope_id, state in dirty_states: + if not self.includes(scope_id, triggering_scope): + continue + request = self._request_for(scope_id, state) + existing = pending.get(request.scope_id) + if ( + existing is None + or request.target is not None + and request.target.replaces(existing.target) + ): + pending[request.scope_id] = request + return pending + + def _request_for(self, scope_id: str, state: ObjectState) -> TimeTravelWindowRequest: + function_scope = self.function_scope(scope_id) + if function_scope is not None: + owner_state = ObjectStateRegistry.get_by_scope(function_scope.owner_scope_id) + return TimeTravelWindowRequest( + scope_id=function_scope.owner_scope_id, + object_state=owner_state if owner_state is not None else state, + target=FunctionTokenTarget(function_scope.function_token), + ) + field_path = fallback_field_path(state.last_changed_field, state.dirty_fields) + return TimeTravelWindowRequest( + scope_id=scope_id, + object_state=state, + target=FieldPathTarget(field_path) if field_path else None, + ) + + def navigate( + self, + pending: dict[str, TimeTravelWindowRequest], + triggering_scope: str | None, + ) -> None: + """Open or focus each requested window; create it only for a known edit.""" + + for request in pending.values(): + field_path = request.target.to_field_path() if request.target is not None else None + with TimeTravelProfiler.phase( + "pyqt_reactive.time_travel.navigate_window", + scope=request.scope_id, + field_path=field_path, + ): + result = ScopeWindowNavigationService.navigate( + WindowNavigationRequest( + scope_id=request.scope_id, + object_state=request.object_state, + field_path=field_path, + create_if_missing=triggering_scope is not None, + ) + ) + if result.created: + logger.info("Time travel opened the window for %s", request.scope_id) + if result.window is not None: + self.window_shown(request.scope_id, request.target) + diff --git a/src/pyqt_reactive/widgets/shared/manager_time_travel_binding.py b/src/pyqt_reactive/widgets/shared/manager_time_travel_binding.py index c3a19b30..b6b516c8 100644 --- a/src/pyqt_reactive/widgets/shared/manager_time_travel_binding.py +++ b/src/pyqt_reactive/widgets/shared/manager_time_travel_binding.py @@ -23,7 +23,7 @@ def __init__(self, manager: Any, item_access: Any) -> None: @property def in_time_travel(self) -> bool: - return ObjectStateRegistry._in_time_travel + return ObjectStateRegistry.is_restoring_snapshot() def connect(self) -> None: ObjectStateRegistry.add_unregister_callback(self.on_registry_unregister) diff --git a/src/pyqt_reactive/widgets/time_travel.py b/src/pyqt_reactive/widgets/time_travel.py new file mode 100644 index 00000000..5a91ecac --- /dev/null +++ b/src/pyqt_reactive/widgets/time_travel.py @@ -0,0 +1,404 @@ +""" +Time-travel controls for the ObjectState history. + +Provides a compact slider UI for scrubbing through the ENTIRE system history. +All ObjectStates across all scopes are captured together for coherent time-travel. +""" + +import logging +from collections.abc import Callable +from dataclasses import dataclass +from typing import Any + +from objectstate.object_state import ObjectStateRegistry +from PyQt6.QtCore import QPoint, Qt, pyqtSignal +from PyQt6.QtGui import QFont +from PyQt6.QtWidgets import ( + QComboBox, + QHBoxLayout, + QLabel, + QPushButton, + QSizePolicy, + QSlider, + QToolTip, + QWidget, +) + +from pyqt_reactive.theming import ColorScheme +from pyqt_reactive.widgets import ThemedCheckBox + +logger = logging.getLogger(__name__) + + +@dataclass(frozen=True, slots=True) +class TimeTravelCommands: + """Restore operations on the ObjectState history, guarded and followed by a refresh. + + ``before_restore`` may raise to refuse a restore (the application's mutation + policy); ``refresh`` runs after every accepted restore. ``hidden_scopes`` are + application-internal scopes whose snapshots the history views do not list. + """ + + refresh: Callable[[], None] + before_restore: Callable[[], None] + hidden_scopes: frozenset[str] = frozenset({""}) + + def history(self) -> list[dict[str, Any]]: + """History entries for snapshots that touched a listed scope, oldest first.""" + + return ObjectStateRegistry.get_history_info( + filter_fn=lambda scope_id: scope_id not in self.hidden_scopes + ) + + def back(self) -> bool: + return self._run(ObjectStateRegistry.time_travel_back) + + def forward(self) -> bool: + return self._run(ObjectStateRegistry.time_travel_forward) + + def to_head(self) -> bool: + return self._run(ObjectStateRegistry.time_travel_to_head) + + def to_index(self, index: int) -> bool: + return self._run(lambda: ObjectStateRegistry.time_travel_to(index)) + + def switch_branch(self, branch: str) -> bool: + return self._run(lambda: ObjectStateRegistry.switch_branch(branch)) + + def delete_branch(self, branch: str) -> bool: + def delete() -> None: + if ObjectStateRegistry.get_current_branch() == branch: + ObjectStateRegistry.switch_branch(ObjectStateRegistry.MAIN_BRANCH) + ObjectStateRegistry.delete_branch(branch) + + return self._run(delete) + + def _run(self, operation: Callable[[], object]) -> bool: + """Authorize and execute one ObjectState restore operation.""" + + try: + self.before_restore() + result = operation() + except Exception as exc: + logger.warning("Time-travel restore refused: %s", exc) + return False + self.refresh() + if isinstance(result, bool): + return result + return True + + +class TimeTravelWidget(QWidget): + """Compact time-travel debugging widget for ObjectStateRegistry. + + Tracks the ENTIRE system state (all ObjectStates) at each point in time. + When you scrub, ALL config windows update simultaneously. + + Features: + - Slider to scrub through global history + - Back/Forward buttons + - Label showing current snapshot info + - Tooltip showing num states captured + """ + + # Emitted when time-travel position changes + position_changed = pyqtSignal(int) # (index) + + def __init__( + self, + color_scheme: ColorScheme | None = None, + show_browse_button: bool = True, + *, + commands: TimeTravelCommands, + parent=None, + ): + super().__init__(parent) + self.color_scheme = color_scheme or ColorScheme() + self._show_browse_button = show_browse_button + self.commands = commands + self._setup_ui() + + # Subscribe to history changes (event-based, no polling) + self._history_subscription = ObjectStateRegistry.add_history_changed_callback( + self._update_ui + ) + self.destroyed.connect(self._history_subscription.release) + + def _create_icon_button(self, text: str, tooltip: str) -> QPushButton: + """Create a compact icon button with consistent styling.""" + btn = QPushButton(text) + btn.setToolTip(tooltip) + btn.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed) + # Let the button size to its content + btn.setMinimumWidth(0) + btn.adjustSize() + return btn + + def _setup_ui(self): + """Build the compact UI.""" + layout = QHBoxLayout(self) + layout.setContentsMargins(4, 0, 4, 0) + layout.setSpacing(4) + + # Time-travel icon/label + self.icon_label = QLabel("⏱️") + self.icon_label.setToolTip("Time-Travel Debugging (All States)") + layout.addWidget(self.icon_label) + + # Back button + self.back_btn = self._create_icon_button("◀", "Step back in history") + self.back_btn.clicked.connect(self._on_back) + layout.addWidget(self.back_btn) + + # Slider + self.slider = QSlider(Qt.Orientation.Horizontal) + self.slider.setMinimum(0) + self.slider.setMaximum(0) + self.slider.setFixedWidth(150) + self.slider.setToolTip("Scrub through system state history") + self.slider.valueChanged.connect(self._on_slider_changed) + self.slider.sliderMoved.connect(self._show_tooltip_at_position) + layout.addWidget(self.slider) + + # Forward button + self.forward_btn = self._create_icon_button("▶", "Step forward in history") + self.forward_btn.clicked.connect(self._on_forward) + layout.addWidget(self.forward_btn) + + # Head button (return to present) + self.head_btn = self._create_icon_button("⏭", "Return to present (latest state)") + self.head_btn.clicked.connect(self._on_head) + layout.addWidget(self.head_btn) + + # Branch dropdown (auto-hides when only one branch) + self.branch_combo = QComboBox() + self.branch_combo.setSizeAdjustPolicy(QComboBox.SizeAdjustPolicy.AdjustToContents) + self.branch_combo.setToolTip("Switch branch") + self.branch_combo.currentTextChanged.connect(self._on_branch_changed) + layout.addWidget(self.branch_combo) + + # Skip unsaved checkbox + self.skip_unsaved_cb = ThemedCheckBox("Saves only") + self.skip_unsaved_cb.setToolTip("Skip unsaved changes, jump only between save points") + self.skip_unsaved_cb.setChecked(False) + layout.addWidget(self.skip_unsaved_cb) + + # Browse button - opens full snapshot browser window (optional) + if self._show_browse_button: + self.browse_btn = self._create_icon_button("⏳", "Open Snapshot Browser") + self.browse_btn.clicked.connect(self._on_browse) + layout.addWidget(self.browse_btn) + + # Status label: right-most widget, text left-aligned + self.status_label = QLabel("No history") + self.status_label.setMinimumWidth(180) + self.status_label.setAlignment(Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter) + self.status_label.setSizePolicy( + QSizePolicy.Policy.Expanding, + QSizePolicy.Policy.Preferred, + ) + font = QFont() + font.setPointSize(9) + self.status_label.setFont(font) + layout.addWidget(self.status_label) + + # Apply the canonical control styles to both the timeline buttons and + # its branch selector. The application stylesheet deliberately avoids + # form geometry, so local composite controls opt into their owned input + # style explicitly. + self.setStyleSheet( + self.color_scheme.styles.generate_button_style() + + self.color_scheme.styles.generate_combobox_style() + ) + + self._update_ui() + + def _update_ui(self): + """Update slider and labels from registry history. + + History: [oldest, ..., newest] - index 0 = oldest, len-1 = head. + Slider: 0 = oldest, max = head. Direct mapping, no inversion needed. + """ + history = self.commands.history() + + if not history: + self.slider.setMaximum(0) + self.slider.setEnabled(False) + self.back_btn.setEnabled(False) + self.forward_btn.setEnabled(False) + self.head_btn.setEnabled(False) + self.status_label.setText("No history yet") + return + + self.slider.setEnabled(True) + self.slider.setMaximum(len(history) - 1) + + # Find current position within the filtered list. + # NOTE: history entries include a global "index" which is not guaranteed to be + # contiguous after filtering; using it as a list index will crash. + current_pos = next((i for i, h in enumerate(history) if h["is_current"]), len(history) - 1) + self.slider.blockSignals(True) + self.slider.setValue(current_pos) + self.slider.blockSignals(False) + + # Update status + current = history[current_pos] + is_traveling = ObjectStateRegistry.is_time_traveling() + + status = f"{current['timestamp']} | {current['label'][:30]}" + if is_traveling: + status += f" ({current_pos + 1}/{len(history)})" + else: + status += " (HEAD)" + + self.status_label.setText(status) + + # Enable/disable buttons (back = toward 0, forward = toward len-1) + self.back_btn.setEnabled(current_pos > 0) + self.forward_btn.setEnabled(current_pos < len(history) - 1) + self.head_btn.setEnabled(is_traveling) + + # Visual indicator when time-traveling + if is_traveling: + self.icon_label.setText("⏪") + self.icon_label.setStyleSheet("color: orange;") + else: + self.icon_label.setText("⏱️") + self.icon_label.setStyleSheet("") + + # Update branch dropdown + branches = ObjectStateRegistry.list_branches() + self.branch_combo.setVisible(len(branches) >= 1) + if branches: + self.branch_combo.blockSignals(True) + self.branch_combo.clear() + for b in branches: + self.branch_combo.addItem(b["name"]) + if b["is_current"]: + self.branch_combo.setCurrentText(b["name"]) + self.branch_combo.blockSignals(False) + + def _on_branch_changed(self, name: str): + """Handle branch dropdown selection change.""" + if ( + name + and name != ObjectStateRegistry.get_current_branch() + and not self.commands.switch_branch(name) + ): + self._update_ui() + + def _on_slider_changed(self, value: int): + """Handle slider value change. Direct mapping: slider value = history index.""" + history = self.commands.history() + if value < 0 or value >= len(history): + return + + if not self.commands.to_index(history[value]["index"]): + self._update_ui() + return + self._update_ui() + self.position_changed.emit(value) + + def _is_save_snapshot(self, label: str) -> bool: + """Check if a snapshot label represents a save operation.""" + return label.startswith(("save", "init")) + + def _find_next_save_index( + self, history: list[dict[str, Any]], current_pos: int, direction: int + ) -> int: + """Find next save snapshot in given direction. + + Args: + history: Filtered history list. + current_pos: Current position in the filtered list (0 = oldest, len-1 = head) + direction: -1 for back (toward older), +1 for forward (toward newer) + + Returns: + Index of next save snapshot, or current if none found + """ + if not history: + return current_pos + + search_pos = current_pos + direction + while 0 <= search_pos < len(history): + if self._is_save_snapshot(history[search_pos]["label"]): + return search_pos + search_pos += direction + + # No save found - stay at current or go to boundary + if direction > 0: + return len(history) - 1 # Go to head + return current_pos + + def _on_back(self): + """Step back in history (toward older = lower index).""" + if self.skip_unsaved_cb.isChecked(): + history = self.commands.history() + if history: + current_pos = next( + (i for i, h in enumerate(history) if h["is_current"]), + len(history) - 1, + ) + target_pos = self._find_next_save_index(history, current_pos, -1) + if target_pos != current_pos: + self.commands.to_index(history[target_pos]["index"]) + else: + self.commands.back() + self._update_ui() + + def _on_forward(self): + """Step forward in history (toward newer = higher index).""" + if self.skip_unsaved_cb.isChecked(): + history = self.commands.history() + if history: + current_pos = next( + (i for i, h in enumerate(history) if h["is_current"]), + len(history) - 1, + ) + target_pos = self._find_next_save_index(history, current_pos, +1) + if target_pos != current_pos: + self.commands.to_index(history[target_pos]["index"]) + else: + self.commands.forward() + self._update_ui() + + def _on_head(self): + """Return to present.""" + self.commands.to_head() + self._update_ui() + + def _on_browse(self): + """Open the full Snapshot Browser window.""" + from pyqt_reactive.windows.snapshot_browser_window import SnapshotBrowserWindow + + color_scheme = None + parent = self.window() + if parent and hasattr(parent, "color_scheme"): + color_scheme = parent.color_scheme + + self._snapshot_browser = SnapshotBrowserWindow( + color_scheme=color_scheme, + commands=self.commands, + parent=self, + ) + self._snapshot_browser.show() + + def _show_tooltip_at_position(self, value: int): + """Show tooltip with snapshot details. Slider value = history index.""" + history = self.commands.history() + if value < 0 or value >= len(history): + return + + h = history[value] + tooltip = f"{h['timestamp']}
{h['label']}
States captured: {h['num_states']}" + pos = self.slider.mapToGlobal(QPoint(self.slider.width() // 2, -30)) + QToolTip.showText(pos, tooltip) + + def refresh(self): + """Refresh UI from registry (call after external changes).""" + self._update_ui() + + def closeEvent(self, event): # noqa: N802 - Qt virtual method name + """Unsubscribe from callbacks on close.""" + self._history_subscription.release() + super().closeEvent(event) diff --git a/src/pyqt_reactive/windows/snapshot_browser_window.py b/src/pyqt_reactive/windows/snapshot_browser_window.py new file mode 100644 index 00000000..0d313c19 --- /dev/null +++ b/src/pyqt_reactive/windows/snapshot_browser_window.py @@ -0,0 +1,257 @@ +""" +Snapshot Browser Window - Time-travel visualization using AbstractTableBrowser. + +Displays all history snapshots in a table view with search/filter support. +Double-click to time-travel to any snapshot. +""" + +from dataclasses import dataclass + +from objectstate.object_state import ObjectStateRegistry +from PyQt6.QtCore import Qt +from PyQt6.QtGui import QAction +from PyQt6.QtWidgets import ( + QComboBox, + QFrame, + QLabel, + QMainWindow, + QToolBar, + QVBoxLayout, + QWidget, +) + +from pyqt_reactive.theming import ColorScheme +from pyqt_reactive.widgets.shared.abstract_table_browser import ( + AbstractTableBrowser, + ColumnDef, + TableSelectionMode, +) +from pyqt_reactive.widgets.time_travel import TimeTravelCommands, TimeTravelWidget + + +@dataclass +class SnapshotInfo: + """Snapshot data for display in table.""" + + index: int + timestamp: str + timestamp_raw: float + label: str + num_states: int + is_current: bool + + +class SnapshotTableBrowser(AbstractTableBrowser[SnapshotInfo]): + """Table browser for time-travel snapshots.""" + + def __init__( + self, + *args, + commands: TimeTravelCommands, + **kwargs, + ) -> None: + self._commands = commands + super().__init__(*args, **kwargs) + + def get_columns(self) -> list[ColumnDef]: + return [ + ColumnDef("Index", "index", width=60), + ColumnDef("Time", "timestamp", width=100), + ColumnDef("Label", "label", width=250), + ColumnDef("States", "num_states", width=60), + ] + + def extract_row_data(self, item: SnapshotInfo) -> list[str]: + prefix = "→ " if item.is_current else "" + return [ + f"{prefix}{item.index}", + item.timestamp, + item.label, + str(item.num_states), + ] + + def get_searchable_text(self, item: SnapshotInfo) -> str: + return f"{item.label} {item.timestamp}" + + def get_search_placeholder(self) -> str: + return "Search snapshots..." + + def on_item_double_clicked(self, key: str, item: SnapshotInfo): + """Time-travel to the selected snapshot by index.""" + if not self._commands.to_index(item.index): + return + self._refresh_from_registry() + + def _refresh_from_registry(self): + """Refresh table from ObjectStateRegistry history.""" + history = self._commands.history() + items: dict[str, SnapshotInfo] = {} + + for h in history: + key = str(h["index"]) + items[key] = SnapshotInfo( + index=h["index"], + timestamp=h["timestamp"], + timestamp_raw=0.0, # Not exposed by get_history_info + label=h["label"], + num_states=h["num_states"], + is_current=h["is_current"], + ) + + self.set_items(items) + + +class SnapshotBrowserWindow(QMainWindow): + """Main window for browsing time-travel snapshots.""" + + def __init__( + self, + color_scheme: ColorScheme | None = None, + *, + commands: TimeTravelCommands, + parent=None, + ): + super().__init__(parent) + self.setWindowTitle("Snapshot Browser - Time Travel") + self.resize(700, 500) + + # Add Dialog hint so tiling WMs don't fullscreen this window + # QMainWindow defaults to Window type which tiling WMs tile/fullscreen + self.setWindowFlags(self.windowFlags() | Qt.WindowType.Dialog) + + self.color_scheme = color_scheme or ColorScheme() + self.commands = commands + + # Central widget + central = QWidget() + self.setCentralWidget(central) + layout = QVBoxLayout(central) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(0) + + # Toolbar + self._setup_toolbar() + + # Snapshot table browser (main content) + self.browser = SnapshotTableBrowser( + color_scheme=self.color_scheme, + selection_mode=TableSelectionMode.SINGLE, + commands=self.commands, + parent=self, + ) + layout.addWidget(self.browser, 1) # stretch factor 1 + + # Separator + separator = QFrame() + separator.setFrameShape(QFrame.Shape.HLine) + separator.setFrameShadow(QFrame.Shadow.Sunken) + layout.addWidget(separator) + + # Timeline widget at bottom (stays in sync with all other timeline widgets) + # No browse button - we're already in the browser window + self.timeline_widget = TimeTravelWidget( + color_scheme=self.color_scheme, + show_browse_button=False, + commands=self.commands, + parent=self, + ) + layout.addWidget(self.timeline_widget) + + # Connect timeline widget position changes to refresh table + self.timeline_widget.position_changed.connect(self._on_timeline_position_changed) + + # Subscribe to history changes to refresh table (event-based, no polling) + self._history_subscription = ObjectStateRegistry.add_history_changed_callback( + self._on_refresh + ) + self.destroyed.connect(self._history_subscription.release) + + # Initial load + self.browser._refresh_from_registry() + + def _setup_toolbar(self): + """Create toolbar with actions.""" + toolbar = QToolBar("Snapshot Actions") + self.addToolBar(toolbar) + + # Jump to HEAD action + head_action = QAction("⏭ HEAD", self) + head_action.setToolTip("Return to present (latest state)") + head_action.triggered.connect(self._on_jump_to_head) + toolbar.addAction(head_action) + + # Refresh action + refresh_action = QAction("🔄 Refresh", self) + refresh_action.setToolTip("Refresh snapshot list") + refresh_action.triggered.connect(self._on_refresh) + toolbar.addAction(refresh_action) + + # Separator before branch section + toolbar.addSeparator() + + # Branch dropdown + toolbar.addWidget(QLabel("Branch:")) + self.branch_combo = QComboBox() + self.branch_combo.setToolTip("Switch branch") + self.branch_combo.setStyleSheet(self.color_scheme.styles.generate_combobox_style()) + self.branch_combo.currentTextChanged.connect(self._on_branch_changed) + toolbar.addWidget(self.branch_combo) + + # Delete branch action + self.delete_branch_action = QAction("🗑️", self) + self.delete_branch_action.setToolTip("Delete current branch (cannot delete 'main')") + self.delete_branch_action.triggered.connect(self._on_delete_branch) + toolbar.addAction(self.delete_branch_action) + + # Initial branch UI state + self._update_branch_ui() + + def _update_branch_ui(self): + """Update branch dropdown and delete button state.""" + branches = ObjectStateRegistry.list_branches() + self.branch_combo.blockSignals(True) + self.branch_combo.clear() + for b in branches: + self.branch_combo.addItem(b["name"]) + if b["is_current"]: + self.branch_combo.setCurrentText(b["name"]) + self.branch_combo.blockSignals(False) + + # Can't delete main + current = ObjectStateRegistry.get_current_branch() + self.delete_branch_action.setEnabled(current != "main") + + def _on_branch_changed(self, name: str): + """Handle branch dropdown selection change.""" + if name and name != ObjectStateRegistry.get_current_branch(): + if not self.commands.switch_branch(name): + self._update_branch_ui() + return + self._update_branch_ui() + + def _on_delete_branch(self): + """Delete current branch and switch to main.""" + current = ObjectStateRegistry.get_current_branch() + if current != "main": + if not self.commands.delete_branch(current): + return + + def _on_jump_to_head(self): + """Return to present state.""" + if not self.commands.to_head(): + return + self.browser._refresh_from_registry() + + def _on_refresh(self): + """Refresh the snapshot list and branch UI.""" + self.browser._refresh_from_registry() + self._update_branch_ui() + + def _on_timeline_position_changed(self, index: int): + """Handle timeline widget position change - refresh table.""" + self.browser._refresh_from_registry() + + def closeEvent(self, event): # noqa: N802 - Qt virtual method name + """Unsubscribe from callbacks on close.""" + self._history_subscription.release() + super().closeEvent(event) diff --git a/tests/test_time_travel.py b/tests/test_time_travel.py new file mode 100644 index 00000000..af2cf73a --- /dev/null +++ b/tests/test_time_travel.py @@ -0,0 +1,112 @@ +"""Time-travel commands, history views and post-restore navigation.""" + +from __future__ import annotations + +from objectstate import ObjectStateRegistry, ScopeKey +from PyQt6.QtCore import QTimer + +from pyqt_reactive.services.time_travel_navigation import ( + FieldPathTarget, + FunctionScopeRef, + FunctionTokenTarget, + TimeTravelNavigator, +) +from pyqt_reactive.widgets.shared.abstract_table_browser import TableSelectionMode +from pyqt_reactive.widgets.time_travel import TimeTravelCommands + + +def _function_child(scope_id: str) -> FunctionScopeRef | None: + scope = ScopeKey(scope_id) + if scope.depth == 3 and scope.parts[1].startswith("editor"): + return FunctionScopeRef(owner_scope_id=scope.parent, function_token=scope.last) + return None + + +NAVIGATOR = TimeTravelNavigator(function_scope=_function_child) + + +def test_navigation_follows_only_the_triggering_edit() -> None: + editor = "/root::editor_1" + function = f"{editor}::fn_0" + + assert NAVIGATOR.includes("/root::editor_9", None) + assert NAVIGATOR.includes(editor, editor) + assert not NAVIGATOR.includes(function, editor) + assert not NAVIGATOR.includes("/root::editor_9", editor) + assert NAVIGATOR.includes(function, function) + assert NAVIGATOR.includes(editor, function) + assert not NAVIGATOR.includes("/root::editor_1", "/root") + + +def test_field_target_replaces_stale_function_target() -> None: + field = FieldPathTarget("settings.source") + function = FunctionTokenTarget("fn_0") + + assert field.replaces(function) + assert not function.replaces(field) + assert function.replaces(None) + + +def test_navigation_runs_after_refreshes_queued_in_the_same_turn(monkeypatch) -> None: + callbacks: list[object] = [] + + def fake_single_shot(delay, callback) -> None: + assert delay == 0 + callbacks.append(callback) + + monkeypatch.setattr(QTimer, "singleShot", staticmethod(fake_single_shot)) + order: list[str] = [] + TimeTravelNavigator.defer(lambda: order.append("navigate")) + QTimer.singleShot(0, lambda: order.append("refresh")) + + while callbacks: + callbacks.pop(0)() + + assert order == ["refresh", "navigate"] + + +def test_refused_restore_neither_runs_nor_refreshes() -> None: + operations: list[str] = [] + refreshes: list[bool] = [] + + def refuse() -> None: + raise RuntimeError("mutation refused") + + commands = TimeTravelCommands(refresh=lambda: refreshes.append(True), before_restore=refuse) + + assert not commands._run(lambda: operations.append("mutated")) + assert operations == [] and refreshes == [] + + +def test_history_hides_the_application_scopes(monkeypatch) -> None: + seen: list[bool] = [] + + def history_info(filter_fn): + seen.extend((filter_fn("visible"), filter_fn("hidden"), filter_fn(""))) + return [] + + monkeypatch.setattr(ObjectStateRegistry, "get_history_info", staticmethod(history_info)) + commands = TimeTravelCommands( + refresh=lambda: None, + before_restore=lambda: None, + hidden_scopes=frozenset({"", "hidden"}), + ) + + assert commands.history() == [] + assert seen == [True, False, False] + + +def test_snapshot_browser_lists_history_in_a_single_selection_table(qapp) -> None: + from pyqt_reactive.windows.snapshot_browser_window import SnapshotBrowserWindow + + window = SnapshotBrowserWindow( + commands=TimeTravelCommands(refresh=lambda: None, before_restore=lambda: None) + ) + try: + assert window.browser._selection_mode is TableSelectionMode.SINGLE + assert window.browser.window() is window + assert "QComboBox::drop-down" not in window.branch_combo.styleSheet() + assert "QComboBox::drop-down" not in window.timeline_widget.styleSheet() + finally: + window.close() + qapp.processEvents() From 062fa62522ef19c65a0dc6ab2aef8b287c985d83 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Sun, 11 Oct 2026 03:49:33 -0400 Subject: [PATCH 09/16] Grid selector: select cells of a row-and-column grid Moved from OpenHCS's plate view, where only the cell addressing was the application's. GridSelectorWidget lays out the available cells by an injected GridAddressing (default grid, cell position, row label) or by given positions, and selects by click, drag, rectangle, row and column header and invert. Cell looks and event targets are small class families instead of enums dispatched through side tables. Co-Authored-By: Claude Opus 5.5 --- src/pyqt_reactive/widgets/grid_selector.py | 639 +++++++++++++++++++++ tests/test_grid_selector.py | 89 +++ 2 files changed, 728 insertions(+) create mode 100644 src/pyqt_reactive/widgets/grid_selector.py create mode 100644 tests/test_grid_selector.py diff --git a/src/pyqt_reactive/widgets/grid_selector.py b/src/pyqt_reactive/widgets/grid_selector.py new file mode 100644 index 00000000..92c15ce2 --- /dev/null +++ b/src/pyqt_reactive/widgets/grid_selector.py @@ -0,0 +1,639 @@ +"""Select cells of a row-and-column grid by click, drag, rectangle or header. + +The application supplies how its cell ids sit on the grid (``GridAddressing``) +and, optionally, the positions directly when ids do not spell them. +""" + +from __future__ import annotations + +import logging +from abc import ABC, abstractmethod +from collections.abc import Callable, Mapping +from dataclasses import dataclass +from typing import ClassVar, Protocol + +from PyQt6.QtCore import QEvent, QRect, Qt, pyqtSignal +from PyQt6.QtGui import QFont, QFontMetrics +from PyQt6.QtWidgets import ( + QFrame, + QGridLayout, + QHBoxLayout, + QLabel, + QPushButton, + QSizePolicy, + QVBoxLayout, + QWidget, +) + +from pyqt_reactive.theming import ColorScheme + +logger = logging.getLogger(__name__) + +GridPosition = tuple[int, int] +"""One-based (row, column).""" + + +class GridAddressing(Protocol): + """How an application's cell ids sit on the grid.""" + + default_grid: tuple[int, int] + """(rows, columns) shown when no cell places the grid's extent.""" + + def grid_index(self, value: object) -> GridPosition | None: + """One-based position a cell id spells; None when it spells none.""" + + def row_label(self, row: int) -> str: + """Label of the one-based ``row``.""" + + +class CellState(ABC): + """How a cell button looks; names are ``ColorScheme`` attributes.""" + + background: ClassVar[str] + text: ClassVar[str] + border: ClassVar[str | None] = None + hover_background: ClassVar[str | None] = None + + @classmethod + def style(cls, cs: ColorScheme) -> str: + border = f"2px solid {cs.to_hex(getattr(cs, cls.border))}" if cls.border else "none" + hover = ( + f"QPushButton:hover {{ background-color: " + f"{cs.to_hex(getattr(cs, cls.hover_background))}; }}" + if cls.hover_background + else "" + ) + return f""" + QPushButton {{ + background-color: {cs.to_hex(getattr(cs, cls.background))}; + color: {cs.to_hex(getattr(cs, cls.text))}; + border: {border}; + border-radius: 3px; + }} + {hover} + """ + + +class EmptyCell(CellState): + """A grid position no available cell occupies.""" + + background = "button_disabled_bg" + text = "button_disabled_text" + + +class AvailableCell(CellState): + """A cell that can be selected.""" + + background = "button_normal_bg" + text = "button_text" + hover_background = "button_hover_bg" + + +class SelectedCell(CellState): + background = "selection_bg" + text = "selection_text" + border = "border_color" + + +@dataclass(frozen=True, slots=True) +class GridBounds: + """Tight bounding rectangle of the occupied grid positions.""" + + min_row: int + max_row: int + min_col: int + max_col: int + + @property + def rows(self) -> list[int]: + return list(range(self.min_row, self.max_row + 1)) + + @property + def cols(self) -> list[int]: + return list(range(self.min_col, self.max_col + 1)) + + @property + def dimensions(self) -> tuple[int, int]: + return (self.max_row - self.min_row + 1, self.max_col - self.min_col + 1) + + +@dataclass(frozen=True, slots=True) +class GridModel: + """Which cells are available and where each sits.""" + + available: frozenset[str] + cell_at_position: dict[GridPosition, str] + position_of_cell: dict[str, GridPosition] + dimensions: tuple[int, int] + bounds: GridBounds | None + + @classmethod + def empty(cls, addressing: GridAddressing) -> GridModel: + return cls( + available=frozenset(), + cell_at_position={}, + position_of_cell={}, + dimensions=addressing.default_grid, + bounds=None, + ) + + @classmethod + def of( + cls, + addressing: GridAddressing, + cell_ids: set[str], + dimensions: tuple[int, int] | None = None, + cell_at_position: Mapping[GridPosition, str] | None = None, + ) -> GridModel: + if not cell_ids: + return cls.empty(addressing) + positions = ( + dict(cell_at_position) + if cell_at_position is not None + else { + position: cell_id + for cell_id in cell_ids + if (position := addressing.grid_index(cell_id)) is not None + } + ) + occupied = [position for position, cell_id in positions.items() if cell_id in cell_ids] + bounds = ( + GridBounds( + min_row=min(row for row, _ in occupied), + max_row=max(row for row, _ in occupied), + min_col=min(col for _, col in occupied), + max_col=max(col for _, col in occupied), + ) + if occupied + else None + ) + return cls( + available=frozenset(cell_ids), + cell_at_position=positions, + position_of_cell={cell_id: position for position, cell_id in positions.items()}, + dimensions=( + dimensions + if dimensions is not None + else bounds.dimensions + if bounds is not None + else addressing.default_grid + ), + bounds=bounds, + ) + + @property + def is_empty(self) -> bool: + return not self.available + + @property + def rows(self) -> list[int]: + return self.bounds.rows if self.bounds is not None else [] + + @property + def cols(self) -> list[int]: + return self.bounds.cols if self.bounds is not None else [] + + def cell_at(self, row: int, col: int) -> str | None: + return self.cell_at_position.get((row, col)) + + def cells_in_row(self, row: int) -> list[str]: + return self._cells_where(lambda position: position[0] == row) + + def cells_in_column(self, col: int) -> list[str]: + return self._cells_where(lambda position: position[1] == col) + + def _cells_where(self, keep: Callable[[GridPosition], bool]) -> list[str]: + return sorted( + cell_id + for cell_id, position in self.position_of_cell.items() + if keep(position) and cell_id in self.available + ) + + +class CellButton(QPushButton): + """A cell button that fills its grid cell.""" + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) + + +class AspectRatioContainer(QWidget): + """Sizes its child to keep the grid's aspect ratio, centered in the available space.""" + + MIN_CELL_SIZE = 8 + + def __init__(self, child_widget: QWidget, *args, **kwargs): + super().__init__(*args, **kwargs) + self.aspect_ratio = 1.0 + self.num_cols = 1 + self.num_rows = 1 + self.child_widget = child_widget + self.child_widget.setParent(self) + self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) + + def set_aspect_ratio(self, num_cols: int, num_rows: int) -> None: + self.num_cols = num_cols + self.num_rows = num_rows + if num_rows > 0: + self.aspect_ratio = num_cols / num_rows + self.setMinimumSize(num_cols * self.MIN_CELL_SIZE, num_rows * self.MIN_CELL_SIZE) + self._update_child_geometry() + + def resizeEvent(self, event): # noqa: N802 - Qt virtual method name + super().resizeEvent(event) + self._update_child_geometry() + + def _update_child_geometry(self) -> None: + if self.aspect_ratio <= 0: + return + available_w = self.width() + available_h = self.height() + if available_w <= 0 or available_h <= 0: + return + height_for_width = int(available_w / self.aspect_ratio) + if height_for_width <= available_h: + child_w, child_h = available_w, height_for_width + else: + child_w, child_h = int(available_h * self.aspect_ratio), available_h + child_w = max(child_w, self.num_cols * self.MIN_CELL_SIZE) + child_h = max(child_h, self.num_rows * self.MIN_CELL_SIZE) + self.child_widget.setGeometry( + (available_w - child_w) // 2, + (available_h - child_h) // 2, + child_w, + child_h, + ) + + +@dataclass(slots=True) +class GridGesture: + """The rectangle gesture in progress and the selection it started from.""" + + start: object + selection_before: frozenset[str] + + +class GridEventTarget(ABC): + """What a mouse event landed on: a cell button or the grid background.""" + + HANDLERS: ClassVar[Mapping[QEvent.Type, str]] = { + QEvent.Type.MouseButtonPress: "press", + QEvent.Type.MouseMove: "move", + QEvent.Type.MouseButtonRelease: "release", + } + + def __init__(self, view: GridSelectorWidget, obj: QWidget) -> None: + self.view = view + self.obj = obj + + def handle(self, event) -> bool | None: + """Handled state, or None when Qt's default handling should apply.""" + + handler = self.HANDLERS.get(event.type()) + return None if handler is None else getattr(self, handler)(event) + + @abstractmethod + def grid_position(self, event): ... + + @abstractmethod + def press(self, event) -> bool | None: ... + + def move(self, event) -> bool | None: + if self.view.gesture is None or not event.buttons() & Qt.MouseButton.LeftButton: + return None + self.view.update_rectangle(self.grid_position(event)) + event.accept() + return True + + def release(self, event) -> bool | None: + if event.button() != Qt.MouseButton.LeftButton or self.view.gesture is None: + return None + self.view.finish_gesture() + event.accept() + return True + + +class CellButtonTarget(GridEventTarget): + def grid_position(self, event): + return self.obj.mapTo(self.view.grid_widget, event.pos()) + + def press(self, event) -> bool | None: + if event.button() != Qt.MouseButton.LeftButton: + return None + cell_id = self.obj.property("cell_id") + self.view.begin_gesture( + self.grid_position(event), + cell_id if cell_id in self.view.model.available else None, + ) + event.accept() + return True + + +class GridBackgroundTarget(GridEventTarget): + def grid_position(self, event): + return event.pos() + + def press(self, event) -> bool | None: + if event.button() != Qt.MouseButton.LeftButton: + return None + if isinstance(self.view.grid_widget.childAt(event.pos()), QPushButton): + return None + self.view.begin_gesture(event.pos(), None) + event.accept() + return True + + +class GridSelectorWidget(QWidget): + """A grid of cell buttons with click, drag, rectangle, header and invert selection. + + Signals: + cells_selected: the selected cell ids after every user change. + detach_requested: the header's detach button was clicked. + """ + + cells_selected = pyqtSignal(set) + detach_requested = pyqtSignal() + + HEADER_STYLE = """ + QPushButton {{ + color: {text}; font-size: {size}px; border: none; background: transparent; + }} + QPushButton:hover {{ color: {hover_text}; background-color: {hover_bg}; }} + """ + + def __init__( + self, + *, + addressing: GridAddressing, + title: str, + cell_noun: str, + color_scheme: ColorScheme | None = None, + parent: QWidget | None = None, + ) -> None: + super().__init__(parent) + self.addressing = addressing + self.cell_noun = cell_noun + self.color_scheme = color_scheme or ColorScheme() + self.model = GridModel.empty(addressing) + self.selected: set[str] = set() + self.cell_buttons: dict[str, QPushButton] = {} + self.gesture: GridGesture | None = None + self._setup_ui(title) + + # -- layout --------------------------------------------------------------- + + def _setup_ui(self, title: str) -> None: + cs = self.color_scheme + layout = QVBoxLayout(self) + layout.setContentsMargins(5, 5, 5, 5) + layout.setSpacing(5) + + header_layout = QHBoxLayout() + title_label = QLabel(title) + title_label.setStyleSheet(f"font-weight: bold; color: {cs.to_hex(cs.text_accent)};") + header_layout.addWidget(title_label) + header_layout.addStretch() + detach_btn = QPushButton("↗") + detach_btn.setToolTip("Detach to separate window") + detach_btn.setFixedWidth(30) + detach_btn.setStyleSheet(cs.styles.generate_button_style()) + detach_btn.clicked.connect(lambda: self.detach_requested.emit()) + header_layout.addWidget(detach_btn) + clear_btn = QPushButton("Clear Selection") + clear_btn.setStyleSheet(cs.styles.generate_button_style()) + clear_btn.clicked.connect(lambda: self.clear_selection()) + header_layout.addWidget(clear_btn) + layout.addLayout(header_layout) + + grid_container = QFrame() + grid_container.setStyleSheet( + f"background-color: {cs.to_hex(cs.panel_bg)}; border-radius: 3px;" + ) + grid_layout_wrapper = QVBoxLayout(grid_container) + grid_layout_wrapper.setContentsMargins(10, 10, 10, 10) + + self.grid_widget = QWidget() + self.grid_widget.setMouseTracking(True) + self.cell_grid_layout = QGridLayout(self.grid_widget) + self.cell_grid_layout.setSpacing(2) + self.cell_grid_layout.setContentsMargins(0, 0, 0, 0) + self.aspect_container = AspectRatioContainer(self.grid_widget) + self.grid_widget.installEventFilter(self) + + self.selection_rect_widget = QLabel(self.grid_widget) + self.selection_rect_widget.setStyleSheet(f""" + background-color: {cs.to_hex(cs.selection_bg)}40; + border: 2px solid {cs.to_hex(cs.selection_bg)}; + """) + self.selection_rect_widget.hide() + self.selection_rect_widget.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents) + + grid_layout_wrapper.addWidget(self.aspect_container, 1) + layout.addWidget(grid_container, 1) + + self.status_label = QLabel(f"No {self.cell_noun}") + self.status_label.setStyleSheet(f"color: {cs.to_hex(cs.text_secondary)};") + layout.addWidget(self.status_label) + + def set_available_cells( + self, + cell_ids: set[str], + dimensions: tuple[int, int] | None = None, + cell_at_position: Mapping[GridPosition, str] | None = None, + ) -> None: + """Show ``cell_ids`` as selectable; positions come from the ids or are given.""" + + self.model = GridModel.of(self.addressing, cell_ids, dimensions, cell_at_position) + self.selected &= self.model.available + if self.model.is_empty: + self._clear_grid() + self.status_label.setText(f"No {self.cell_noun}") + return + self._build_grid() + self._update_status() + + def _clear_grid(self) -> None: + self.cell_buttons.clear() + while self.cell_grid_layout.count(): + item = self.cell_grid_layout.takeAt(0) + if item.widget(): + item.widget().deleteLater() + + def _header_button(self, text: str, size: int) -> QPushButton: + cs = self.color_scheme + button = QPushButton(text) + button.setFlat(True) + button.setCursor(Qt.CursorShape.PointingHandCursor) + button.setStyleSheet( + self.HEADER_STYLE.format( + text=cs.to_hex(cs.text_secondary), + size=size, + hover_text=cs.to_hex(cs.text_primary), + hover_bg=cs.to_hex(cs.panel_bg), + ) + ) + return button + + def _build_grid(self) -> None: + self._clear_grid() + rows, cols = self.model.rows, self.model.cols + if not rows or not cols: + return + + font = QFont() + font.setPointSize(10) + metrics = QFontMetrics(font) + min_header_width = max(metrics.horizontalAdvance(str(cols[-1])) + 8, 15) + min_header_height = max(metrics.height() + 4, 15) + + invert_btn = self._header_button("⇄", 12) + invert_btn.setMinimumSize(18, 18) + invert_btn.setToolTip("Invert Selection") + invert_btn.clicked.connect(lambda: self.invert_selection()) + self.cell_grid_layout.addWidget(invert_btn, 0, 0) + + for grid_col, col in enumerate(cols, start=1): + header = self._header_button(str(col), 10) + header.setMinimumSize(min_header_width, 18) + header.setMaximumHeight(18) + header.setSizePolicy(QSizePolicy.Policy.Ignored, QSizePolicy.Policy.Fixed) + header.clicked.connect( + lambda _checked, c=col: self._toggle(self.model.cells_in_column(c)) + ) + self.cell_grid_layout.addWidget(header, 0, grid_col) + + for grid_row, row in enumerate(rows, start=1): + header = self._header_button(self.addressing.row_label(row), 10) + header.setMinimumSize(18, min_header_height) + header.setMaximumWidth(18) + header.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Ignored) + header.clicked.connect(lambda _checked, r=row: self._toggle(self.model.cells_in_row(r))) + self.cell_grid_layout.addWidget(header, grid_row, 0) + + for grid_col, col in enumerate(cols, start=1): + self.cell_grid_layout.addWidget(self._cell_button(row, col), grid_row, grid_col) + + for grid_col in range(1, len(cols) + 1): + self.cell_grid_layout.setColumnStretch(grid_col, 1) + for grid_row in range(1, len(rows) + 1): + self.cell_grid_layout.setRowStretch(grid_row, 1) + self.aspect_container.set_aspect_ratio(len(cols) + 1, len(rows) + 1) + + def _cell_button(self, row: int, col: int) -> QPushButton: + cell_id = self.model.cell_at(row, col) + button = CellButton() + size = AspectRatioContainer.MIN_CELL_SIZE + button.setMinimumSize(size, size) + button.setCheckable(True) + button.installEventFilter(self) + if cell_id is not None and cell_id in self.model.available: + selected = cell_id in self.selected + button.setChecked(selected) + button.setStyleSheet( + (SelectedCell if selected else AvailableCell).style(self.color_scheme) + ) + button.setProperty("cell_id", cell_id) + self.cell_buttons[cell_id] = button + else: + button.setEnabled(False) + button.setStyleSheet(EmptyCell.style(self.color_scheme)) + button.setProperty("cell_id", None) + return button + + # -- selection ---------------------------------------------------------------- + + def _set_selected(self, cell_id: str, selected: bool) -> None: + button = self.cell_buttons.get(cell_id) + if button is None or (cell_id in self.selected) == selected: + return + if selected: + self.selected.add(cell_id) + else: + self.selected.discard(cell_id) + button.setChecked(selected) + button.setStyleSheet((SelectedCell if selected else AvailableCell).style(self.color_scheme)) + + def _publish(self) -> None: + self._update_status() + self.cells_selected.emit(self.selected.copy()) + + def _toggle(self, cell_ids: list[str]) -> None: + if not cell_ids: + return + select = not all(cell_id in self.selected for cell_id in cell_ids) + for cell_id in cell_ids: + self._set_selected(cell_id, select) + self._publish() + + def select_cells(self, cell_ids: set[str], *, emit_signal: bool = True) -> None: + """Make ``cell_ids`` (those available) the selection.""" + + for cell_id in tuple(self.selected): + self._set_selected(cell_id, False) + for cell_id in cell_ids: + self._set_selected(cell_id, True) + self._update_status() + if emit_signal: + self.cells_selected.emit(self.selected.copy()) + + def clear_selection(self, *, emit_signal: bool = True) -> None: + self.select_cells(set(), emit_signal=emit_signal) + + def invert_selection(self) -> None: + inverted = set(self.model.available) - self.selected + self.select_cells(inverted, emit_signal=False) + self._publish() + + def _update_status(self) -> None: + text = f"{len(self.model.available)} {self.cell_noun} available" + if self.selected: + text += f" | {len(self.selected)} selected" + self.status_label.setText(text) + + # -- gestures ----------------------------------------------------------------- + + def eventFilter(self, obj, event): # noqa: N802 - Qt virtual method name + if isinstance(obj, QPushButton): + target = CellButtonTarget(self, obj) + elif obj is self.grid_widget: + target = GridBackgroundTarget(self, obj) + else: + return super().eventFilter(obj, event) + handled = target.handle(event) + return super().eventFilter(obj, event) if handled is None else handled + + def begin_gesture(self, start, cell_id: str | None) -> None: + """Start a rectangle at ``start``; on a cell, also toggle it and paint by drag.""" + + self.gesture = GridGesture(start=start, selection_before=frozenset(self.selected)) + self.grid_widget.grabMouse() + self._show_rectangle(QRect(start, start).normalized()) + if cell_id is not None: + self._set_selected(cell_id, cell_id not in self.selected) + self._publish() + + def update_rectangle(self, current) -> None: + rect = QRect(self.gesture.start, current).normalized() + self._show_rectangle(rect) + inside = { + cell_id + for cell_id, button in self.cell_buttons.items() + if rect.intersects(QRect(button.pos(), button.size())) + } + for cell_id in self.model.available: + self._set_selected( + cell_id, + cell_id in inside or cell_id in self.gesture.selection_before, + ) + self._publish() + + def finish_gesture(self) -> None: + self.grid_widget.releaseMouse() + self.gesture = None + self.selection_rect_widget.hide() + + def _show_rectangle(self, rect: QRect) -> None: + self.selection_rect_widget.setGeometry(rect) + self.selection_rect_widget.raise_() + self.selection_rect_widget.show() diff --git a/tests/test_grid_selector.py b/tests/test_grid_selector.py new file mode 100644 index 00000000..33cc6691 --- /dev/null +++ b/tests/test_grid_selector.py @@ -0,0 +1,89 @@ +"""Grid selection over an application's cell addressing.""" + +from __future__ import annotations + +from PyQt6.QtCore import QPoint + +from pyqt_reactive.widgets.grid_selector import GridModel, GridSelectorWidget + + +class LetterRows: + """Cell ids like ``B2``: a row letter and a column number.""" + + default_grid = (8, 12) + + @staticmethod + def grid_index(value: object) -> tuple[int, int] | None: + text = str(value) + if len(text) < 2 or not text[0].isalpha() or not text[1:].isdigit(): + return None + return (ord(text[0].upper()) - ord("A") + 1, int(text[1:])) + + @staticmethod + def row_label(row: int) -> str: + return chr(ord("A") + row - 1) + + +def test_model_places_cells_by_their_ids_and_bounds_the_grid() -> None: + model = GridModel.of(LetterRows, {"B2", "C4", "nonsense"}) + + assert model.cell_at(2, 2) == "B2" and model.position_of_cell["C4"] == (3, 4) + assert (model.rows, model.cols) == ([2, 3], [2, 3, 4]) + assert model.dimensions == (2, 3) + assert GridModel.of(LetterRows, set()).dimensions == (8, 12) + + +def test_model_uses_supplied_positions_and_dimensions() -> None: + model = GridModel.of( + LetterRows, + {"R02C03", "R04C05"}, + dimensions=(16, 24), + cell_at_position={(2, 3): "R02C03", (4, 5): "R04C05"}, + ) + + assert (model.rows, model.cols) == ([2, 3, 4], [3, 4, 5]) + assert model.cell_at(4, 5) == "R04C05" and model.cell_at(3, 4) is None + assert model.dimensions == (16, 24) + + +def test_model_lists_available_cells_by_row_and_column() -> None: + model = GridModel.of(LetterRows, {"A1", "A2", "B1"}) + + assert model.cells_in_row(1) == ["A1", "A2"] + assert model.cells_in_column(1) == ["A1", "B1"] + + +def test_widget_selects_inverts_and_clears(qapp) -> None: + widget = GridSelectorWidget(addressing=LetterRows, title="Grid", cell_noun="cells") + published: list[set[str]] = [] + widget.cells_selected.connect(published.append) + widget.set_available_cells({"A1", "A2"}) + + widget.select_cells({"A1", "Z9"}, emit_signal=False) + assert widget.selected == {"A1"} and published == [] + + widget.invert_selection() + assert widget.selected == {"A2"} and published == [{"A2"}] + assert widget.status_label.text() == "2 cells available | 1 selected" + + widget.clear_selection(emit_signal=False) + assert widget.selected == set() + + +def test_rectangle_gesture_adds_to_the_selection_it_started_from(qapp) -> None: + widget = GridSelectorWidget(addressing=LetterRows, title="Grid", cell_noun="cells") + widget.set_available_cells({"A1", "A2", "B1", "B2"}) + widget.resize(400, 400) + widget.show() + qapp.processEvents() + widget.select_cells({"B2"}, emit_signal=False) + + a1 = widget.cell_buttons["A1"] + a2 = widget.cell_buttons["A2"] + widget.begin_gesture(a1.pos() + QPoint(1, 1), None) + widget.update_rectangle(a2.pos() + QPoint(1, 1)) + widget.finish_gesture() + + assert widget.selected == {"A1", "A2", "B2"} + assert widget.gesture is None + widget.close() From ecbdc4b68b20593a7e898d9dfaefe5f7696200ba Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Sun, 11 Oct 2026 03:57:29 -0400 Subject: [PATCH 10/16] GridAddressing is a nominal ABC The library's contracts are nominal ABCs, not typing protocols; applications implement GridAddressing for their cell ids. Co-Authored-By: Claude Opus 5.5 --- src/pyqt_reactive/widgets/grid_selector.py | 12 ++++++++---- tests/test_grid_selector.py | 13 +++++++------ 2 files changed, 15 insertions(+), 10 deletions(-) diff --git a/src/pyqt_reactive/widgets/grid_selector.py b/src/pyqt_reactive/widgets/grid_selector.py index 92c15ce2..42473cf6 100644 --- a/src/pyqt_reactive/widgets/grid_selector.py +++ b/src/pyqt_reactive/widgets/grid_selector.py @@ -10,7 +10,7 @@ from abc import ABC, abstractmethod from collections.abc import Callable, Mapping from dataclasses import dataclass -from typing import ClassVar, Protocol +from typing import ClassVar from PyQt6.QtCore import QEvent, QRect, Qt, pyqtSignal from PyQt6.QtGui import QFont, QFontMetrics @@ -33,15 +33,19 @@ """One-based (row, column).""" -class GridAddressing(Protocol): +class GridAddressing(ABC): """How an application's cell ids sit on the grid.""" - default_grid: tuple[int, int] - """(rows, columns) shown when no cell places the grid's extent.""" + @property + @abstractmethod + def default_grid(self) -> tuple[int, int]: + """(rows, columns) shown when no cell places the grid's extent.""" + @abstractmethod def grid_index(self, value: object) -> GridPosition | None: """One-based position a cell id spells; None when it spells none.""" + @abstractmethod def row_label(self, row: int) -> str: """Label of the one-based ``row``.""" diff --git a/tests/test_grid_selector.py b/tests/test_grid_selector.py index 33cc6691..8a013d40 100644 --- a/tests/test_grid_selector.py +++ b/tests/test_grid_selector.py @@ -4,26 +4,27 @@ from PyQt6.QtCore import QPoint -from pyqt_reactive.widgets.grid_selector import GridModel, GridSelectorWidget +from pyqt_reactive.widgets.grid_selector import GridAddressing, GridModel, GridSelectorWidget -class LetterRows: +class LetterRowAddressing(GridAddressing): """Cell ids like ``B2``: a row letter and a column number.""" default_grid = (8, 12) - @staticmethod - def grid_index(value: object) -> tuple[int, int] | None: + def grid_index(self, value: object) -> tuple[int, int] | None: text = str(value) if len(text) < 2 or not text[0].isalpha() or not text[1:].isdigit(): return None return (ord(text[0].upper()) - ord("A") + 1, int(text[1:])) - @staticmethod - def row_label(row: int) -> str: + def row_label(self, row: int) -> str: return chr(ord("A") + row - 1) +LetterRows = LetterRowAddressing() + + def test_model_places_cells_by_their_ids_and_bounds_the_grid() -> None: model = GridModel.of(LetterRows, {"B2", "C4", "nonsense"}) From bb26812257f3968da906f9cfda9a8ceec0ba973e Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Sun, 11 Oct 2026 03:57:29 -0400 Subject: [PATCH 11/16] Tabbed config windows over ObjectState, with the application's document format Moved from OpenHCS, which keeps only its document format and window ids: - windows.config_window: ConfigWindow (tree + form per config tab, Reset, View Code, transactional Save with save participants, Cancel restore), ConfigEditSession (live/saved global-config context around an edit), ConfigCodeDocumentDriver and the ConfigDocumentFormat ABC the application implements for View Code. - services.code_editor_form_updater: CodeEditorFormUpdater applies an edited object's changed fields to a form; its patch_lazy_constructors wrapper is gone (callers use objectstate.patch_lazy_constructors). - use_external_code_editor(): one reading of the host's external-editor environment switch, named by FormGenConfig.external_editor_env_var; the function editor and manager actions no longer read an application's variable name. Co-Authored-By: Claude Opus 5.5 --- src/pyqt_reactive/protocols/form_config.py | 2 + .../services/code_editor_form_updater.py | 236 ++++ .../services/window_code_document.py | 11 +- .../widgets/function_list_editor.py | 10 +- .../shared/manager_action_controller.py | 9 +- src/pyqt_reactive/windows/config_window.py | 1036 +++++++++++++++++ tests/test_code_editor_form_updater.py | 149 +++ tests/test_config_window.py | 72 ++ 8 files changed, 1511 insertions(+), 14 deletions(-) create mode 100644 src/pyqt_reactive/services/code_editor_form_updater.py create mode 100644 src/pyqt_reactive/windows/config_window.py create mode 100644 tests/test_code_editor_form_updater.py create mode 100644 tests/test_config_window.py diff --git a/src/pyqt_reactive/protocols/form_config.py b/src/pyqt_reactive/protocols/form_config.py index 0faf4b8e..1f504f83 100644 --- a/src/pyqt_reactive/protocols/form_config.py +++ b/src/pyqt_reactive/protocols/form_config.py @@ -31,6 +31,8 @@ class FormGenConfig: performance_logger_name: str = "pyqt_reactive.performance" performance_log_filename: str = "performance.log" path_cache_file: Optional[str] = None + external_editor_env_var: str = "PYQT_REACTIVE_USE_EXTERNAL_EDITOR" + """Environment variable that, set to 1/true/yes, edits code in an external editor.""" # Global config instance (set by application) diff --git a/src/pyqt_reactive/services/code_editor_form_updater.py b/src/pyqt_reactive/services/code_editor_form_updater.py new file mode 100644 index 00000000..f0528968 --- /dev/null +++ b/src/pyqt_reactive/services/code_editor_form_updater.py @@ -0,0 +1,236 @@ +"""Shared utility for updating forms from code editor changes. + +This module provides utilities for parsing edited code and updating form +managers with only the explicitly set fields, preserving ``None`` values for +unspecified fields. +""" + +from __future__ import annotations + +import logging +from dataclasses import fields, is_dataclass +from typing import Any, get_args, get_origin + +from objectstate import semantic_values_equal + +logger = logging.getLogger(__name__) + + +class CodeEditorFormUpdater: + """Utility for updating forms from code editor changes. + + The code editor always produces a *concrete* instance (dataclass or + regular object). For lazy configs, we rely on the pattern that raw + attribute access (``object.__getattribute__``) returns ``None`` for + inherited / unset fields. The form layer then interprets ``None`` as + "show placeholder", while concrete values override placeholders. + """ + + @staticmethod + def update_form_from_instance( + form_manager, + new_instance: Any, + broadcast_callback=None, + ) -> None: + """Update a form manager with values from a new instance. + + Args: + form_manager: ParameterFormManager instance. + new_instance: New object/dataclass instance with updated values. + broadcast_callback: Optional callback to broadcast the new + instance (e.g., to an event bus). + """ + + is_instance_dataclass = is_dataclass(new_instance) + if is_instance_dataclass: + instance_fields = [field.name for field in fields(new_instance)] + else: + # Fallback: drive off whatever the form currently knows about. + instance_fields = list(form_manager.parameters.keys()) + + for field_name in instance_fields: + if field_name not in form_manager.parameters: + logger.debug( + "CodeEditorFormUpdater: field %s missing from form_manager %s", + field_name, + getattr(form_manager, "field_id", ""), + ) + continue + + if is_instance_dataclass: + new_value = CodeEditorFormUpdater._get_raw_field_value(new_instance, field_name) + else: + new_value = getattr(new_instance, field_name, None) + + if is_dataclass(new_value) and not isinstance(new_value, type): + CodeEditorFormUpdater._update_nested_dataclass(form_manager, field_name, new_value) + else: + logger.debug("CodeEditorFormUpdater: updating %s to %r", field_name, new_value) + CodeEditorFormUpdater._update_parameter_if_changed( + form_manager, + field_name, + new_value, + ) + + # NOTE: + # Cross-window propagation and placeholder refresh are handled by + # ParameterFormManager.update_parameter together with the + # FieldChangeDispatcher / LiveContextService stack. Callers usually + # do NOT need to trigger a global refresh after using this helper; + # only special flows (e.g., global-config snapshot restore/cancel) + # should call ParameterFormManager.trigger_global_cross_window_refresh(). + + if broadcast_callback: + broadcast_callback(new_instance) + + # ------------------------------------------------------------------ + # Nested dataclass handling + # ------------------------------------------------------------------ + + @staticmethod + def _update_nested_dataclass( + form_manager, + field_name: str, + new_value: Any, + ) -> None: + """Recursively update a nested dataclass field and all its children. + + ``new_value`` is a concrete dataclass instance coming from the code + editor. We walk its fields and drive the *nested* manager entirely via + ``update_parameter`` so that FieldChangeDispatcher and + ValueCollectionService can do the right thing (parent reconstruction, + _user_set_fields tracking, cross-window propagation, etc.). + """ + + nested_manager = form_manager.nested_managers.get(field_name) + if not nested_manager: + # No dedicated nested manager – treat as a regular field on the + # parent. This is still routed through FieldChangeDispatcher. + CodeEditorFormUpdater._update_parameter_if_changed( + form_manager, + field_name, + new_value, + ) + return + + for field in fields(new_value): + nested_field_value = CodeEditorFormUpdater._get_raw_field_value(new_value, field.name) + if field.name not in nested_manager.parameters: + continue + + if is_dataclass(nested_field_value) and not isinstance(nested_field_value, type): + CodeEditorFormUpdater._update_nested_dataclass_in_manager( + nested_manager, field.name, nested_field_value + ) + else: + CodeEditorFormUpdater._update_parameter_if_changed( + nested_manager, + field.name, + nested_field_value, + ) + + @staticmethod + def _update_nested_dataclass_in_manager( + manager, + field_name: str, + new_value: Any, + ) -> None: + """Helper to update nested dataclass within a specific manager.""" + + nested_manager = manager.nested_managers.get(field_name) + if not nested_manager: + CodeEditorFormUpdater._update_parameter_if_changed( + manager, + field_name, + new_value, + ) + return + + for field in fields(new_value): + nested_field_value = CodeEditorFormUpdater._get_raw_field_value(new_value, field.name) + if field.name not in nested_manager.parameters: + continue + + if is_dataclass(nested_field_value) and not isinstance(nested_field_value, type): + CodeEditorFormUpdater._update_nested_dataclass_in_manager( + nested_manager, field.name, nested_field_value + ) + else: + CodeEditorFormUpdater._update_parameter_if_changed( + nested_manager, + field.name, + nested_field_value, + ) + + @staticmethod + def _update_parameter_if_changed( + form_manager, + field_name: str, + new_value: Any, + ) -> bool: + """Dispatch one code-mode field only when its raw value changed. + + ``ObjectState`` already rejects identical assignments before cache + invalidation, but ``ParameterFormManager.update_parameter`` performs + widget synchronization and emits root/cross-window signals before the + caller can benefit from that no-op. Code documents reconstruct a whole + object, so filtering against the manager's raw, path-scoped parameters + avoids turning a one-field edit into one dispatch per dataclass field. + + Returns: + ``True`` when the field was sent through the normal form dispatcher. + """ + + current_parameters = form_manager.parameters + if field_name not in current_parameters: + return False + if semantic_values_equal( + current_parameters[field_name], + new_value, + ): + logger.debug( + "CodeEditorFormUpdater: skipping unchanged field %s", + field_name, + ) + return False + + form_manager.update_parameter(field_name, new_value) + return True + + @staticmethod + def _get_raw_field_value(obj: Any, field_name: str): + """Fetch field without triggering lazy ``__getattr__`` logic.""" + + try: + return object.__getattribute__(obj, field_name) + except AttributeError: + return getattr(obj, field_name, None) + + @staticmethod + def _is_dataclass(field_type: Any) -> bool: + """Check if a field type represents (or wraps) a dataclass.""" + + origin = get_origin(field_type) + if origin is not None: + return any( + CodeEditorFormUpdater._is_dataclass(arg) + for arg in get_args(field_type) + if arg is not type(None) + ) + try: + return is_dataclass(field_type) + except TypeError: + return False + + @staticmethod + def _get_dataclass_field_value(instance: Any, field_obj) -> Any: + """Get a field value from a dataclass, preserving raw ``None``. + + For nested dataclasses we want to avoid triggering any lazy resolution + logic, so we go through ``_get_raw_field_value``. For primitives and + non-dataclass fields we fall back to normal ``getattr``. + """ + + if CodeEditorFormUpdater._is_dataclass(field_obj.type): + return CodeEditorFormUpdater._get_raw_field_value(instance, field_obj.name) + return getattr(instance, field_obj.name, None) diff --git a/src/pyqt_reactive/services/window_code_document.py b/src/pyqt_reactive/services/window_code_document.py index da1b5c2a..2d31d314 100644 --- a/src/pyqt_reactive/services/window_code_document.py +++ b/src/pyqt_reactive/services/window_code_document.py @@ -3,13 +3,15 @@ from __future__ import annotations import hashlib +import os from abc import ABC, abstractmethod from dataclasses import dataclass from typing import Generic, TypeVar -from pyqt_reactive.protocols import get_codegen_provider +from pyqt_reactive.protocols import get_codegen_provider, get_form_config PYTHON_MIME_TYPE = "text/x-python" +EXTERNAL_EDITOR_ENABLED_VALUES = frozenset(("1", "true", "yes")) DeclarationT = TypeVar("DeclarationT") @@ -78,3 +80,10 @@ def validate_source(self, source: str) -> None: def apply_source(self, source: str) -> None: """Apply source through the same state path as interactive code mode.""" raise NotImplementedError + + +def use_external_code_editor() -> bool: + """Whether code documents open in an external editor (the host's environment switch).""" + + value = os.environ.get(get_form_config().external_editor_env_var, "") + return value.lower() in EXTERNAL_EDITOR_ENABLED_VALUES diff --git a/src/pyqt_reactive/widgets/function_list_editor.py b/src/pyqt_reactive/widgets/function_list_editor.py index 60e024c0..e6445ae7 100644 --- a/src/pyqt_reactive/widgets/function_list_editor.py +++ b/src/pyqt_reactive/widgets/function_list_editor.py @@ -8,7 +8,6 @@ from __future__ import annotations import logging -import os import copy from contextlib import contextmanager, nullcontext from dataclasses import dataclass @@ -1079,12 +1078,9 @@ def edit_function_code(self): editor_service = SimpleCodeEditorService(self) - # Check if user wants external editor (check environment variable) - use_external = os.environ.get("OPENHCS_USE_EXTERNAL_EDITOR", "").lower() in ( - "1", - "true", - "yes", - ) + from pyqt_reactive.services.window_code_document import use_external_code_editor + + use_external = use_external_code_editor() # Launch editor with the authored nominal declaration type. editor_service.edit_code( diff --git a/src/pyqt_reactive/widgets/shared/manager_action_controller.py b/src/pyqt_reactive/widgets/shared/manager_action_controller.py index 982eadef..adae853c 100644 --- a/src/pyqt_reactive/widgets/shared/manager_action_controller.py +++ b/src/pyqt_reactive/widgets/shared/manager_action_controller.py @@ -4,12 +4,13 @@ import inspect import logging -import os from collections.abc import Callable, Mapping from contextlib import AbstractContextManager from dataclasses import dataclass, field from typing import Any, Generic, Optional, TypeAlias, TypeVar +from pyqt_reactive.services.window_code_document import use_external_code_editor + logger = logging.getLogger(__name__) @@ -205,11 +206,7 @@ def show_code_editor( from pyqt_reactive.widgets.editors.simple_code_editor import SimpleCodeEditorService editor_service = SimpleCodeEditorService(operations.widget) - use_external = os.environ.get("OPENHCS_USE_EXTERNAL_EDITOR", "").lower() in ( - "1", - "true", - "yes", - ) + use_external = use_external_code_editor() editor_service.edit_code( initial_content=code, diff --git a/src/pyqt_reactive/windows/config_window.py b/src/pyqt_reactive/windows/config_window.py new file mode 100644 index 00000000..71143764 --- /dev/null +++ b/src/pyqt_reactive/windows/config_window.py @@ -0,0 +1,1036 @@ +"""Tabbed configuration editor windows over ObjectState.""" + +from __future__ import annotations + +import dataclasses +from abc import ABC, abstractmethod +import logging +from collections.abc import Callable +from dataclasses import dataclass, field +from functools import partial +from typing import ClassVar, Generic, TypeVar, cast + +from objectstate import ( + ObjectState, + ObjectStateEditSession, + ObjectStateRegistry, + ObjectStateTransactionCheckpoint, + get_live_global_config, + get_saved_global_config, + is_global_config_type, + patch_lazy_constructors, + set_live_global_config, + set_saved_global_config, +) +from objectstate.lazy_factory import LazyDataclass, retain_only_authored_paths +from PyQt6.QtCore import pyqtSignal +from PyQt6.QtWidgets import ( + QHBoxLayout, + QLabel, + QMessageBox, + QPushButton, + QTreeWidget, + QTreeWidgetItem, + QVBoxLayout, + QWidget, +) + +from pyqt_reactive.forms.layout_constants import CURRENT_LAYOUT +from pyqt_reactive.forms.parameter_form_manager import ( + FormManagerConfig, + ParameterFormManager, +) +from pyqt_reactive.services.code_editor_form_updater import CodeEditorFormUpdater +from pyqt_reactive.services.parameter_ops_service import ParameterOpsService +from pyqt_reactive.services.window_code_document import ( + PYTHON_MIME_TYPE, + WindowCodeDocument, + WindowCodeDocumentDriver, + use_external_code_editor, +) +from pyqt_reactive.services.window_navigation import ( + ObjectStateNavigationTargetAuthority, + RegisteredWindowNavigationRequest, + WindowNavigationDriver, +) +from pyqt_reactive.theming import ColorScheme, WidgetTheme +from pyqt_reactive.widgets.editors.simple_code_editor import SimpleCodeEditorService +from pyqt_reactive.widgets.shared import ( + ActionTabbedWindowBody, + ActionTabMaterialization, + ActionTabSpec, + BaseFormDialog, + DirtyWindowPresentation, + FormWindowActionHeader, + HeaderAction, + HeaderActionGroup, + ManagedStateRestorePolicy, + ManagedWindowActionCapabilities, + create_scrollable_form_body, +) +from pyqt_reactive.widgets.shared.clickable_help_components import ( + HelpButton, + HelpContext, +) +from pyqt_reactive.widgets.shared.config_hierarchy_tree import ( + ConfigHierarchyTreeHelper, +) +from pyqt_reactive.widgets.shared.form_window_action_header import ( + HeaderActionGroupRole, +) +from pyqt_reactive.widgets.shared.scrollable_form_mixin import ( + ScrollableFormMixin, + ScrollableFormWindowNavigationDriver, +) + +logger = logging.getLogger(__name__) + + +ConfigT = TypeVar("ConfigT") + + +class ConfigDocumentFormat(ABC): + """An application's Python source format for its configuration objects.""" + + @abstractmethod + def render(self, config: object, *, expected_config_type: type, clean_mode: bool) -> str: + """Render ``config`` as reviewable source.""" + + @abstractmethod + def from_source(self, source: str, *, expected_config_type: type) -> object: + """Read the config a source document declares.""" + + +@dataclass(frozen=True, slots=True) +class GlobalConfigContextCheckpoint(Generic[ConfigT]): + """Exact external and edit-session context at one transaction boundary.""" + + saved: ConfigT | None + live: ConfigT | None + original_live: ConfigT | None + dirty: bool + + +@dataclass(slots=True) +class ConfigEditSession(Generic[ConfigT]): + """Own non-visual config edit state for ConfigWindow.""" + + state: ObjectState + original_config: ConfigT + global_context_dirty: bool = False + saving: bool = False + _original_live_global_config: ConfigT | None = field(init=False, repr=False) + _object_session: ObjectStateEditSession[ConfigT] = field(init=False, repr=False) + + def __post_init__(self) -> None: + self._original_live_global_config = None + if self.is_global_config: + self._original_live_global_config = get_live_global_config(self.config_class) + self._object_session = ObjectStateEditSession( + state_provider=lambda: self.state, + fallback_object=self.original_config, + expected_type=self.config_class, + ) + + @property + def config_class(self) -> type[ConfigT]: + return type(self.original_config) + + @property + def is_global_config(self) -> bool: + return is_global_config_type(self.config_class) + + def to_object(self) -> ConfigT: + """Reconstruct the current config object from ObjectState storage.""" + return self._object_session.to_object() + + def to_code_document_object(self) -> ConfigT: + """Reconstruct config while retaining only authored lazy field paths. + + Flattened ``ObjectState`` storage reconstructs every nested lazy + dataclass so raw inheritance markers remain intact. Clean code mode + must not mistake those reconstructed containers for user-authored + overrides, so project the object through ObjectState's authoritative + signature-diff paths before serialization. + """ + + config = self.to_object() + if not isinstance(config, LazyDataclass): + return config + return cast( + ConfigT, + retain_only_authored_paths(config, self.state.signature_diff_fields), + ) + + def begin_save_callback(self, window_id: int) -> None: + self.saving = True + logger.info("SAVE_CONFIG: Set saving=True before callback (id=%s)", window_id) + + def end_save_callback(self, window_id: int) -> None: + self.saving = False + logger.info("SAVE_CONFIG: Reset saving=False (id=%s)", window_id) + + def mark_global_field_changed(self) -> None: + """Track unsaved global-context changes unless a save is in progress.""" + if self.saving: + return + self.global_context_dirty = True + + def apply_code_edit_context(self, new_config: ConfigT) -> None: + """Publish an unsaved code edit to the live UI context only.""" + if not self.is_global_config: + return + set_live_global_config(self.config_class, new_config) + self.global_context_dirty = True + logger.debug( + "Updated LIVE thread-local %s context", + self.config_class.__name__, + ) + + def capture_global_context(self) -> GlobalConfigContextCheckpoint[ConfigT] | None: + """Capture saved and live stores independently before a fallible save.""" + + if not self.is_global_config: + return None + return GlobalConfigContextCheckpoint( + saved=get_saved_global_config(self.config_class), + live=get_live_global_config(self.config_class), + original_live=self._original_live_global_config, + dirty=self.global_context_dirty, + ) + + def restore_global_context( + self, + checkpoint: GlobalConfigContextCheckpoint[ConfigT] | None, + ) -> None: + """Restore the exact saved/live split captured before a save.""" + + if not self.is_global_config or checkpoint is None: + return + set_saved_global_config(self.config_class, checkpoint.saved) + set_live_global_config(self.config_class, checkpoint.live) + self._original_live_global_config = checkpoint.original_live + self.global_context_dirty = checkpoint.dirty + + def publish_saved_global_config(self, new_config: ConfigT) -> None: + """Publish a saved global config to the saved/live thread-local stores.""" + if not self.is_global_config: + return + set_saved_global_config(self.config_class, new_config) + set_live_global_config(self.config_class, new_config) + logger.debug( + "Updated SAVED and LIVE thread-local %s on SAVE", + self.config_class.__name__, + ) + self._original_live_global_config = new_config + self.global_context_dirty = False + + def restore_global_context_if_dirty(self) -> bool: + """Restore only the live context that preceded this edit session.""" + if not self.is_global_config or not self.global_context_dirty: + return False + set_live_global_config( + self.config_class, + self._original_live_global_config, + ) + self.global_context_dirty = False + return True + + +class ConfigCodeDocumentDriver(WindowCodeDocumentDriver, Generic[ConfigT]): + """Render and apply configuration source through the application's document format.""" + + def __init__( + self, + *, + document_format: ConfigDocumentFormat, + title: str, + config_type: type[ConfigT], + current_config: Callable[[], ConfigT], + apply_config: Callable[[ConfigT], None], + before_read: Callable[[], None] | None = None, + before_apply: Callable[[], None] | None = None, + ) -> None: + self._document_format = document_format + self._title = title + self._config_type = config_type + self._current_config = current_config + self._apply_config = apply_config + self._before_read = before_read + self._before_apply = before_apply + + def read_document(self, clean: bool = True) -> WindowCodeDocument: + if self._before_read is not None: + self._before_read() + source = self._document_format.render( + self._current_config(), + expected_config_type=self._config_type, + clean_mode=clean, + ) + return WindowCodeDocument( + title=self._title, + source=source, + mime_type=PYTHON_MIME_TYPE, + declaration_type=self._config_type, + ) + + def validate_source(self, source: str) -> None: + self._config_from_source(source) + + def apply_source(self, source: str) -> None: + if self._before_apply is not None: + self._before_apply() + self._apply_config(self._config_from_source(source)) + + def _config_from_source(self, source: str) -> ConfigT: + with patch_lazy_constructors(): + return self._document_format.from_source( + source, + expected_config_type=self._config_type, + ) + + +@dataclass(frozen=True, slots=True) +class ConfigSaveParticipant: + """Reversible external effect participating in a ConfigWindow save.""" + + apply: Callable[[object], None] + rollback: Callable[[object], None] + + +@dataclass(frozen=True, slots=True) +class ConfigWindowTabSpec: + """One caller-owned config state and its persistence boundary.""" + + state: ObjectState + save_participant: ConfigSaveParticipant | None = None + before_mutation: Callable[[], None] | None = None + + @property + def label(self) -> str: + return type(self.state.saved_object).__name__ + + +@dataclass(slots=True) +class _ConfigEditorTabView: + form_manager: ParameterFormManager + tree_helper: ConfigHierarchyTreeHelper + tree_widget: QTreeWidget + content: QWidget + actions: QWidget + scroll_area: QWidget + code_document_driver: ConfigCodeDocumentDriver + + +@dataclass(slots=True) +class _ConfigEditorTab: + spec: ConfigWindowTabSpec + state: ObjectState + session: ConfigEditSession + restore_policy: ManagedStateRestorePolicy + state_changed_callback: Callable[[set[str]], None] + help_button: HelpButton | None + view: _ConfigEditorTabView | None = None + + def require_view(self) -> _ConfigEditorTabView: + if self.view is None: + raise RuntimeError(f"Config tab {self.spec.label} is not materialized.") + return self.view + + @property + def form_manager(self) -> ParameterFormManager: + return self.require_view().form_manager + + @property + def tree_helper(self) -> ConfigHierarchyTreeHelper: + return self.require_view().tree_helper + + @property + def tree_widget(self) -> QTreeWidget: + return self.require_view().tree_widget + + @property + def content(self) -> QWidget: + return self.require_view().content + + @property + def actions(self) -> QWidget: + return self.require_view().actions + + @property + def scroll_area(self) -> QWidget: + return self.require_view().scroll_area + + @property + def code_document_driver(self) -> ConfigCodeDocumentDriver: + return self.require_view().code_document_driver + + +class _ActiveConfigCodeDocumentDriver(WindowCodeDocumentDriver): + """Expose the selected config tab through the managed-window code API.""" + + def __init__(self, active_driver: Callable[[], WindowCodeDocumentDriver]) -> None: + self._active_driver = active_driver + + def read_document(self, clean: bool = True) -> WindowCodeDocument: + return self._active_driver().read_document(clean=clean) + + def validate_source(self, source: str) -> None: + self._active_driver().validate_source(source) + + def apply_source(self, source: str) -> None: + self._active_driver().apply_source(source) + + +class ConfigWindowNavigationDriver(ScrollableFormWindowNavigationDriver): + """Navigate fields through the config tab whose ObjectState owns them.""" + + _owner: ConfigWindow + + def __init__(self, owner: ConfigWindow) -> None: + super().__init__(owner) + + def accepts_field_path( + self, + request: RegisteredWindowNavigationRequest, + ) -> bool: + return self._tab_index(request) is not None + + def prepare(self, request: RegisteredWindowNavigationRequest) -> None: + tab_index = self._tab_index(request) + if tab_index is not None: + self._owner._tab_body.set_current_index(tab_index) + + def _tab_index( + self, + request: RegisteredWindowNavigationRequest, + ) -> int | None: + field_path = request.field_path + if field_path is None: + return None + + scoped_indices = tuple( + index + for index, tab in enumerate(self._owner._tabs) + if tab.state.scope_id == request.requested_scope_id + ) + candidate_indices = scoped_indices or tuple(range(len(self._owner._tabs))) + matching_indices = tuple( + index + for index in candidate_indices + if ObjectStateNavigationTargetAuthority.owns_field_path( + self._owner._tabs[index].state, + field_path, + ) + ) + if len(matching_indices) != 1: + return None + return matching_indices[0] + + +class ConfigWindow(ScrollableFormMixin, BaseFormDialog): + """Edit one or more configuration objects over ObjectState, one tab each. + + Each tab shows a field tree and a form for one config's ObjectState, with + Reset and View Code actions; Save commits every tab in one transaction with + each tab's save participant, and Cancel restores them. ``document_format`` + is the application's source format for View Code. + """ + + document_format: ClassVar[ConfigDocumentFormat] + + config_saved = pyqtSignal(object) + config_cancelled = pyqtSignal() + + def __init__( + self, + tabs: tuple[ConfigWindowTabSpec, ...], + color_scheme: ColorScheme | None = None, + parent=None, + scope_id: str | None = None, + title_text: str | None = None, + ) -> None: + if not tabs: + raise ValueError("ConfigWindow requires at least one config tab.") + super().__init__(parent) + + self.scope_id = scope_id + self.theme = WidgetTheme.from_optional(color_scheme) + self._scope_accent_color = None + self._header_label: QLabel | None = None + self._save_button: QPushButton | None = None + self._default_size_applied = False + self._base_window_title = title_text or f"Config {tabs[0].label}" + self._action_header: FormWindowActionHeader | None = None + self._tabs = [self._create_tab_model(spec) for spec in tabs] + self._tab_body = ActionTabbedWindowBody( + color_scheme=self.theme.scheme, + parent=self, + ) + self._tab_body.tab_materialization_failed.connect(self._on_tab_materialization_failed) + + for tab in self._tabs: + self._tab_body.add_tab( + ActionTabSpec( + label=tab.spec.label, + materialization_factory=partial(self._materialize_tab, tab), + ) + ) + + self.state = self.active_tab.state + self.state_restore_policy = self.active_tab.restore_policy + self._sync_tab_action_visibility() + self._code_document_driver = _ActiveConfigCodeDocumentDriver( + lambda: self.active_tab.code_document_driver + ) + + self.setup_ui() + self._tab_body.current_changed.connect(self._on_tab_changed) + self.connect_change_detection() + self.detect_changes() + logger.debug("Config window initialized with %d tab(s)", len(self._tabs)) + + @property + def active_tab(self) -> _ConfigEditorTab: + return self._tabs[max(self._tab_body.current_index(), 0)] + + @property + def form_manager(self) -> ParameterFormManager: + return self.active_tab.form_manager + + @property + def tree_widget(self) -> QTreeWidget: + return self.active_tab.tree_widget + + @property + def scroll_area(self): + return self.active_tab.scroll_area + + def _create_tab_model(self, spec: ConfigWindowTabSpec) -> _ConfigEditorTab: + state = spec.state + config = state.saved_object + config_type = type(config) + session = ConfigEditSession( + state=state, + original_config=config, + ) + restore_policy = ManagedStateRestorePolicy(propagate_descendants=not state.has_delegate) + + def state_changed(_fields: set[str]) -> None: + self.detect_changes() + + state.on_state_changed(state_changed) + return _ConfigEditorTab( + spec=spec, + state=state, + session=session, + restore_policy=restore_policy, + state_changed_callback=state_changed, + help_button=self._build_help_button(config_type), + ) + + def _materialize_tab( + self, + tab: _ConfigEditorTab, + ) -> ActionTabMaterialization: + state = tab.state + spec = tab.spec + session = tab.session + config_type = type(state.saved_object) + form_config = FormManagerConfig( + parent=None, + scope_id=self.scope_id, + color_scheme=self.theme.scheme, + scope_accent_color=self._scope_accent_color, + before_mutation=spec.before_mutation, + ) + form_config.field_id = "" + form_manager = ParameterFormManager(state=state, config=form_config) + form_manager.form_build_failed.connect(partial(self._on_form_build_failed, tab)) + if session.is_global_config: + form_manager.parameter_changed.connect( + lambda _name, _value, current_session=session: ( + current_session.mark_global_field_changed() + ) + ) + + tree_helper = ConfigHierarchyTreeHelper() + tree_widget = tree_helper.create_tree_from_root_dataclass( + root_dataclass=config_type, + form_manager=form_manager, + state=state, + strip_config_suffix=True, + on_item_double_clicked=partial( + self._on_tree_item_double_clicked, + tree_helper, + form_manager, + ), + ) + body_parts = create_scrollable_form_body( + form_widget=form_manager, + tree_widget=tree_widget, + tree_initial_size=300, + form_initial_size=700, + parent=self, + ) + + code_document_driver = ConfigCodeDocumentDriver( + document_format=self.document_format, + title=f"View/Edit {config_type.__name__}", + config_type=config_type, + current_config=session.to_code_document_object, + apply_config=partial( + self._apply_config_from_code_document, + session, + form_manager, + ), + before_read=partial( + ParameterOpsService().refresh_with_live_context, + form_manager, + ), + before_apply=spec.before_mutation, + ) + actions = self._build_tab_actions( + form_manager, + code_document_driver, + ) + tab.view = _ConfigEditorTabView( + form_manager=form_manager, + tree_helper=tree_helper, + tree_widget=tree_widget, + content=body_parts.body_widget, + actions=actions, + scroll_area=body_parts.scroll_area, + code_document_driver=code_document_driver, + ) + return ActionTabMaterialization( + content=body_parts.body_widget, + actions=actions, + ) + + def _on_form_build_failed( + self, + tab: _ConfigEditorTab, + error: Exception, + ) -> None: + logger.error( + "Failed to construct config tab %s: %s", + tab.spec.label, + error, + ) + QMessageBox.critical( + self, + "Configuration Form Error", + f"Failed to construct {tab.spec.label}:\n{error}", + ) + self.reject() + + def _on_tab_materialization_failed( + self, + index: int, + error: Exception, + ) -> None: + tab = self._tabs[index] + logger.error( + "Failed to materialize config tab %s: %s", + tab.spec.label, + error, + ) + QMessageBox.critical( + self, + "Configuration Form Error", + f"Failed to construct {tab.spec.label}:\n{error}", + ) + self.reject() + + def _build_tab_actions( + self, + form_manager: ParameterFormManager, + code_document_driver: ConfigCodeDocumentDriver, + ) -> QWidget: + actions = QWidget(self) + layout = QHBoxLayout(actions) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(4) + button_styles = self.theme.scheme.styles.generate_config_button_styles() + + reset_button = QPushButton("Reset") + reset_button.clicked.connect(form_manager.reset_all_parameters) + reset_button.setStyleSheet(button_styles["compact"]) + layout.addWidget(reset_button) + + code_button = QPushButton("View Code") + code_button.clicked.connect(partial(self._view_code, code_document_driver)) + code_button.setStyleSheet(button_styles["compact"]) + layout.addWidget(code_button) + + for index in range(layout.count()): + widget = layout.itemAt(index).widget() + if widget is not None: + widget.setFixedHeight(CURRENT_LAYOUT.button_height) + return actions + + def _build_help_button(self, config_type: type[object]) -> HelpButton | None: + if not dataclasses.is_dataclass(config_type): + return None + help_button = HelpButton( + help_context=HelpContext( + help_target=config_type, + color_scheme=self.theme.scheme, + scope_accent_color=self._scope_accent_color, + ), + text="Help", + ) + help_button.setStyleSheet( + self.theme.scheme.styles.generate_config_button_styles()["compact"] + ) + help_button.setFixedHeight(CURRENT_LAYOUT.button_height) + return help_button + + def form_managers(self) -> tuple[ParameterFormManager, ...]: + return tuple(tab.view.form_manager for tab in self._tabs if tab.view is not None) + + def window_code_document_driver(self) -> WindowCodeDocumentDriver: + return self._code_document_driver + + def window_navigation_driver(self) -> WindowNavigationDriver: + return ConfigWindowNavigationDriver(self) + + def managed_window_action_capabilities( + self, + ) -> ManagedWindowActionCapabilities: + return ManagedWindowActionCapabilities( + save_and_close=True, + save_without_close=True, + discard_and_close=True, + ) + + def agent_save_managed_window(self, *, close_window: bool) -> None: + self.save_config(close_window=close_window) + + def dirty_window_widgets(self) -> tuple[QLabel, QPushButton] | None: + if self._header_label is None or self._save_button is None: + return None + return self._header_label, self._save_button + + def dirty_window_presentation(self) -> DirtyWindowPresentation: + is_dirty = any(tab.state.is_raw_dirty for tab in self._tabs) + has_signature_diff = any(bool(tab.state.signature_diff_fields) for tab in self._tabs) + return DirtyWindowPresentation( + window_title=self._base_window_title, + header_text=self._base_window_title, + save_label="Save", + is_dirty=is_dirty, + has_signature_diff=has_signature_diff, + mark_save_label_dirty=False, + ) + + def detect_changes(self) -> None: + has_changes = any(tab.state.is_raw_dirty for tab in self._tabs) + if has_changes != self.dirty_state.has_changes: + self.dirty_state.has_changes = has_changes + self.changes_detected.emit(has_changes) + else: + self.apply_dirty_window_presentation() + + def setup_ui(self) -> None: + self.setWindowTitle(self._base_window_title) + self.setModal(False) + if self.size().isEmpty(): + self.resize(650, 650) + + layout = QVBoxLayout(self) + layout.setContentsMargins(4, 4, 4, 4) + layout.setSpacing(4) + button_styles = self.theme.scheme.styles.generate_config_button_styles() + + cancel_button = QPushButton("Cancel") + cancel_button.clicked.connect(self.reject) + cancel_button.setStyleSheet(button_styles["compact"]) + + self._save_button = QPushButton("Save") + self._save_button.setEnabled(False) + self.setup_save_button(self._save_button, self.save_config) + self._save_button.setStyleSheet(button_styles["compact"]) + + for button in (cancel_button, self._save_button): + button.setFixedHeight(CURRENT_LAYOUT.button_height) + + active_tab_actions = self._tab_body.release_active_actions_widget() + + header = FormWindowActionHeader( + title_text=self._base_window_title, + title_color=self.theme.scheme.to_hex(self.theme.scheme.text_accent), + action_groups=( + HeaderActionGroup( + "group_title_companion", + tuple( + HeaderAction(f"tab_{index}_help", tab.help_button) + for index, tab in enumerate(self._tabs) + if tab.help_button is not None + ), + role=HeaderActionGroupRole.TITLE_COMPANION, + ), + HeaderActionGroup( + "group_auxiliary", + (HeaderAction("active_tab_actions", active_tab_actions),), + role=HeaderActionGroupRole.AUXILIARY, + ), + HeaderActionGroup( + "group_commit", + ( + HeaderAction("cancel", cancel_button), + HeaderAction("save", self._save_button), + ), + role=HeaderActionGroupRole.COMMIT, + ), + ), + parent=self, + ) + self._action_header = header + self._header_label = header.header_label + self._sync_tab_action_visibility() + layout.addWidget(header) + layout.addWidget(self._tab_body, 1) + + self.setStyleSheet( + self.theme.scheme.styles.generate_config_window_style() + + "\n" + + self.theme.scheme.styles.generate_tree_widget_style() + ) + if self.scope_id is not None: + self.init_scope_border() + + def _on_tab_changed(self, _index: int) -> None: + self._sync_tab_action_visibility() + self.state = self.active_tab.state + self.state_restore_policy = self.active_tab.restore_policy + self.apply_scope_accent_styling() + self.detect_changes() + + def _sync_tab_action_visibility(self) -> None: + active_index = max(self._tab_body.current_index(), 0) + for index, tab in enumerate(self._tabs): + if tab.help_button is not None: + tab.help_button.setVisible(index == active_index) + if self._action_header is not None: + self._action_header.refresh_layout() + + def _on_tree_item_double_clicked( + self, + tree_helper: ConfigHierarchyTreeHelper, + form_manager: ParameterFormManager, + item: QTreeWidgetItem, + column: int, + ) -> None: + del column + tree_helper.activate_item( + item, + scroll_to_section=self._scroll_to_section, + field_for_class=partial( + tree_helper.field_for_class_in_dataclass_instance, + form_manager.object_instance, + ), + ) + + def reset_to_defaults(self) -> None: + self.active_tab.form_manager.reset_all_parameters() + + def save_config(self, *, close_window: bool = True) -> None: + candidates: tuple[tuple[_ConfigEditorTab, object], ...] = () + previous_objects: dict[int, object] = {} + attempted_participants: list[_ConfigEditorTab] = [] + committed: tuple[tuple[_ConfigEditorTab, object], ...] = () + state_checkpoints: list[tuple[ObjectState, ObjectStateTransactionCheckpoint]] = [] + global_context_checkpoints: dict[int, object] = {} + transaction_started = False + try: + # Widget validation and authorization precede even reconstruction + # cache changes, so a rejected save has no transaction side effects. + for tab in self._tabs: + if tab.view is not None: + tab.form_manager.validate_current_values() + self.require_managed_state_mutation_allowed() + + states = list(ObjectStateRegistry.get_all()) + for tab in self._tabs: + if all(state is not tab.state for state in states): + states.append(tab.state) + state_checkpoints = [ + (state, ObjectStateTransactionCheckpoint.capture(state)) for state in states + ] + previous_objects = {id(tab): tab.state.saved_object for tab in self._tabs} + global_context_checkpoints = { + id(tab): checkpoint + for tab in self._tabs + if (checkpoint := tab.session.capture_global_context()) is not None + } + transaction_started = True + + with ObjectStateRegistry.atomic_success( + "save configuration", + self.scope_id, + ): + candidates = tuple((tab, tab.session.to_object()) for tab in self._tabs) + + for tab, _config in candidates: + tab.state.mark_saved() + + committed = tuple((tab, tab.state.saved_object) for tab, _ in candidates) + for tab, config in committed: + tab.session.publish_saved_global_config(config) + + # External effects run only after every ObjectState and global + # context exposes the committed object. Include the current + # participant before invocation so its own partial apply is + # compensated if it raises. + for tab, config in committed: + participant = tab.spec.save_participant + if participant is None: + continue + attempted_participants.append(tab) + tab.session.begin_save_callback(id(self)) + try: + participant.apply(config) + finally: + tab.session.end_save_callback(id(self)) + + except Exception as error: + rollback_errors: list[Exception] = [] + if transaction_started: + # Compensate external effects first. ObjectState restoration + # emits coherent change notifications, so those observers must + # see the pre-transaction external and global world. + for tab in reversed(attempted_participants): + participant = tab.spec.save_participant + if participant is None: + continue + tab.session.begin_save_callback(id(self)) + try: + participant.rollback(previous_objects[id(tab)]) + except Exception as rollback_error: + rollback_errors.append(rollback_error) + finally: + tab.session.end_save_callback(id(self)) + for tab in self._tabs: + try: + tab.session.restore_global_context(global_context_checkpoints.get(id(tab))) + except Exception as rollback_error: + rollback_errors.append(rollback_error) + for state, checkpoint in state_checkpoints: + try: + checkpoint.restore(state) + except Exception as rollback_error: + rollback_errors.append(rollback_error) + ObjectStateRegistry.increment_token(notify=True) + if rollback_errors: + error.add_note( + "Configuration rollback also reported: " + + "; ".join(f"{type(item).__name__}: {item}" for item in rollback_errors) + ) + logger.error("Failed to save configuration: %s", error, exc_info=True) + QMessageBox.critical( + self, + "Save Error", + f"Failed to save configuration:\n{error}", + ) + return + + # The transaction and its history snapshot are committed. UI + # notifications and window finalization must never drive compensation + # against that durable boundary. + for _tab, config in committed: + try: + self.config_saved.emit(config) + except Exception: + logger.exception("Post-commit config_saved observer failed") + try: + ObjectStateRegistry.increment_token(notify=True) + except Exception: + logger.exception("Post-commit ObjectState notification failed") + try: + if close_window: + self.accept_committed_state() + else: + self.detect_changes() + except Exception: + logger.exception("Post-commit config window finalization failed") + + def _view_code( + self, + code_document_driver: ConfigCodeDocumentDriver | None = None, + ) -> None: + driver = code_document_driver or self.active_tab.code_document_driver + try: + document = driver.read_document() + SimpleCodeEditorService(self).edit_code( + initial_content=document.source, + title=document.title, + callback=driver.apply_source, + use_external=use_external_code_editor(), + declaration_type=document.declaration_type, + code_data={"clean_mode": True}, + ) + except Exception as error: + logger.error("Failed to view config code: %s", error, exc_info=True) + QMessageBox.critical( + self, + "View Code Error", + f"Failed to view code:\n{error}", + ) + + @staticmethod + def _apply_config_from_code_document( + session: ConfigEditSession, + form_manager: ParameterFormManager, + new_config: object, + ) -> None: + session.apply_code_edit_context(new_config) + CodeEditorFormUpdater.update_form_from_instance(form_manager, new_config) + + def require_managed_state_mutation_allowed(self) -> None: + for tab in self._tabs: + if tab.spec.before_mutation is not None: + tab.spec.before_mutation() + + def restore_managed_state(self) -> None: + for tab in self._tabs: + tab.restore_policy.restore(tab.state) + tab.session.restore_global_context_if_dirty() + + def before_managed_reject(self) -> None: + self.config_cancelled.emit() + + def after_managed_reject(self) -> None: + ObjectStateRegistry.increment_token() + + def showEvent(self, event) -> None: # noqa: N802 - Qt virtual method name + super().showEvent(event) + if not self._default_size_applied: + self.resize(650, 650) + self._default_size_applied = True + self.setProperty("_fixed_default_size", True) + + def apply_scope_accent_styling(self) -> None: + super().apply_scope_accent_styling() + accent_color = self.get_scope_accent_color() + if not accent_color: + return + if self._save_button is not None: + self._save_button.setStyleSheet( + self.theme.scheme.styles.generate_scope_accent_button_style(accent_color) + ) + if self._header_label is not None: + self._header_label.setStyleSheet(f"color: {accent_color.name()};") + tree_style = self.get_scope_tree_selection_stylesheet() + for tab in self._tabs: + if tree_style and tab.view is not None: + tab.tree_widget.setStyleSheet(f"{tab.tree_widget.styleSheet()}\n{tree_style}") + if tab.help_button is not None: + tab.help_button.set_scope_accent_color(accent_color) + + def before_managed_close(self) -> None: + for tab in self._tabs: + tab.state.off_state_changed(tab.state_changed_callback) + if tab.view is not None: + tab.tree_helper.cleanup_subscriptions() diff --git a/tests/test_code_editor_form_updater.py b/tests/test_code_editor_form_updater.py new file mode 100644 index 00000000..57641c95 --- /dev/null +++ b/tests/test_code_editor_form_updater.py @@ -0,0 +1,149 @@ +"""Change-filtering tests for code-document form synchronization.""" + +from __future__ import annotations + +from dataclasses import dataclass + +from pyqt_reactive.services.code_editor_form_updater import CodeEditorFormUpdater + + +@dataclass(frozen=True) +class _NestedConfig: + enabled: bool | None = None + label: str | None = None + + +@dataclass(frozen=True) +class _RootConfig: + name: str | None = None + nested: _NestedConfig | None = None + + +class _Step: + """A plain (non-dataclass) object edited through a form.""" + + def __init__(self, *, name: str, description: str | None = None, enabled: bool = True): + self.name = name + self.description = description + self.enabled = enabled + + +class _RecordingFormManager: + def __init__( + self, + parameters: dict[str, object], + *, + nested_managers: dict[str, "_RecordingFormManager"] | None = None, + ) -> None: + self.parameters = parameters + self.nested_managers = nested_managers or {} + self.updated: list[tuple[str, object]] = [] + + def update_parameter(self, field_name: str, value: object) -> None: + self.updated.append((field_name, value)) + self.parameters[field_name] = value + + +def test_update_form_from_instance_dispatches_only_changed_nested_leaf() -> None: + nested_manager = _RecordingFormManager( + { + "enabled": None, + "label": "same", + } + ) + root_manager = _RecordingFormManager( + { + "name": "same", + "nested": _NestedConfig(enabled=None, label="same"), + }, + nested_managers={"nested": nested_manager}, + ) + + CodeEditorFormUpdater.update_form_from_instance( + root_manager, + _RootConfig( + name="same", + nested=_NestedConfig(enabled=False, label="same"), + ), + ) + + assert root_manager.updated == [] + assert nested_manager.updated == [("enabled", False)] + + +def test_update_form_from_instance_skips_fully_unchanged_document() -> None: + nested_manager = _RecordingFormManager( + { + "enabled": None, + "label": "same", + } + ) + root_manager = _RecordingFormManager( + { + "name": "same", + "nested": _NestedConfig(enabled=None, label="same"), + }, + nested_managers={"nested": nested_manager}, + ) + + CodeEditorFormUpdater.update_form_from_instance( + root_manager, + _RootConfig( + name="same", + nested=_NestedConfig(enabled=None, label="same"), + ), + ) + + assert root_manager.updated == [] + assert nested_manager.updated == [] + + +def test_update_form_from_instance_preserves_explicit_reset_to_none() -> None: + root_manager = _RecordingFormManager( + { + "name": "override", + "nested": None, + } + ) + + CodeEditorFormUpdater.update_form_from_instance( + root_manager, + _RootConfig(name=None, nested=None), + ) + + assert root_manager.updated == [("name", None)] + + +def test_update_form_from_instance_updates_unexpanded_dataclass_once() -> None: + original_nested = _NestedConfig(enabled=None, label="same") + replacement_nested = _NestedConfig(enabled=True, label="same") + root_manager = _RecordingFormManager( + { + "name": "same", + "nested": original_nested, + } + ) + + CodeEditorFormUpdater.update_form_from_instance( + root_manager, + _RootConfig(name="same", nested=replacement_nested), + ) + + assert root_manager.updated == [("nested", replacement_nested)] + + +def test_update_form_from_non_dataclass_step_dispatches_only_changed_field() -> None: + root_manager = _RecordingFormManager( + { + "name": "Original", + "description": None, + "enabled": True, + } + ) + + CodeEditorFormUpdater.update_form_from_instance( + root_manager, + _Step(name="Renamed"), + ) + + assert root_manager.updated == [("name", "Renamed")] diff --git a/tests/test_config_window.py b/tests/test_config_window.py new file mode 100644 index 00000000..f09f9737 --- /dev/null +++ b/tests/test_config_window.py @@ -0,0 +1,72 @@ +"""A config window edits ObjectState configs and renders them in the application's format.""" + +from __future__ import annotations + +from dataclasses import dataclass + +import pytest +from objectstate import ObjectState, ObjectStateRegistry, set_base_config_type + +from pyqt_reactive.windows.config_window import ( + ConfigDocumentFormat, + ConfigSaveParticipant, + ConfigWindow, + ConfigWindowTabSpec, +) + + +@dataclass(frozen=True) +class Settings: + count: int = 1 + + +class LineFormat(ConfigDocumentFormat): + """``count=`` per document.""" + + def render(self, config, *, expected_config_type, clean_mode=True) -> str: + assert isinstance(config, expected_config_type) + return f"count={config.count}" + + def from_source(self, source: str, *, expected_config_type): + return expected_config_type(count=int(source.split("=", 1)[1])) + + +class SettingsWindow(ConfigWindow): + document_format = LineFormat() + + +@pytest.fixture(autouse=True) +def _clear_registry(): + set_base_config_type(Settings) + ObjectStateRegistry.clear() + yield + ObjectStateRegistry.clear() + + +def test_view_code_and_save_go_through_the_declared_format_and_participant(qapp) -> None: + state = ObjectState(Settings(count=2), scope_id="/settings") + ObjectStateRegistry.register(state, _skip_snapshot=True) + saved: list[object] = [] + window = SettingsWindow( + tabs=( + ConfigWindowTabSpec( + state=state, + save_participant=ConfigSaveParticipant(apply=saved.append, rollback=lambda _c: None), + ), + ), + scope_id="/settings", + ) + try: + qapp.processEvents() + driver = window.window_code_document_driver() + assert driver.read_document().source == "count=2" + + driver.apply_source("count=7") + window.save_config() + + assert [config.count for config in saved] == [7] + assert state.saved_object.count == 7 + finally: + window.before_managed_close() + window.deleteLater() + qapp.processEvents() From ff704d2fb4464afb96108ce2c2152ba9a38f3ab6 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Sun, 11 Oct 2026 04:03:35 -0400 Subject: [PATCH 12/16] Flash debug keys and provenance logging read scopes through ScopeKey Co-Authored-By: Claude Opus 5.5 --- src/pyqt_reactive/animation/flash_mixin.py | 6 +++--- .../widgets/shared/clickable_help_components.py | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/pyqt_reactive/animation/flash_mixin.py b/src/pyqt_reactive/animation/flash_mixin.py index 8bc5e194..bcd081f0 100644 --- a/src/pyqt_reactive/animation/flash_mixin.py +++ b/src/pyqt_reactive/animation/flash_mixin.py @@ -2265,7 +2265,7 @@ def paintEvent(self, event) -> None: continue if not rect.isValid() or not rect.intersects(dirty_rect): if debug_enabled: - short_key = record.key.split('::')[-1] if '::' in record.key else record.key[-30:] + short_key = ScopeKey(record.key).last logger.debug( "[FLASH] SKIPPED key=%s source=%s rect=%s (invalid or off-screen)", short_key, @@ -2279,7 +2279,7 @@ def paintEvent(self, event) -> None: painter.setPen(Qt.PenStyle.NoPen) if record.path is not None: if debug_enabled: - short_key = record.key.split('::')[-1] if '::' in record.key else record.key[-30:] + short_key = ScopeKey(record.key).last br = record.path.boundingRect() logger.debug( "[FLASH] DRAWING WITH PATH key=%s source=%s fillRect=%d,%d %dx%d path.boundingRect=%.0f,%.0f %.0fx%.0f", @@ -2302,7 +2302,7 @@ def paintEvent(self, event) -> None: painter.restore() if debug_enabled: - short_key = record.key.split('::')[-1] if '::' in record.key else record.key[-30:] + short_key = ScopeKey(record.key).last logger.debug( "[FLASH] DREW RECT key=%s source=%s rect=%d,%d %dx%d radius=%s path=%s", short_key, diff --git a/src/pyqt_reactive/widgets/shared/clickable_help_components.py b/src/pyqt_reactive/widgets/shared/clickable_help_components.py index e8560abe..69e18c27 100644 --- a/src/pyqt_reactive/widgets/shared/clickable_help_components.py +++ b/src/pyqt_reactive/widgets/shared/clickable_help_components.py @@ -567,7 +567,7 @@ def _navigate_to_source(self, source_scope_id: str, source_type: type) -> None: logger.warning("Provenance navigation unavailable: %s", exc) return - logger.info(f"🔗 Navigate: {self._dotted_path} → {source_scope_id}::{target_path}") + 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 ( From 193dcbec2c9b6284d8653662be33aabe01946045 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Sun, 11 Oct 2026 04:04:46 -0400 Subject: [PATCH 13/16] Release 0.5.0 against objectstate 1.4 and python-introspect 0.2.6 requirements-ci.txt validates against the unreleased ObjectState and python-introspect branch heads until they are published. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 41 +++++++++++++++++++++++++++++++++++ pyproject.toml | 2 +- requirements-ci.txt | 2 +- src/pyqt_reactive/__init__.py | 2 +- 4 files changed, 44 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d9ea8cc9..fd4d681d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,46 @@ # Changelog +## 0.5.0 + +Generic Qt machinery moved here from OpenHCS, with the application's choices +injected instead of named: + +- `widgets.editable_table`: editable tables whose columns and cell editors + derive from a row dataclass (labels from python-introspect's `FieldLabel`). +- `widgets.grid_selector`: `GridSelectorWidget` selects cells of a row-and-column + grid; the application implements `GridAddressing`. +- `widgets.time_travel` (`TimeTravelCommands`, `TimeTravelWidget`), + `windows.snapshot_browser_window` and `services.time_travel_navigation` + (`TimeTravelNavigator`, navigation targets as a class family). +- `windows.config_window`: tabbed config windows over ObjectState + (`ConfigWindow`, `ConfigEditSession`, `ConfigCodeDocumentDriver`); the + application implements `ConfigDocumentFormat`. `services.code_editor_form_updater`. +- `widgets.shared.dock_layout` (`DockPane`, `DockFloatController`, `DockPanes`, + `DockLayoutStore`) and `services.application_shortcuts` (`ShortcutLifecycle`). + +Changed: + +- `PathCacheKey` is an open class family; each context is a subclass whose + stored key is its snake-case class name. `PathNameRole` is a class family. + Removed: the enum members naming one application's file kinds, the unused + keys, `get_cached_path`, `clear_cache`, `remove_cached_path` and the four + backward-compatibility alias functions. +- `use_external_code_editor()` reads the environment variable named by + `FormGenConfig.external_editor_env_var`; library widgets no longer read an + application's variable. +- Scope hierarchy operations use `objectstate.ScopeKey`; containment is by + whole segments. +- `AbstractManagerWidget` no longer requires `service_adapter.get_event_bus()`. +- `PatternDataManager.extract_func_and_kwargs` raises `TypeError` for a + malformed entry instead of returning `(None, {})`. + +Fixed: + +- The manager header's status marquee reuses one timer; each long status + message used to leave another live `QTimer` on the manager widget. + +Requires objectstate 1.4 and python-introspect 0.2.6. + ## 0.4.0 - Remove `pyqt_reactive.process_launch`. The background process launch policy diff --git a/pyproject.toml b/pyproject.toml index e3df2ece..3c517041 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -29,7 +29,7 @@ dependencies = [ "magicgui>=0.7.0", "metaclass-registry>=0.1.5", "PyQt6>=6.4.0", - "objectstate>=1.1.6", + "objectstate>=1.4,<2", "psutil>=5.9", "Pygments>=2.15", "pyzmq>=22.0", diff --git a/requirements-ci.txt b/requirements-ci.txt index 9c3704fd..4e28a5fe 100644 --- a/requirements-ci.txt +++ b/requirements-ci.txt @@ -1,3 +1,3 @@ # Source candidates needed to validate this checkout before coordinated publication. -objectstate @ git+https://github.com/OpenHCSDev/ObjectState.git@44379b0c75fb5bb69c27b4c6a3f4ca6eb28712ca +objectstate @ git+https://github.com/OpenHCSDev/ObjectState.git@9e32ef3619aef50f817c77252a8ad7cc01035307 python-introspect @ git+https://github.com/OpenHCSDev/python-introspect.git@d8071f0f14471416c3ee27b5dadd7b463382c3ff diff --git a/src/pyqt_reactive/__init__.py b/src/pyqt_reactive/__init__.py index eea5000c..018b69cc 100644 --- a/src/pyqt_reactive/__init__.py +++ b/src/pyqt_reactive/__init__.py @@ -19,7 +19,7 @@ - Cross-window reactive updates """ -__version__ = "0.4.0" +__version__ = "0.5.0" # Public API will be populated as modules are added __all__ = [ From 86d9ccd08a0e5bea5960cd92faed0eeab8a08481 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Sun, 11 Oct 2026 04:09:53 -0400 Subject: [PATCH 14/16] TimeTravelNavigator calls target_shown only for a window it focused on a target Co-Authored-By: Claude Opus 5.5 --- src/pyqt_reactive/services/time_travel_navigation.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/pyqt_reactive/services/time_travel_navigation.py b/src/pyqt_reactive/services/time_travel_navigation.py index dfc96343..9ab30dc1 100644 --- a/src/pyqt_reactive/services/time_travel_navigation.py +++ b/src/pyqt_reactive/services/time_travel_navigation.py @@ -114,11 +114,11 @@ class TimeTravelNavigator: Only the scope that triggered the restored snapshot (and, for a function child, its owning editor) is followed: snapshots capture the whole registry, so older dirty states would otherwise reopen unrelated editors. - ``window_shown`` lets the application adjust a window it navigated to. + ``target_shown`` lets the application adjust a window it navigated to. """ function_scope: FunctionScopeResolver - window_shown: Callable[[str, NavigationTarget | None], None] = lambda _scope, _target: None + target_shown: Callable[[str, NavigationTarget], None] = lambda _scope, _target: None def on_time_travel_complete(self, dirty_states, triggering_scope: str | None) -> None: """Registry callback: navigate once the restore's own refreshes have run.""" @@ -211,6 +211,6 @@ def navigate( ) if result.created: logger.info("Time travel opened the window for %s", request.scope_id) - if result.window is not None: - self.window_shown(request.scope_id, request.target) + if result.window is not None and request.target is not None: + self.target_shown(request.scope_id, request.target) From 3a0b944fbbc1c9640fd15e00bb1c5687806df913 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Sun, 11 Oct 2026 04:35:57 -0400 Subject: [PATCH 15/16] Shortcut focus check works without QScintilla; lint on added lines; CI ObjectState head Application shortcuts yield inside either code editor dialog, so the check no longer imports PyQt6.Qsci, an optional dependency the CI does not install. Co-Authored-By: Claude Opus 5.5 --- requirements-ci.txt | 2 +- src/pyqt_reactive/services/application_shortcuts.py | 9 ++++++--- src/pyqt_reactive/windows/config_window.py | 2 +- tests/test_code_editor_form_updater.py | 2 +- tests/test_config_window.py | 4 +++- 5 files changed, 12 insertions(+), 7 deletions(-) diff --git a/requirements-ci.txt b/requirements-ci.txt index 4e28a5fe..97bf8786 100644 --- a/requirements-ci.txt +++ b/requirements-ci.txt @@ -1,3 +1,3 @@ # Source candidates needed to validate this checkout before coordinated publication. -objectstate @ git+https://github.com/OpenHCSDev/ObjectState.git@9e32ef3619aef50f817c77252a8ad7cc01035307 +objectstate @ git+https://github.com/OpenHCSDev/ObjectState.git@4317a8ca91aa8a74b341dbea15eacfbb2c786b9e python-introspect @ git+https://github.com/OpenHCSDev/python-introspect.git@d8071f0f14471416c3ee27b5dadd7b463382c3ff diff --git a/src/pyqt_reactive/services/application_shortcuts.py b/src/pyqt_reactive/services/application_shortcuts.py index 20057566..cd3c5ff2 100644 --- a/src/pyqt_reactive/services/application_shortcuts.py +++ b/src/pyqt_reactive/services/application_shortcuts.py @@ -26,15 +26,18 @@ def from_event(cls, event) -> str | None: class CodeEditorFocus: - """Application shortcuts yield while a code editor has keyboard focus.""" + """Application shortcuts yield while a code editor dialog has keyboard focus.""" @staticmethod def allows_application_shortcuts() -> bool: - from PyQt6.Qsci import QsciScintilla + from pyqt_reactive.widgets.editors.simple_code_editor import ( + CodeEditorDialog, + QScintillaCodeEditorDialog, + ) widget = QApplication.focusWidget() while widget is not None: - if isinstance(widget, QsciScintilla): + if isinstance(widget, (QScintillaCodeEditorDialog, CodeEditorDialog)): return False widget = widget.parentWidget() return True diff --git a/src/pyqt_reactive/windows/config_window.py b/src/pyqt_reactive/windows/config_window.py index 71143764..34367613 100644 --- a/src/pyqt_reactive/windows/config_window.py +++ b/src/pyqt_reactive/windows/config_window.py @@ -3,8 +3,8 @@ from __future__ import annotations import dataclasses -from abc import ABC, abstractmethod import logging +from abc import ABC, abstractmethod from collections.abc import Callable from dataclasses import dataclass, field from functools import partial diff --git a/tests/test_code_editor_form_updater.py b/tests/test_code_editor_form_updater.py index 57641c95..14b93c09 100644 --- a/tests/test_code_editor_form_updater.py +++ b/tests/test_code_editor_form_updater.py @@ -33,7 +33,7 @@ def __init__( self, parameters: dict[str, object], *, - nested_managers: dict[str, "_RecordingFormManager"] | None = None, + nested_managers: dict[str, _RecordingFormManager] | None = None, ) -> None: self.parameters = parameters self.nested_managers = nested_managers or {} diff --git a/tests/test_config_window.py b/tests/test_config_window.py index f09f9737..1a8daa16 100644 --- a/tests/test_config_window.py +++ b/tests/test_config_window.py @@ -51,7 +51,9 @@ def test_view_code_and_save_go_through_the_declared_format_and_participant(qapp) tabs=( ConfigWindowTabSpec( state=state, - save_participant=ConfigSaveParticipant(apply=saved.append, rollback=lambda _c: None), + save_participant=ConfigSaveParticipant( + apply=saved.append, rollback=lambda _c: None + ), ), ), scope_id="/settings", From 21fa7a072aa2a44f792352cb30cd2fba5602caa9 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Sun, 11 Oct 2026 05:24:50 -0400 Subject: [PATCH 16/16] Depend on released objectstate 2.0 and python-introspect 0.2.6 Co-Authored-By: Claude Opus 5.5 --- pyproject.toml | 4 ++-- requirements-ci.txt | 3 +-- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 3c517041..3d7300c0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -29,11 +29,11 @@ dependencies = [ "magicgui>=0.7.0", "metaclass-registry>=0.1.5", "PyQt6>=6.4.0", - "objectstate>=1.4,<2", + "objectstate>=2.0,<3", "psutil>=5.9", "Pygments>=2.15", "pyzmq>=22.0", - "python-introspect>=0.2.6", + "python-introspect>=0.2.6,<0.3", "zmqruntime>=0.6,<0.7", ] diff --git a/requirements-ci.txt b/requirements-ci.txt index 97bf8786..8d6bb699 100644 --- a/requirements-ci.txt +++ b/requirements-ci.txt @@ -1,3 +1,2 @@ # Source candidates needed to validate this checkout before coordinated publication. -objectstate @ git+https://github.com/OpenHCSDev/ObjectState.git@4317a8ca91aa8a74b341dbea15eacfbb2c786b9e -python-introspect @ git+https://github.com/OpenHCSDev/python-introspect.git@d8071f0f14471416c3ee27b5dadd7b463382c3ff +# Empty: every first-party dependency is released (objectstate 2.0.0, python-introspect 0.2.6).