"""Scène d'intention attendue — workpack A attention scope multi-écrans. Cf. docs/coordination/inbox_codex/2026-05-25_0610_claude-to-codex_workpack-A-attention-scope-multi-ecrans.md """ from __future__ import annotations from dataclasses import dataclass, field, asdict from typing import Any, Dict, List, Optional, Tuple @dataclass(frozen=True) class SceneExpected: """Description du périmètre visuel attendu pour servir l'intention. Construit au build serveur, transporté additif jusqu'au client, consommé par une garde `_assert_scene_active()` avant tout geste — surtout les raccourcis clavier qui partent sinon dans la fenêtre active globale. Attributs scene_id : ID stable de la scène app_name : nom de l'application attendue (ex 'Notepad') title_patterns : patterns de titre acceptables (substrings) title_anti : patterns de titre interdits (anti-intention) monitor_index : index du moniteur (1-based mss). None = quelconque monitor_geometry : (left, top, width, height) en pixels. Optionnel. window_rect_hint : (left, top, right, bottom) zone attendue. Optionnel. scene_role : 'editor' | 'dialog' | 'menu' | 'browser_tab' | ... required : True si le geste DOIT être bloqué si scène absente stability_ms : durée min de stabilité avant le geste accepted_transitions: scènes vers lesquelles transition est attendue """ scene_id: str = "" app_name: str = "" title_patterns: Tuple[str, ...] = field(default_factory=tuple) title_anti: Tuple[str, ...] = field(default_factory=tuple) monitor_index: Optional[int] = None monitor_geometry: Optional[Tuple[int, int, int, int]] = None window_rect_hint: Optional[Tuple[int, int, int, int]] = None scene_role: str = "" required: bool = True stability_ms: int = 0 accepted_transitions: Tuple[str, ...] = field(default_factory=tuple) def to_dict(self) -> Dict[str, Any]: d = asdict(self) d["title_patterns"] = list(self.title_patterns) d["title_anti"] = list(self.title_anti) d["accepted_transitions"] = list(self.accepted_transitions) if self.monitor_geometry is not None: d["monitor_geometry"] = list(self.monitor_geometry) if self.window_rect_hint is not None: d["window_rect_hint"] = list(self.window_rect_hint) return d @classmethod def from_dict(cls, data: Optional[Dict[str, Any]]) -> "SceneExpected": if not data: return cls() def _tuple_of_4(v): if v is None: return None try: lst = list(v) if len(lst) != 4: return None return tuple(int(x) for x in lst) except (TypeError, ValueError): return None return cls( scene_id=str(data.get("scene_id", "") or ""), app_name=str(data.get("app_name", "") or ""), title_patterns=tuple(str(x) for x in (data.get("title_patterns") or [])), title_anti=tuple(str(x) for x in (data.get("title_anti") or [])), monitor_index=(int(data["monitor_index"]) if data.get("monitor_index") is not None else None), monitor_geometry=_tuple_of_4(data.get("monitor_geometry")), window_rect_hint=_tuple_of_4(data.get("window_rect_hint")), scene_role=str(data.get("scene_role", "") or ""), required=bool(data.get("required", True)), stability_ms=int(data.get("stability_ms", 0) or 0), accepted_transitions=tuple(str(x) for x in (data.get("accepted_transitions") or [])), ) def matches_title(self, observed_title: str) -> bool: """Vrai si le titre observé est cohérent avec la scène (patterns + anti).""" if not observed_title: return False norm = observed_title.lower() for anti in self.title_anti: if anti and anti.lower() in norm: return False if not self.title_patterns: return True return any(p and p.lower() in norm for p in self.title_patterns) def is_empty(self) -> bool: return not (self.scene_id or self.app_name or self.title_patterns)