feat(lea): substitute save menu gesture
This commit is contained in:
@@ -16,6 +16,7 @@ Auteur: Dom — Mars 2026
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
import re
|
import re
|
||||||
|
import unicodedata
|
||||||
import uuid
|
import uuid
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from difflib import SequenceMatcher
|
from difflib import SequenceMatcher
|
||||||
@@ -24,6 +25,11 @@ from typing import Dict, List, Optional, Tuple
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
SAVE_COMMAND_LABELS = {"enregistrer", "save", "sauvegarder"}
|
||||||
|
SAVE_AS_LABELS = {"enregistrer sous", "save as", "sauvegarder sous"}
|
||||||
|
FILE_MENU_LABELS = {"fichier", "file", "menu fichier", "file menu"}
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class Gesture:
|
class Gesture:
|
||||||
"""Un geste primitif universel."""
|
"""Un geste primitif universel."""
|
||||||
@@ -564,6 +570,7 @@ class GestureCatalog:
|
|||||||
Patterns :
|
Patterns :
|
||||||
- Clic en haut à droite de la fenêtre (x > 95%, y < 5%) → fermer
|
- Clic en haut à droite de la fenêtre (x > 95%, y < 5%) → fermer
|
||||||
- target_text contenant ✕, ×, X, □, ─, etc.
|
- target_text contenant ✕, ×, X, □, ─, etc.
|
||||||
|
- Commande applicative "Enregistrer" sûre → Ctrl+S
|
||||||
"""
|
"""
|
||||||
# Vérifier le target_text
|
# Vérifier le target_text
|
||||||
target_text = (
|
target_text = (
|
||||||
@@ -583,6 +590,9 @@ class GestureCatalog:
|
|||||||
if target_lower in ("─", "—", "_", "minimize", "réduire"):
|
if target_lower in ("─", "—", "_", "minimize", "réduire"):
|
||||||
return self._by_id.get("win_minimize")
|
return self._by_id.get("win_minimize")
|
||||||
|
|
||||||
|
if self._is_save_command_action(action):
|
||||||
|
return self._by_id.get("edit_save")
|
||||||
|
|
||||||
# Vérifier la position relative (coin haut-droite = fermer)
|
# Vérifier la position relative (coin haut-droite = fermer)
|
||||||
x_pct = action.get("x_pct", 0)
|
x_pct = action.get("x_pct", 0)
|
||||||
y_pct = action.get("y_pct", 0)
|
y_pct = action.get("y_pct", 0)
|
||||||
@@ -596,6 +606,128 @@ class GestureCatalog:
|
|||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
def _normalize_ui_text(self, value: str) -> str:
|
||||||
|
"""Normaliser un libellé UI pour comparer accents, casse et raccourcis."""
|
||||||
|
text = str(value or "").strip().lower()
|
||||||
|
text = unicodedata.normalize("NFKD", text)
|
||||||
|
text = "".join(ch for ch in text if not unicodedata.combining(ch))
|
||||||
|
text = text.replace("’", "'")
|
||||||
|
text = re.sub(r"\s+", " ", text)
|
||||||
|
text = re.sub(r"\s*\([^)]*ctrl\s*\+?\s*s[^)]*\)\s*$", "", text)
|
||||||
|
text = re.sub(r"\s+ctrl\s*\+?\s*s\s*$", "", text)
|
||||||
|
return text.strip()
|
||||||
|
|
||||||
|
def _action_text_candidates(self, action: Dict) -> List[str]:
|
||||||
|
"""Retourner les libellés utiles d'une action et de son target_spec."""
|
||||||
|
target_spec = action.get("target_spec") or {}
|
||||||
|
candidates = [
|
||||||
|
action.get("target_text", ""),
|
||||||
|
action.get("target_description", ""),
|
||||||
|
action.get("description", ""),
|
||||||
|
target_spec.get("by_text", ""),
|
||||||
|
target_spec.get("target_text", ""),
|
||||||
|
target_spec.get("vlm_description", ""),
|
||||||
|
]
|
||||||
|
return [str(c) for c in candidates if c]
|
||||||
|
|
||||||
|
def _action_role_text(self, action: Dict) -> str:
|
||||||
|
target_spec = action.get("target_spec") or {}
|
||||||
|
uia = action.get("uia_snapshot") or {}
|
||||||
|
role_parts = [
|
||||||
|
action.get("role", ""),
|
||||||
|
action.get("control_type", ""),
|
||||||
|
target_spec.get("by_role", ""),
|
||||||
|
target_spec.get("role", ""),
|
||||||
|
target_spec.get("control_type", ""),
|
||||||
|
uia.get("control_type", ""),
|
||||||
|
uia.get("class_name", ""),
|
||||||
|
]
|
||||||
|
return " ".join(self._normalize_ui_text(part) for part in role_parts if part)
|
||||||
|
|
||||||
|
def _action_context_text(self, action: Dict) -> str:
|
||||||
|
target_spec = action.get("target_spec") or {}
|
||||||
|
hints = target_spec.get("context_hints") or {}
|
||||||
|
context_parts = [
|
||||||
|
action.get("window_title", ""),
|
||||||
|
target_spec.get("window_title", ""),
|
||||||
|
target_spec.get("vlm_description", ""),
|
||||||
|
hints.get("window_title", ""),
|
||||||
|
hints.get("interaction", ""),
|
||||||
|
hints.get("source", ""),
|
||||||
|
hints.get("menu_path", ""),
|
||||||
|
]
|
||||||
|
return " ".join(self._normalize_ui_text(part) for part in context_parts if part)
|
||||||
|
|
||||||
|
def _is_file_menu_action(self, action: Dict) -> bool:
|
||||||
|
labels = {self._normalize_ui_text(text) for text in self._action_text_candidates(action)}
|
||||||
|
return bool(labels & FILE_MENU_LABELS)
|
||||||
|
|
||||||
|
def _is_save_command_label(self, action: Dict) -> bool:
|
||||||
|
for text in self._action_text_candidates(action):
|
||||||
|
label = self._normalize_ui_text(text)
|
||||||
|
if not label:
|
||||||
|
continue
|
||||||
|
if any(save_as in label for save_as in SAVE_AS_LABELS):
|
||||||
|
return False
|
||||||
|
if label in SAVE_COMMAND_LABELS:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _is_save_dialog_action(self, action: Dict) -> bool:
|
||||||
|
context = self._action_context_text(action)
|
||||||
|
if any(save_as in context for save_as in SAVE_AS_LABELS):
|
||||||
|
return True
|
||||||
|
dialog_markers = (
|
||||||
|
"save dialog",
|
||||||
|
"save_dialog",
|
||||||
|
"dialog",
|
||||||
|
"boite de dialogue",
|
||||||
|
"fenetre enregistrer sous",
|
||||||
|
"confirmer l'enregistrement",
|
||||||
|
"save changes",
|
||||||
|
)
|
||||||
|
return any(marker in context for marker in dialog_markers)
|
||||||
|
|
||||||
|
def _is_save_command_action(self, action: Dict) -> bool:
|
||||||
|
if not self._is_save_command_label(action):
|
||||||
|
return False
|
||||||
|
if self._is_save_dialog_action(action):
|
||||||
|
return False
|
||||||
|
|
||||||
|
role = self._action_role_text(action)
|
||||||
|
context = self._action_context_text(action)
|
||||||
|
command_markers = (
|
||||||
|
"menu",
|
||||||
|
"menuitem",
|
||||||
|
"item de menu",
|
||||||
|
"toolbar",
|
||||||
|
"barre d'outils",
|
||||||
|
"tool bar",
|
||||||
|
"ruban",
|
||||||
|
"ribbon",
|
||||||
|
"commande",
|
||||||
|
"command",
|
||||||
|
)
|
||||||
|
return any(marker in role or marker in context for marker in command_markers)
|
||||||
|
|
||||||
|
def _substitute_action(
|
||||||
|
self,
|
||||||
|
action: Dict,
|
||||||
|
gesture: Gesture,
|
||||||
|
*,
|
||||||
|
original_type: str,
|
||||||
|
source_action_ids: Optional[List[str]] = None,
|
||||||
|
reason: str = "",
|
||||||
|
) -> Dict:
|
||||||
|
new_action = gesture.to_replay_action()
|
||||||
|
new_action["action_id"] = action.get("action_id", new_action["action_id"])
|
||||||
|
new_action["original_type"] = original_type
|
||||||
|
if source_action_ids:
|
||||||
|
new_action["substitution_source_action_ids"] = source_action_ids
|
||||||
|
if reason:
|
||||||
|
new_action["substitution_reason"] = reason
|
||||||
|
return new_action
|
||||||
|
|
||||||
def optimize_replay_actions(self, actions: List[Dict]) -> List[Dict]:
|
def optimize_replay_actions(self, actions: List[Dict]) -> List[Dict]:
|
||||||
"""
|
"""
|
||||||
Optimiser une liste d'actions de replay en substituant les gestes connus.
|
Optimiser une liste d'actions de replay en substituant les gestes connus.
|
||||||
@@ -610,13 +742,45 @@ class GestureCatalog:
|
|||||||
substitutions = 0
|
substitutions = 0
|
||||||
|
|
||||||
for action in actions:
|
for action in actions:
|
||||||
|
if (
|
||||||
|
action.get("type") == "click"
|
||||||
|
and optimized
|
||||||
|
and optimized[-1].get("type") == "click"
|
||||||
|
and self._is_file_menu_action(optimized[-1])
|
||||||
|
and self._is_save_command_label(action)
|
||||||
|
and not self._is_save_dialog_action(action)
|
||||||
|
):
|
||||||
|
gesture = self._by_id.get("edit_save")
|
||||||
|
previous = optimized.pop()
|
||||||
|
source_ids = [
|
||||||
|
source_id for source_id in (
|
||||||
|
previous.get("action_id"),
|
||||||
|
action.get("action_id"),
|
||||||
|
)
|
||||||
|
if source_id
|
||||||
|
]
|
||||||
|
optimized.append(
|
||||||
|
self._substitute_action(
|
||||||
|
action,
|
||||||
|
gesture,
|
||||||
|
original_type="click_sequence",
|
||||||
|
source_action_ids=source_ids,
|
||||||
|
reason="file_menu_save_to_ctrl_s",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
substitutions += 1
|
||||||
|
logger.debug("Séquence Fichier > Enregistrer substituée par Ctrl+S")
|
||||||
|
continue
|
||||||
|
|
||||||
gesture = self.match_action(action)
|
gesture = self.match_action(action)
|
||||||
if gesture and action.get("type") != "key_combo":
|
if gesture and action.get("type") != "key_combo":
|
||||||
# Substituer par le raccourci clavier
|
# Substituer par le raccourci clavier
|
||||||
new_action = gesture.to_replay_action()
|
new_action = self._substitute_action(
|
||||||
# Conserver l'action_id original pour le tracking
|
action,
|
||||||
new_action["action_id"] = action.get("action_id", new_action["action_id"])
|
gesture,
|
||||||
new_action["original_type"] = action.get("type")
|
original_type=action.get("type", ""),
|
||||||
|
reason=f"{gesture.id}_gesture_substitution",
|
||||||
|
)
|
||||||
optimized.append(new_action)
|
optimized.append(new_action)
|
||||||
substitutions += 1
|
substitutions += 1
|
||||||
logger.debug(
|
logger.debug(
|
||||||
|
|||||||
@@ -413,6 +413,101 @@ class TestReplayOptimization:
|
|||||||
assert optimized[0]["keys"] == ["alt", "f4"]
|
assert optimized[0]["keys"] == ["alt", "f4"]
|
||||||
assert optimized[0]["action_id"] == "t1"
|
assert optimized[0]["action_id"] == "t1"
|
||||||
|
|
||||||
|
def test_optimize_file_save_sequence_to_ctrl_s(self, catalog):
|
||||||
|
"""Fichier > Enregistrer est remplace par Ctrl+S."""
|
||||||
|
actions = [
|
||||||
|
{
|
||||||
|
"type": "click",
|
||||||
|
"target_text": "Fichier",
|
||||||
|
"target_spec": {"by_text": "Fichier", "by_role": "menuitem"},
|
||||||
|
"action_id": "file_menu",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "click",
|
||||||
|
"target_text": "Enregistrer",
|
||||||
|
"target_spec": {"by_text": "Enregistrer", "by_role": "menuitem"},
|
||||||
|
"action_id": "save_menu",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
optimized = catalog.optimize_replay_actions(actions)
|
||||||
|
assert len(optimized) == 1
|
||||||
|
assert optimized[0]["type"] == "key_combo"
|
||||||
|
assert optimized[0]["keys"] == ["ctrl", "s"]
|
||||||
|
assert optimized[0]["gesture_id"] == "edit_save"
|
||||||
|
assert optimized[0]["action_id"] == "save_menu"
|
||||||
|
assert optimized[0]["original_type"] == "click_sequence"
|
||||||
|
assert optimized[0]["substitution_source_action_ids"] == ["file_menu", "save_menu"]
|
||||||
|
assert optimized[0]["substitution_reason"] == "file_menu_save_to_ctrl_s"
|
||||||
|
|
||||||
|
def test_optimize_menu_save_action_to_ctrl_s(self, catalog):
|
||||||
|
"""Une commande de menu Enregistrer isolee est remplacee par Ctrl+S."""
|
||||||
|
actions = [
|
||||||
|
{
|
||||||
|
"type": "click",
|
||||||
|
"target_spec": {"by_text": "Enregistrer", "by_role": "menuitem"},
|
||||||
|
"action_id": "save_menu",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
optimized = catalog.optimize_replay_actions(actions)
|
||||||
|
assert len(optimized) == 1
|
||||||
|
assert optimized[0]["type"] == "key_combo"
|
||||||
|
assert optimized[0]["keys"] == ["ctrl", "s"]
|
||||||
|
assert optimized[0]["gesture_id"] == "edit_save"
|
||||||
|
|
||||||
|
def test_optimize_toolbar_save_action_to_ctrl_s(self, catalog):
|
||||||
|
"""Un bouton Enregistrer de barre d'outils est remplace par Ctrl+S."""
|
||||||
|
actions = [
|
||||||
|
{
|
||||||
|
"type": "click",
|
||||||
|
"target_spec": {
|
||||||
|
"by_text": "Enregistrer",
|
||||||
|
"by_role": "button",
|
||||||
|
"vlm_description": "Bouton Enregistrer dans la barre d'outils",
|
||||||
|
},
|
||||||
|
"action_id": "save_toolbar",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
optimized = catalog.optimize_replay_actions(actions)
|
||||||
|
assert len(optimized) == 1
|
||||||
|
assert optimized[0]["type"] == "key_combo"
|
||||||
|
assert optimized[0]["keys"] == ["ctrl", "s"]
|
||||||
|
assert optimized[0]["gesture_id"] == "edit_save"
|
||||||
|
|
||||||
|
def test_save_dialog_button_not_substituted(self, catalog):
|
||||||
|
"""Le bouton Enregistrer d'un dialogue Enregistrer sous reste un clic."""
|
||||||
|
actions = [
|
||||||
|
{
|
||||||
|
"type": "click",
|
||||||
|
"target_spec": {
|
||||||
|
"by_text": "Enregistrer",
|
||||||
|
"by_role": "button",
|
||||||
|
"window_title": "Enregistrer sous",
|
||||||
|
"context_hints": {
|
||||||
|
"interaction": "save_dialog_primary_button",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"action_id": "save_dialog",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
optimized = catalog.optimize_replay_actions(actions)
|
||||||
|
assert len(optimized) == 1
|
||||||
|
assert optimized[0] is actions[0]
|
||||||
|
assert optimized[0]["type"] == "click"
|
||||||
|
|
||||||
|
def test_save_as_menu_not_substituted(self, catalog):
|
||||||
|
"""Enregistrer sous n'est pas equivalent a Ctrl+S."""
|
||||||
|
actions = [
|
||||||
|
{
|
||||||
|
"type": "click",
|
||||||
|
"target_spec": {"by_text": "Enregistrer sous", "by_role": "menuitem"},
|
||||||
|
"action_id": "save_as_menu",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
optimized = catalog.optimize_replay_actions(actions)
|
||||||
|
assert len(optimized) == 1
|
||||||
|
assert optimized[0] is actions[0]
|
||||||
|
assert optimized[0]["type"] == "click"
|
||||||
|
|
||||||
def test_optimize_action_without_id(self, catalog):
|
def test_optimize_action_without_id(self, catalog):
|
||||||
"""Action substituee sans action_id recoit un id genere."""
|
"""Action substituee sans action_id recoit un id genere."""
|
||||||
actions = [{"type": "click", "x_pct": 0.97, "y_pct": 0.02}]
|
actions = [{"type": "click", "x_pct": 0.97, "y_pct": 0.02}]
|
||||||
|
|||||||
Reference in New Issue
Block a user