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
+113
View File
@@ -0,0 +1,113 @@
"""
screens.py — écrans de jeu : éléments posés en glisser-déposer sur un
écran, sans jamais toucher à un fichier HTML/CSS, enchaînement des écrans,
et actions déclenchées par clic ou soumission de formulaire.
Version 3 — inspirée d'Elementor / Bricks Builder : un catalogue fermé de
WIDGETS (Texte, Titre, Image, Bouton, Case à cocher, Répéteur de
données...), chacun avec ses propres réglages ("controls") : un réglage =
un champ, une case à cocher, un bouton radio ou un curseur — jamais de nom
de balise ni de nom de propriété CSS à taper. En coulisses, chaque widget
correspond à une vraie balise HTML avec de vrais attributs et un vrai
style CSS ; l'utilisateur ne les manipule jamais directement.
Tout est stocké dans la même base SQLite que le reste du jeu
(projects/<slug>/game.db) :
- _screens : un écran du jeu (accueil, formulaire, scène...)
- _element_types : le "catalogue" des éléments de jeu réutilisables
(Personnage, Outil, IA...) que l'utilisateur crée
- _screen_elements : chaque élément posé sur un écran (widget, position
et taille en POURCENTAGE de l'écran, contenu,
attributs et style — dérivés des réglages du
widget, jamais saisis en texte libre)
- _actions : ce qui se passe au clic / à la soumission d'un
élément (écran suivant, écran précédent, aller à
un écran précis, ne rien faire)
Un élément peut aussi être un "Répéteur de données" : au lieu d'un contenu
fixe, il est relié à un objet défini dans ce jeu (voir db.py) et affiche
automatiquement une ligne par donnée existante — par exemple, tous les
niveaux d'un parcours — à partir d'un modèle de texte avec des
{{nom_de_champ}}.
Ce fichier est désormais un package (voir les sous-dossiers) organisé
selon "un fichier = une fonction, un dossier = une responsabilité" ; ce
module se contente de ré-exporter l'intégralité de l'API publique
utilisée par app.py et les templates, afin que `import screens` continue
de fonctionner à l'identique.
"""
from .widgets.registry import WIDGETS
from .widgets.container_widgets import CONTAINER_WIDGETS
from .widgets.layout_capable_widgets import LAYOUT_CAPABLE_WIDGETS
from .widgets.font_options import GOOGLE_FONTS_LINK
from .widgets.widget_meta import widget_meta
from .widgets.controls_with_values import controls_with_values
from .labels.trigger_labels import TRIGGER_LABELS
from .labels.action_type_labels import ACTION_TYPE_LABELS
from .labels.element_action_properties import (
ELEMENT_ACTION_PROPERTIES, ELEMENT_ACTION_PROPERTY_LABELS,
ELEMENT_SURBRILLANCE_VALUES, ELEMENT_SURBRILLANCE_LABELS,
ELEMENT_DESACTIVE_VALUES, ELEMENT_DESACTIVE_LABELS,
)
from .labels.element_visibility import ELEMENT_VISIBILITY_VALUES, ELEMENT_VISIBILITY_LABELS
from .labels.data_operations import DATA_OPERATIONS, DATA_OPERATION_LABELS
from .flow.constants import TRIGGER_EVENTS, CONDITION_OPERATORS, CONDITION_OPERATOR_LABELS
from .rendering.render_element_html import render_element_html
from .rendering.trigger_for import _trigger_for
from .screens_repo.ensure_schema import ensure_schema
from .screens_repo.list_screens import list_screens
from .screens_repo.get_screen import get_screen
from .screens_repo.create_screen import create_screen
from .screens_repo.rename_screen import rename_screen
from .screens_repo.delete_screen import delete_screen
from .screens_repo.move_screen import move_screen
from .screens_repo.screens_referencing import screens_referencing
from .screens_repo.set_screen_aspect import set_screen_aspect
from .elements.list_elements import list_elements
from .elements.get_element import get_element
from .elements.add_element import add_element
from .elements.update_element_geometry import update_element_geometry
from .elements.save_element_controls import save_element_controls
from .elements.delete_element import delete_element
from .elements.rename_element import rename_element
from .elements.element_label import element_label
from .element_types.list_element_types import list_element_types
from .element_types.create_element_type import create_element_type
from .element_types.update_element_type import update_element_type
from .element_types.element_type_usage_count import element_type_usage_count
from .element_types.delete_element_type import delete_element_type
from .element_types.instantiate_template_tree import instantiate_template_tree
from .legacy_actions.list_actions import list_actions
from .legacy_actions.add_action import add_action
from .legacy_actions.get_action import get_action
from .legacy_actions.delete_action import delete_action
from .flow.ensure_flow_schema import ensure_flow_schema
from .flow.list_flow_nodes import list_flow_nodes
from .flow.list_flow_edges import list_flow_edges
from .flow.get_flow_node import get_flow_node
from .flow.add_flow_node import add_flow_node
from .flow.move_flow_node import move_flow_node
from .flow.delete_flow_node import delete_flow_node
from .flow.add_flow_edge import add_flow_edge
from .flow.delete_flow_edge import delete_flow_edge
from .data_actions.apply_data_action import apply_data_action
from .animations.ensure_animation_schema import ensure_animation_schema
from .animations.list_animation_clips import list_animation_clips
from .animations.get_animation_clip import get_animation_clip
from .animations.add_animation_clip import add_animation_clip
from .animations.update_animation_clip import update_animation_clip
from .animations.delete_animation_clip import delete_animation_clip
from .animations.animate_css_catalog import ANIMATE_CSS_CATALOG, ANIMATE_CSS_LABELS
from .payload.full_game_payload import full_game_payload
View File
+37
View File
@@ -0,0 +1,37 @@
import json
import db
from .ensure_animation_schema import ensure_animation_schema
# Point de départ raisonnable pour un nouveau clip "personnalisé" : un
# fondu simple de 0 à 1, que le créateur du jeu peut ensuite modifier ou
# remplacer entièrement dans l'éditeur de images-clés.
_DEFAULT_CUSTOM_KEYFRAMES = [
{"percent": 0, "props": {"opacity": "0"}},
{"percent": 100, "props": {"opacity": "1"}},
]
def add_animation_clip(slug, screen_id, element_id, kind="animate_css", animate_name=None,
custom_keyframes=None, start_time=0, duration=1, delay=0,
easing="ease", iteration_count="1"):
ensure_animation_schema(slug)
if kind == "custom" and not custom_keyframes:
custom_keyframes = _DEFAULT_CUSTOM_KEYFRAMES
if kind == "animate_css" and not animate_name:
animate_name = "fadeIn"
keyframes_json = json.dumps(custom_keyframes) if custom_keyframes else None
conn = db.connect(slug)
conn.execute(
"""INSERT INTO _animation_clips
(screen_id, element_id, kind, animate_name, custom_keyframes,
start_time, duration, delay, easing, iteration_count)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
(screen_id, element_id, kind, animate_name, keyframes_json,
float(start_time), float(duration), float(delay), easing, str(iteration_count)),
)
clip_id = conn.execute("SELECT last_insert_rowid() AS id").fetchone()["id"]
conn.commit()
conn.close()
return clip_id
+76
View File
@@ -0,0 +1,76 @@
# Catalogue des animations de la bibliothèque Animate.css (v4), chargée
# depuis un CDN dans l'éditeur et en mode jouable (voir extra_head de
# screen_edit.html et le <head> de play.html) — chaque entrée correspond
# exactement à une classe "animate__xxx" fournie par la bibliothèque, posée
# telle quelle sur l'élément ciblé (voir applyAnimationClip() dans
# templates/play.html). Regroupées par catégorie pour un menu déroulant
# plus lisible que 80 entrées à plat.
ANIMATE_CSS_CATALOG = [
("Attirer l'attention", [
("bounce", "Rebond"), ("flash", "Flash"), ("pulse", "Pulsation"),
("rubberBand", "Élastique"), ("shakeX", "Secousse horizontale"),
("shakeY", "Secousse verticale"), ("headShake", "Hoche la tête"),
("swing", "Balancement"), ("tada", "Tada"), ("wobble", "Vacillement"),
("jello", "Gélatine"), ("heartBeat", "Battement de cœur"),
]),
("Entrée — fondu", [
("fadeIn", "Apparition"), ("fadeInDown", "Apparition depuis le haut"),
("fadeInLeft", "Apparition depuis la gauche"), ("fadeInRight", "Apparition depuis la droite"),
("fadeInUp", "Apparition depuis le bas"),
]),
("Sortie — fondu", [
("fadeOut", "Disparition"), ("fadeOutDown", "Disparition vers le bas"),
("fadeOutLeft", "Disparition vers la gauche"), ("fadeOutRight", "Disparition vers la droite"),
("fadeOutUp", "Disparition vers le haut"),
]),
("Entrée — rebond", [
("bounceIn", "Rebond entrant"), ("bounceInDown", "Rebond depuis le haut"),
("bounceInLeft", "Rebond depuis la gauche"), ("bounceInRight", "Rebond depuis la droite"),
("bounceInUp", "Rebond depuis le bas"),
]),
("Sortie — rebond", [
("bounceOut", "Rebond sortant"), ("bounceOutDown", "Rebond vers le bas"),
("bounceOutLeft", "Rebond vers la gauche"), ("bounceOutRight", "Rebond vers la droite"),
("bounceOutUp", "Rebond vers le haut"),
]),
("Entrée — glissement", [
("slideInDown", "Glisse depuis le haut"), ("slideInLeft", "Glisse depuis la gauche"),
("slideInRight", "Glisse depuis la droite"), ("slideInUp", "Glisse depuis le bas"),
]),
("Sortie — glissement", [
("slideOutDown", "Glisse vers le bas"), ("slideOutLeft", "Glisse vers la gauche"),
("slideOutRight", "Glisse vers la droite"), ("slideOutUp", "Glisse vers le haut"),
]),
("Entrée — zoom", [
("zoomIn", "Zoom entrant"), ("zoomInDown", "Zoom depuis le haut"),
("zoomInLeft", "Zoom depuis la gauche"), ("zoomInRight", "Zoom depuis la droite"),
("zoomInUp", "Zoom depuis le bas"),
]),
("Sortie — zoom", [
("zoomOut", "Zoom sortant"), ("zoomOutDown", "Zoom vers le bas"),
("zoomOutLeft", "Zoom vers la gauche"), ("zoomOutRight", "Zoom vers la droite"),
("zoomOutUp", "Zoom vers le haut"),
]),
("Rotation", [
("rotateIn", "Rotation entrante"), ("rotateOut", "Rotation sortante"),
("rotateInDownLeft", "Rotation depuis haut-gauche"), ("rotateInDownRight", "Rotation depuis haut-droite"),
("rotateInUpLeft", "Rotation depuis bas-gauche"), ("rotateInUpRight", "Rotation depuis bas-droite"),
]),
("Retournement", [
("flip", "Retournement"), ("flipInX", "Retournement horizontal entrant"),
("flipInY", "Retournement vertical entrant"), ("flipOutX", "Retournement horizontal sortant"),
("flipOutY", "Retournement vertical sortant"),
]),
("Vitesse lumière", [
("lightSpeedInRight", "Entrée vitesse lumière — droite"), ("lightSpeedInLeft", "Entrée vitesse lumière — gauche"),
("lightSpeedOutRight", "Sortie vitesse lumière — droite"), ("lightSpeedOutLeft", "Sortie vitesse lumière — gauche"),
]),
("Spéciales", [
("jackInTheBox", "Boîte à surprise"), ("rollIn", "Roule à l'entrée"), ("rollOut", "Roule à la sortie"),
("backInDown", "Recul entrant — haut"), ("backInUp", "Recul entrant — bas"),
("backOutDown", "Recul sortant — bas"), ("backOutUp", "Recul sortant — haut"),
]),
]
# Map key -> label, pour l'affichage côté éditeur (nom du nœud, etc.).
ANIMATE_CSS_LABELS = {key: label for _cat, items in ANIMATE_CSS_CATALOG for key, label in items}
@@ -0,0 +1,8 @@
import db
def delete_animation_clip(slug, clip_id):
conn = db.connect(slug)
conn.execute("DELETE FROM _animation_clips WHERE id = ?", (clip_id,))
conn.commit()
conn.close()
@@ -0,0 +1,29 @@
import db
def ensure_animation_schema(slug):
"""Crée la table des clips d'animation si elle n'existe pas encore —
même principe que ensure_flow_schema.py : un jeu créé avant l'ajout de
cette fonctionnalité n'a pas besoin d'être migré à la main, la première
ouverture du panneau timeline suffit."""
conn = db.connect(slug)
conn.executescript(
"""
CREATE TABLE IF NOT EXISTS _animation_clips (
id INTEGER PRIMARY KEY AUTOINCREMENT,
screen_id INTEGER NOT NULL REFERENCES _screens(id) ON DELETE CASCADE,
element_id INTEGER NOT NULL REFERENCES _screen_elements(id) ON DELETE CASCADE,
kind TEXT NOT NULL DEFAULT 'animate_css',
animate_name TEXT,
custom_keyframes TEXT,
start_time REAL NOT NULL DEFAULT 0,
duration REAL NOT NULL DEFAULT 1,
delay REAL NOT NULL DEFAULT 0,
easing TEXT NOT NULL DEFAULT 'ease',
iteration_count TEXT NOT NULL DEFAULT '1',
created_at TEXT DEFAULT CURRENT_TIMESTAMP
);
"""
)
conn.commit()
conn.close()
+11
View File
@@ -0,0 +1,11 @@
import db
from .ensure_animation_schema import ensure_animation_schema
def get_animation_clip(slug, clip_id):
ensure_animation_schema(slug)
conn = db.connect(slug)
row = conn.execute("SELECT * FROM _animation_clips WHERE id = ?", (clip_id,)).fetchone()
conn.close()
return dict(row) if row else None
@@ -0,0 +1,13 @@
import db
from .ensure_animation_schema import ensure_animation_schema
def list_animation_clips(slug, screen_id):
ensure_animation_schema(slug)
conn = db.connect(slug)
rows = conn.execute(
"SELECT * FROM _animation_clips WHERE screen_id = ? ORDER BY start_time, id", (screen_id,)
).fetchall()
conn.close()
return [dict(r) for r in rows]
@@ -0,0 +1,33 @@
import json
import db
from .ensure_animation_schema import ensure_animation_schema
# Champs modifiables après création — le déplacement/redimensionnement à la
# souris dans le panneau timeline n'envoie que start_time/duration, le
# formulaire d'édition du clip peut envoyer n'importe lequel des autres.
_UPDATABLE_FIELDS = {
"kind", "animate_name", "start_time", "duration", "delay", "easing", "iteration_count",
}
def update_animation_clip(slug, clip_id, **fields):
ensure_animation_schema(slug)
sets, vals = [], []
for key, value in fields.items():
if key == "custom_keyframes":
sets.append("custom_keyframes = ?")
vals.append(json.dumps(value) if value else None)
elif key in _UPDATABLE_FIELDS and value is not None:
if key in ("start_time", "duration", "delay"):
value = float(value)
sets.append(f"{key} = ?")
vals.append(value)
if not sets:
return
vals.append(clip_id)
conn = db.connect(slug)
conn.execute(f"UPDATE _animation_clips SET {', '.join(sets)} WHERE id = ?", vals)
conn.commit()
conn.close()
+1
View File
@@ -0,0 +1 @@
"""Application des actions "modifier une donnée d'objet" en mode jouable."""
+76
View File
@@ -0,0 +1,76 @@
import db
def apply_data_action(slug, action):
"""Exécute au moment du clic (mode jouable) une action "modifier_donnee" :
lit l'objet/la ligne/le champ visés dans l'action, calcule la nouvelle
valeur selon l'opération choisie, et l'enregistre — un vrai UPDATE SQL,
limité à cette seule colonne (les autres champs de la ligne ne sont pas
touchés)."""
definition_id = action.get("target_definition_id")
row_id = action.get("target_row_id")
field_name = action.get("target_field")
operation = action.get("data_operation")
raw_value = action.get("data_value")
if not (definition_id and row_id and field_name and operation):
return False
definition = db.get_definition(slug, definition_id)
if not definition:
return False
field_def = next((f for f in definition["fields"] if f["name"] == field_name), None)
if not field_def:
return False
row = db.get_row(slug, definition, row_id)
if not row:
return False
col = db.slugify(field_def["name"]).replace("-", "_")
if field_def["type"] == "relation":
col = col + "_id"
current = row.get(col)
if operation == "definir_texte":
new_value = raw_value or ""
elif operation == "definir_nombre":
try:
new_value = float(raw_value) if field_def["type"] == "nombre_decimal" else int(float(raw_value))
except (TypeError, ValueError):
new_value = 0
elif operation == "definir_bool_vrai":
new_value = 1
elif operation == "definir_bool_faux":
new_value = 0
elif operation == "basculer_bool":
new_value = 0 if current else 1
elif operation in ("incrementer", "decrementer"):
try:
amount = float(raw_value) if raw_value not in (None, "") else 0
except (TypeError, ValueError):
amount = 0
if operation == "decrementer":
amount = -amount
result = (current or 0) + amount
new_value = result if field_def["type"] == "nombre_decimal" else int(result)
else:
return False
new_value = _clamp_to_field_bounds(new_value, field_def)
db.update_row_field(slug, definition, row_id, field_def, new_value)
return True
def _clamp_to_field_bounds(value, field_def):
"""2.2 — bornage automatique : ramène la valeur dans [min_value,
max_value] si l'un ou l'autre est réglé sur ce champ, pour qu'une
jauge (voir render_jauge.py) ne puisse jamais dépasser 100 % ni
descendre sous 0 % (ou toute autre borne définie), quel que soit le
nombre d'actions "Augmenter de..."/"Diminuer de..." posées dans le
graphe de logique. Ne s'applique qu'aux champs numériques — un champ
non numérique n'a pas de min_value/max_value (voir create_definition)."""
if field_def["type"] not in ("nombre_entier", "nombre_decimal"):
return value
min_v, max_v = field_def.get("min_value"), field_def.get("max_value")
if min_v is not None and value < min_v:
value = min_v
if max_v is not None and value > max_v:
value = max_v
return value if field_def["type"] == "nombre_decimal" else int(value)
+1
View File
@@ -0,0 +1 @@
"""Catalogue d'éléments de jeu réutilisables (Personnage, Outil, IA...)."""
@@ -0,0 +1,21 @@
import db
from ..screens_repo.ensure_schema import ensure_schema
from ..screens_repo.create_screen import create_screen
def create_element_type(slug, name, icon, bound_definition_id=None):
"""Un élément de jeu est un conteneur réutilisable : sa "recette" (les
éléments qu'il contient, imbriqués et stylés) vit dans un écran-modèle
caché, créé ici en même temps que l'entrée de catalogue — on le
remplit ensuite exactement comme le contenu de n'importe quel
conteneur, via l'éditeur d'écran normal pointé sur cet écran-modèle."""
ensure_schema(slug)
template_screen_id = create_screen(slug, "Modèle : " + name, is_template=True)
conn = db.connect(slug)
conn.execute(
"INSERT INTO _element_types (name, icon, template_screen_id, bound_definition_id) VALUES (?, ?, ?, ?)",
(name, icon or "🧩", template_screen_id, int(bound_definition_id) if bound_definition_id else None),
)
conn.commit()
conn.close()
@@ -0,0 +1,15 @@
import db
def delete_element_type(slug, element_type_id):
conn = db.connect(slug)
row = conn.execute(
"SELECT template_screen_id FROM _element_types WHERE id = ?", (element_type_id,)
).fetchone()
conn.execute("DELETE FROM _element_types WHERE id = ?", (element_type_id,))
if row and row["template_screen_id"]:
# Supprime aussi l'écran-modèle caché associé — son propre contenu
# (_screen_elements) part avec lui via ON DELETE CASCADE.
conn.execute("DELETE FROM _screens WHERE id = ?", (row["template_screen_id"],))
conn.commit()
conn.close()
@@ -0,0 +1,10 @@
import db
def element_type_usage_count(slug, element_type_id):
conn = db.connect(slug)
count = conn.execute(
"SELECT COUNT(*) AS c FROM _screen_elements WHERE element_type_id = ?", (element_type_id,)
).fetchone()["c"]
conn.close()
return count
@@ -0,0 +1,48 @@
import db
def instantiate_template_tree(slug, template_screen_id, target_screen_id, target_parent_id=None):
"""Copie EN PROFONDEUR le contenu d'un écran-modèle vers un écran réel
(ou à l'intérieur d'un conteneur d'un écran réel) : chaque élément copié
devient une vraie ligne indépendante en base, modifiable séparément par
la suite — exactement le comportement d'un élément de jeu "à l'ancienne"
posé sur un écran. Renvoie la liste des ids créés au premier niveau."""
conn = db.connect(slug)
rows = conn.execute(
"SELECT * FROM _screen_elements WHERE screen_id = ? ORDER BY id", (template_screen_id,)
).fetchall()
conn.close()
source = [dict(r) for r in rows]
created_top_ids = []
def copy_one(src, new_parent_id):
conn = db.connect(slug)
max_z = conn.execute(
"SELECT MAX(z_index) AS m FROM _screen_elements WHERE screen_id = ?", (target_screen_id,)
).fetchone()["m"]
conn.execute(
"""INSERT INTO _screen_elements
(screen_id, parent_id, widget, name, content, attributes, x, y, width, height, z_index, style)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
(
target_screen_id, new_parent_id, src["widget"], src.get("name"),
src["content"], src["attributes"], src["x"], src["y"], src["width"], src["height"],
(max_z or 0) + 1, src["style"],
),
)
new_id = conn.execute("SELECT last_insert_rowid() AS id").fetchone()["id"]
conn.commit()
conn.close()
return new_id
def walk(src_parent_id, new_parent_id):
for child in [s for s in source if s.get("parent_id") == src_parent_id]:
new_id = copy_one(child, new_parent_id)
walk(child["id"], new_id)
for t in [s for s in source if not s.get("parent_id")]:
new_id = copy_one(t, target_parent_id)
created_top_ids.append(new_id)
walk(t["id"], new_id)
return created_top_ids
@@ -0,0 +1,19 @@
import json
import db
from ..screens_repo.ensure_schema import ensure_schema
def list_element_types(slug):
ensure_schema(slug)
conn = db.connect(slug)
rows = conn.execute("SELECT * FROM _element_types ORDER BY id").fetchall()
conn.close()
result = []
for r in rows:
d = dict(r)
d["default_style"] = json.loads(d["default_style"] or "{}")
d["default_attributes"] = json.loads(d["default_attributes"] or "{}")
result.append(d)
return result
@@ -0,0 +1,49 @@
import json
import db
def _load_template_tree(slug, element_type_id):
"""Charge le contenu (éléments de premier niveau + descendants) de
l'écran-modèle d'un élément de jeu, pour un rendu EN DIRECT (sans copier
quoi que ce soit en base) — utilisé quand un Répéteur de données affiche
cet élément de jeu comme modèle de ligne (voir _render_repeater) : on
relit et on rejoue son arbre à chaque ligne de données, avec les
{{champ}} de cette ligne. Renvoie None si l'élément de jeu n'a pas (ou
plus) d'écran-modèle valide."""
conn = db.connect(slug)
et_row = conn.execute(
"SELECT template_screen_id FROM _element_types WHERE id = ?", (element_type_id,)
).fetchone()
if not et_row or not et_row["template_screen_id"]:
conn.close()
return None
rows = conn.execute(
"SELECT * FROM _screen_elements WHERE screen_id = ? ORDER BY z_index, id",
(et_row["template_screen_id"],),
).fetchall()
conn.close()
elements = []
for r in rows:
d = dict(r)
d["style"] = json.loads(d["style"] or "{}")
d["attributes"] = json.loads(d["attributes"] or "{}")
elements.append(d)
children_map = {}
for d in elements:
if d.get("parent_id"):
children_map.setdefault(d["parent_id"], []).append(d)
top = [d for d in elements if not d.get("parent_id")]
if not top:
return None
# Astuce de rendu : ces éléments sont "de premier niveau" dans leur
# écran-modèle (parent_id NULL en base, donc positionnés en absolu par
# défaut), mais une fois le modèle réutilisé ailleurs (ligne de
# Répéteur), ils doivent se comporter comme des enfants en flux normal,
# pas comme des boîtes positionnées en absolu sur un écran plein. On leur
# donne donc un parent_id "factice" (une chaîne, jamais un vrai id) rien
# que pour ce rendu éphémère — _style_string ne teste que sa présence,
# jamais sa valeur exacte, et cette copie n'est jamais enregistrée.
for t in top:
t["parent_id"] = t.get("parent_id") or "template-root"
return {"top": top, "children_map": children_map}
@@ -0,0 +1,13 @@
import db
def update_element_type(slug, element_type_id, name=None, bound_definition_id=None):
conn = db.connect(slug)
if name:
conn.execute("UPDATE _element_types SET name = ? WHERE id = ?", (name, element_type_id))
conn.execute(
"UPDATE _element_types SET bound_definition_id = ? WHERE id = ?",
(int(bound_definition_id) if bound_definition_id else None, element_type_id),
)
conn.commit()
conn.close()
+1
View File
@@ -0,0 +1 @@
"""Éléments posés sur un écran (_screen_elements)."""
+84
View File
@@ -0,0 +1,84 @@
import json
import db
from ..widgets.registry import WIDGETS
from ..widgets.widget_meta import widget_meta
from ..widgets.default_style_for_widget import default_style_and_attributes
from ..element_types.list_element_types import list_element_types
from ..element_types.instantiate_template_tree import instantiate_template_tree
def add_element(slug, screen_id, widget_key, element_type_id=None, parent_id=None):
"""parent_id : si fourni, l'élément est posé PHYSIQUEMENT à l'intérieur
de cet élément parent (qui doit être un conteneur/répéteur/groupe de
champs) plutôt que directement sur l'écran — sa position/taille ne sert
alors plus (il suit le flux du parent), mais on garde des valeurs par
défaut pour rester cohérent si jamais il en sort un jour.
element_type_id : si fourni ET que cet élément de jeu a un écran-modèle
(le cas normal désormais — voir create_element_type), tout son arbre est
COPIÉ en profondeur ici plutôt que de créer un seul élément plat : c'est
ce qui permet à un élément de jeu d'être un mini-conteneur réutilisable
avec plusieurs éléments imbriqués à l'intérieur, pas juste un widget
unique avec un contenu par défaut."""
if element_type_id:
types = {t["id"]: t for t in list_element_types(slug)}
et = types.get(int(element_type_id))
if et and et.get("template_screen_id"):
created = instantiate_template_tree(slug, et["template_screen_id"], screen_id, parent_id)
if created:
conn = db.connect(slug)
conn.execute(
"UPDATE _screen_elements SET element_type_id = ? WHERE id = ?", (et["id"], created[0])
)
conn.commit()
conn.close()
return created[0]
# Modèle vide (aucun élément dedans pour l'instant) : on retombe
# sur l'ancien comportement plat ci-dessous, pour ne jamais
# échouer silencieusement à poser quelque chose.
widget_key = widget_key if widget_key in WIDGETS else "conteneur"
meta = widget_meta(widget_key)
# Réglages dont la valeur par défaut a un effet visuel voulu dès la
# création (ex: une image démarre avec "cover" pour se recadrer
# proprement) — hors couleurs, qui restent volontairement transparentes/
# sans bordure tant qu'on ne les choisit pas explicitement.
attributes, style = default_style_and_attributes(widget_key)
content = meta.get("default_content", "")
width, height = meta.get("w", 30), meta.get("h", 10)
conn = db.connect(slug)
max_z = conn.execute(
"SELECT MAX(z_index) AS m FROM _screen_elements WHERE screen_id = ?", (screen_id,)
).fetchone()["m"]
# Décalage en cascade : sans ça, chaque nouvel élément posé DIRECTEMENT
# SUR L'ÉCRAN atterrit exactement au même endroit (10%, 10%) que le
# précédent et le recouvre entièrement — ce qui peut donner l'impression
# trompeuse qu'un "élément indésirable" vient d'apparaître et d'en
# cacher un autre (le plus souvent après une confusion entre "Ajouter un
# élément sur l'écran" et "Ajouter DANS ce conteneur", qui posent tous
# les deux un nouvel élément mais pas au même endroit dans l'arbre).
x = y = 10
if not parent_id:
top_level_count = conn.execute(
"SELECT COUNT(*) AS c FROM _screen_elements WHERE screen_id = ? AND parent_id IS NULL",
(screen_id,),
).fetchone()["c"]
offset = (top_level_count % 8) * 4
x = y = 10 + offset
conn.execute(
"""INSERT INTO _screen_elements
(screen_id, parent_id, widget, element_type_id, content, attributes, x, y, width, height, z_index, style)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
(
screen_id, parent_id or None, widget_key, element_type_id or None, content, json.dumps(attributes),
x, y, width, height, (max_z or 0) + 1,
json.dumps(style),
),
)
element_id = conn.execute("SELECT last_insert_rowid() AS id").fetchone()["id"]
conn.commit()
conn.close()
return element_id
+8
View File
@@ -0,0 +1,8 @@
import db
def delete_element(slug, element_id):
conn = db.connect(slug)
conn.execute("DELETE FROM _screen_elements WHERE id = ?", (element_id,))
conn.commit()
conn.close()
+10
View File
@@ -0,0 +1,10 @@
from ..widgets.registry import WIDGETS
def element_label(el, widgets_catalog=None):
"""Nom lisible d'un élément pour les listes/menus : son nom personnalisé
s'il en a un, sinon le libellé de son widget (ex: "Image")."""
catalog = widgets_catalog if widgets_catalog is not None else WIDGETS
if el.get("name"):
return el["name"]
return catalog.get(el.get("widget"), {}).get("label", el.get("widget", "?"))
+15
View File
@@ -0,0 +1,15 @@
import json
import db
def get_element(slug, element_id):
conn = db.connect(slug)
row = conn.execute("SELECT * FROM _screen_elements WHERE id = ?", (element_id,)).fetchone()
conn.close()
if not row:
return None
d = dict(row)
d["style"] = json.loads(d["style"] or "{}")
d["attributes"] = json.loads(d["attributes"] or "{}")
return d
+34
View File
@@ -0,0 +1,34 @@
import json
import db
from ..rendering.render_element_html import render_element_html
def list_elements(slug, screen_id):
"""Renvoie TOUS les éléments de l'écran, à plat (parent_id indique
l'imbrication) — pratique pour construire l'arbre côté appelant. Seuls
les éléments de premier niveau (parent_id NULL) reçoivent un
"rendered_html" : celui d'un conteneur/répéteur/groupe de champs inclut
déjà, en le générant récursivement, le HTML de tout ce qui est posé à
l'intérieur — les enfants n'ont donc pas besoin d'être dessinés une
deuxième fois séparément."""
conn = db.connect(slug)
rows = conn.execute(
"SELECT * FROM _screen_elements WHERE screen_id = ? ORDER BY z_index, id", (screen_id,)
).fetchall()
conn.close()
result = []
for r in rows:
d = dict(r)
d["style"] = json.loads(d["style"] or "{}")
d["attributes"] = json.loads(d["attributes"] or "{}")
result.append(d)
children_map = {}
for d in result:
if d.get("parent_id"):
children_map.setdefault(d["parent_id"], []).append(d)
for d in result:
if not d.get("parent_id"):
d["rendered_html"] = render_element_html(d, slug, children_map)
return result
+8
View File
@@ -0,0 +1,8 @@
import db
def rename_element(slug, element_id, name):
conn = db.connect(slug)
conn.execute("UPDATE _screen_elements SET name = ? WHERE id = ?", ((name or "").strip() or None, element_id))
conn.commit()
conn.close()
+113
View File
@@ -0,0 +1,113 @@
import json
import db
from ..widgets.widget_meta import widget_meta
from .get_element import get_element
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 == "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
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
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)
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()
@@ -0,0 +1,14 @@
import db
def update_element_geometry(slug, element_id, x, y, width, height):
"""Utilisé par le glisser-déposer / redimensionnement (et par les champs
Position & taille du panneau de droite) — ne touche qu'à la position et
à la taille, rien d'autre."""
conn = db.connect(slug)
conn.execute(
"UPDATE _screen_elements SET x = ?, y = ?, width = ?, height = ? WHERE id = ?",
(x, y, width, height, element_id),
)
conn.commit()
conn.close()
+2
View File
@@ -0,0 +1,2 @@
"""Logique visuelle (éditeur de flow à nœuds) : déclencheurs, conditions,
actions, sous forme de graphe propre à chaque écran."""
+13
View File
@@ -0,0 +1,13 @@
import db
def add_flow_edge(slug, screen_id, from_node_id, from_port, to_node_id):
conn = db.connect(slug)
conn.execute(
"INSERT INTO _flow_edges (screen_id, from_node_id, from_port, to_node_id) VALUES (?, ?, ?, ?)",
(screen_id, from_node_id, from_port or "out", to_node_id),
)
edge_id = conn.execute("SELECT last_insert_rowid() AS id").fetchone()["id"]
conn.commit()
conn.close()
return edge_id
+21
View File
@@ -0,0 +1,21 @@
import db
from .ensure_flow_schema import ensure_flow_schema
from .constants import FLOW_NODE_FIELDS
def add_flow_node(slug, screen_id, node_type, x=40, y=40, **fields):
ensure_flow_schema(slug)
cols = ["screen_id", "node_type", "x", "y"]
vals = [screen_id, node_type, x, y]
for k, v in fields.items():
if k in FLOW_NODE_FIELDS and v not in (None, ""):
cols.append(k)
vals.append(v)
placeholders = ", ".join("?" for _ in cols)
conn = db.connect(slug)
conn.execute(f"INSERT INTO _flow_nodes ({', '.join(cols)}) VALUES ({placeholders})", vals)
node_id = conn.execute("SELECT last_insert_rowid() AS id").fetchone()["id"]
conn.commit()
conn.close()
return node_id
+47
View File
@@ -0,0 +1,47 @@
# ---------- Logique visuelle (éditeur de flow à nœuds) ----------
#
# Remplace l'ancien système "Actions" attaché à chaque élément : toute la
# logique d'une scène (déclencheurs, conditions, actions) vit maintenant dans
# un graphe de nœuds propre à l'écran, visible dans le panneau "Logique de
# la scène" de l'éditeur (voir screen_edit.html) — un nœud Déclencheur
# ("Au clic sur X") relié à des nœuds Condition (deux sorties Vrai/Faux) et
# Action (les mêmes effets qu'avant : changer d'écran, modifier un élément,
# modifier une donnée). L'ancien système _actions reste en base pour ne rien
# casser sur les jeux déjà créés, mais n'est plus exposé dans l'éditeur ni
# utilisé par le mode jouable.
TRIGGER_EVENTS = [
("clic", "Au clic"),
("soumission", "À la soumission"),
# Pas de "trigger_element_id" pour celui-ci : il concerne l'ÉCRAN entier,
# pas un élément précis (voir 1.2 dans claude/forge-engine-lacunes-boitemail.md
# côté projet Forge — "affichage piloté par la donnée"). Se déclenche
# côté jouable à chaque fois que l'écran est montré (premier affichage,
# retour en arrière, changement d'écran...) ET après toute donnée
# modifiée pendant qu'on est déjà sur cet écran — ce qui permet de
# cacher/montrer un élément selon l'état de la partie SANS qu'un clic
# explicite soit nécessaire pour le réévaluer.
("affichage", "À l'affichage de l'écran"),
]
CONDITION_OPERATORS = [
("egal", "est égal à"),
("different", "est différent de"),
("superieur", "est supérieur à"),
("inferieur", "est inférieur à"),
("superieur_egal", "est supérieur ou égal à"),
("inferieur_egal", "est inférieur ou égal à"),
]
CONDITION_OPERATOR_LABELS = dict(CONDITION_OPERATORS)
FLOW_NODE_FIELDS = {
"trigger_element_id", "trigger_event",
"cond_definition_id", "cond_row_id", "cond_field", "cond_field_type", "cond_operator", "cond_value",
# 2.4 — conditions combinées (ET/OU) : cond_clauses est une liste JSON de
# clauses supplémentaires (en plus de la clause "historique" ci-dessus,
# qui reste la première clause) + cond_combinator ('et'/'ou') pour savoir
# comment les combiner. Absents => comportement legacy (une seule clause).
"cond_clauses", "cond_combinator",
"action_type", "target_screen_id", "target_element_id", "element_property", "element_value",
"target_definition_id", "target_row_id", "target_field", "data_operation", "data_value",
}
+8
View File
@@ -0,0 +1,8 @@
import db
def delete_flow_edge(slug, edge_id):
conn = db.connect(slug)
conn.execute("DELETE FROM _flow_edges WHERE id = ?", (edge_id,))
conn.commit()
conn.close()
+9
View File
@@ -0,0 +1,9 @@
import db
def delete_flow_node(slug, node_id):
conn = db.connect(slug)
conn.execute("DELETE FROM _flow_edges WHERE from_node_id = ? OR to_node_id = ?", (node_id, node_id))
conn.execute("DELETE FROM _flow_nodes WHERE id = ?", (node_id,))
conn.commit()
conn.close()
+55
View File
@@ -0,0 +1,55 @@
import db
def ensure_flow_schema(slug):
conn = db.connect(slug)
conn.executescript(
"""
CREATE TABLE IF NOT EXISTS _flow_nodes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
screen_id INTEGER NOT NULL REFERENCES _screens(id) ON DELETE CASCADE,
node_type TEXT NOT NULL,
x REAL NOT NULL DEFAULT 40,
y REAL NOT NULL DEFAULT 40,
trigger_element_id INTEGER REFERENCES _screen_elements(id),
trigger_event TEXT,
cond_definition_id INTEGER,
cond_row_id INTEGER,
cond_field TEXT,
cond_field_type TEXT,
cond_operator TEXT,
cond_value TEXT,
action_type TEXT,
target_screen_id INTEGER REFERENCES _screens(id),
target_element_id INTEGER REFERENCES _screen_elements(id),
element_property TEXT,
element_value TEXT,
target_definition_id INTEGER,
target_row_id INTEGER,
target_field TEXT,
data_operation TEXT,
data_value TEXT
);
CREATE TABLE IF NOT EXISTS _flow_edges (
id INTEGER PRIMARY KEY AUTOINCREMENT,
screen_id INTEGER NOT NULL REFERENCES _screens(id) ON DELETE CASCADE,
from_node_id INTEGER NOT NULL REFERENCES _flow_nodes(id) ON DELETE CASCADE,
from_port TEXT NOT NULL DEFAULT 'out',
to_node_id INTEGER NOT NULL REFERENCES _flow_nodes(id) ON DELETE CASCADE
);
"""
)
# 2.4 — conditions combinées (ET/OU) : un nœud Condition peut tester
# PLUSIEURS champs à la fois plutôt qu'un seul. cond_clauses (JSON, une
# liste de clauses en plus de la clause "historique" cond_definition_id/
# cond_field/etc.) + cond_combinator ('et'/'ou') sont ajoutés en ALTER
# TABLE pour ne rien casser sur les jeux déjà créés — un nœud sans
# cond_clauses garde exactement son comportement d'avant (une seule
# comparaison), voir evaluateConditionNode() dans templates/play.html.
cols = {r["name"] for r in conn.execute("PRAGMA table_info(_flow_nodes)").fetchall()}
if "cond_clauses" not in cols:
conn.execute("ALTER TABLE _flow_nodes ADD COLUMN cond_clauses TEXT")
if "cond_combinator" not in cols:
conn.execute("ALTER TABLE _flow_nodes ADD COLUMN cond_combinator TEXT NOT NULL DEFAULT 'et'")
conn.commit()
conn.close()
+8
View File
@@ -0,0 +1,8 @@
import db
def get_flow_node(slug, node_id):
conn = db.connect(slug)
row = conn.execute("SELECT * FROM _flow_nodes WHERE id = ?", (node_id,)).fetchone()
conn.close()
return dict(row) if row else None
+11
View File
@@ -0,0 +1,11 @@
import db
from .ensure_flow_schema import ensure_flow_schema
def list_flow_edges(slug, screen_id):
ensure_flow_schema(slug)
conn = db.connect(slug)
rows = conn.execute("SELECT * FROM _flow_edges WHERE screen_id = ? ORDER BY id", (screen_id,)).fetchall()
conn.close()
return [dict(r) for r in rows]
+11
View File
@@ -0,0 +1,11 @@
import db
from .ensure_flow_schema import ensure_flow_schema
def list_flow_nodes(slug, screen_id):
ensure_flow_schema(slug)
conn = db.connect(slug)
rows = conn.execute("SELECT * FROM _flow_nodes WHERE screen_id = ? ORDER BY id", (screen_id,)).fetchall()
conn.close()
return [dict(r) for r in rows]
+8
View File
@@ -0,0 +1,8 @@
import db
def move_flow_node(slug, node_id, x, y):
conn = db.connect(slug)
conn.execute("UPDATE _flow_nodes SET x = ?, y = ? WHERE id = ?", (x, y, node_id))
conn.commit()
conn.close()
+2
View File
@@ -0,0 +1,2 @@
"""Petites constantes de libellés UI utilisées par le panneau de propriétés
et l'éditeur de flow."""
+11
View File
@@ -0,0 +1,11 @@
ACTION_TYPE_LABELS = {
"ecran_suivant": "Afficher l'écran suivant",
"ecran_precedent": "Afficher l'écran précédent",
"aller_a": "Aller à un écran précis",
"modifier_element": "Modifier un élément",
"modifier_donnee": "Modifier une donnée d'un objet",
"ouvrir_ligne": "Ouvrir la ligne de Répéteur cliquée",
"activer_onglet": "Afficher cet élément, masquer tous ses frères",
"attendre": "Attendre quelques secondes avant de continuer",
"rien": "Ne rien faire",
}
+12
View File
@@ -0,0 +1,12 @@
# ---------- Actions : modifier une donnée d'objet ----------
DATA_OPERATIONS = [
("definir_texte", "Donner cette valeur — texte"),
("definir_nombre", "Donner cette valeur — nombre"),
("definir_bool_vrai", "Mettre à Vrai"),
("definir_bool_faux", "Mettre à Faux"),
("basculer_bool", "Basculer Vrai / Faux"),
("incrementer", "Augmenter de..."),
("decrementer", "Diminuer de..."),
]
DATA_OPERATION_LABELS = dict(DATA_OPERATIONS)
@@ -0,0 +1,38 @@
# ---------- Actions : modifier un élément ----------
ELEMENT_ACTION_PROPERTIES = [
("visibilite", "Visibilité"),
("bg_color", "Couleur de fond"),
("border_color", "Couleur de bordure"),
("width", "Largeur en %"),
("height", "Hauteur en %"),
# 3.3 (Confort) — surbrillance générique dynamique : un liseré qui
# clignote autour de l'élément, pour attirer l'attention du joueur vers
# ce qu'il doit toucher ensuite (ex. un mentor qui "montre" un bouton) —
# sans avoir à poser une bordure de couleur togglée à la main. Voir
# ELEMENT_SURBRILLANCE_VALUES et applyElementProperty() dans play.html.
("surbrillance", "Surbrillance — liseré clignotant"),
# 3.5 (Confort) — verrouillage d'un élément après décision : grisé +
# ignore les clics, DISTINCT de "invisible" (qui garde sa place mais
# disparaît visuellement) — un bouton "Répondre" qui reste visible mais
# devient inerte une fois le mail traité, par exemple. Voir
# ELEMENT_DESACTIVE_VALUES et applyElementProperty() / bindClicks() dans
# play.html (pointer-events:none empêche aussi le déclencheur "Au clic"
# de se redéclencher tant que l'élément est désactivé).
("desactive", "Désactivé — grisé, ignore les clics"),
]
ELEMENT_ACTION_PROPERTY_LABELS = dict(ELEMENT_ACTION_PROPERTIES)
ELEMENT_SURBRILLANCE_VALUES = [
("on", "Activer la surbrillance"),
("off", "Désactiver la surbrillance"),
("toggle", "Basculer activée / désactivée"),
]
ELEMENT_SURBRILLANCE_LABELS = dict(ELEMENT_SURBRILLANCE_VALUES)
ELEMENT_DESACTIVE_VALUES = [
("on", "Désactiver — griser, ignorer les clics"),
("off", "Réactiver"),
("toggle", "Basculer désactivé / réactivé"),
]
ELEMENT_DESACTIVE_LABELS = dict(ELEMENT_DESACTIVE_VALUES)
+7
View File
@@ -0,0 +1,7 @@
ELEMENT_VISIBILITY_VALUES = [
("visible", "Rendre visible"),
("masque", "Masquer — n'occupe plus de place"),
("invisible", "Rendre invisible — garde sa place"),
("toggle", "Basculer visible / masqué"),
]
ELEMENT_VISIBILITY_LABELS = dict(ELEMENT_VISIBILITY_VALUES)
+5
View File
@@ -0,0 +1,5 @@
TRIGGER_LABELS = {
"clic": "Au clic",
"soumission": "À la soumission",
"affichage": "À l'affichage de l'écran",
}
+2
View File
@@ -0,0 +1,2 @@
"""Ancien système d'actions par élément (_actions) — conservé pour ne rien
casser sur les jeux déjà créés, remplacé par le module flow/."""
+25
View File
@@ -0,0 +1,25 @@
import db
def add_action(
slug, element_id, trigger_event, action_type, target_screen_id=None,
target_element_id=None, element_property=None, element_value=None,
target_definition_id=None, target_row_id=None, target_field=None,
data_operation=None, data_value=None,
):
conn = db.connect(slug)
conn.execute(
"""INSERT INTO _actions
(element_id, trigger_event, action_type, target_screen_id,
target_element_id, element_property, element_value,
target_definition_id, target_row_id, target_field, data_operation, data_value)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
(
element_id, trigger_event, action_type, target_screen_id or None,
target_element_id or None, element_property or None, element_value or None,
target_definition_id or None, target_row_id or None, target_field or None,
data_operation or None, data_value or None,
),
)
conn.commit()
conn.close()
+8
View File
@@ -0,0 +1,8 @@
import db
def delete_action(slug, action_id):
conn = db.connect(slug)
conn.execute("DELETE FROM _actions WHERE id = ?", (action_id,))
conn.commit()
conn.close()
+8
View File
@@ -0,0 +1,8 @@
import db
def get_action(slug, action_id):
conn = db.connect(slug)
row = conn.execute("SELECT * FROM _actions WHERE id = ?", (action_id,)).fetchone()
conn.close()
return dict(row) if row else None
+8
View File
@@ -0,0 +1,8 @@
import db
def list_actions(slug, element_id):
conn = db.connect(slug)
rows = conn.execute("SELECT * FROM _actions WHERE element_id = ? ORDER BY id", (element_id,)).fetchall()
conn.close()
return [dict(r) for r in rows]
+1
View File
@@ -0,0 +1 @@
"""Sérialisation de tout le jeu pour le mode jouable."""
+45
View File
@@ -0,0 +1,45 @@
import db
from ..screens_repo.list_screens import list_screens
from ..elements.list_elements import list_elements
from ..flow.list_flow_nodes import list_flow_nodes
from ..flow.list_flow_edges import list_flow_edges
from ..animations.list_animation_clips import list_animation_clips
def full_game_payload(slug):
"""Toutes les données nécessaires au runtime JS de la page de jeu jouable
(/game/<slug>/play) : chaque écran avec ses éléments (HTML déjà généré,
y compris les Répéteurs de données, lus en direct), le graphe de logique
(nœuds + fils) de chaque écran, et un instantané des données du jeu
(avec le type de chaque champ, nécessaire pour évaluer une condition
correctement) — un seul aller-retour serveur, ensuite tout se joue côté
client (aucune navigation ne recharge la page, sauf pour appliquer une
action "modifier une donnée", qui doit passer par le serveur)."""
screens_ = list_screens(slug)
payload_screens = []
flows = {}
animations = {}
for s in screens_:
elements = list_elements(slug, s["id"])
payload_screens.append({**s, "elements": elements})
flows[str(s["id"])] = {
"nodes": list_flow_nodes(slug, s["id"]),
"edges": list_flow_edges(slug, s["id"]),
}
animations[str(s["id"])] = list_animation_clips(slug, s["id"])
definitions = db.list_definitions(slug)
data = {}
fields_meta = {}
for d in definitions:
full = db.get_definition(slug, d["id"])
rows = db.list_rows(slug, full)
cols = {f["name"]: db.slugify(f["name"]).replace("-", "_") for f in full["fields"]}
data[str(d["id"])] = [
{**{fname: r.get(col) for fname, col in cols.items()}, "id": r["id"]}
for r in rows
]
fields_meta[str(d["id"])] = [{"name": f["name"], "type": f["type"]} for f in full["fields"]]
return {"screens": payload_screens, "flows": flows, "animations": animations, "data": data, "fields_meta": fields_meta}
+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
+1
View File
@@ -0,0 +1 @@
"""Accès aux écrans du jeu (_screens) et au schéma de base commun au module."""
+21
View File
@@ -0,0 +1,21 @@
import db
from .ensure_schema import ensure_schema
def create_screen(slug, name, is_template=False):
ensure_schema(slug)
conn = db.connect(slug)
if is_template:
order_index = 0
else:
max_order = conn.execute("SELECT MAX(order_index) AS m FROM _screens WHERE is_template = 0").fetchone()["m"]
order_index = (max_order or 0) + 1
conn.execute(
"INSERT INTO _screens (name, order_index, is_template) VALUES (?, ?, ?)",
(name, order_index, 1 if is_template else 0),
)
screen_id = conn.execute("SELECT last_insert_rowid() AS id").fetchone()["id"]
conn.commit()
conn.close()
return screen_id
+8
View File
@@ -0,0 +1,8 @@
import db
def delete_screen(slug, screen_id):
conn = db.connect(slug)
conn.execute("DELETE FROM _screens WHERE id = ?", (screen_id,))
conn.commit()
conn.close()
+124
View File
@@ -0,0 +1,124 @@
import db
def ensure_schema(slug):
"""Crée les tables de ce module si elles n'existent pas encore — les
jeux créés avant l'ajout de cette fonctionnalité n'ont pas besoin d'être
migrés manuellement, la première visite d'un écran de ce jeu suffit."""
conn = db.connect(slug)
conn.executescript(
"""
CREATE TABLE IF NOT EXISTS _screens (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
order_index INTEGER NOT NULL DEFAULT 0,
is_template INTEGER NOT NULL DEFAULT 0,
created_at TEXT DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS _element_types (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
icon TEXT NOT NULL DEFAULT '🧩',
default_widget TEXT NOT NULL DEFAULT 'conteneur',
default_content TEXT NOT NULL DEFAULT '',
default_attributes TEXT NOT NULL DEFAULT '{}',
default_style TEXT NOT NULL DEFAULT '{}',
template_screen_id INTEGER REFERENCES _screens(id),
bound_definition_id INTEGER,
created_at TEXT DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS _screen_elements (
id INTEGER PRIMARY KEY AUTOINCREMENT,
screen_id INTEGER NOT NULL REFERENCES _screens(id) ON DELETE CASCADE,
parent_id INTEGER REFERENCES _screen_elements(id) ON DELETE CASCADE,
widget TEXT NOT NULL DEFAULT 'conteneur',
element_type_id INTEGER REFERENCES _element_types(id),
name TEXT,
content TEXT NOT NULL DEFAULT '',
attributes TEXT NOT NULL DEFAULT '{}',
x REAL NOT NULL DEFAULT 10,
y REAL NOT NULL DEFAULT 10,
width REAL NOT NULL DEFAULT 20,
height REAL NOT NULL DEFAULT 10,
z_index INTEGER NOT NULL DEFAULT 1,
style TEXT NOT NULL DEFAULT '{}',
created_at TEXT DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS _actions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
element_id INTEGER NOT NULL REFERENCES _screen_elements(id) ON DELETE CASCADE,
trigger_event TEXT NOT NULL,
action_type TEXT NOT NULL,
target_screen_id INTEGER REFERENCES _screens(id),
target_element_id INTEGER REFERENCES _screen_elements(id),
element_property TEXT,
element_value TEXT,
target_definition_id INTEGER,
target_row_id INTEGER,
target_field TEXT,
data_operation TEXT,
data_value TEXT
);
"""
)
cols = {r["name"] for r in conn.execute("PRAGMA table_info(_screen_elements)").fetchall()}
if "widget" not in cols:
conn.execute("ALTER TABLE _screen_elements ADD COLUMN widget TEXT NOT NULL DEFAULT 'conteneur'")
if "attributes" not in cols:
conn.execute("ALTER TABLE _screen_elements ADD COLUMN attributes TEXT NOT NULL DEFAULT '{}'")
if "parent_id" not in cols:
conn.execute("ALTER TABLE _screen_elements ADD COLUMN parent_id INTEGER REFERENCES _screen_elements(id) ON DELETE CASCADE")
if "name" not in cols:
conn.execute("ALTER TABLE _screen_elements ADD COLUMN name TEXT")
type_cols = {r["name"] for r in conn.execute("PRAGMA table_info(_element_types)").fetchall()}
if "default_widget" not in type_cols:
conn.execute("ALTER TABLE _element_types ADD COLUMN default_widget TEXT NOT NULL DEFAULT 'conteneur'")
conn.execute("ALTER TABLE _element_types ADD COLUMN default_content TEXT NOT NULL DEFAULT ''")
conn.execute("ALTER TABLE _element_types ADD COLUMN default_attributes TEXT NOT NULL DEFAULT '{}'")
if "template_screen_id" not in type_cols:
# Un élément de jeu n'est plus un simple widget isolé : c'est un
# conteneur avec un vrai arbre d'éléments imbriqués à l'intérieur,
# construit exactement comme le contenu de n'importe quel conteneur
# sur un écran — ce petit arbre vit dans un écran-modèle CACHÉ
# (voir "is_template" ci-dessous), pointé par cette colonne.
conn.execute("ALTER TABLE _element_types ADD COLUMN template_screen_id INTEGER REFERENCES _screens(id)")
if "bound_definition_id" not in type_cols:
# "Objet lié" optionnel — comme pour le Répéteur de données, rend
# {{nom_du_champ}} disponible dans tout l'arbre de ce modèle.
conn.execute("ALTER TABLE _element_types ADD COLUMN bound_definition_id INTEGER")
screen_cols = {r["name"] for r in conn.execute("PRAGMA table_info(_screens)").fetchall()}
if "is_template" not in screen_cols:
# Un écran-modèle héberge le contenu d'un élément de jeu réutilisable
# (voir template_screen_id ci-dessus) : il n'apparaît jamais dans la
# liste des écrans du jeu, ni dans le mode jouable, ni dans les
# menus "aller à cet écran" — seul son contenu (les éléments qu'il
# contient) est utilisé, jamais l'écran lui-même.
conn.execute("ALTER TABLE _screens ADD COLUMN is_template INTEGER NOT NULL DEFAULT 0")
if "preview_aspect" not in screen_cols:
# Format d'aperçu choisi pour cet écran (Portrait 9/16, Paysage 16/9,
# ou Carré 1/1) — mémorisé par écran pour qu'il reste le même après
# un ajout/suppression d'élément ou un changement d'élément
# sélectionné (ces actions redemandent le panneau au serveur, qui
# doit donc connaître le choix déjà fait, sinon il repart sur la
# valeur par défaut à chaque fois). Sert aussi en mode Jouer, pour
# garder la zone de jeu à la bonne proportion quelle que soit la
# forme réelle de la fenêtre du joueur (voir #playFrame dans
# play.html) — sans ça, les positions/tailles en % se déforment dès
# que la fenêtre du joueur n'a pas la même proportion que celle
# utilisée à la conception.
conn.execute("ALTER TABLE _screens ADD COLUMN preview_aspect TEXT NOT NULL DEFAULT '9/16'")
action_cols = {r["name"] for r in conn.execute("PRAGMA table_info(_actions)").fetchall()}
for col_name, col_def in [
("target_element_id", "INTEGER REFERENCES _screen_elements(id)"),
("element_property", "TEXT"),
("element_value", "TEXT"),
("target_definition_id", "INTEGER"),
("target_row_id", "INTEGER"),
("target_field", "TEXT"),
("data_operation", "TEXT"),
("data_value", "TEXT"),
]:
if col_name not in action_cols:
conn.execute(f"ALTER TABLE _actions ADD COLUMN {col_name} {col_def}")
conn.commit()
conn.close()
+8
View File
@@ -0,0 +1,8 @@
import db
def get_screen(slug, screen_id):
conn = db.connect(slug)
row = conn.execute("SELECT * FROM _screens WHERE id = ?", (screen_id,)).fetchone()
conn.close()
return dict(row) if row else None
+18
View File
@@ -0,0 +1,18 @@
import db
from .ensure_schema import ensure_schema
def list_screens(slug, include_templates=False):
"""Par défaut, n'affiche que les vrais écrans du jeu (pas les
écrans-modèles qui hébergent le contenu des éléments de jeu réutilisables
— voir ensure_schema) : c'est ce qu'on veut partout où on énumère "les
écrans du jeu" (liste, menu "aller à cet écran", mode jouable)."""
ensure_schema(slug)
conn = db.connect(slug)
if include_templates:
rows = conn.execute("SELECT * FROM _screens ORDER BY order_index, id").fetchall()
else:
rows = conn.execute("SELECT * FROM _screens WHERE is_template = 0 ORDER BY order_index, id").fetchall()
conn.close()
return [dict(r) for r in rows]
+21
View File
@@ -0,0 +1,21 @@
import db
from .list_screens import list_screens
def move_screen(slug, screen_id, direction):
"""direction: -1 (monter, l'écran devient plus tôt dans l'enchaînement)
ou +1 (descendre) — échange l'order_index avec l'écran voisin."""
screens_ = list_screens(slug)
idx = next((i for i, s in enumerate(screens_) if s["id"] == screen_id), None)
if idx is None:
return
swap_idx = idx + direction
if swap_idx < 0 or swap_idx >= len(screens_):
return
a, b = screens_[idx], screens_[swap_idx]
conn = db.connect(slug)
conn.execute("UPDATE _screens SET order_index = ? WHERE id = ?", (b["order_index"], a["id"]))
conn.execute("UPDATE _screens SET order_index = ? WHERE id = ?", (a["order_index"], b["id"]))
conn.commit()
conn.close()
+8
View File
@@ -0,0 +1,8 @@
import db
def rename_screen(slug, screen_id, new_name):
conn = db.connect(slug)
conn.execute("UPDATE _screens SET name = ? WHERE id = ?", (new_name, screen_id))
conn.commit()
conn.close()
@@ -0,0 +1,16 @@
import db
def screens_referencing(slug, screen_id):
"""Écrans dont une action cible (aller_a) précisément cet écran — pour
avertir avant suppression plutôt que de casser silencieusement le lien."""
conn = db.connect(slug)
rows = conn.execute(
"""SELECT DISTINCT s.id, s.name FROM _actions a
JOIN _screen_elements e ON e.id = a.element_id
JOIN _screens s ON s.id = e.screen_id
WHERE a.action_type = 'aller_a' AND a.target_screen_id = ?""",
(screen_id,),
).fetchall()
conn.close()
return [dict(r) for r in rows]
+20
View File
@@ -0,0 +1,20 @@
import db
from .ensure_schema import ensure_schema
_ALLOWED = {"9/16", "16/9", "1/1"}
def set_screen_aspect(slug, screen_id, aspect):
"""Mémorise le format d'aperçu choisi (Portrait/Paysage/Carré) pour cet
écran — voir le commentaire sur la colonne preview_aspect dans
ensure_schema.py. Ignore silencieusement une valeur inconnue plutôt que
de planter, au cas où le client enverrait autre chose que les trois
boutons prévus."""
if aspect not in _ALLOWED:
return
ensure_schema(slug)
conn = db.connect(slug)
conn.execute("UPDATE _screens SET preview_aspect = ? WHERE id = ?", (aspect, screen_id))
conn.commit()
conn.close()
+1
View File
@@ -0,0 +1 @@
"""Catalogue de widgets et constructeurs de réglages ("controls")."""
+1
View File
@@ -0,0 +1 @@
BORDER_STYLE_OPTIONS = [("solid", "Trait plein"), ("dashed", "Tirets"), ("dotted", "Pointillés"), ("none", "Aucune")]
+1
View File
@@ -0,0 +1 @@
CONTAINER_WIDGETS = {"conteneur", "repeteur_donnees", "groupe_champs", "superposition"}
@@ -0,0 +1 @@
"""Groupes de réglages ("controls") réutilisés par plusieurs widgets."""
@@ -0,0 +1,20 @@
from ..controls.c_slider import c_slider
from ..controls.c_color import c_color
from ..controls.c_select import c_select
from ..border_style_options import BORDER_STYLE_OPTIONS
# Les épaisseurs "par côté" ci-dessous sont volontairement placées APRÈS
# "border-width" (tous les côtés) dans cette même liste : pour une même
# propriété CSS, la déclaration qui apparaît EN DERNIER dans l'attribut
# style l'emporte — un réglage par côté prime donc sur le réglage global
# quand les deux sont utilisés ensemble. La couleur et le style de bordure
# restent communs aux 4 côtés.
BORDER_CONTROLS = [
c_slider("border-width", "Épaisseur de la bordure, tous les côtés", "style:border-width", min=0, max=20, default=0, group="Bordure"),
c_color("border-color", "Couleur de la bordure", "style:border-color", "#2b3140", group="Bordure"),
c_select("border-style", "Style de la bordure", "style:border-style", BORDER_STYLE_OPTIONS, default="solid", group="Bordure"),
c_slider("border-top-width", "Épaisseur — haut", "style:border-top-width", min=0, max=20, default=0, group="Bordure"),
c_slider("border-right-width", "Épaisseur — droite", "style:border-right-width", min=0, max=20, default=0, group="Bordure"),
c_slider("border-bottom-width", "Épaisseur — bas", "style:border-bottom-width", min=0, max=20, default=0, group="Bordure"),
c_slider("border-left-width", "Épaisseur — gauche", "style:border-left-width", min=0, max=20, default=0, group="Bordure"),
]
@@ -0,0 +1,21 @@
from ..controls.c_text import c_text
# 3.1 (Confort) — interactions au survol : mécanisme générique, disponible
# sur TOUS les widgets (via UNIVERSAL_CONTROLS), pour révéler un texte
# différent quand le joueur survole l'élément avec la souris — le cas
# d'usage documenté : survoler un nom révèle l'adresse réelle, survoler un
# lien révèle l'URL réelle. Laisser vide = aucun changement au survol (donc
# aucune régression sur les jeux existants, dont l'attribut est absent).
# Rendu : voir render_element_html.py (attribut data-hover-text) et
# bindHoverTexts() dans templates/play.html (échange du texte au survol,
# ignore volontairement les éléments qui ont des enfants — conteneurs — pour
# ne jamais écraser une mise en page imbriquée).
HOVER_CONTROLS = [
c_text(
"survol_texte",
"Texte affiché au survol de la souris",
"attr:_survol_texte",
placeholder="ex. mathilde.d@forgebase.fr, https://vrai-lien.exemple...",
group="Survol",
),
]
@@ -0,0 +1,44 @@
from ..controls.c_preset import c_preset
from ..controls.c_checkbox import c_checkbox
from ..controls.c_slider import c_slider
LAYOUT_CONTROLS = [
c_preset(
"disposition_interne", "Disposition interne",
[
("libre", "Empilement libre", {"display": "", "flex-direction": ""}),
("ligne", "Alignés côte à côte", {"display": "flex", "flex-direction": "row"}),
("ligne_inverse", "Alignés côte à côte, ordre inversé", {"display": "flex", "flex-direction": "row-reverse"}),
("colonne", "Alignés les uns sous les autres", {"display": "flex", "flex-direction": "column"}),
("colonne_inverse", "Alignés les uns sous les autres, ordre inversé", {"display": "flex", "flex-direction": "column-reverse"}),
],
default="libre", group="Disposition",
),
c_preset(
"alignement_transversal", "Alignement transversal",
[
("etire", "Étirés pour remplir", {"align-items": ""}),
("debut", "Alignés au début", {"align-items": "flex-start"}),
("centre_t", "Centrés", {"align-items": "center"}),
("fin", "Alignés à la fin", {"align-items": "flex-end"}),
],
default="etire", group="Disposition",
),
c_preset(
"alignement_principal", "Répartition",
[
("debut_p", "Regroupés au début", {"justify-content": ""}),
("centre_p", "Regroupés au centre", {"justify-content": "center"}),
("fin_p", "Regroupés à la fin", {"justify-content": "flex-end"}),
("espace_entre", "Espacement égal entre eux", {"justify-content": "space-between"}),
("espace_autour", "Espacement égal autour de chacun", {"justify-content": "space-around"}),
("espace_uniforme", "Espacement totalement uniforme", {"justify-content": "space-evenly"}),
],
default="debut_p", group="Disposition",
),
c_checkbox(
"flex-wrap", "Autoriser le retour à la ligne si ça ne tient pas",
"style:flex-wrap", true_value="wrap", false_value="nowrap", default=False, group="Disposition",
),
c_slider("gap", "Espace entre les éléments", "style:gap", min=0, max=40, default=8, group="Disposition"),
]
@@ -0,0 +1,16 @@
from ..controls.c_slider import c_slider
# Taille fixe (en pixels), surtout utile pour un élément posé À L'INTÉRIEUR
# d'un conteneur/répéteur/groupe de champs : par défaut, ces éléments
# remplissent la largeur disponible et s'ajustent en hauteur à leur contenu
# (voir _style_string) — 0 garde ce comportement automatique ; une valeur
# non nulle impose une taille fixe (ex: une image de 120px de large dans une
# ligne, pour que "Espacement égal entre eux"/"autour"/"uniforme" sur le
# conteneur parent ait un effet visible, ce qui n'est pas le cas tant que
# chaque élément remplit 100% de la largeur).
SIZE_OVERRIDE_CONTROLS = [
c_slider("child-width", "Largeur fixe", "style:width",
min=0, max=1000, default=0, group="Taille dans le conteneur"),
c_slider("child-height", "Hauteur fixe", "style:height",
min=0, max=1000, default=0, group="Taille dans le conteneur"),
]
@@ -0,0 +1,20 @@
from ..controls.c_slider import c_slider
# Les réglages "par côté" sont volontairement placés APRÈS les réglages
# globaux ci-dessous dans cette même liste : le style HTML final est généré
# dans cet ordre, et pour une même propriété CSS (ex. padding-top vs
# padding), la déclaration qui apparaît EN DERNIER dans l'attribut style
# l'emporte — donc un réglage par côté prime bien sur le réglage global s'il
# est utilisé en plus.
SPACING_CONTROLS = [
c_slider("padding", "Marge intérieure — tous les côtés", "style:padding", min=0, max=60, default=0, group="Espacement"),
c_slider("margin", "Marge extérieure — tous les côtés", "style:margin", min=0, max=60, default=0, group="Espacement"),
c_slider("padding-top", "Padding — haut", "style:padding-top", min=0, max=60, default=0, group="Espacement"),
c_slider("padding-right", "Padding — droite", "style:padding-right", min=0, max=60, default=0, group="Espacement"),
c_slider("padding-bottom", "Padding — bas", "style:padding-bottom", min=0, max=60, default=0, group="Espacement"),
c_slider("padding-left", "Padding — gauche", "style:padding-left", min=0, max=60, default=0, group="Espacement"),
c_slider("margin-top", "Margin — haut", "style:margin-top", min=0, max=60, default=0, group="Espacement"),
c_slider("margin-right", "Margin — droite", "style:margin-right", min=0, max=60, default=0, group="Espacement"),
c_slider("margin-bottom", "Margin — bas", "style:margin-bottom", min=0, max=60, default=0, group="Espacement"),
c_slider("margin-left", "Margin — gauche", "style:margin-left", min=0, max=60, default=0, group="Espacement"),
]
@@ -0,0 +1,6 @@
from ..controls.c_select import c_select
from ..font_options import FONT_OPTIONS
TEXT_STYLE_CONTROLS_EXTRA = [
c_select("font-family", "Police de caractère", "style:font-family", FONT_OPTIONS, default="inherit", group="Texte"),
]
@@ -0,0 +1,8 @@
from ..controls.c_scale import c_scale
from .visibility_control import VISIBILITY_CONTROL
from .size_override_controls import SIZE_OVERRIDE_CONTROLS
from .spacing_controls import SPACING_CONTROLS
from .border_controls import BORDER_CONTROLS
from .hover_controls import HOVER_CONTROLS
UNIVERSAL_CONTROLS = [VISIBILITY_CONTROL] + SIZE_OVERRIDE_CONTROLS + SPACING_CONTROLS + BORDER_CONTROLS + [c_scale()] + HOVER_CONTROLS
@@ -0,0 +1,16 @@
from ..controls.c_preset import c_preset
VISIBILITY_CONTROL = c_preset(
"visibilite", "Visibilité",
[
# "display" n'est volontairement pas touché par "visible" / "invisible" :
# un conteneur en disposition ligne/colonne (flex) doit garder son
# display:flex quand il redevient visible — seul "masqué" doit
# explicitement forcer display:none (et gagne, car ce réglage est
# appliqué après la disposition interne lors de l'enregistrement).
("visible", "Visible normalement", {"visibility": ""}),
("masque", "Masqué — n'occupe plus de place", {"display": "none", "visibility": ""}),
("invisible", "Invisible — garde sa place", {"visibility": "hidden"}),
],
default="visible", group="Disposition",
)
+44
View File
@@ -0,0 +1,44 @@
import re
def _control_value(el, control):
"""Lit la valeur actuelle d'un réglage à partir de l'élément stocké
(content / attributs / style), avec repli sur sa valeur par défaut."""
ctype = control["type"]
style = el.get("style") or {}
if ctype == "preset":
for key, _label, props in control["options"]:
if all((style.get(k) or "") == (v or "") for k, v in props.items()):
return key
return control.get("default")
if ctype == "toggle_token":
_kind, name = control["target"].split(":", 1)
current = style.get(name, "") or ""
return control["token"] in current.split()
if ctype == "scale":
transform = style.get("transform", "") or ""
m = re.search(r"scale\(([\d.]+)\)", transform)
return float(m.group(1)) if m else control.get("default", 1)
target = control["target"]
if target == "content":
return el.get("content") or control.get("default", "")
kind, name = target.split(":", 1)
store = el.get("attributes") if kind == "attr" else style
store = store or {}
if ctype == "checkbox":
if name not in store:
return bool(control.get("default", False))
return store[name] not in (None, "", control.get("false_value"))
if ctype == "slider":
raw = store.get(name)
if raw is None:
return control.get("default", 0)
try:
return float(str(raw).replace(control.get("unit", ""), "").strip() or 0)
except ValueError:
return control.get("default", 0)
return store.get(name, control.get("default", ""))
+1
View File
@@ -0,0 +1 @@
"""Constructeurs de réglages ("controls") pour les widgets."""
+2
View File
@@ -0,0 +1,2 @@
def c_align(key, label, target, default="left", group=None):
return {"key": key, "label": label, "type": "align", "target": target, "default": default, "group": group}
+2
View File
@@ -0,0 +1,2 @@
def c_checkbox(key, label, target, true_value="1", false_value="", default=False, group=None):
return {"key": key, "label": label, "type": "checkbox", "target": target, "true_value": true_value, "false_value": false_value, "default": default, "group": group}
+2
View File
@@ -0,0 +1,2 @@
def c_color(key, label, target, default="#e8eaf0", group=None):
return {"key": key, "label": label, "type": "color", "target": target, "default": default, "group": group}
+3
View File
@@ -0,0 +1,3 @@
def c_preset(key, label, options, default=None, group=None):
"""options: liste de (clé, libellé, {propriété_css: valeur, ...})."""
return {"key": key, "label": label, "type": "preset", "options": options, "default": default or options[0][0], "group": group}
+2
View File
@@ -0,0 +1,2 @@
def c_scale(key="scale", label="Échelle", group="Disposition"):
return {"key": key, "label": label, "type": "scale", "min": 0.1, "max": 3, "step": 0.05, "default": 1, "group": group}
+2
View File
@@ -0,0 +1,2 @@
def c_select(key, label, target, options, default=None, group=None):
return {"key": key, "label": label, "type": "select", "target": target, "options": options, "default": default or options[0][0], "group": group}

Some files were not shown because too many files have changed in this diff Show More