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>
199 lines
12 KiB
Python
199 lines
12 KiB
Python
import html as html_lib
|
|
|
|
from ..widgets.widget_meta import widget_meta
|
|
from .visible_attrs import _visible_attrs
|
|
from .style_string import _style_string
|
|
from .attr_string import _attr_string
|
|
from .apply_ctx import _apply_ctx
|
|
from .render_children import _render_children
|
|
from .render_table import _render_table
|
|
from .render_select import _render_select
|
|
from .render_fieldset import _render_fieldset
|
|
from .render_checkbox_or_radio import _render_checkbox_or_radio
|
|
from .render_repeater import _render_repeater
|
|
from .render_jauge import _render_jauge
|
|
from .render_overlay import _render_overlay
|
|
from .render_onglets import _render_onglets
|
|
from .render_icone import _render_icone
|
|
from .resolve_bound_row import _resolve_bound_row_ctx
|
|
from .visibility_condition import is_element_visible
|
|
from ..element_types.load_template_tree import _load_template_tree
|
|
from ..element_types.is_overlay_only import _is_overlay_only_element_type
|
|
|
|
|
|
def render_element_html(el, slug=None, children_map=None, ctx=None, parent_flex_direction=None):
|
|
"""Construit le HTML réel (balise + attributs + style + contenu, y
|
|
compris les éléments physiquement posés à l'intérieur pour un
|
|
conteneur/répéteur/groupe de champs) — utilisé par l'éditeur (canevas)
|
|
et le mode jouable. N'écrit jamais rien sur disque : uniquement du HTML
|
|
généré à la volée à partir de ce qui est stocké en base. `slug` est
|
|
nécessaire pour le Répéteur de données (il doit lire les données du jeu
|
|
au moment de l'affichage). `ctx` porte les valeurs {{champ}} de la ligne
|
|
en cours quand cet élément est descendant d'un répéteur. `parent_flex_direction`
|
|
est la disposition interne (voir _parent_flex_direction) du conteneur qui
|
|
pose physiquement cet élément, s'il y en a un — elle influence la largeur
|
|
par défaut de cet élément (voir _style_string)."""
|
|
meta = widget_meta(el.get("widget"))
|
|
attrs_raw = el.get("attributes") or {}
|
|
# La condition de visibilité (voir VISIBILITY_CONDITION_CONTROLS) n'est
|
|
# appliquée qu'en mode JOUABLE (ctx["_forge_play_mode"], posé par
|
|
# full_game_payload.py via list_elements(..., enforce_visibility=True))
|
|
# — jamais dans l'éditeur, où l'élément doit rester visible/sélectionnable
|
|
# même si la condition serait actuellement fausse, sans quoi il
|
|
# deviendrait impossible de le retrouver pour le régler. Un commentaire
|
|
# marqueur ("visibilityGated") reste posé sur le HTML même quand la
|
|
# condition est vraie, pour que refreshRuntimeData() (play.html) sache
|
|
# qu'il faut réévaluer cet élément après toute action "Modifier une
|
|
# donnée"/"Modifier une variable" — pas seulement Jauge/Répéteur, qui ont
|
|
# déjà chacun leur propre marqueur ("jaugeBar"/"repeaterItem") couvrant
|
|
# ce cas d'office.
|
|
has_visibility_condition = attrs_raw.get("_visibilite_cond_mode") in ("variable", "objet")
|
|
play_mode = bool((ctx or {}).get("_forge_play_mode"))
|
|
if play_mode and has_visibility_condition and not is_element_visible(slug, attrs_raw, ctx):
|
|
return f'<div class="visibilityGated" data-element-id="{el["id"]}" style="display:none;"></div>'
|
|
|
|
def _mark(html_str):
|
|
return html_str + "<!--visibilityGated-->" if has_visibility_condition else html_str
|
|
|
|
bound_ctx = _resolve_bound_row_ctx(slug, attrs_raw)
|
|
if bound_ctx:
|
|
ctx = {**(ctx or {}), **bound_ctx}
|
|
# Chaque branche "special_render" passe elle aussi par _mark() : sans
|
|
# ça, un widget de ce genre (Icône, Tableau, Superposition...) actuellement
|
|
# VISIBLE sous condition ne portait aucun marqueur, et refreshRuntimeData()
|
|
# (play.html) ne savait donc jamais qu'il fallait le réévaluer après une
|
|
# action "Modifier une donnée/variable" — il restait figé dans son état
|
|
# de l'affichage précédent (ex: une icône "enveloppe fermée" restée
|
|
# visible après l'ouverture du mail, en même temps que "enveloppe
|
|
# ouverte" qui apparaît, elle, correctement puisqu'un élément CACHÉ
|
|
# porte toujours son marqueur via le placeholder ci-dessus).
|
|
special = meta.get("special_render")
|
|
if special == "table":
|
|
return _mark(_render_table(el, meta, slug, children_map, ctx, parent_flex_direction))
|
|
if special == "select_options":
|
|
return _mark(_render_select(el, meta, slug, children_map, ctx, parent_flex_direction))
|
|
if special == "fieldset_legend":
|
|
return _mark(_render_fieldset(el, meta, slug, children_map, ctx, parent_flex_direction))
|
|
if special == "checkbox_label":
|
|
return _mark(_render_checkbox_or_radio(el, meta, slug, children_map, ctx, parent_flex_direction))
|
|
if special == "repeater":
|
|
return _mark(_render_repeater(el, meta, slug, children_map, ctx, parent_flex_direction))
|
|
if special == "jauge":
|
|
return _mark(_render_jauge(el, meta, slug, children_map, ctx, parent_flex_direction))
|
|
if special == "overlay":
|
|
return _mark(_render_overlay(el, meta, slug, children_map, ctx, parent_flex_direction))
|
|
if special == "onglets":
|
|
return _mark(_render_onglets(el, meta, slug, children_map, ctx, parent_flex_direction))
|
|
if special == "icone":
|
|
return _mark(_render_icone(el, meta, slug, children_map, ctx, parent_flex_direction))
|
|
|
|
tag = meta.get("tag")
|
|
if tag is None: # widget à balise dynamique (ex: "titre" -> h1/h2/h3)
|
|
niveau = (el.get("attributes") or {}).get("_niveau", "2")
|
|
tag = f"h{niveau}"
|
|
|
|
attrs = _visible_attrs(el, meta, ctx)
|
|
style = _style_string(el, parent_flex_direction=parent_flex_direction, widget_key=el.get("widget"))
|
|
|
|
# 3.1 (Confort) — interactions au survol : un texte de remplacement
|
|
# optionnel, affiché à la place du contenu normal quand le joueur
|
|
# survole cet élément à la souris (voir bindHoverTexts() dans
|
|
# templates/play.html). Absent par défaut => aucun changement, donc
|
|
# aucune régression sur les éléments déjà créés.
|
|
hover_text = (el.get("attributes") or {}).get("_survol_texte")
|
|
if hover_text:
|
|
attrs["data-hover-text"] = _apply_ctx(hover_text, ctx)
|
|
|
|
if meta.get("self_closing"):
|
|
return _mark(f"<{tag} {_attr_string(attrs, style)}>")
|
|
|
|
content = _apply_ctx(el.get("content") or "", ctx)
|
|
child_html, is_overlay_template = (
|
|
_render_element_type_children(el, slug, ctx) if el.get("element_type_id") else (None, False)
|
|
)
|
|
if child_html is None:
|
|
child_html = _render_children(el, slug, children_map, ctx)
|
|
elif is_overlay_template:
|
|
# Un exemplaire d'élément de jeu est posé par défaut avec le widget
|
|
# générique "conteneur" (add_element.py, "default_widget") — utile
|
|
# pour la plupart des modèles, mais QUAND le modèle entier n'est
|
|
# qu'une "Superposition / boîte de dialogue", la classe Bulma "box"
|
|
# de ce widget (widgets/registry.py "conteneur") lui donnait un
|
|
# fond opaque TOUJOURS VISIBLE à l'endroit où l'exemplaire a été
|
|
# déposé, alors que la superposition à l'intérieur gère déjà
|
|
# entièrement sa propre apparence et son propre masquage
|
|
# (position:fixed plein écran, démarre masquée) — vécu comme "un
|
|
# conteneur vide apparaît sur la scène, pas la boîte de dialogue"
|
|
# (en réalité la boîte de dialogue existe bien, juste masquée comme
|
|
# prévu ; c'est le conteneur AUTOUR qui n'aurait jamais dû avoir sa
|
|
# propre apparence visuelle).
|
|
#
|
|
# On ne court-circuite PLUS entièrement cette enveloppe (un essai
|
|
# précédent le faisait, en renvoyant `child_html` directement) :
|
|
# cette balise porte le SEUL id/data-element-id de CET EXEMPLAIRE
|
|
# précis (id=el["id"], distinct de l'id interne du modèle,
|
|
# ex. l'overlay du modèle "dialog" garde son propre id 72 alors que
|
|
# CET exemplaire est, par ex., 77) — une action "Modifier un
|
|
# élément" posée sur LA SCÈNE qui l'accueille (pas sur le modèle
|
|
# lui-même) cible cet id d'exemplaire, jamais celui, interne et
|
|
# partagé par tous les exemplaires, du modèle. La court-circuiter
|
|
# entièrement faisait disparaître cet id du DOM, rendant
|
|
# IMPOSSIBLE tout contrôle de la boîte de dialogue depuis la
|
|
# logique de la scène qui l'accueille ("j'ai aucun contrôle sur la
|
|
# visibilité de la modale depuis la scène"). On garde donc la
|
|
# balise (avec son id), mais SANS la classe "box" du widget
|
|
# générique — juste une balise transparente, invisible tant
|
|
# qu'elle ne porte ni fond ni bordure."""
|
|
attrs.pop("class", None)
|
|
return _mark(f"<div {_attr_string(attrs, style)}>{child_html}</div>")
|
|
|
|
if tag in ("ul", "ol"):
|
|
items = [line.strip() for line in content.split("\n") if line.strip()]
|
|
inner = "".join(f"<li>{html_lib.escape(item)}</li>" for item in items) + child_html
|
|
return _mark(f"<{tag} {_attr_string(attrs, style)}>{inner}</{tag}>")
|
|
|
|
safe_content = html_lib.escape(content).replace("\n", "<br>") if content else ""
|
|
return _mark(f"<{tag} {_attr_string(attrs, style)}>{safe_content}{child_html}</{tag}>")
|
|
|
|
|
|
def _render_element_type_children(el, slug, ctx):
|
|
"""Contenu d'un exemplaire d'élément de jeu posé sur un écran (voir
|
|
add_element.py, colonne element_type_id) : rechargé EN DIRECT depuis
|
|
l'écran-modèle à chaque affichage, jamais depuis d'éventuels enfants
|
|
copiés en base — exactement le même principe qu'un modèle de ligne de
|
|
Répéteur (voir _render_repeater/_load_template_tree). Modifier
|
|
l'élément de jeu dans son propre éditeur met donc à jour tous ses
|
|
exemplaires déjà posés, sur n'importe quel écran, sans avoir à les
|
|
retoucher un par un. Renvoie (None, False) si l'élément de jeu n'a
|
|
plus de modèle valide, pour que l'appelant retombe sur le rendu
|
|
générique (d'éventuels enfants en base issus d'une version antérieure
|
|
de ce mécanisme) plutôt que d'afficher un exemplaire silencieusement
|
|
vide. Le second élément renvoyé indique si le modèle N'EST QUE une
|
|
superposition (voir l'appelant : dans ce cas précis, l'enveloppe
|
|
générique "conteneur" de l'exemplaire ne doit pas avoir sa propre
|
|
apparence visuelle)."""
|
|
template_tree = _load_template_tree(slug, el["element_type_id"])
|
|
if not template_tree:
|
|
return None, False
|
|
is_overlay_only = _is_overlay_only_element_type(slug, el["element_type_id"])
|
|
if is_overlay_only:
|
|
# Le masquage de CET EXEMPLAIRE est entièrement délégué à
|
|
# l'enveloppe (voir l'appelant : add_element.py la fait démarrer
|
|
# masquée par défaut, exactement comme la superposition elle-même)
|
|
# — la superposition INTERNE au modèle ne doit donc plus appliquer
|
|
# SON PROPRE réglage "Visibilité" une fois posée comme exemplaire
|
|
# ailleurs (sinon double masquage : rendre l'enveloppe visible
|
|
# depuis la scène qui l'accueille resterait sans aucun effet
|
|
# visible tant que la superposition interne, elle, reste "Masqué"
|
|
# dans le modèle). On retire donc "display" de sa copie de style
|
|
# avant rendu (copie éphémère, jamais réenregistrée) — seul le
|
|
# display:flex de base (render_overlay.py) s'applique alors.
|
|
overlay_root = dict(template_tree["top"][0])
|
|
overlay_root["style"] = {k: v for k, v in overlay_root["style"].items() if k != "display"}
|
|
template_tree["top"][0] = overlay_root
|
|
html = "".join(
|
|
render_element_html(c, slug, template_tree["children_map"], ctx)
|
|
for c in template_tree["top"]
|
|
)
|
|
return html, is_overlay_only
|