diff --git a/CHANGELOG.md b/CHANGELOG.md index d9ea8cc..fd4d681 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 a03c060..3d7300c 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.1.6", + "objectstate>=2.0,<3", "psutil>=5.9", "Pygments>=2.15", "pyzmq>=22.0", - "python-introspect>=0.2.5", + "python-introspect>=0.2.6,<0.3", "zmqruntime>=0.6,<0.7", ] diff --git a/requirements-ci.txt b/requirements-ci.txt index 60bef0b..8d6bb69 100644 --- a/requirements-ci.txt +++ b/requirements-ci.txt @@ -1,2 +1,2 @@ # Source candidates needed to validate this checkout before coordinated publication. -objectstate @ git+https://github.com/OpenHCSDev/ObjectState.git@44379b0c75fb5bb69c27b4c6a3f4ca6eb28712ca +# Empty: every first-party dependency is released (objectstate 2.0.0, python-introspect 0.2.6). diff --git a/src/pyqt_reactive/__init__.py b/src/pyqt_reactive/__init__.py index eea5000..018b69c 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__ = [ diff --git a/src/pyqt_reactive/animation/flash_mixin.py b/src/pyqt_reactive/animation/flash_mixin.py index 9005983..bcd081f 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, @@ -2273,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, @@ -2287,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", @@ -2310,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, @@ -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/core/path_cache.py b/src/pyqt_reactive/core/path_cache.py index 7488330..33395c3 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/forms/parameter_form_manager.py b/src/pyqt_reactive/forms/parameter_form_manager.py index fb5efdf..775ca44 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/protocols/form_config.py b/src/pyqt_reactive/protocols/form_config.py index 0faf4b8..1f504f8 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/application_shortcuts.py b/src/pyqt_reactive/services/application_shortcuts.py new file mode 100644 index 0000000..cd3c5ff --- /dev/null +++ b/src/pyqt_reactive/services/application_shortcuts.py @@ -0,0 +1,158 @@ +"""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 dialog has keyboard focus.""" + + @staticmethod + def allows_application_shortcuts() -> bool: + from pyqt_reactive.widgets.editors.simple_code_editor import ( + CodeEditorDialog, + QScintillaCodeEditorDialog, + ) + + widget = QApplication.focusWidget() + while widget is not None: + if isinstance(widget, (QScintillaCodeEditorDialog, CodeEditorDialog)): + 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/services/code_editor_form_updater.py b/src/pyqt_reactive/services/code_editor_form_updater.py new file mode 100644 index 0000000..f052896 --- /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/function_pattern_code_document.py b/src/pyqt_reactive/services/function_pattern_code_document.py index 2a0bbd4..ae09cc1 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/pattern_data_manager.py b/src/pyqt_reactive/services/pattern_data_manager.py index c1d99e7..05c7a2a 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/services/scope_color_service.py b/src/pyqt_reactive/services/scope_color_service.py index 3ba07ab..a3ee022 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 7b02e76..0b8e656 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/time_travel_navigation.py b/src/pyqt_reactive/services/time_travel_navigation.py new file mode 100644 index 0000000..9ab30dc --- /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. + ``target_shown`` lets the application adjust a window it navigated to. + """ + + function_scope: FunctionScopeResolver + 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.""" + + 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 and request.target is not None: + self.target_shown(request.scope_id, request.target) + diff --git a/src/pyqt_reactive/services/window_code_document.py b/src/pyqt_reactive/services/window_code_document.py index da1b5c2..2d31d31 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/services/window_manager.py b/src/pyqt_reactive/services/window_manager.py index 298e711..68fb0ce 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/editable_table.py b/src/pyqt_reactive/widgets/editable_table.py new file mode 100644 index 0000000..c001bb4 --- /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/src/pyqt_reactive/widgets/editors/simple_code_editor.py b/src/pyqt_reactive/widgets/editors/simple_code_editor.py index 1e35166..cbe6112 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 80c9b88..815c4c5 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/src/pyqt_reactive/widgets/function_list_editor.py b/src/pyqt_reactive/widgets/function_list_editor.py index c62c043..e6445ae 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 @@ -51,6 +50,7 @@ DetachableActionBarHost, ) from pyqt_reactive.widgets.shared.scope_visual_config import ScopeColorScheme +from objectstate import ScopeKey logger = logging.getLogger(__name__) @@ -221,14 +221,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 @@ -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 @@ -1085,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( @@ -1348,8 +1338,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 +1347,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 +1883,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/src/pyqt_reactive/widgets/function_pane.py b/src/pyqt_reactive/widgets/function_pane.py index 85f8abd..5a4bd7a 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/grid_selector.py b/src/pyqt_reactive/widgets/grid_selector.py new file mode 100644 index 0000000..42473cf --- /dev/null +++ b/src/pyqt_reactive/widgets/grid_selector.py @@ -0,0 +1,643 @@ +"""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 + +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(ABC): + """How an application's cell ids sit on the grid.""" + + @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``.""" + + +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/src/pyqt_reactive/widgets/shared/abstract_manager_widget.py b/src/pyqt_reactive/widgets/shared/abstract_manager_widget.py index a25e936..72c4565 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() ) diff --git a/src/pyqt_reactive/widgets/shared/clickable_help_components.py b/src/pyqt_reactive/widgets/shared/clickable_help_components.py index e8560ab..69e18c2 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 ( 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 0000000..6d9abb9 --- /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/src/pyqt_reactive/widgets/shared/manager_action_controller.py b/src/pyqt_reactive/widgets/shared/manager_action_controller.py index 982eade..adae853 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/widgets/shared/manager_status_controller.py b/src/pyqt_reactive/widgets/shared/manager_status_controller.py index 4eefb35..2d212f4 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/src/pyqt_reactive/widgets/shared/manager_time_travel_binding.py b/src/pyqt_reactive/widgets/shared/manager_time_travel_binding.py index c3a19b3..b6b516c 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/shared/scope_color_strategy.py b/src/pyqt_reactive/widgets/shared/scope_color_strategy.py index 9683909..aa4cc0b 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 06cfa28..bc91fba 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 f23ee6f..852f9f0 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/src/pyqt_reactive/widgets/time_travel.py b/src/pyqt_reactive/widgets/time_travel.py new file mode 100644 index 0000000..5a91eca --- /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/config_window.py b/src/pyqt_reactive/windows/config_window.py new file mode 100644 index 0000000..3436761 --- /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 +import logging +from abc import ABC, abstractmethod +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/src/pyqt_reactive/windows/snapshot_browser_window.py b/src/pyqt_reactive/windows/snapshot_browser_window.py new file mode 100644 index 0000000..0d313c1 --- /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_application_shortcuts.py b/tests/test_application_shortcuts.py new file mode 100644 index 0000000..9859a22 --- /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_code_editor_form_updater.py b/tests/test_code_editor_form_updater.py new file mode 100644 index 0000000..14b93c0 --- /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 0000000..1a8daa1 --- /dev/null +++ b/tests/test_config_window.py @@ -0,0 +1,74 @@ +"""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() diff --git a/tests/test_dock_layout.py b/tests/test_dock_layout.py new file mode 100644 index 0000000..4dd2a9d --- /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 diff --git a/tests/test_editable_table.py b/tests/test_editable_table.py new file mode 100644 index 0000000..b2e1aa4 --- /dev/null +++ b/tests/test_editable_table.py @@ -0,0 +1,155 @@ +"""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() diff --git a/tests/test_grid_selector.py b/tests/test_grid_selector.py new file mode 100644 index 0000000..8a013d4 --- /dev/null +++ b/tests/test_grid_selector.py @@ -0,0 +1,90 @@ +"""Grid selection over an application's cell addressing.""" + +from __future__ import annotations + +from PyQt6.QtCore import QPoint + +from pyqt_reactive.widgets.grid_selector import GridAddressing, GridModel, GridSelectorWidget + + +class LetterRowAddressing(GridAddressing): + """Cell ids like ``B2``: a row letter and a column number.""" + + default_grid = (8, 12) + + 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:])) + + 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"}) + + 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() diff --git a/tests/test_manager_ui_scaffold.py b/tests/test_manager_ui_scaffold.py index 3562687..72d09a9 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() diff --git a/tests/test_path_name_roles.py b/tests/test_path_name_roles.py index 051b2e3..fe175cd 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"] diff --git a/tests/test_pattern_data_manager.py b/tests/test_pattern_data_manager.py new file mode 100644 index 0000000..65af203 --- /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 diff --git a/tests/test_time_travel.py b/tests/test_time_travel.py new file mode 100644 index 0000000..af2cf73 --- /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()