first commit
This commit is contained in:
@@ -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
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -0,0 +1 @@
|
||||
"""Application des actions "modifier une donnée d'objet" en mode jouable."""
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -0,0 +1 @@
|
||||
"""Éléments posés sur un écran (_screen_elements)."""
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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", "?"))
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -0,0 +1,2 @@
|
||||
"""Logique visuelle (éditeur de flow à nœuds) : déclencheurs, conditions,
|
||||
actions, sous forme de graphe propre à chaque écran."""
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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",
|
||||
}
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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]
|
||||
@@ -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]
|
||||
@@ -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()
|
||||
@@ -0,0 +1,2 @@
|
||||
"""Petites constantes de libellés UI utilisées par le panneau de propriétés
|
||||
et l'éditeur de flow."""
|
||||
@@ -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",
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -0,0 +1,5 @@
|
||||
TRIGGER_LABELS = {
|
||||
"clic": "Au clic",
|
||||
"soumission": "À la soumission",
|
||||
"affichage": "À l'affichage de l'écran",
|
||||
}
|
||||
@@ -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/."""
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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]
|
||||
@@ -0,0 +1 @@
|
||||
"""Sérialisation de tout le jeu pour le mode jouable."""
|
||||
@@ -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}
|
||||
@@ -0,0 +1 @@
|
||||
"""Génération du HTML réel des éléments posés sur un écran."""
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -0,0 +1,2 @@
|
||||
def _auto_id(el):
|
||||
return f"elt-{el['id']}"
|
||||
@@ -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>'
|
||||
@@ -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)
|
||||
@@ -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}>"
|
||||
@@ -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>'
|
||||
@@ -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>'
|
||||
)
|
||||
@@ -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>"
|
||||
)
|
||||
@@ -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>'
|
||||
@@ -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>'
|
||||
@@ -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>'
|
||||
@@ -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 "")
|
||||
@@ -0,0 +1,4 @@
|
||||
def _trigger_for(widget_key, attributes):
|
||||
if (attributes or {}).get("type") == "submit":
|
||||
return "soumission"
|
||||
return "clic"
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
"""Accès aux écrans du jeu (_screens) et au schéma de base commun au module."""
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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]
|
||||
@@ -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()
|
||||
@@ -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]
|
||||
@@ -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()
|
||||
@@ -0,0 +1 @@
|
||||
"""Catalogue de widgets et constructeurs de réglages ("controls")."""
|
||||
@@ -0,0 +1 @@
|
||||
BORDER_STYLE_OPTIONS = [("solid", "Trait plein"), ("dashed", "Tirets"), ("dotted", "Pointillés"), ("none", "Aucune")]
|
||||
@@ -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",
|
||||
)
|
||||
@@ -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", ""))
|
||||
@@ -0,0 +1 @@
|
||||
"""Constructeurs de réglages ("controls") pour les widgets."""
|
||||
@@ -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}
|
||||
@@ -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}
|
||||
@@ -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}
|
||||
@@ -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}
|
||||
@@ -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}
|
||||
@@ -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
Reference in New Issue
Block a user