Deux fonctionnalités demandées, développées et corrigées dans cet
échange :
1. "Donnée liée" (Texte/Titre) : le réglage à 2 filtres fixes (toujours
combinés en ET) devient une liste de conditions ILLIMITÉE, avec un
choix ET/OU pour les combiner (screens/widgets/controls/c_clause_list.py,
screens/clause_list_codec.py). Rétrocompatible avec les anciens
éléments (_data_filtre_champ/_data_filtre2_champ), convertis à la
volée à la lecture, sans migration. Après un premier essai à la
présentation trop compacte et technique (retour utilisateur : "pas de
champ technique, pas de notation bizarre {{ }}"), la présentation
finale reprend EXACTEMENT l'ancien style (labels "Champ"/"...est"/
"...cette valeur", même sélecteur de valeur fixe/dynamique/variable
déjà existant, jamais la syntaxe brute), simplement répétée par
condition (templates/partials/clause_row.html), avec un bouton
"+ Ajouter une condition" bien visible et une liste scrollable
(static/style.css, .clauseListWrap). Le même moteur (filter_repeater_
rows.py généralisé) profite aussi au Répéteur de données en interne.
2. Nœud Condition de la Logique de la scène : peut désormais tester une
VARIABLE GLOBALE en plus d'un champ d'objet (cond_source/cond_variable/
cond_variable_chemin — screens/flow/ensure_flow_schema.py), sur la
clause principale ET chaque clause supplémentaire (ET/OU). Évalué côté
CLIENT (templates/play.html, evaluateConditionClause), contre un
nouveau gameData.variables exposé par full_game_payload.py — tenu à
jour par refreshRuntimeData() après toute action qui modifie une
variable, sans changement supplémentaire nécessaire. Le panneau de
condition reste utilisable même sans aucun objet défini dans le jeu
(avant, il disparaissait entièrement).
Vérifié : 153 tests pytest (nouveaux : test_data_binding_clause_list.py,
test_condition_variable.py) + logique JS d'évaluation des conditions
vérifiée isolément avec Node (variable scalaire, objet avec chemin
chaîné, tableau par index, variable introuvable, booléen, rétrocompatibilité
legacy) + rendu des deux pages (éditeur/jeu) vérifié sur le vrai projet
"test" en plus des jeux de test.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
228 lines
10 KiB
Python
228 lines
10 KiB
Python
import json
|
|
|
|
import db
|
|
|
|
from ..widgets.widget_meta import widget_meta
|
|
from ..widgets.layout_capable_widgets import LAYOUT_CAPABLE_WIDGETS
|
|
from ..clause_list_codec import encode_clauses
|
|
from .get_element import get_element
|
|
|
|
|
|
def _hex_to_rgb(hex_color):
|
|
h = (hex_color or "").lstrip("#")
|
|
if len(h) != 6:
|
|
return (0, 0, 0)
|
|
try:
|
|
return tuple(int(h[i:i + 2], 16) for i in (0, 2, 4))
|
|
except ValueError:
|
|
return (0, 0, 0)
|
|
|
|
|
|
def _is_default_value(control, raw_value):
|
|
"""Compare la valeur reçue du formulaire à la valeur par défaut du
|
|
réglage (en tenant compte de l'unité ajoutée pour les sliders), pour
|
|
savoir si le champ a simplement été renvoyé tel quel sans être
|
|
personnalisé."""
|
|
default = control.get("default", "")
|
|
if control["type"] == "slider":
|
|
unit = control.get("unit", "")
|
|
default = f"{default}{unit}" if default not in (None, "") else ""
|
|
return str(raw_value) == str(default)
|
|
|
|
|
|
def save_element_controls(slug, element_id, form):
|
|
"""Lit, pour chaque réglage du widget de cet élément, la valeur envoyée
|
|
par le panneau de propriétés (un seul formulaire, un champ par réglage)
|
|
et met à jour content / attributs / style en conséquence."""
|
|
el = get_element(slug, element_id)
|
|
if not el:
|
|
return
|
|
meta = widget_meta(el["widget"])
|
|
content = el["content"]
|
|
attributes = dict(el["attributes"])
|
|
style = dict(el["style"])
|
|
toggle_groups = {} # propriété CSS -> ensemble des "tokens" cochés (ex: text-decoration)
|
|
|
|
for control in meta.get("controls", []):
|
|
ctype = control["type"]
|
|
field_name = "ctrl_" + control["key"]
|
|
|
|
if ctype == "toggle_token":
|
|
_kind, name = control["target"].split(":", 1)
|
|
toggle_groups.setdefault(name, set())
|
|
if form.get(field_name) is not None:
|
|
toggle_groups[name].add(control["token"])
|
|
continue
|
|
|
|
if ctype == "clause_list":
|
|
# Nombre ILLIMITÉ de lignes (voir c_clause_list.py) : un champ
|
|
# tableau par colonne ("field_name_champ[]"/"_operateur[]"/
|
|
# "_valeur[]"), reconstituées ici en une liste de clauses par
|
|
# position (zip) — même principe que field_name[]/field_type[]
|
|
# pour les champs d'un objet (routes/objects/parse_field_rows.py).
|
|
champs = form.getlist(field_name + "_champ[]")
|
|
operateurs = form.getlist(field_name + "_operateur[]")
|
|
valeurs = form.getlist(field_name + "_valeur[]")
|
|
clauses = [
|
|
{"champ": champ, "operateur": operateurs[i] if i < len(operateurs) else "egal",
|
|
"valeur": valeurs[i] if i < len(valeurs) else ""}
|
|
for i, champ in enumerate(champs)
|
|
]
|
|
encoded = encode_clauses(clauses)
|
|
if encoded:
|
|
attributes[control["clauses_attr"]] = encoded
|
|
else:
|
|
attributes.pop(control["clauses_attr"], None)
|
|
combinateur = form.get(field_name + "_combinateur") or "et"
|
|
if combinateur == "ou":
|
|
attributes[control["combinator_attr"]] = combinateur
|
|
else:
|
|
attributes.pop(control["combinator_attr"], None)
|
|
continue
|
|
|
|
if ctype == "preset":
|
|
chosen = form.get(field_name, control.get("default"))
|
|
opt = next((o for o in control["options"] if o[0] == chosen), None)
|
|
if opt:
|
|
for k, v in opt[2].items():
|
|
if v in (None, ""):
|
|
style.pop(k, None)
|
|
else:
|
|
style[k] = v
|
|
if (
|
|
control["key"] == "visibilite" and chosen == "visible"
|
|
and el["widget"] not in LAYOUT_CAPABLE_WIDGETS
|
|
):
|
|
# "Visible" ne touche volontairement pas "display" (voir
|
|
# visibility_control.py) : sur un widget qui A un
|
|
# réglage "Disposition interne" (LAYOUT_CONTROLS,
|
|
# traité avant celui-ci sur le MÊME enregistrement,
|
|
# voir widget_meta.py), ce contrôle réaffirme lui-même
|
|
# un "display" non-"none" (flex/etc), donc "Visible" n'a
|
|
# rien à faire. Mais un widget SANS ce contrôle (ex.
|
|
# "superposition") n'a PERSONNE d'autre pour l'effacer :
|
|
# un "display:none" laissé par un "Masqué" précédent (ou
|
|
# par le réglage par défaut à la création, voir
|
|
# default_style_for_widget.py) restait donc bloqué
|
|
# masqué pour toujours, quel que soit le nombre de fois
|
|
# où "Visible" était ensuite choisi.
|
|
style.pop("display", None)
|
|
continue
|
|
|
|
if ctype == "scale":
|
|
try:
|
|
val = float(form.get(field_name, control.get("default", 1)))
|
|
except (TypeError, ValueError):
|
|
val = 1.0
|
|
if abs(val - 1) < 1e-6:
|
|
style.pop("transform", None)
|
|
else:
|
|
style["transform"] = f"scale({val})"
|
|
continue
|
|
|
|
if ctype == "size":
|
|
# Un seul champ CSS ("width"/"height") mais deux entrées de
|
|
# formulaire (valeur + unité) — voir size_override_controls.py
|
|
# pour pourquoi ce n'est PAS deux contrôles slider séparés.
|
|
_kind, name = control["target"].split(":", 1)
|
|
try:
|
|
num = float(form.get(field_name + "_value") or 0)
|
|
except (TypeError, ValueError):
|
|
num = 0.0
|
|
unit = form.get(field_name + "_unit", "%")
|
|
if unit not in ("px", "%"):
|
|
unit = "%"
|
|
if num <= 0:
|
|
style.pop(name, None)
|
|
else:
|
|
num_str = str(int(num)) if num == int(num) else str(num)
|
|
style[name] = f"{num_str}{unit}"
|
|
continue
|
|
|
|
if ctype == "shadow":
|
|
# 6 entrées de formulaire (décalage X/Y, flou, étendue, couleur,
|
|
# opacité — voir c_shadow.py) combinées en une seule valeur CSS
|
|
# "box-shadow" (couleur+opacité fusionnées en rgba(), un
|
|
# <input type="color"> seul ne portant pas de canal alpha).
|
|
_kind, name = control["target"].split(":", 1)
|
|
defaults = control.get("default", {})
|
|
|
|
def _num(suffix, fallback):
|
|
try:
|
|
return float(form.get(field_name + suffix, fallback))
|
|
except (TypeError, ValueError):
|
|
return fallback
|
|
|
|
x = _num("_x", defaults.get("x", 0))
|
|
y = _num("_y", defaults.get("y", 4))
|
|
blur = _num("_blur", defaults.get("blur", 12))
|
|
spread = _num("_spread", defaults.get("spread", 0))
|
|
opacity = _num("_opacity", defaults.get("opacity", 0.4))
|
|
color = form.get(field_name + "_color") or defaults.get("color", "#000000")
|
|
if x == 0 and y == 0 and blur == 0 and spread == 0:
|
|
style.pop(name, None)
|
|
else:
|
|
r, g, b = _hex_to_rgb(color)
|
|
|
|
def _px(n):
|
|
return str(int(n)) if n == int(n) else str(n)
|
|
|
|
style[name] = f"{_px(x)}px {_px(y)}px {_px(blur)}px {_px(spread)}px rgba({r}, {g}, {b}, {opacity})"
|
|
continue
|
|
|
|
target = control["target"]
|
|
if ctype == "checkbox":
|
|
checked = form.get(field_name) is not None
|
|
raw_value = control["true_value"] if checked else control["false_value"]
|
|
else:
|
|
raw_value = form.get(field_name, control.get("default", ""))
|
|
if ctype == "slider":
|
|
unit = control.get("unit", "")
|
|
raw_value = f"{raw_value}{unit}" if raw_value not in (None, "") else ""
|
|
|
|
if target == "content":
|
|
content = raw_value
|
|
else:
|
|
kind, name = target.split(":", 1)
|
|
if kind == "class":
|
|
# "class:xxx" : un jeton de classe CSS externe (ex. un
|
|
# modificateur Bulma) parmi d'autres, chacun mémorisé sous sa
|
|
# propre clé "_class_xxx" pour ne pas s'écraser entre eux —
|
|
# voir _visible_attrs.py qui les rassemble avec la classe de
|
|
# base du widget (fixed_attrs) au moment du rendu.
|
|
store, name = attributes, "_class_" + name
|
|
else:
|
|
store = attributes if kind == "attr" else style
|
|
if raw_value in (None, ""):
|
|
store.pop(name, None)
|
|
elif ctype != "checkbox" and name not in store and _is_default_value(control, raw_value):
|
|
# Ce réglage n'a jamais été personnalisé (la clé est absente
|
|
# du style/des attributs stockés) et la valeur reçue est
|
|
# simplement celle par défaut affichée dans le champ (un
|
|
# <input type="color"> ou un slider a TOUJOURS une valeur à
|
|
# l'écran, même quand rien n'a été choisi). Comme le
|
|
# panneau de propriétés envoie TOUS les réglages du widget
|
|
# en un seul formulaire, sauvegarder n'importe quel autre
|
|
# champ (ex. changer la disposition) écrirait sinon cette
|
|
# valeur par défaut "en dur" dans l'élément — ce qui faisait
|
|
# apparaître par exemple une couleur de fond qui n'avait
|
|
# jamais été demandée. On laisse donc ce réglage "non
|
|
# défini" tant qu'il n'a pas été explicitement changé.
|
|
pass
|
|
else:
|
|
store[name] = raw_value
|
|
|
|
for prop, tokens in toggle_groups.items():
|
|
if tokens:
|
|
style[prop] = " ".join(sorted(tokens))
|
|
else:
|
|
style.pop(prop, None)
|
|
|
|
conn = db.connect(slug)
|
|
conn.execute(
|
|
"UPDATE _screen_elements SET content = ?, attributes = ?, style = ? WHERE id = ?",
|
|
(content, json.dumps(attributes), json.dumps(style), element_id),
|
|
)
|
|
conn.commit()
|
|
conn.close()
|