diff --git a/screens/rendering/render_element_html.py b/screens/rendering/render_element_html.py
index bd978cae..213790c5 100644
--- a/screens/rendering/render_element_html.py
+++ b/screens/rendering/render_element_html.py
@@ -14,6 +14,7 @@ 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 .resolve_bound_row import _resolve_bound_row_ctx
def render_element_html(el, slug=None, children_map=None, ctx=None, parent_flex_direction=None):
@@ -29,6 +30,9 @@ def render_element_html(el, slug=None, children_map=None, ctx=None, parent_flex_
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"))
+ bound_ctx = _resolve_bound_row_ctx(slug, el.get("attributes") or {})
+ if bound_ctx:
+ ctx = {**(ctx or {}), **bound_ctx}
special = meta.get("special_render")
if special == "table":
return _render_table(el, meta, slug, children_map, ctx, parent_flex_direction)
diff --git a/screens/rendering/resolve_bound_row.py b/screens/rendering/resolve_bound_row.py
new file mode 100644
index 00000000..cc706783
--- /dev/null
+++ b/screens/rendering/resolve_bound_row.py
@@ -0,0 +1,38 @@
+import db
+
+from .filter_repeater_rows import filter_repeater_rows
+
+
+def _resolve_bound_row_ctx(slug, attrs):
+ """Si cet élément est lié à un objet de données (attr:_data_definition_id —
+ réglage "Lier à un objet de données" des widgets Texte/Titre, voir
+ DATA_BINDING_CONTROLS), retrouve LA ligne qui correspond aux 1 ou 2
+ filtres réglés (_data_filtre*/_data_filtre2*) — même moteur que le
+ filtre du Répéteur de données, voir filter_repeater_rows.py — et
+ renvoie ses champs sous forme de {nom_du_champ: valeur en texte},
+ exploitable par {{champ}} dans le contenu. Permet d'afficher UNE seule
+ valeur calculée (ex: l'objectif du niveau en cours) sans avoir besoin de
+ poser un Répéteur, qui affiche lui plusieurs lignes. Renvoie None si
+ rien n'est lié ou si aucune ligne ne correspond."""
+ definition_id = attrs.get("_data_definition_id")
+ if not definition_id or not slug:
+ return None
+ try:
+ definition = db.get_definition(slug, int(definition_id))
+ except (TypeError, ValueError):
+ return None
+ if not definition:
+ return None
+ rows = db.list_rows(slug, definition)
+ rows = filter_repeater_rows(slug, definition, rows, {
+ "_filtre_champ": attrs.get("_data_filtre_champ"),
+ "_filtre_operateur": attrs.get("_data_filtre_operateur"),
+ "_filtre_valeur": attrs.get("_data_filtre_valeur"),
+ "_filtre2_champ": attrs.get("_data_filtre2_champ"),
+ "_filtre2_operateur": attrs.get("_data_filtre2_operateur"),
+ "_filtre2_valeur": attrs.get("_data_filtre2_valeur"),
+ })
+ if not rows:
+ return None
+ row = rows[0]
+ return {f["name"]: str(row.get(db.slugify(f["name"]).replace("-", "_"), "") or "") for f in definition["fields"]}
diff --git a/screens/widgets/control_groups/data_binding_controls.py b/screens/widgets/control_groups/data_binding_controls.py
new file mode 100644
index 00000000..6207c70f
--- /dev/null
+++ b/screens/widgets/control_groups/data_binding_controls.py
@@ -0,0 +1,26 @@
+from ..controls.c_text import c_text
+from ..controls.c_select import c_select
+from ...flow.constants import CONDITION_OPERATORS
+
+# Permet à un Texte/Titre d'afficher directement la valeur d'un champ d'un
+# AUTRE objet de données, sans avoir besoin de le poser dans un Répéteur —
+# utile pour une valeur unique calculée (ex: l'objectif du niveau en cours)
+# plutôt qu'une liste. La ligne affichée est celle qui correspond aux 1 ou 2
+# filtres réglés ci-dessous (mêmes réglages, même moteur que ceux du
+# Répéteur de données — voir filter_repeater_rows.py) ; sans filtre, c'est la
+# ligne la plus récente de l'objet choisi (convention déjà utilisée par la
+# Jauge pour un objet "état de partie" qui ne garde qu'une seule ligne).
+DATA_BINDING_CONTROLS = [
+ c_select("data_definition_id", "Lier à un objet de données (pour {{champ}} ci-dessus)",
+ "attr:_data_definition_id", [("", "(sélectionner un objet)")], default="", group="Donnée liée"),
+ c_text("data_filtre_champ", "Trouver la ligne où ce champ",
+ "attr:_data_filtre_champ", placeholder="ex. number", default="", group="Donnée liée"),
+ c_select("data_filtre_operateur", "...est", "attr:_data_filtre_operateur", CONDITION_OPERATORS, default="egal", group="Donnée liée"),
+ c_text("data_filtre_valeur", "...cette valeur, ou {{NomObjet.champ}} pour une donnée qui change en cours de partie",
+ "attr:_data_filtre_valeur", placeholder="ex. 3 ou {{game.current_level}}", default="", group="Donnée liée"),
+ c_text("data_filtre2_champ", "ET (optionnel) : ce 2ᵉ champ",
+ "attr:_data_filtre2_champ", placeholder="ex. parcour_id", default="", group="Donnée liée"),
+ c_select("data_filtre2_operateur", "...est", "attr:_data_filtre2_operateur", CONDITION_OPERATORS, default="egal", group="Donnée liée"),
+ c_text("data_filtre2_valeur", "...cette valeur, ou {{NomObjet.champ}}",
+ "attr:_data_filtre2_valeur", placeholder="ex. 1 ou {{game.current_parcours}}", default="", group="Donnée liée"),
+]
diff --git a/screens/widgets/controls_with_values.py b/screens/widgets/controls_with_values.py
index 1cc3b500..b2cc63c2 100644
--- a/screens/widgets/controls_with_values.py
+++ b/screens/widgets/controls_with_values.py
@@ -24,7 +24,7 @@ def controls_with_values(slug, el):
result = []
for control in meta.get("controls", []):
control = dict(control)
- if control["key"] == "definition_id":
+ if control["key"] in ("definition_id", "data_definition_id"):
defs = db.list_definitions(slug)
control["options"] = [("", "(sélectionner un objet)")] + [(str(d["id"]), d["name"]) for d in defs]
if control["key"] == "template_type_id":
diff --git a/screens/widgets/registry.py b/screens/widgets/registry.py
index 0184bd59..757de5c8 100644
--- a/screens/widgets/registry.py
+++ b/screens/widgets/registry.py
@@ -8,6 +8,7 @@ from .controls.c_toggle_token import c_toggle_token
from .controls.c_align import c_align
from .controls.c_select import c_select
from .control_groups.text_style_controls_extra import TEXT_STYLE_CONTROLS_EXTRA
+from .control_groups.data_binding_controls import DATA_BINDING_CONTROLS
from .font_options import FONT_OPTIONS
from ..flow.constants import CONDITION_OPERATORS
@@ -16,7 +17,8 @@ WIDGETS = {
"label": "Texte", "icon": "📝", "tag": "p", "has_content": True,
"w": 40, "h": 10, "default_content": "Texte du paragraphe.",
"controls": [
- c_textarea("content", "Texte", "content", default="Texte du paragraphe.", group="Contenu"),
+ c_textarea("content", "Texte — {{champ}} affiche la valeur de ce champ sur la ligne trouvée ci-dessous", "content", default="Texte du paragraphe.", group="Contenu"),
+ ] + DATA_BINDING_CONTROLS + [
c_slider("font-size", "Taille du texte", "style:font-size", min=10, max=64, default=16, group="Texte"),
c_color("color", "Couleur du texte", "style:color", "#e8eaf0", group="Texte"),
c_align("text-align", "Alignement", "style:text-align", group="Texte"),
@@ -30,9 +32,10 @@ WIDGETS = {
"label": "Titre", "icon": "🔠", "tag": None, "has_content": True,
"w": 55, "h": 10, "default_content": "Titre",
"controls": [
- c_textarea("content", "Texte", "content", default="Titre", group="Contenu"),
+ c_textarea("content", "Texte — {{champ}} affiche la valeur de ce champ sur la ligne trouvée ci-dessous", "content", default="Titre", group="Contenu"),
c_select("niveau", "Importance du titre", "attr:_niveau",
[("1", "Très grand — titre principal"), ("2", "Grand"), ("3", "Moyen")], default="2", group="Contenu"),
+ ] + DATA_BINDING_CONTROLS + [
c_slider("font-size", "Taille du texte", "style:font-size", min=14, max=80, default=32, group="Texte"),
c_color("color", "Couleur du texte", "style:color", "#e8eaf0", group="Texte"),
c_align("text-align", "Alignement", "style:text-align", default="left", group="Texte"),