first commit
Build and deploy / build-and-push (push) Successful in 17s
Build and deploy / deploy (push) Successful in 10s

This commit is contained in:
william
2026-08-21 16:23:49 +02:00
committed by william
commit 3f4ebc4527
248 changed files with 10180 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
"""Génération du HTML réel des éléments posés sur un écran."""
+9
View File
@@ -0,0 +1,9 @@
def _apply_ctx(text, ctx):
"""À l'intérieur d'un Répéteur de données, {{champ}} est remplacé par
la valeur de ce champ pour la ligne en cours — appliqué au contenu ET
aux attributs de l'élément et de tous ses descendants."""
if not ctx or not text:
return text
for k, v in ctx.items():
text = text.replace("{{" + k + "}}", v)
return text
+10
View File
@@ -0,0 +1,10 @@
import html as html_lib
def _attr_string(attrs, extra_style):
parts = [f'style="{html_lib.escape(extra_style)}"']
for k, v in attrs.items():
if v in (None, "", False):
continue
parts.append(html_lib.escape(str(k)) if v is True else f'{html_lib.escape(str(k))}="{html_lib.escape(str(v))}"')
return " ".join(parts)
+2
View File
@@ -0,0 +1,2 @@
def _auto_id(el):
return f"elt-{el['id']}"
+94
View File
@@ -0,0 +1,94 @@
import re
import db
# {{NomDeLObjet.nom_du_champ}} : référence à un AUTRE objet de données, pour
# comparer le champ filtré à une valeur qui change en cours de partie (ex:
# le niveau atteint) plutôt qu'à une valeur fixe tapée en dur — même
# convention de syntaxe que les {{champ}} déjà utilisés ailleurs (contenu
# d'un Répéteur, voir _apply_ctx / applyOpenRowBindings côté JS).
_REF_PATTERN = re.compile(r"^\{\{\s*([^.{}]+)\.([^.{}]+)\s*\}\}$")
def _resolve_filter_value(slug, raw_value):
"""Si raw_value est une référence "{{Objet.champ}}", va lire la valeur
ACTUELLE de ce champ sur la ligne la plus récente de cet objet (par
convention, un objet utilisé comme "état de partie" — niveau courant,
outils débloqués... — ne garde qu'une seule ligne, mise à jour en place
par des actions "Modifier une donnée" plutôt que d'en créer une
nouvelle à chaque fois). Sinon, renvoie raw_value tel quel (valeur
fixe)."""
if not raw_value:
return raw_value
m = _REF_PATTERN.match(raw_value.strip())
if not m:
return raw_value
object_name, field_name = m.group(1).strip(), m.group(2).strip()
for d in db.list_definitions(slug):
if d["name"] == object_name:
full = db.get_definition(slug, d["id"])
rows = db.list_rows(slug, full)
if rows:
return rows[0].get(db.slugify(field_name).replace("-", "_"))
return None
return None # objet de référence introuvable — filtre ne matchera rien plutôt que planter
def _compare(actual, operator, expected, field_type):
"""Équivalent Python de compareValues() dans templates/play.html — les
deux doivent rester alignées si on ajoute un opérateur."""
if field_type == "booleen":
a = 1 if actual else 0
e = 1 if str(expected).strip().lower() in ("1", "true", "vrai") else 0
return a != e if operator == "different" else a == e
try:
an, en = float(actual), float(expected)
numeric = True
except (TypeError, ValueError):
numeric = False
if numeric:
if operator == "egal":
return an == en
if operator == "different":
return an != en
if operator == "superieur":
return an > en
if operator == "inferieur":
return an < en
if operator == "superieur_egal":
return an >= en
if operator == "inferieur_egal":
return an <= en
return False
as_, es_ = ("" if actual is None else str(actual)), ("" if expected is None else str(expected))
if operator == "egal":
return as_ == es_
if operator == "different":
return as_ != es_
if operator == "superieur":
return as_ > es_
if operator == "inferieur":
return as_ < es_
if operator == "superieur_egal":
return as_ >= es_
if operator == "inferieur_egal":
return as_ <= es_
return False
def filter_repeater_rows(slug, definition, rows, attrs):
"""Ne garde, parmi les lignes d'un Répéteur, que celles qui correspondent
au filtre optionnel réglé dans ses propriétés (_filtre_champ /
_filtre_operateur / _filtre_valeur) — voir 1.2 dans la doc de cadrage
Forge Engine ("affichage piloté par la donnée sur un écran unique") :
c'est ce qui permet à un seul écran de n'afficher, par exemple, que les
mails du niveau en cours plutôt que la boîte de réception entière."""
field_name = (attrs.get("_filtre_champ") or "").strip()
operator = attrs.get("_filtre_operateur") or "egal"
if not field_name:
return rows
raw_value = attrs.get("_filtre_valeur") or ""
expected = _resolve_filter_value(slug, raw_value)
field_type = next((f["type"] for f in definition["fields"] if f["name"] == field_name), "texte")
col = db.slugify(field_name).replace("-", "_")
return [r for r in rows if _compare(r.get(col), operator, expected, field_type)]
@@ -0,0 +1,9 @@
def _parent_flex_direction(parent_el):
"""Sens de la disposition interne d'un conteneur/répéteur/groupe de
champs (voir le réglage "Disposition interne"), ou None s'il est en
empilement libre (pas de display:flex) — utilisé pour adapter la taille
par défaut de ses enfants (voir _style_string)."""
style = (parent_el or {}).get("style") or {}
if style.get("display") != "flex":
return None
return style.get("flex-direction") or "row"
@@ -0,0 +1,15 @@
import html as html_lib
from .visible_attrs import _visible_attrs
from .apply_ctx import _apply_ctx
from .style_string import _style_string
from .attr_string import _attr_string
def _render_checkbox_or_radio(el, meta, slug=None, children_map=None, ctx=None, parent_flex_direction=None):
input_type = "checkbox" if el.get("widget") == "case_a_cocher" else "radio"
attrs = _visible_attrs(el, meta, ctx)
attrs["type"] = input_type
style = _style_string(el, " display:flex; align-items:center; gap:8px;", parent_flex_direction)
content = _apply_ctx(el.get("content") or "", ctx)
return f'<label {_attr_string({}, style)}><input {_attr_string(attrs, "")}> {html_lib.escape(content)}</label>'
+13
View File
@@ -0,0 +1,13 @@
from .parent_flex_direction import _parent_flex_direction
def _render_children(el, slug, children_map, ctx):
"""Rendu récursif des éléments physiquement posés à l'intérieur d'un
conteneur/répéteur/groupe de champs — chacun avec sa propre disposition,
et le même contexte de ligne (ctx) que son parent s'il est dans un
répéteur de données."""
from .render_element_html import render_element_html
kids = (children_map or {}).get(el["id"], [])
flex_dir = _parent_flex_direction(el)
return "".join(render_element_html(c, slug, children_map, ctx, parent_flex_direction=flex_dir) for c in kids)
+76
View File
@@ -0,0 +1,76 @@
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
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"))
special = meta.get("special_render")
if special == "table":
return _render_table(el, meta, slug, children_map, ctx, parent_flex_direction)
if special == "select_options":
return _render_select(el, meta, slug, children_map, ctx, parent_flex_direction)
if special == "fieldset_legend":
return _render_fieldset(el, meta, slug, children_map, ctx, parent_flex_direction)
if special == "checkbox_label":
return _render_checkbox_or_radio(el, meta, slug, children_map, ctx, parent_flex_direction)
if special == "repeater":
return _render_repeater(el, meta, slug, children_map, ctx, parent_flex_direction)
if special == "jauge":
return _render_jauge(el, meta, slug, children_map, ctx, parent_flex_direction)
if special == "overlay":
return _render_overlay(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 f"<{tag} {_attr_string(attrs, style)}>"
content = _apply_ctx(el.get("content") or "", ctx)
child_html = _render_children(el, slug, children_map, ctx)
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 f"<{tag} {_attr_string(attrs, style)}>{inner}</{tag}>"
safe_content = html_lib.escape(content).replace("\n", "<br>") if content else ""
return f"<{tag} {_attr_string(attrs, style)}>{safe_content}{child_html}</{tag}>"
+15
View File
@@ -0,0 +1,15 @@
import html as html_lib
from .visible_attrs import _visible_attrs
from .apply_ctx import _apply_ctx
from .style_string import _style_string
from .attr_string import _attr_string
from .render_children import _render_children
def _render_fieldset(el, meta, slug=None, children_map=None, ctx=None, parent_flex_direction=None):
attrs = _visible_attrs(el, meta, ctx)
style = _style_string(el, parent_flex_direction=parent_flex_direction, widget_key=el.get("widget"))
content = _apply_ctx(el.get("content") or "", ctx) or "Groupe de champs"
child_html = _render_children(el, slug, children_map, ctx)
return f'<fieldset {_attr_string(attrs, style)}><legend>{html_lib.escape(content)}</legend>{child_html}</fieldset>'
+97
View File
@@ -0,0 +1,97 @@
import html as html_lib
import db
from .style_string import _style_string
from .auto_id import _auto_id
def _hex_to_rgb(hex_color):
hex_color = (hex_color or "#5b8cff").lstrip("#")
if len(hex_color) != 6:
hex_color = "5b8cff"
return tuple(int(hex_color[i:i + 2], 16) for i in (0, 2, 4))
def _interpolate_color(low_hex, high_hex, ratio):
"""Dégradé linéaire simple entre deux couleurs (rouge en bas, vert en
haut par défaut, comme demandé dans le document d'interface de Boîte
Mail) — ratio est déjà borné entre 0 et 1 par l'appelant."""
lo, hi = _hex_to_rgb(low_hex), _hex_to_rgb(high_hex)
mixed = tuple(round(lo[i] + (hi[i] - lo[i]) * ratio) for i in range(3))
return "#{:02x}{:02x}{:02x}".format(*mixed)
def _read_latest_field_value(slug, definition_id, field_name):
"""Lit la valeur du champ demandé sur la ligne la PLUS RÉCENTE de
l'objet visé — même convention que le filtre de Répéteur (1.2) : un
objet utilisé comme jauge de partie (Réputation, Trésorerie...) ne
garde qu'une seule ligne, mise à jour en place."""
if not definition_id or not slug:
return None
definition = db.get_definition(slug, int(definition_id))
if not definition:
return None
rows = db.list_rows(slug, definition)
if not rows:
return None
col = db.slugify(field_name).replace("-", "_")
return rows[0].get(col)
def _render_jauge(el, meta, slug, children_map=None, ctx=None, parent_flex_direction=None):
"""Jauge/barre dont le remplissage (largeur en %) et la couleur (dégradé
bas -> haut) se recalculent automatiquement à partir d'un champ
numérique d'un objet de données — voir 2.1 dans la doc de cadrage Forge
Engine ("binding live d'une donnée vers un style"). Recalculée à chaque
régénération du HTML du jeu (chargement de /play, et après toute action
"Modifier une donnée"), donc toujours à jour sans action supplémentaire
à poser dans le graphe de logique."""
attrs = el.get("attributes") or {}
style = _style_string(el, " overflow:hidden; position:relative; background:#1c2130; border-radius:6px;", parent_flex_direction)
definition_id = attrs.get("_definition_id")
field_name = (attrs.get("_champ") or "").strip()
try:
min_v = float(attrs.get("_min", "0") or 0)
except (TypeError, ValueError):
min_v = 0.0
try:
max_v = float(attrs.get("_max", "100") or 100)
except (TypeError, ValueError):
max_v = 100.0
couleur_basse = attrs.get("_couleur_basse") or "#e05252"
couleur_haute = attrs.get("_couleur_haute") or "#4caf50"
afficher_valeur = attrs.get("_afficher_valeur") == "1"
raw_value = _read_latest_field_value(slug, definition_id, field_name) if field_name else None
try:
value = float(raw_value)
except (TypeError, ValueError):
value = min_v
span = (max_v - min_v) or 1
ratio = max(0.0, min(1.0, (value - min_v) / span))
percent = round(ratio * 100, 1)
fill_color = _interpolate_color(couleur_basse, couleur_haute, ratio)
label_html = ""
if afficher_valeur:
label_html = (
f'<div style="position:absolute; inset:0; display:flex; align-items:center; '
f'justify-content:center; font-family:system-ui,sans-serif; font-size:12px; '
f'color:#e8eaf0; text-shadow:0 1px 2px rgba(0,0,0,.6);">{html_lib.escape(str(raw_value if raw_value is not None else "—"))}</div>'
)
# La classe "jaugeBar" est repérée côté client (play.html, refreshRuntimeData)
# pour savoir que ce wrapper doit être régénéré après toute action
# "Modifier une donnée" — sinon la jauge resterait figée sur sa valeur
# de départ après un changement, exactement comme il fallait déjà
# repérer "repeaterItem" pour les Répéteurs (voir 1.1/1.2).
bar = (
f'<div class="jaugeBar" style="position:absolute; left:0; top:0; bottom:0; width:{percent}%; '
f'background-color:{fill_color}; transition:width .3s ease, background-color .3s ease;"></div>'
)
return (
f'<div id="{_auto_id(el)}" data-element-id="{el["id"]}" style="{html_lib.escape(style)}">'
f'{bar}{label_html}</div>'
)
+54
View File
@@ -0,0 +1,54 @@
import html as html_lib
from .visible_attrs import _visible_attrs
from .style_string import _style_string
from .render_children import _render_children
def _render_overlay(el, meta, slug, children_map=None, ctx=None, parent_flex_direction=None):
"""3.4 (Confort) — overlay/modale réutilisable : une boîte de dialogue
prête à l'emploi, par-dessus TOUT le reste de l'écran, fermeture
manuelle uniquement (aucun clic-en-dehors-pour-fermer volontairement,
conformément au manque documenté). Contrairement aux autres widgets, sa
position ne dépend PAS de x/y/width/height (glissé-déposé sur le
canevas) : `position:fixed; inset:0` la fait toujours couvrir tout
l'écran, quel que soit l'endroit où elle a été posée dans l'éditeur —
seul un voile semi-transparent + une boîte centrée, contenant les
éléments posés à l'intérieur (comme un conteneur normal).
Ouverture/fermeture : PAS de mécanisme dédié — elle réutilise l'action
existante "Modifier un élément → Visibilité" (masquer/rendre visible),
exactement comme n'importe quel autre élément. C'est pour ça qu'on
fusionne le style de positionnement fixe ci-dessous AVEC le style normal
de l'élément (_style_string) : si l'élément est réglé "Masqué" dans ses
propriétés, ou si une action "Modifier un élément" le cache plus tard,
ce masquage continue de fonctionner normalement."""
attrs_raw = el.get("attributes") or {}
box_color = attrs_raw.get("_couleur_boite") or "#1f2430"
radius = attrs_raw.get("_arrondi") or "12"
attrs = _visible_attrs(el, meta, ctx)
base_overlay_style = (
"position:fixed; inset:0; z-index:9999; display:flex; "
"align-items:center; justify-content:center; background:rgba(0,0,0,0.6);"
)
style = base_overlay_style + " " + _style_string(el, parent_flex_direction=parent_flex_direction)
attr_parts = [f'style="{html_lib.escape(style)}"']
for k, v in attrs.items():
if v in (None, "", False):
continue
attr_parts.append(
html_lib.escape(str(k)) if v is True else f'{html_lib.escape(str(k))}="{html_lib.escape(str(v))}"'
)
box_style = (
f"background:{html_lib.escape(str(box_color))}; border-radius:{html_lib.escape(str(radius))}px; "
"padding:24px; max-width:90%; max-height:90%; overflow:auto; box-sizing:border-box;"
)
child_html = _render_children(el, slug, children_map, ctx)
return (
f'<div {" ".join(attr_parts)}>'
f'<div class="forgeOverlayBox" style="{html_lib.escape(box_style)}">{child_html}</div>'
f"</div>"
)
+71
View File
@@ -0,0 +1,71 @@
import html as html_lib
import db
from .style_string import _style_string
from .auto_id import _auto_id
from .apply_ctx import _apply_ctx
from .filter_repeater_rows import filter_repeater_rows
from ..element_types.load_template_tree import _load_template_tree
def _render_repeater(el, meta, slug, children_map, ctx=None, parent_flex_direction=None):
from .render_element_html import render_element_html
style = _style_string(el, " overflow:auto;", parent_flex_direction, widget_key=el.get("widget"))
attrs_raw = el.get("attributes") or {}
definition_id = attrs_raw.get("_definition_id")
# data-definition-id sur le Répéteur lui-même : lu côté client (play.html)
# pour savoir, au clic sur une de ses lignes, à quel objet de données
# appartient la ligne cliquée (voir déclencheur "clic sur une ligne de
# Répéteur" et l'action "Ouvrir la ligne cliquée").
definition_attr = f' data-definition-id="{html_lib.escape(str(definition_id))}"' if definition_id else ""
template_type_id = attrs_raw.get("_template_type_id")
template = _apply_ctx(el.get("content") or "", ctx)
children = (children_map or {}).get(el["id"], [])
if not definition_id or not slug:
return f'<div id="{_auto_id(el)}" data-element-id="{el["id"]}" style="{html_lib.escape(style)}"><em>Répéteur : choisis un objet de données dans les propriétés.</em></div>'
definition = db.get_definition(slug, int(definition_id))
if not definition:
return f'<div id="{_auto_id(el)}" data-element-id="{el["id"]}" style="{html_lib.escape(style)}"><em>Objet introuvable — a-t-il été supprimé ?</em></div>'
# (definition_attr est ajouté au conteneur du Répéteur plus bas, une fois
# qu'on sait que l'objet est valide.)
rows = db.list_rows(slug, definition)
rows = filter_repeater_rows(slug, definition, rows, attrs_raw)
field_cols = {f["name"]: db.slugify(f["name"]).replace("-", "_") for f in definition["fields"]}
# Modèle de ligne = un élément de jeu (conteneur stylisé réutilisable) :
# on recharge son arbre EN DIRECT à chaque ligne de données (jamais copié
# en base), et on l'affiche avec les {{champ}} de cette ligne — voir
# _load_template_tree pour le détail de cette astuce de rendu.
template_tree = None
if template_type_id:
try:
template_tree = _load_template_tree(slug, int(template_type_id))
except (TypeError, ValueError):
template_tree = None
items = []
for row in rows:
row_ctx = dict(ctx or {})
row_ctx.update({fname: str(row.get(col, "") or "") for fname, col in field_cols.items()})
if template_tree:
inner = "".join(
render_element_html(c, slug, template_tree["children_map"], row_ctx)
for c in template_tree["top"]
)
elif children:
inner = "".join(render_element_html(c, slug, children_map, row_ctx) for c in children)
else:
line = template
for fname, val in row_ctx.items():
line = line.replace("{{" + fname + "}}", val)
inner = html_lib.escape(line)
# data-row-id : identifiant STABLE de la vraie ligne de données
# affichée (par opposition à data-element-id, qui vaut l'id du
# modèle de ligne et se répète à l'identique sur chaque ligne) —
# c'est ce qui permet au clic de savoir "quel mail" a été ouvert.
items.append(f'<div class="repeaterItem" data-row-id="{row["id"]}">{inner}</div>')
empty_msg = "Aucune ligne ne correspond au filtre réglé." if attrs_raw.get("_filtre_champ") else "Aucune donnée dans cet objet pour l'instant."
inner_all = "".join(items) if items else f"<em>{empty_msg}</em>"
return f'<div id="{_auto_id(el)}" data-element-id="{el["id"]}"{definition_attr} style="{html_lib.escape(style)}">{inner_all}</div>'
+15
View File
@@ -0,0 +1,15 @@
import html as html_lib
from .visible_attrs import _visible_attrs
from .apply_ctx import _apply_ctx
from .style_string import _style_string
from .attr_string import _attr_string
def _render_select(el, meta, slug=None, children_map=None, ctx=None, parent_flex_direction=None):
attrs = _visible_attrs(el, meta, ctx)
style = _style_string(el, parent_flex_direction=parent_flex_direction)
content = _apply_ctx(el.get("content") or "", ctx)
options = [o.strip() for o in content.split("\n") if o.strip()]
opts_html = "".join(f'<option value="{html_lib.escape(o)}">{html_lib.escape(o)}</option>' for o in options)
return f'<select {_attr_string(attrs, style)}>{opts_html}</select>'
+24
View File
@@ -0,0 +1,24 @@
import html as html_lib
from .visible_attrs import _visible_attrs
from .apply_ctx import _apply_ctx
from .style_string import _style_string
from .attr_string import _attr_string
def _render_table(el, meta, slug=None, children_map=None, ctx=None, parent_flex_direction=None):
attrs = _visible_attrs(el, meta, ctx)
header_row = (el.get("attributes") or {}).get("_header_row") in ("1", "true", "on")
content = _apply_ctx(el.get("content") or "", ctx)
style = _style_string(el, " border-collapse:collapse;", parent_flex_direction)
rows = [r for r in content.split("\n") if r.strip()]
html_rows = []
for i, r in enumerate(rows):
cells = r.split(";")
cell_tag = "th" if (header_row and i == 0) else "td"
cells_html = "".join(
f'<{cell_tag} style="border:1px solid #2b3140; padding:6px 8px;">{html_lib.escape(c.strip())}</{cell_tag}>'
for c in cells
)
html_rows.append(f"<tr>{cells_html}</tr>")
return f'<table {_attr_string(attrs, style)}>{"".join(html_rows)}</table>'
+70
View File
@@ -0,0 +1,70 @@
from ..widgets.layout_capable_widgets import LAYOUT_CAPABLE_WIDGETS
def _style_string(el, extra_base="", parent_flex_direction=None, widget_key=None):
style = dict(el.get("style") or {})
if widget_key in LAYOUT_CAPABLE_WIDGETS and "display" not in style:
# "Empilement libre" (valeur par défaut de "Disposition interne")
# doit quand même permettre l'espacement (gap) entre les enfants —
# or le CSS "gap" n'a aucun effet sur un display bloc classique, il
# lui faut un conteneur flex/grid. Tant qu'aucune disposition n'a
# été choisie explicitement, on bascule donc ce widget sur un flex
# en colonne : visuellement identique à un simple empilement, mais
# l'espacement fonctionne dès la création, sans avoir à changer la
# disposition interne au préalable.
style["display"] = "flex"
style.setdefault("flex-direction", "column")
is_child = bool(el.get("parent_id"))
if is_child:
# Élément physiquement posé à l'intérieur d'un conteneur/répéteur/
# groupe de champs : une hauteur fixée à 100% se calcule contre la
# hauteur du parent et écraserait/déborderait les autres enfants
# (le conteneur coupe alors ce qui dépasse -> l'enfant "disparaît").
# La hauteur s'adapte donc à son contenu, comme dans une vraie page.
# Largeur : dans une disposition en LIGNE (flex-direction row), la
# largeur est l'axe principal — si chaque enfant réclame 100% de
# cette largeur, ils entrent en collision (l'algorithme flex les
# comprime alors tous, jusqu'à les faire quasi disparaître). Dans les
# autres cas (empilement libre, ou disposition en COLONNE où la
# largeur est l'axe transversal), 100% reste le bon défaut.
width_default = "auto" if parent_flex_direction in ("row", "row-reverse") else "100%"
base_style = f"width:{width_default}; height:auto; box-sizing:border-box; margin:0;" + extra_base
else:
base_style = "width:100%; height:100%; box-sizing:border-box; margin:0;" + extra_base
has_size_override = False
def _emit(k, v):
nonlocal has_size_override
if v in (None, ""):
return False
if k == "transform" and not is_child:
# Un élément posé directement sur un écran a déjà ce "transform"
# posé sur son cadre .canvasElementInner / .playElementInner
# (voir filters/element_style_filter.py:_element_transform_style)
# — l'écrire ici aussi (sur la balise intérieure, à l'intérieur
# d'un cadre qui reste fixe) donnait l'impression que l'échelle/
# l'animation restait "coincée à l'intérieur" au lieu de
# s'appliquer à l'élément entier. Un élément posé à l'intérieur
# d'un conteneur/répéteur/groupe de champs n'a pas ce cadre
# séparé (sa balise EST déjà tout l'élément) : pour lui,
# "transform" reste ici.
return False
if k in ("width", "height") and str(v).strip() in ("0px", "0"):
# 0 = réglage "Taille dans le conteneur" laissé à sa valeur par
# défaut (voir SIZE_OVERRIDE_CONTROLS) : ne pas l'écrire, pour
# garder le comportement automatique de base_style ci-dessus.
return False
if k in ("width", "height"):
has_size_override = True
return True
user_style = "; ".join(f"{k}:{v}" for k, v in style.items() if _emit(k, v))
if has_size_override:
# Une taille fixe explicite (voir "Taille dans le conteneur") ne doit
# pas être écrasée par la compression automatique d'une disposition
# flex (flex-shrink vaut 1 par défaut sur tout enfant) — sinon un
# élément à qui on a donné une largeur/hauteur précise peut quand
# même se retrouver comprimé si ses voisins manquent de place.
user_style = (user_style + "; " if user_style else "") + "flex-shrink:0"
return base_style + (" " + user_style + ";" if user_style else "")
+4
View File
@@ -0,0 +1,4 @@
def _trigger_for(widget_key, attributes):
if (attributes or {}).get("type") == "submit":
return "soumission"
return "clic"
+22
View File
@@ -0,0 +1,22 @@
from .apply_ctx import _apply_ctx
from .auto_id import _auto_id
def _visible_attrs(el, meta, ctx=None):
attrs = {
k: (_apply_ctx(v, ctx) if isinstance(v, str) else v)
for k, v in (el.get("attributes") or {}).items() if not k.startswith("_")
}
attrs.update(meta.get("fixed_attrs", {}))
attrs.setdefault("id", _auto_id(el))
attrs["data-element-id"] = el["id"]
if el.get("parent_id"):
# Élément posé à l'intérieur d'un conteneur/répéteur/groupe de
# champs : pas de cadre de positionnement séparé autour de lui (voir
# element_style_filter.py) — sa propre balise EST déjà tout
# l'élément, donc c'est elle que la timeline d'animation doit viser.
# Un élément posé directement sur un écran, lui, porte ce marqueur
# sur son cadre (.canvasElement / .playElement) à la place — voir
# les gabarits screen_edit.html et play.html.
attrs["data-anim-target"] = el["id"]
return attrs