Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
4 changes: 2 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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",
]

Expand Down
2 changes: 1 addition & 1 deletion requirements-ci.txt
Original file line number Diff line number Diff line change
@@ -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).
2 changes: 1 addition & 1 deletion src/pyqt_reactive/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
- Cross-window reactive updates
"""

__version__ = "0.4.0"
__version__ = "0.5.0"

# Public API will be populated as modules are added
__all__ = [
Expand Down
84 changes: 28 additions & 56 deletions src/pyqt_reactive/animation/flash_mixin.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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.

Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand All @@ -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",
Expand All @@ -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,
Expand Down Expand Up @@ -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.
Expand All @@ -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

Expand All @@ -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()
Expand Down Expand Up @@ -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)
Expand All @@ -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,)

Expand All @@ -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)
Expand Down Expand Up @@ -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(
Expand Down
Loading
Loading