Merge pull request 'Dev' (#1) from dev into main
Build and deploy / build-and-push (push) Successful in 1m17s
Build and deploy / deploy (push) Successful in 12s

Reviewed-on: #1
This commit was merged in pull request #1.
This commit is contained in:
2026-08-26 04:56:43 +00:00
365 changed files with 6030 additions and 1258 deletions
+13 -2
View File
@@ -20,7 +20,7 @@ exécute un vrai CREATE TABLE, et remplir le formulaire généré exécute un
vrai INSERT dans cette table. vrai INSERT dans cette table.
""" """
from .constants import PROJECTS_DIR, FIELD_TYPES from .constants import PROJECTS_DIR, FIELD_TYPES, GLOBAL_VARIABLE_TYPES
from .slugify import slugify from .slugify import slugify
from .table_name_for import table_name_for from .table_name_for import table_name_for
from .game_dir import game_dir from .game_dir import game_dir
@@ -52,8 +52,16 @@ from .rows.update_row_field import update_row_field
from .rows.delete_row import delete_row from .rows.delete_row import delete_row
from .rows.rows_referencing import rows_referencing from .rows.rows_referencing import rows_referencing
from .global_vars.list_global_variables import list_global_variables
from .global_vars.get_global_variable import get_global_variable
from .global_vars.create_global_variable import create_global_variable
from .global_vars.update_global_variable_value import update_global_variable_value
from .global_vars.update_global_variable import update_global_variable
from .global_vars.delete_global_variable import delete_global_variable
from .global_vars.delete_global_variable_by_id import delete_global_variable_by_id
__all__ = [ __all__ = [
"PROJECTS_DIR", "FIELD_TYPES", "PROJECTS_DIR", "FIELD_TYPES", "GLOBAL_VARIABLE_TYPES",
"slugify", "table_name_for", "game_dir", "db_path", "connect", "slugify", "table_name_for", "game_dir", "db_path", "connect",
"list_games", "game_meta", "create_game", "update_game_name", "delete_game", "list_games", "game_meta", "create_game", "update_game_name", "delete_game",
"list_definitions", "get_definition", "create_definition", "rename_definition", "list_definitions", "get_definition", "create_definition", "rename_definition",
@@ -61,4 +69,7 @@ __all__ = [
"delete_definition", "delete_definition",
"list_rows", "relation_options", "insert_row", "get_row", "update_row", "list_rows", "relation_options", "insert_row", "get_row", "update_row",
"update_row_field", "delete_row", "rows_referencing", "update_row_field", "delete_row", "rows_referencing",
"list_global_variables", "get_global_variable", "create_global_variable",
"update_global_variable_value", "update_global_variable", "delete_global_variable",
"delete_global_variable_by_id",
] ]
+52
View File
@@ -17,4 +17,56 @@ def connect(slug):
conn.execute("PRAGMA foreign_keys = ON") conn.execute("PRAGMA foreign_keys = ON")
conn.execute("PRAGMA journal_mode = WAL") conn.execute("PRAGMA journal_mode = WAL")
conn.execute("PRAGMA busy_timeout = 8000") conn.execute("PRAGMA busy_timeout = 8000")
_track_for_teardown(conn)
return conn return conn
def _track_for_teardown(conn):
"""Filet de sécurité : chaque fonction de db/ ouvre sa propre connexion
et est censée la fermer elle-même (conn.close()) avant de rendre la
main — mais si une exception survient ENTRE l'ouverture et cette
fermeture (une erreur de programmation, une contrainte violée...), le
conn.close() prévu n'est jamais atteint. En mode debug (voir app.py),
le débogueur Werkzeug garde alors la trace complète de l'erreur en
mémoire pour l'inspection interactive — ce qui inclut la variable
locale `conn`, empêchant le ramasse-miettes Python de la libérer et
donc SQLite de relâcher son verrou d'écriture. Toute requête suivante
qui écrit se heurte alors à "database is locked" jusqu'au redémarrage
du serveur, même longtemps après l'erreur d'origine. En enregistrant
ici la connexion sur le contexte de la requête Flask en cours (quand il
y en a un), on garantit sa fermeture à la fin de la requête via
_close_leaked_connections ci-dessous, que la requête ait réussi ou
planté — sans rien changer au comportement des ~80 fonctions qui
ferment déjà correctement leur connexion (fermer une connexion SQLite
déjà fermée ne fait rien)."""
try:
from flask import g, has_app_context
except ImportError:
return
if not has_app_context():
return
if not hasattr(g, "_forge_db_connections"):
g._forge_db_connections = []
g._forge_db_connections.append(conn)
def _install_teardown_safety_net():
"""Appelé une seule fois (voir le bas de ce fichier) — enregistre le
filet de sécurité sur l'appli Flask. `core.flask_app` ne dépend de rien
dans `db/`, donc cet import ne crée pas de dépendance circulaire."""
try:
from core.flask_app import app
except ImportError:
return
@app.teardown_request
def _close_leaked_connections(exception=None): # noqa: ARG001 - signature imposée par Flask
from flask import g
for conn in getattr(g, "_forge_db_connections", ()):
try:
conn.close()
except sqlite3.Error:
pass
_install_teardown_safety_net()
+11
View File
@@ -14,3 +14,14 @@ FIELD_TYPES = {
"date": {"label": "Date", "sql": "TEXT"}, "date": {"label": "Date", "sql": "TEXT"},
"relation": {"label": "Relation vers un autre objet", "sql": "INTEGER"}, "relation": {"label": "Relation vers un autre objet", "sql": "INTEGER"},
} }
# Types disponibles pour une variable globale (voir db/global_vars/) — un
# sous-ensemble de FIELD_TYPES : ni "relation" (une variable globale n'a pas
# d'objet à pointer) ni "texte_long"/"date" (pas gérés par
# apply_variable_action.py, qui ne coerce que ces 4 types).
GLOBAL_VARIABLE_TYPES = {
"texte": FIELD_TYPES["texte"],
"nombre_entier": FIELD_TYPES["nombre_entier"],
"nombre_decimal": FIELD_TYPES["nombre_decimal"],
"booleen": FIELD_TYPES["booleen"],
}
View File
+27
View File
@@ -0,0 +1,27 @@
from ..connection import connect
from .ensure_global_vars_schema import ensure_global_vars_schema
def create_global_variable(slug, name, var_type="texte", default_value=""):
"""Crée une variable globale si son nom n'existe pas déjà (les noms sont
uniques — voir ensure_global_vars_schema.py) ; si elle existe déjà, ne
touche à rien et renvoie simplement son id existant, pour que "créer"
depuis le sélecteur de condition de visibilité (voir screen_edit.html)
reste sans risque même en cas de double clic/soumission."""
name = (name or "").strip()
if not name:
return None
ensure_global_vars_schema(slug)
conn = connect(slug)
existing = conn.execute("SELECT id FROM _global_variables WHERE name = ?", (name,)).fetchone()
if existing:
conn.close()
return existing["id"]
conn.execute(
"INSERT INTO _global_variables (name, type, value) VALUES (?, ?, ?)",
(name, var_type, default_value),
)
new_id = conn.execute("SELECT last_insert_rowid() AS id").fetchone()["id"]
conn.commit()
conn.close()
return new_id
+10
View File
@@ -0,0 +1,10 @@
from ..connection import connect
from .ensure_global_vars_schema import ensure_global_vars_schema
def delete_global_variable(slug, name):
ensure_global_vars_schema(slug)
conn = connect(slug)
conn.execute("DELETE FROM _global_variables WHERE name = ?", (name,))
conn.commit()
conn.close()
@@ -0,0 +1,14 @@
from ..connection import connect
from .ensure_global_vars_schema import ensure_global_vars_schema
def delete_global_variable_by_id(slug, var_id):
"""Version par id de delete_global_variable — utilisée par la page de
gestion des variables (game_variables.html), où chaque ligne connaît son
id technique mais pas nécessairement un nom encore garanti unique côté
formulaire d'URL (espaces, accents...)."""
ensure_global_vars_schema(slug)
conn = connect(slug)
conn.execute("DELETE FROM _global_variables WHERE id = ?", (var_id,))
conn.commit()
conn.close()
@@ -0,0 +1,23 @@
from ..connection import connect
def ensure_global_vars_schema(slug):
"""Migration légère (même principe que ensure_field_bounds_schema.py) :
crée _global_variables si absente — aucune migration manuelle nécessaire
pour les jeux créés avant cette fonctionnalité, la prochaine lecture
suffit. Une variable globale vit pour TOUT le jeu (pas par écran, pas
par ligne) : "nom" est donc UNIQUE, une seule valeur actuelle par nom."""
conn = connect(slug)
conn.execute(
"""
CREATE TABLE IF NOT EXISTS _global_variables (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
type TEXT NOT NULL DEFAULT 'texte',
value TEXT,
created_at TEXT DEFAULT CURRENT_TIMESTAMP
);
"""
)
conn.commit()
conn.close()
+16
View File
@@ -0,0 +1,16 @@
from ..connection import connect
from .ensure_global_vars_schema import ensure_global_vars_schema
def get_global_variable(slug, name):
"""Par NOM (unique), pas par id — c'est comme ça qu'une variable est
référencée partout ailleurs (condition de visibilité, action "Modifier
une variable"), jamais par un id technique que l'utilisateur ne voit
pas."""
if not name:
return None
ensure_global_vars_schema(slug)
conn = connect(slug)
row = conn.execute("SELECT * FROM _global_variables WHERE name = ?", (name,)).fetchone()
conn.close()
return dict(row) if row else None
+10
View File
@@ -0,0 +1,10 @@
from ..connection import connect
from .ensure_global_vars_schema import ensure_global_vars_schema
def list_global_variables(slug):
ensure_global_vars_schema(slug)
conn = connect(slug)
rows = conn.execute("SELECT * FROM _global_variables ORDER BY name").fetchall()
conn.close()
return [dict(r) for r in rows]
+16
View File
@@ -0,0 +1,16 @@
from ..connection import connect
from .ensure_global_vars_schema import ensure_global_vars_schema
def update_global_variable(slug, var_id, var_type, value):
"""Modifie le type et la valeur d'une variable existante depuis la page
de gestion (voir game_variables.html) — le NOM reste volontairement
immuable après création : c'est par ce nom qu'une condition de
visibilité ou une action "Modifier une variable" la référence (voir
get_global_variable.py), la renommer casserait ces réglages en
silence."""
ensure_global_vars_schema(slug)
conn = connect(slug)
conn.execute("UPDATE _global_variables SET type = ?, value = ? WHERE id = ?", (var_type, str(value), var_id))
conn.commit()
conn.close()
@@ -0,0 +1,18 @@
from ..connection import connect
from .ensure_global_vars_schema import ensure_global_vars_schema
from .get_global_variable import get_global_variable
def update_global_variable_value(slug, name, new_value):
"""Écrit une nouvelle valeur BRUTE (déjà calculée par l'appelant) sur la
variable — utilisé aussi bien par l'action "Modifier une variable" (voir
screens/global_vars/apply_variable_action.py) que par une éventuelle
modification directe (page de gestion des variables)."""
if not get_global_variable(slug, name):
return False
ensure_global_vars_schema(slug)
conn = connect(slug)
conn.execute("UPDATE _global_variables SET value = ? WHERE name = ?", (str(new_value), name))
conn.commit()
conn.close()
return True
+1 -1
View File
@@ -1 +1 @@
from . import games, objects, screens, elements, legacy_actions, flow, element_types, uploads, play, animations from . import games, objects, screens, elements, legacy_actions, flow, element_types, uploads, play, animations, global_vars
+5
View File
@@ -2,7 +2,12 @@ from . import (
element_add, element_add,
element_add_child, element_add_child,
element_geometry, element_geometry,
element_reorder,
element_move_to_container,
element_save, element_save,
element_delete, element_delete,
element_delete_impact,
element_duplicate,
element_rename, element_rename,
element_tab_add,
) )
+7
View File
@@ -17,4 +17,11 @@ def element_add(slug, screen_id):
et = types.get(int(element_type_id)) et = types.get(int(element_type_id))
widget = et["default_widget"] if et else "conteneur" widget = et["default_widget"] if et else "conteneur"
new_id = screens.add_element(slug, screen_id, widget, element_type_id) new_id = screens.add_element(slug, screen_id, widget, element_type_id)
icon_slug = request.form.get("icon_class")
if widget == "icone" and icon_slug in screens.FONTAWESOME_ICONS:
# Posé depuis la galerie d'icônes (voir le panneau de propriétés,
# section "Icônes") : l'icône choisie remplace celle posée par
# défaut à la création plutôt que de repartir d'une icône neutre que
# l'utilisateur devrait ensuite changer manuellement.
screens.save_element_controls(slug, new_id, {"ctrl_icon_class": icon_slug})
return redirect(url_for("screen_edit", slug=slug, screen_id=screen_id, selected=new_id)) return redirect(url_for("screen_edit", slug=slug, screen_id=screen_id, selected=new_id))
+16
View File
@@ -0,0 +1,16 @@
from flask import jsonify
import screens
from core.flask_app import app
@app.route("/game/<slug>/elements/<int:element_id>/delete-impact")
def element_delete_impact(slug, element_id):
"""Appelé par la modale de confirmation de suppression (screen_edit.html)
AVANT que l'utilisateur ne confirme, pour l'avertir si des nœuds de la
Logique de la scène (déclencheur/action) seraient aussi supprimés en
même temps que l'élément — voir delete_element.py, qui les supprime
silencieusement sinon (nécessaire pour éviter un plantage "FOREIGN KEY
constraint failed", voir son commentaire)."""
return jsonify({"flow_node_count": screens.flow_nodes_referencing_element(slug, element_id)})
+14
View File
@@ -0,0 +1,14 @@
from flask import redirect, url_for, abort
import screens
from core.flask_app import app
@app.route("/game/<slug>/elements/<int:element_id>/duplicate", methods=["POST"])
def element_duplicate(slug, element_id):
element = screens.get_element(slug, element_id)
if not element:
abort(404)
new_id = screens.duplicate_element(slug, element_id)
return redirect(url_for("screen_edit", slug=slug, screen_id=element["screen_id"], selected=new_id))
@@ -0,0 +1,21 @@
from flask import request, jsonify, abort
import screens
from core.flask_app import app
@app.route("/game/<slug>/elements/<int:element_id>/move-to-container", methods=["POST"])
def element_move_to_container(slug, element_id):
"""Appelé en AJAX quand on glisse un élément DANS l'arborescence pour le
déposer sur un autre élément (un conteneur) plutôt qu'au-dessus/en
dessous — le fait passer enfant de ce conteneur, en dernière position
(voir move_element_to_container, qui refuse aussi silencieusement les
cas invalides : cible non-conteneur, ou cycle)."""
element = screens.get_element(slug, element_id)
if not element:
abort(404)
data = request.get_json(force=True)
new_parent_id = int(data["parent_id"])
screens.move_element_to_container(slug, element_id, new_parent_id)
return jsonify({"ok": True})
+22
View File
@@ -0,0 +1,22 @@
from flask import request, jsonify, abort
import screens
from core.flask_app import app
@app.route("/game/<slug>/elements/<int:element_id>/reorder", methods=["POST"])
def element_reorder(slug, element_id):
"""Appelé en AJAX quand on glisse un élément au-dessus/en dessous d'un
autre DANS LA MÊME ARBORESCENCE (panneau de gauche) pour changer l'ordre
des éléments d'un même conteneur — voir reorder_element."""
element = screens.get_element(slug, element_id)
if not element:
abort(404)
data = request.get_json(force=True)
target_id = int(data["target_id"])
position = data.get("position")
if position not in ("before", "after"):
abort(400)
screens.reorder_element(slug, element["screen_id"], element_id, target_id, position)
return jsonify({"ok": True})
+14
View File
@@ -0,0 +1,14 @@
from flask import redirect, url_for, abort
import screens
from core.flask_app import app
@app.route("/game/<slug>/elements/<int:element_id>/tabs/add", methods=["POST"])
def element_tab_add(slug, element_id):
onglets = screens.get_element(slug, element_id)
if not onglets:
abort(404)
screens.add_tab(slug, element_id, onglets["screen_id"])
return redirect(url_for("screen_edit", slug=slug, screen_id=onglets["screen_id"], selected=element_id))
+1
View File
@@ -5,5 +5,6 @@ from . import (
flow_edge_add, flow_edge_add,
flow_edge_delete, flow_edge_delete,
flow_node_run_data, flow_node_run_data,
flow_node_run_variable,
runtime_payload, runtime_payload,
) )
+10 -2
View File
@@ -1,4 +1,4 @@
from flask import jsonify from flask import jsonify, request
import screens import screens
@@ -10,9 +10,17 @@ def flow_node_run_data(slug, node_id):
"""Exécuté côté serveur au moment du clic, en mode jouable, pour un nœud """Exécuté côté serveur au moment du clic, en mode jouable, pour un nœud
Action de type "Modifier une donnée d'un objet" — identique au Action de type "Modifier une donnée d'un objet" — identique au
fonctionnement de l'ancienne action_run_data, mais lit sa config depuis fonctionnement de l'ancienne action_run_data, mais lit sa config depuis
un nœud du graphe de logique plutôt que depuis la table _actions.""" un nœud du graphe de logique plutôt que depuis la table _actions.
Si le nœud vise CLICKED_ROW_ID (voir "Ligne cliquée (Répéteur)" dans
screen_edit.html), la vraie ligne n'est connue qu'au moment du clic —
le client (play.html) la transmet dans le corps JSON de cette requête,
et elle remplace ici target_row_id avant d'exécuter l'action."""
node = screens.get_flow_node(slug, node_id) node = screens.get_flow_node(slug, node_id)
if not node or node.get("node_type") != "action" or node.get("action_type") != "modifier_donnee": if not node or node.get("node_type") != "action" or node.get("action_type") != "modifier_donnee":
return jsonify({"ok": False}), 400 return jsonify({"ok": False}), 400
if node.get("target_row_id") == screens.CLICKED_ROW_ID:
body = request.get_json(silent=True) or {}
node = {**node, "target_row_id": body.get("clicked_row_id")}
ok = screens.apply_data_action(slug, node) ok = screens.apply_data_action(slug, node)
return jsonify({"ok": ok}) return jsonify({"ok": ok})
+17
View File
@@ -0,0 +1,17 @@
from flask import jsonify
import screens
from core.flask_app import app
@app.route("/game/<slug>/flow/nodes/<int:node_id>/run-variable", methods=["POST"])
def flow_node_run_variable(slug, node_id):
"""Exécuté côté serveur au moment du clic, en mode jouable, pour un nœud
Action de type "Modifier une variable globale" — même principe que
flow_node_run_data.py, pour une variable plutôt qu'un champ d'objet."""
node = screens.get_flow_node(slug, node_id)
if not node or node.get("node_type") != "action" or node.get("action_type") != "modifier_variable":
return jsonify({"ok": False}), 400
ok = screens.apply_variable_action(slug, node)
return jsonify({"ok": ok})
+1 -1
View File
@@ -7,7 +7,7 @@ from core.flask_app import app
@app.route("/game/<slug>/rename", methods=["POST"]) @app.route("/game/<slug>/rename", methods=["POST"])
def game_rename(slug): def game_rename(slug):
new_name = request.form.get("name", "").strip() new_name = request.form.get("game_name", "").strip()
if new_name: if new_name:
db.update_game_name(slug, new_name) db.update_game_name(slug, new_name)
return redirect(url_for("game_dashboard", slug=slug)) return redirect(url_for("game_dashboard", slug=slug))
+4
View File
@@ -0,0 +1,4 @@
from .create_global_var import create_global_var
from .game_variables import game_variables
from .global_var_edit import global_var_edit
from .global_var_delete import global_var_delete
+18
View File
@@ -0,0 +1,18 @@
from flask import request, redirect, url_for
import db
from core.flask_app import app
@app.route("/game/<slug>/variables/new", methods=["POST"])
def create_global_var(slug):
"""Depuis la page de gestion des variables (game_variables.html) —
idempotent par nom (voir db.create_global_variable), donc sans risque en
cas de double soumission."""
name = (request.form.get("name") or "").strip()
var_type = request.form.get("type") or "texte"
default_value = request.form.get("default_value") or ""
if name:
db.create_global_variable(slug, name, var_type, default_value)
return redirect(url_for("game_variables", slug=slug))
+19
View File
@@ -0,0 +1,19 @@
from flask import render_template
import db
from core.flask_app import app
@app.route("/game/<slug>/variables")
def game_variables(slug):
"""Page de gestion des variables globales (voir db/global_vars/) : un
objet "jeu" implicite dont les propriétés/valeurs sont visibles partout
(condition de visibilité, action "Modifier une variable"), plutôt
qu'attachées à un objet de données précis."""
game = db.game_meta(slug)
variables = db.list_global_variables(slug)
return render_template(
"game_variables.html", game=game, variables=variables,
variable_types=db.GLOBAL_VARIABLE_TYPES,
)
+11
View File
@@ -0,0 +1,11 @@
from flask import redirect, url_for
import db
from core.flask_app import app
@app.route("/game/<slug>/variables/<int:var_id>/delete", methods=["POST"])
def global_var_delete(slug, var_id):
db.delete_global_variable_by_id(slug, var_id)
return redirect(url_for("game_variables", slug=slug))
+13
View File
@@ -0,0 +1,13 @@
from flask import request, redirect, url_for
import db
from core.flask_app import app
@app.route("/game/<slug>/variables/<int:var_id>/edit", methods=["POST"])
def global_var_edit(slug, var_id):
var_type = request.form.get("type") or "texte"
value = request.form.get("value") or ""
db.update_global_variable(slug, var_id, var_type, value)
return redirect(url_for("game_variables", slug=slug))
+10 -16
View File
@@ -22,7 +22,12 @@ def screen_edit(slug, screen_id):
if selected_id: if selected_id:
selected_el = next((e for e in elements if e["id"] == selected_id), None) selected_el = next((e for e in elements if e["id"] == selected_id), None)
if selected_el: if selected_el:
selected_controls = screens.controls_with_values(slug, selected_el) template_definition_id = None
if screen.get("is_template"):
owning_type = screens.get_element_type_by_template_screen(slug, screen_id)
if owning_type:
template_definition_id = owning_type.get("bound_definition_id")
selected_controls = screens.controls_with_values(slug, selected_el, template_definition_id)
children_of = {} children_of = {}
for el in elements: for el in elements:
if el.get("parent_id"): if el.get("parent_id"):
@@ -31,27 +36,14 @@ def screen_edit(slug, screen_id):
elements_by_id = {el["id"]: el for el in elements} elements_by_id = {el["id"]: el for el in elements}
definitions = db.list_definitions(slug) definitions = db.list_definitions(slug)
definitions_data = {} definitions_data = {str(d["id"]): screens.data_definition_options(slug, d["id"]) for d in definitions}
for d in definitions:
full = db.get_definition(slug, d["id"])
rows = db.list_rows(slug, full)
display_field = full["fields"][0]["name"] if full["fields"] else None
display_col = db.slugify(display_field).replace("-", "_") if display_field else None
row_options = [
{
"id": r["id"],
"label": f"{r.get(display_col)} (#{r['id']})" if display_col else f"Ligne #{r['id']}",
}
for r in rows
]
field_options = [{"name": f["name"], "type": f["type"]} for f in full["fields"] if f["type"] != "relation"]
definitions_data[str(d["id"])] = {"rows": row_options, "fields": field_options}
flow_nodes = screens.list_flow_nodes(slug, screen_id) flow_nodes = screens.list_flow_nodes(slug, screen_id)
flow_edges = screens.list_flow_edges(slug, screen_id) flow_edges = screens.list_flow_edges(slug, screen_id)
elements_labels = {el["id"]: screens.element_label(el, screens.WIDGETS) for el in elements} elements_labels = {el["id"]: screens.element_label(el, screens.WIDGETS) for el in elements}
screens_map = {s["id"]: s["name"] for s in all_screens} screens_map = {s["id"]: s["name"] for s in all_screens}
animation_clips = screens.list_animation_clips(slug, screen_id) animation_clips = screens.list_animation_clips(slug, screen_id)
global_variables = db.list_global_variables(slug)
return render_template( return render_template(
"screen_edit.html", "screen_edit.html",
@@ -87,4 +79,6 @@ def screen_edit(slug, screen_id):
animation_clips=animation_clips, animation_clips_json=json.dumps(animation_clips), animation_clips=animation_clips, animation_clips_json=json.dumps(animation_clips),
animate_css_catalog=screens.ANIMATE_CSS_CATALOG, animate_css_catalog=screens.ANIMATE_CSS_CATALOG,
animate_css_labels_json=json.dumps(screens.ANIMATE_CSS_LABELS), animate_css_labels_json=json.dumps(screens.ANIMATE_CSS_LABELS),
fontawesome_icons=screens.FONTAWESOME_ICONS,
global_variables=global_variables,
) )
+10 -2
View File
@@ -41,8 +41,10 @@ from .widgets.registry import WIDGETS
from .widgets.container_widgets import CONTAINER_WIDGETS from .widgets.container_widgets import CONTAINER_WIDGETS
from .widgets.layout_capable_widgets import LAYOUT_CAPABLE_WIDGETS from .widgets.layout_capable_widgets import LAYOUT_CAPABLE_WIDGETS
from .widgets.font_options import GOOGLE_FONTS_LINK from .widgets.font_options import GOOGLE_FONTS_LINK
from .widgets.fontawesome_icons import FONTAWESOME_ICONS
from .widgets.widget_meta import widget_meta from .widgets.widget_meta import widget_meta
from .widgets.controls_with_values import controls_with_values from .widgets.controls_with_values import controls_with_values
from .widgets.data_definition_options import data_definition_options
from .labels.trigger_labels import TRIGGER_LABELS from .labels.trigger_labels import TRIGGER_LABELS
from .labels.action_type_labels import ACTION_TYPE_LABELS from .labels.action_type_labels import ACTION_TYPE_LABELS
@@ -54,7 +56,7 @@ from .labels.element_action_properties import (
from .labels.element_visibility import ELEMENT_VISIBILITY_VALUES, ELEMENT_VISIBILITY_LABELS from .labels.element_visibility import ELEMENT_VISIBILITY_VALUES, ELEMENT_VISIBILITY_LABELS
from .labels.data_operations import DATA_OPERATIONS, DATA_OPERATION_LABELS from .labels.data_operations import DATA_OPERATIONS, DATA_OPERATION_LABELS
from .flow.constants import TRIGGER_EVENTS, CONDITION_OPERATORS, CONDITION_OPERATOR_LABELS from .flow.constants import TRIGGER_EVENTS, CONDITION_OPERATORS, CONDITION_OPERATOR_LABELS, CLICKED_ROW_ID
from .rendering.render_element_html import render_element_html from .rendering.render_element_html import render_element_html
from .rendering.trigger_for import _trigger_for from .rendering.trigger_for import _trigger_for
@@ -72,18 +74,23 @@ from .screens_repo.set_screen_aspect import set_screen_aspect
from .elements.list_elements import list_elements from .elements.list_elements import list_elements
from .elements.get_element import get_element from .elements.get_element import get_element
from .elements.add_element import add_element from .elements.add_element import add_element
from .elements.add_tab import add_tab
from .elements.update_element_geometry import update_element_geometry from .elements.update_element_geometry import update_element_geometry
from .elements.reorder_element import reorder_element
from .elements.move_element_to_container import move_element_to_container
from .elements.save_element_controls import save_element_controls from .elements.save_element_controls import save_element_controls
from .elements.delete_element import delete_element from .elements.delete_element import delete_element
from .elements.flow_nodes_referencing_element import flow_nodes_referencing_element
from .elements.duplicate_element import duplicate_element
from .elements.rename_element import rename_element from .elements.rename_element import rename_element
from .elements.element_label import element_label from .elements.element_label import element_label
from .element_types.list_element_types import list_element_types from .element_types.list_element_types import list_element_types
from .element_types.get_element_type_by_template_screen import get_element_type_by_template_screen
from .element_types.create_element_type import create_element_type from .element_types.create_element_type import create_element_type
from .element_types.update_element_type import update_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.element_type_usage_count import element_type_usage_count
from .element_types.delete_element_type import delete_element_type 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.list_actions import list_actions
from .legacy_actions.add_action import add_action from .legacy_actions.add_action import add_action
@@ -101,6 +108,7 @@ from .flow.add_flow_edge import add_flow_edge
from .flow.delete_flow_edge import delete_flow_edge from .flow.delete_flow_edge import delete_flow_edge
from .data_actions.apply_data_action import apply_data_action from .data_actions.apply_data_action import apply_data_action
from .data_actions.apply_variable_action import apply_variable_action
from .animations.ensure_animation_schema import ensure_animation_schema from .animations.ensure_animation_schema import ensure_animation_schema
from .animations.list_animation_clips import list_animation_clips from .animations.list_animation_clips import list_animation_clips
@@ -0,0 +1,53 @@
import db
def apply_variable_action(slug, action):
"""Exécute au moment du clic (mode jouable) une action
"modifier_variable" — même logique/vocabulaire d'opérations que
apply_data_action.py (DATA_OPERATIONS), appliqué à une variable globale
(une seule valeur pour tout le jeu) plutôt qu'au champ d'une ligne
d'objet."""
var_name = action.get("target_variable")
operation = action.get("data_operation")
raw_value = action.get("data_value")
if not (var_name and operation):
return False
variable = db.get_global_variable(slug, var_name)
if not variable:
return False
var_type = variable["type"]
current = variable.get("value")
if var_type in ("nombre_entier", "nombre_decimal"):
try:
current = float(current) if current not in (None, "") else 0
except (TypeError, ValueError):
current = 0
elif var_type == "booleen":
current = 1 if str(current).strip().lower() in ("1", "true", "vrai") else 0
if operation == "definir_texte":
new_value = raw_value or ""
elif operation == "definir_nombre":
try:
new_value = float(raw_value) if var_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 var_type == "nombre_decimal" else int(result)
else:
return False
return db.update_global_variable_value(slug, var_name, new_value)
@@ -0,0 +1,16 @@
import db
from ..screens_repo.ensure_schema import ensure_schema
def get_element_type_by_template_screen(slug, screen_id):
"""L'entrée du catalogue (voir _element_types) dont l'écran-modèle EST
cet écran, s'il y en a une — utile pour retrouver, depuis l'éditeur
d'un écran-modèle, l'objet éventuellement lié à cet élément de jeu
(bound_definition_id, réglé à la création — voir element_types.html)
sans devoir tout lister soi-même."""
ensure_schema(slug)
conn = db.connect(slug)
row = conn.execute("SELECT * FROM _element_types WHERE template_screen_id = ?", (screen_id,)).fetchone()
conn.close()
return dict(row) if row else None
@@ -1,48 +0,0 @@
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
+15 -22
View File
@@ -6,7 +6,6 @@ from ..widgets.registry import WIDGETS
from ..widgets.widget_meta import widget_meta from ..widgets.widget_meta import widget_meta
from ..widgets.default_style_for_widget import default_style_and_attributes from ..widgets.default_style_for_widget import default_style_and_attributes
from ..element_types.list_element_types import list_element_types 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): def add_element(slug, screen_id, widget_key, element_type_id=None, parent_id=None):
@@ -16,30 +15,24 @@ def add_element(slug, screen_id, widget_key, element_type_id=None, parent_id=Non
alors plus (il suit le flux du parent), mais on garde des valeurs par 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. 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 element_type_id : si fourni, un SEUL élément est créé ici (ligne plate,
(le cas normal désormais — voir create_element_type), tout son arbre est comme n'importe quel widget), qui garde juste une référence vers cet
COPIÉ en profondeur ici plutôt que de créer un seul élément plat : c'est élément de jeu — son contenu (l'arbre de son écran-modèle) n'est PAS
ce qui permet à un élément de jeu d'être un mini-conteneur réutilisable copié en base : il est rechargé EN DIRECT à chaque affichage (voir
avec plusieurs éléments imbriqués à l'intérieur, pas juste un widget render_element_html.py), exactement comme un modèle de ligne de
unique avec un contenu par défaut.""" Répéteur (voir _load_template_tree/_render_repeater). Modifier
l'élément de jeu dans son propre éditeur met donc à jour tous ses
exemplaires déjà posés, sur n'importe quel écran, sans avoir à les
retoucher un par un — au prix de ne plus pouvoir personnaliser
individuellement le CONTENU d'un exemplaire précis (sa position/taille
sur l'écran, elles, restent propres à chaque exemplaire, comme
d'habitude)."""
widget_key = widget_key if widget_key in WIDGETS else "conteneur"
if element_type_id: if element_type_id:
types = {t["id"]: t for t in list_element_types(slug)} types = {t["id"]: t for t in list_element_types(slug)}
et = types.get(int(element_type_id)) et = types.get(int(element_type_id))
if et and et.get("template_screen_id"): if et:
created = instantiate_template_tree(slug, et["template_screen_id"], screen_id, parent_id) widget_key = et.get("default_widget") or widget_key
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) meta = widget_meta(widget_key)
# Réglages dont la valeur par défaut a un effet visuel voulu dès la # 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 # création (ex: une image démarre avec "cover" pour se recadrer
+19
View File
@@ -0,0 +1,19 @@
import db
from .add_element import add_element
from .rename_element import rename_element
def add_tab(slug, onglets_id, screen_id):
"""Ajoute un nouvel onglet à un élément "onglets" : un simple conteneur
posé comme enfant (voir render_onglets.py), nommé "Onglet N" par défaut
(N = son rang) — ce nom est directement ce qui s'affiche sur l'onglet,
modifiable ensuite comme celui de n'importe quel élément."""
conn = db.connect(slug)
count = conn.execute(
"SELECT COUNT(*) AS c FROM _screen_elements WHERE parent_id = ?", (onglets_id,)
).fetchone()["c"]
conn.close()
tab_id = add_element(slug, screen_id, "conteneur", parent_id=onglets_id)
rename_element(slug, tab_id, f"Onglet {count + 1}")
return tab_id
+27
View File
@@ -1,8 +1,35 @@
import db import db
from ..flow.ensure_flow_schema import ensure_flow_schema
from .element_descendant_ids import element_descendant_ids
def delete_element(slug, element_id): def delete_element(slug, element_id):
ensure_flow_schema(slug)
conn = db.connect(slug) conn = db.connect(slug)
row = conn.execute("SELECT screen_id FROM _screen_elements WHERE id = ?", (element_id,)).fetchone()
if not row:
conn.close()
return
ids = element_descendant_ids(conn, row["screen_id"], element_id)
placeholders = ",".join("?" * len(ids))
# Un nœud de la Logique de la scène (déclencheur "clic sur cet
# élément"/action "Modifier cet élément"...) ou une ancienne action du
# système _actions (conservé pour compatibilité) qui référence
# l'élément supprimé OU L'UN DE SES DESCENDANTS n'a plus aucun sens une
# fois l'élément disparu — et ces colonnes (trigger_element_id/
# target_element_id) n'ont volontairement PAS de ON DELETE CASCADE (un
# élément ne doit pas pouvoir être supprimé "par erreur" en cascade
# depuis un nœud de logique qu'on modifie). Sans ce nettoyage préalable,
# PRAGMA foreign_keys=ON (voir db/connection.py) fait échouer la
# suppression elle-même avec "FOREIGN KEY constraint failed". Voir
# element_delete_impact.py pour prévenir l'utilisateur AVANT qu'il
# confirme, plutôt que de supprimer ces nœuds en silence.
conn.execute(
f"DELETE FROM _flow_nodes WHERE trigger_element_id IN ({placeholders}) OR target_element_id IN ({placeholders})",
ids + ids,
)
conn.execute(f"DELETE FROM _actions WHERE target_element_id IN ({placeholders})", ids)
conn.execute("DELETE FROM _screen_elements WHERE id = ?", (element_id,)) conn.execute("DELETE FROM _screen_elements WHERE id = ?", (element_id,))
conn.commit() conn.commit()
conn.close() conn.close()
+60
View File
@@ -0,0 +1,60 @@
import db
def duplicate_element(slug, element_id):
"""Duplique un élément et tout son sous-arbre DANS SON CONTENEUR ACTUEL
(même écran, même parent_id que l'original) — le clic droit "Dupliquer"
de l'arborescence n'a pas à faire choisir où poser la copie, elle
apparaît juste à côté de l'original. Renvoie l'id de la copie (celui à
sélectionner ensuite), ou None si l'élément n'existe pas."""
conn = db.connect(slug)
root_row = conn.execute("SELECT * FROM _screen_elements WHERE id = ?", (element_id,)).fetchone()
if not root_row:
conn.close()
return None
root = dict(root_row)
source = [
dict(r) for r in conn.execute(
"SELECT * FROM _screen_elements WHERE screen_id = ?", (root["screen_id"],)
).fetchall()
]
conn.close()
def copy_one(src, new_parent_id, is_root):
conn = db.connect(slug)
max_z = conn.execute(
"SELECT MAX(z_index) AS m FROM _screen_elements WHERE screen_id = ?", (src["screen_id"],)
).fetchone()["m"]
x, y = src["x"], src["y"]
if is_root and not new_parent_id:
# Petit décalage pour que la copie ne recouvre pas exactement
# l'original (qui serait alors invisible en dessous).
x = min(100, (x or 0) + 4)
y = min(100, (y or 0) + 4)
name = src.get("name")
if is_root and name:
name = f"{name} (copie)"
conn.execute(
"""INSERT INTO _screen_elements
(screen_id, parent_id, widget, element_type_id, name, content, attributes,
x, y, width, height, z_index, style)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
(
src["screen_id"], new_parent_id, src["widget"], src.get("element_type_id"), name,
src["content"], src["attributes"], x, 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_child_id = copy_one(child, new_parent_id, is_root=False)
walk(child["id"], new_child_id)
new_root_id = copy_one(root, root.get("parent_id"), is_root=True)
walk(root["id"], new_root_id)
return new_root_id
@@ -0,0 +1,23 @@
def element_descendant_ids(conn, screen_id, root_id):
"""Id de cet élément et de TOUS ses descendants — ceux qu'une
suppression en cascade (parent_id ON DELETE CASCADE, voir
ensure_schema.py) supprime automatiquement en même temps que lui.
Partagé entre delete_element.py (nettoyage des références de la
Logique de la scène avant suppression) et flow_nodes_referencing_
element.py (pour prévenir l'utilisateur AVANT qu'il confirme la
suppression, voir routes/elements/element_delete_impact.py)."""
rows = conn.execute(
"SELECT id, parent_id FROM _screen_elements WHERE screen_id = ?", (screen_id,)
).fetchall()
children_of = {}
for r in rows:
if r["parent_id"]:
children_of.setdefault(r["parent_id"], []).append(r["id"])
ids = [root_id]
stack = [root_id]
while stack:
current = stack.pop()
for child_id in children_of.get(current, []):
ids.append(child_id)
stack.append(child_id)
return ids
@@ -0,0 +1,28 @@
import db
from ..flow.ensure_flow_schema import ensure_flow_schema
from .element_descendant_ids import element_descendant_ids
def flow_nodes_referencing_element(slug, element_id):
"""Nombre de nœuds de la Logique de la scène (déclencheur "clic sur cet
élément", action "Modifier cet élément"/"Activer cet onglet"...) qui
référencent cet élément OU L'UN DE SES DESCENDANTS — donc qui seraient
supprimés SI on supprimait cet élément (voir delete_element.py). Utilisé
pour prévenir l'utilisateur AVANT qu'il confirme la suppression (voir
la modale de confirmation dans screen_edit.html), plutôt que de les
supprimer en silence."""
ensure_flow_schema(slug)
conn = db.connect(slug)
row = conn.execute("SELECT screen_id FROM _screen_elements WHERE id = ?", (element_id,)).fetchone()
if not row:
conn.close()
return 0
ids = element_descendant_ids(conn, row["screen_id"], element_id)
placeholders = ",".join("?" * len(ids))
count = conn.execute(
f"SELECT COUNT(*) AS c FROM _flow_nodes WHERE trigger_element_id IN ({placeholders}) OR target_element_id IN ({placeholders})",
ids + ids,
).fetchone()["c"]
conn.close()
return count
+40 -3
View File
@@ -5,14 +5,20 @@ import db
from ..rendering.render_element_html import render_element_html from ..rendering.render_element_html import render_element_html
def list_elements(slug, screen_id): def list_elements(slug, screen_id, enforce_visibility=False):
"""Renvoie TOUS les éléments de l'écran, à plat (parent_id indique """Renvoie TOUS les éléments de l'écran, à plat (parent_id indique
l'imbrication) — pratique pour construire l'arbre côté appelant. Seuls l'imbrication) — pratique pour construire l'arbre côté appelant. Seuls
les éléments de premier niveau (parent_id NULL) reçoivent un 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 "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é à 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 l'intérieur — les enfants n'ont donc pas besoin d'être dessinés une
deuxième fois séparément.""" deuxième fois séparément.
enforce_visibility : True uniquement pour le mode jouable (voir
full_game_payload.py) — applique la "condition de visibilité" de chaque
élément (voir visibility_condition.py). Toujours False pour l'éditeur,
qui doit continuer à montrer/sélectionner un élément même si sa
condition serait actuellement fausse."""
conn = db.connect(slug) conn = db.connect(slug)
rows = conn.execute( rows = conn.execute(
"SELECT * FROM _screen_elements WHERE screen_id = ? ORDER BY z_index, id", (screen_id,) "SELECT * FROM _screen_elements WHERE screen_id = ? ORDER BY z_index, id", (screen_id,)
@@ -24,11 +30,42 @@ def list_elements(slug, screen_id):
d["style"] = json.loads(d["style"] or "{}") d["style"] = json.loads(d["style"] or "{}")
d["attributes"] = json.loads(d["attributes"] or "{}") d["attributes"] = json.loads(d["attributes"] or "{}")
result.append(d) result.append(d)
# Descendant d'un exemplaire d'élément de jeu (element_type_id réglé sur
# un ancêtre) : un ancien enfant copié en base par l'ex-instantiate_
# template_tree (mécanisme retiré, voir add_element.py) — son contenu
# vient désormais TOUJOURS en direct de l'écran-modèle au moment du
# rendu (voir render_element_html.py/_render_element_type_children),
# jamais de ces lignes, qui peuvent encore traîner en base pour un
# exemplaire posé avant ce changement. Exclu ici pour ne plus jamais
# apparaître sélectionnable dans l'arborescence de l'éditeur — sinon
# ses réglages, plus jamais synchronisés avec le modèle, donnaient
# l'impression trompeuse que "les propriétés ne sont pas prises en
# compte" (une taille de texte modifiée dans le modèle ne se reflétant
# jamais sur cette ligne fantôme, toujours sélectionnable à l'écran).
by_id = {d["id"]: d for d in result}
def _has_linked_ancestor(d):
seen = set()
parent_id = d.get("parent_id")
while parent_id and parent_id not in seen:
seen.add(parent_id)
parent = by_id.get(parent_id)
if not parent:
return False
if parent.get("element_type_id"):
return True
parent_id = parent.get("parent_id")
return False
result = [d for d in result if not _has_linked_ancestor(d)]
children_map = {} children_map = {}
for d in result: for d in result:
if d.get("parent_id"): if d.get("parent_id"):
children_map.setdefault(d["parent_id"], []).append(d) children_map.setdefault(d["parent_id"], []).append(d)
play_ctx = {"_forge_play_mode": True} if enforce_visibility else None
for d in result: for d in result:
if not d.get("parent_id"): if not d.get("parent_id"):
d["rendered_html"] = render_element_html(d, slug, children_map) d["rendered_html"] = render_element_html(d, slug, children_map, play_ctx)
return result return result
@@ -0,0 +1,62 @@
import db
from ..widgets.container_widgets import CONTAINER_WIDGETS
def move_element_to_container(slug, element_id, new_parent_id):
"""Déplace element_id pour qu'il devienne un enfant de new_parent_id
(glisser-déposer dans l'arborescence, voir screen_edit.html) — posé en
DERNIER enfant, l'ordre précis à l'intérieur se règle ensuite comme
d'habitude par glisser-déposer entre frères et sœurs (voir
reorder_element.py). Refuse silencieusement (ne touche à rien) si :
new_parent_id n'est pas un widget conteneur, si c'est element_id
lui-même, ou si c'est un de ses propres descendants (ça créerait un
cycle — un conteneur ne peut pas finir posé à l'intérieur de lui-même)."""
conn = db.connect(slug)
rows = conn.execute(
"SELECT id, screen_id, parent_id, widget, z_index FROM _screen_elements WHERE id IN (?, ?)",
(element_id, new_parent_id),
).fetchall()
by_id = {r["id"]: r for r in rows}
el = by_id.get(element_id)
target = by_id.get(new_parent_id)
if not el or not target or el["screen_id"] != target["screen_id"]:
conn.close()
return
if target["widget"] not in CONTAINER_WIDGETS:
conn.close()
return
if new_parent_id == element_id:
conn.close()
return
# Cycle : new_parent_id ne doit pas être element_id, ni un descendant de
# element_id (direct ou indirect) — sinon element_id finirait posé à
# l'intérieur de sa propre arborescence.
children_of = {}
for r in conn.execute(
"SELECT id, parent_id FROM _screen_elements WHERE screen_id = ?", (el["screen_id"],)
).fetchall():
if r["parent_id"]:
children_of.setdefault(r["parent_id"], []).append(r["id"])
stack = list(children_of.get(element_id, []))
descendants = set()
while stack:
node = stack.pop()
if node in descendants:
continue
descendants.add(node)
stack.extend(children_of.get(node, []))
if new_parent_id in descendants:
conn.close()
return
max_z = conn.execute(
"SELECT MAX(z_index) AS m FROM _screen_elements WHERE screen_id = ?", (el["screen_id"],)
).fetchone()["m"]
conn.execute(
"UPDATE _screen_elements SET parent_id = ?, z_index = ? WHERE id = ?",
(new_parent_id, (max_z or 0) + 1, element_id),
)
conn.commit()
conn.close()
+37
View File
@@ -0,0 +1,37 @@
import db
def reorder_element(slug, screen_id, element_id, target_id, position):
"""Déplace element_id juste avant ("before") ou juste après ("after")
target_id, DANS LE MÊME CONTENEUR (les deux doivent avoir le même
parent_id — sinon on ne touche à rien, pour ne jamais reparenter par
erreur via le glisser-déposer de l'arborescence). L'ordre est porté par
z_index, partagé par tous les éléments de l'écran (pas juste les frères
et sœurs) : on renumérote donc l'écran entier à partir de l'ordre actuel
(z_index, id) pour rester cohérent partout, pas seulement dans ce
conteneur."""
conn = db.connect(slug)
rows = conn.execute(
"SELECT id, parent_id FROM _screen_elements WHERE screen_id = ? ORDER BY z_index, id",
(screen_id,),
).fetchall()
by_id = {r["id"]: r["parent_id"] for r in rows}
if element_id not in by_id or target_id not in by_id:
conn.close()
return
if by_id[element_id] != by_id[target_id]:
conn.close()
return
order = [r["id"] for r in rows]
order.remove(element_id)
target_index = order.index(target_id)
insert_at = target_index + 1 if position == "after" else target_index
order.insert(insert_at, element_id)
conn.executemany(
"UPDATE _screen_elements SET z_index = ? WHERE id = ?",
[(i + 1, eid) for i, eid in enumerate(order)],
)
conn.commit()
conn.close()
+68
View File
@@ -6,6 +6,16 @@ from ..widgets.widget_meta import widget_meta
from .get_element import get_element from .get_element import get_element
def _hex_to_rgb(hex_color):
h = (hex_color or "").lstrip("#")
if len(h) != 6:
return (0, 0, 0)
try:
return tuple(int(h[i:i + 2], 16) for i in (0, 2, 4))
except ValueError:
return (0, 0, 0)
def _is_default_value(control, raw_value): def _is_default_value(control, raw_value):
"""Compare la valeur reçue du formulaire à la valeur par défaut du """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 réglage (en tenant compte de l'unité ajoutée pour les sliders), pour
@@ -64,6 +74,56 @@ def save_element_controls(slug, element_id, form):
style["transform"] = f"scale({val})" style["transform"] = f"scale({val})"
continue continue
if ctype == "size":
# Un seul champ CSS ("width"/"height") mais deux entrées de
# formulaire (valeur + unité) — voir size_override_controls.py
# pour pourquoi ce n'est PAS deux contrôles slider séparés.
_kind, name = control["target"].split(":", 1)
try:
num = float(form.get(field_name + "_value") or 0)
except (TypeError, ValueError):
num = 0.0
unit = form.get(field_name + "_unit", "%")
if unit not in ("px", "%"):
unit = "%"
if num <= 0:
style.pop(name, None)
else:
num_str = str(int(num)) if num == int(num) else str(num)
style[name] = f"{num_str}{unit}"
continue
if ctype == "shadow":
# 6 entrées de formulaire (décalage X/Y, flou, étendue, couleur,
# opacité — voir c_shadow.py) combinées en une seule valeur CSS
# "box-shadow" (couleur+opacité fusionnées en rgba(), un
# <input type="color"> seul ne portant pas de canal alpha).
_kind, name = control["target"].split(":", 1)
defaults = control.get("default", {})
def _num(suffix, fallback):
try:
return float(form.get(field_name + suffix, fallback))
except (TypeError, ValueError):
return fallback
x = _num("_x", defaults.get("x", 0))
y = _num("_y", defaults.get("y", 4))
blur = _num("_blur", defaults.get("blur", 12))
spread = _num("_spread", defaults.get("spread", 0))
opacity = _num("_opacity", defaults.get("opacity", 0.4))
color = form.get(field_name + "_color") or defaults.get("color", "#000000")
if x == 0 and y == 0 and blur == 0 and spread == 0:
style.pop(name, None)
else:
r, g, b = _hex_to_rgb(color)
def _px(n):
return str(int(n)) if n == int(n) else str(n)
style[name] = f"{_px(x)}px {_px(y)}px {_px(blur)}px {_px(spread)}px rgba({r}, {g}, {b}, {opacity})"
continue
target = control["target"] target = control["target"]
if ctype == "checkbox": if ctype == "checkbox":
checked = form.get(field_name) is not None checked = form.get(field_name) is not None
@@ -78,6 +138,14 @@ def save_element_controls(slug, element_id, form):
content = raw_value content = raw_value
else: else:
kind, name = target.split(":", 1) kind, name = target.split(":", 1)
if kind == "class":
# "class:xxx" : un jeton de classe CSS externe (ex. un
# modificateur Bulma) parmi d'autres, chacun mémorisé sous sa
# propre clé "_class_xxx" pour ne pas s'écraser entre eux —
# voir _visible_attrs.py qui les rassemble avec la classe de
# base du widget (fixed_attrs) au moment du rendu.
store, name = attributes, "_class_" + name
else:
store = attributes if kind == "attr" else style store = attributes if kind == "attr" else style
if raw_value in (None, ""): if raw_value in (None, ""):
store.pop(name, None) store.pop(name, None)
+21
View File
@@ -10,9 +10,29 @@
# casser sur les jeux déjà créés, mais n'est plus exposé dans l'éditeur ni # casser sur les jeux déjà créés, mais n'est plus exposé dans l'éditeur ni
# utilisé par le mode jouable. # utilisé par le mode jouable.
# Valeur sentinelle pour cond_row_id/target_row_id (nœud Condition/action
# "Modifier une donnée") : "la ligne de Répéteur sur laquelle on vient de
# cliquer", résolue au moment de l'exécution (window.lastClickedRowId côté
# client — voir play.html) plutôt que figée à la création du nœud. Utile
# quand le déclencheur est "Au clic" sur un Répéteur : la ligne cliquée
# n'est jamais connue à l'avance (chaque ligne du Répéteur exécute le MÊME
# graphe), donc choisir une ligne précise dans l'éditeur n'a pas de sens
# ici — -1 ne collisionne jamais avec un vrai id de ligne (toujours >= 1,
# AUTOINCREMENT SQLite).
CLICKED_ROW_ID = -1
TRIGGER_EVENTS = [ TRIGGER_EVENTS = [
("clic", "Au clic"), ("clic", "Au clic"),
("soumission", "À la soumission"), ("soumission", "À la soumission"),
# 3.1 (Confort) — interactions au survol, reconstruit comme déclencheur
# de flow (au lieu d'un réglage statique dans le panneau de propriétés,
# voir universal_controls.py) : "survol" et "fin_survol" sont deux
# déclencheurs distincts et explicites (comme "Au clic"), sans effet
# implicite — un créateur qui veut qu'un texte affiché au survol
# disparaisse ensuite doit poser l'action inverse sur "Fin du survol"
# lui-même, plutôt que de compter sur un retour automatique.
("survol", "Au survol"),
("fin_survol", "Fin du survol"),
# Pas de "trigger_element_id" pour celui-ci : il concerne l'ÉCRAN entier, # 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 # 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é projet Forge — "affichage piloté par la donnée"). Se déclenche
@@ -44,4 +64,5 @@ FLOW_NODE_FIELDS = {
"cond_clauses", "cond_combinator", "cond_clauses", "cond_combinator",
"action_type", "target_screen_id", "target_element_id", "element_property", "element_value", "action_type", "target_screen_id", "target_element_id", "element_property", "element_value",
"target_definition_id", "target_row_id", "target_field", "data_operation", "data_value", "target_definition_id", "target_row_id", "target_field", "data_operation", "data_value",
"target_variable",
} }
+5
View File
@@ -51,5 +51,10 @@ def ensure_flow_schema(slug):
conn.execute("ALTER TABLE _flow_nodes ADD COLUMN cond_clauses TEXT") conn.execute("ALTER TABLE _flow_nodes ADD COLUMN cond_clauses TEXT")
if "cond_combinator" not in cols: if "cond_combinator" not in cols:
conn.execute("ALTER TABLE _flow_nodes ADD COLUMN cond_combinator TEXT NOT NULL DEFAULT 'et'") conn.execute("ALTER TABLE _flow_nodes ADD COLUMN cond_combinator TEXT NOT NULL DEFAULT 'et'")
if "target_variable" not in cols:
# Action "Modifier une variable" (voir apply_variable_action.py) —
# cible une variable globale par NOM plutôt qu'un champ d'objet ;
# réutilise data_operation/data_value, déjà là pour "modifier_donnee".
conn.execute("ALTER TABLE _flow_nodes ADD COLUMN target_variable TEXT")
conn.commit() conn.commit()
conn.close() conn.close()
+1
View File
@@ -4,6 +4,7 @@ ACTION_TYPE_LABELS = {
"aller_a": "Aller à un écran précis", "aller_a": "Aller à un écran précis",
"modifier_element": "Modifier un élément", "modifier_element": "Modifier un élément",
"modifier_donnee": "Modifier une donnée d'un objet", "modifier_donnee": "Modifier une donnée d'un objet",
"modifier_variable": "Modifier une variable globale",
"ouvrir_ligne": "Ouvrir la ligne de Répéteur cliquée", "ouvrir_ligne": "Ouvrir la ligne de Répéteur cliquée",
"activer_onglet": "Afficher cet élément, masquer tous ses frères", "activer_onglet": "Afficher cet élément, masquer tous ses frères",
"attendre": "Attendre quelques secondes avant de continuer", "attendre": "Attendre quelques secondes avant de continuer",
@@ -2,6 +2,14 @@
ELEMENT_ACTION_PROPERTIES = [ ELEMENT_ACTION_PROPERTIES = [
("visibilite", "Visibilité"), ("visibilite", "Visibilité"),
# Voir TRIGGER_EVENTS ("survol"/"fin_survol") : c'est ce qui permet à un
# créateur de reconstruire l'ancien comportement "texte affiché au
# survol" (et bien d'autres) à la main dans la Logique de la scène —
# poser "Modifier un élément → Contenu" sur un déclencheur "Au survol"
# d'un AUTRE élément. La valeur est un texte littéral (pas encore de
# binding vers un champ d'objet ici — voir applyOpenRowBindings pour le
# mécanisme équivalent, propre à "Ouvrir la ligne cliquée").
("contenu", "Contenu — texte affiché"),
("bg_color", "Couleur de fond"), ("bg_color", "Couleur de fond"),
("border_color", "Couleur de bordure"), ("border_color", "Couleur de bordure"),
("width", "Largeur en %"), ("width", "Largeur en %"),
+2
View File
@@ -2,4 +2,6 @@ TRIGGER_LABELS = {
"clic": "Au clic", "clic": "Au clic",
"soumission": "À la soumission", "soumission": "À la soumission",
"affichage": "À l'affichage de l'écran", "affichage": "À l'affichage de l'écran",
"survol": "Au survol",
"fin_survol": "Fin du survol",
} }
+1 -1
View File
@@ -21,7 +21,7 @@ def full_game_payload(slug):
flows = {} flows = {}
animations = {} animations = {}
for s in screens_: for s in screens_:
elements = list_elements(slug, s["id"]) elements = list_elements(slug, s["id"], enforce_visibility=True)
payload_screens.append({**s, "elements": elements}) payload_screens.append({**s, "elements": elements})
flows[str(s["id"])] = { flows[str(s["id"])] = {
"nodes": list_flow_nodes(slug, s["id"]), "nodes": list_flow_nodes(slug, s["id"]),
+5
View File
@@ -5,5 +5,10 @@ def _apply_ctx(text, ctx):
if not ctx or not text: if not ctx or not text:
return text return text
for k, v in ctx.items(): for k, v in ctx.items():
# "_forge_play_mode" (voir render_element_html.py) est un indicateur
# interne posé dans ce même ctx, pas une valeur de champ {{...}} —
# à ignorer ici.
if k == "_forge_play_mode":
continue
text = text.replace("{{" + k + "}}", v) text = text.replace("{{" + k + "}}", v)
return text return text
+71 -18
View File
@@ -9,6 +9,25 @@ import db
# d'un Répéteur, voir _apply_ctx / applyOpenRowBindings côté JS). # d'un Répéteur, voir _apply_ctx / applyOpenRowBindings côté JS).
_REF_PATTERN = re.compile(r"^\{\{\s*([^.{}]+)\.([^.{}]+)\s*\}\}$") _REF_PATTERN = re.compile(r"^\{\{\s*([^.{}]+)\.([^.{}]+)\s*\}\}$")
# {{$nom_variable}} : référence à une VARIABLE GLOBALE (voir db/global_vars/)
# — le "$" (jamais utilisé dans un nom d'objet/de champ) distingue sans
# ambiguïté cette syntaxe de _REF_PATTERN ci-dessus, qui, elle, attend
# toujours un point ("Objet.champ").
_VAR_REF_PATTERN = re.compile(r"^\{\{\s*\$([^.{}]+)\s*\}\}$")
def _field_column(definition, field_name):
"""Nom de la vraie colonne SQL d'un champ — un champ "relation" est
stocké dans une colonne "<champ>_id" (voir create_definition.py), jamais
sous son nom "propre" : sans ce cas particulier, filtrer ou comparer sur
un champ relation ne trouvait jamais la bonne colonne et ne matchait
donc jamais rien."""
col = db.slugify(field_name).replace("-", "_")
field = next((f for f in definition["fields"] if f["name"] == field_name), None)
if field and field["type"] == "relation":
col += "_id"
return col
def _resolve_filter_value(slug, raw_value): def _resolve_filter_value(slug, raw_value):
"""Si raw_value est une référence "{{Objet.champ}}", va lire la valeur """Si raw_value est une référence "{{Objet.champ}}", va lire la valeur
@@ -16,11 +35,19 @@ def _resolve_filter_value(slug, raw_value):
convention, un objet utilisé comme "état de partie" — niveau courant, convention, un objet utilisé comme "état de partie" — niveau courant,
outils débloqués... — ne garde qu'une seule ligne, mise à jour en place 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 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 nouvelle à chaque fois). Si c'est une référence "{{$nom_variable}}", va
fixe).""" lire la valeur ACTUELLE de cette variable globale (voir
db/global_vars/). Sinon, renvoie raw_value tel quel (valeur fixe)."""
if not raw_value: if not raw_value:
return raw_value return raw_value
m = _REF_PATTERN.match(raw_value.strip()) stripped = raw_value.strip()
var_match = _VAR_REF_PATTERN.match(stripped)
if var_match:
variable = db.get_global_variable(slug, var_match.group(1).strip())
return variable.get("value") if variable else None # variable supprimée depuis : ne matche rien plutôt que planter
m = _REF_PATTERN.match(stripped)
if not m: if not m:
return raw_value return raw_value
object_name, field_name = m.group(1).strip(), m.group(2).strip() object_name, field_name = m.group(1).strip(), m.group(2).strip()
@@ -29,7 +56,7 @@ def _resolve_filter_value(slug, raw_value):
full = db.get_definition(slug, d["id"]) full = db.get_definition(slug, d["id"])
rows = db.list_rows(slug, full) rows = db.list_rows(slug, full)
if rows: if rows:
return rows[0].get(db.slugify(field_name).replace("-", "_")) return rows[0].get(_field_column(full, field_name))
return None return None
return None # objet de référence introuvable — filtre ne matchera rien plutôt que planter return None # objet de référence introuvable — filtre ne matchera rien plutôt que planter
@@ -38,8 +65,23 @@ def _compare(actual, operator, expected, field_type):
"""Équivalent Python de compareValues() dans templates/play.html — les """Équivalent Python de compareValues() dans templates/play.html — les
deux doivent rester alignées si on ajoute un opérateur.""" deux doivent rester alignées si on ajoute un opérateur."""
if field_type == "booleen": if field_type == "booleen":
a = 1 if actual else 0 # "actual" est un int SQLite (0/1) pour un champ d'objet, mais une
e = 1 if str(expected).strip().lower() in ("1", "true", "vrai") else 0 # chaîne pour une variable globale (voir db/global_vars/, toujours
# stockée en TEXT) — "0" est une chaîne non vide donc VRAIE en
# Python, d'où ce traitement explicite plutôt qu'un simple bool(...).
if isinstance(actual, str):
truthy = actual.strip().lower() not in ("", "0", "false", "faux", "none")
else:
truthy = bool(actual)
a = 1 if truthy else 0
# "oui"/"non" est le vocabulaire affiché partout ailleurs dans
# l'app pour un champ booléen (voir data_list.html, "Oui" si vrai
# sinon "Non") — une valeur de comparaison fixe tapée "Oui" doit
# donc être reconnue comme vraie ici aussi, pas seulement "1"/
# "true"/"vrai" (sans quoi "Oui" retombait silencieusement à faux,
# et un widget conditionné sur "est égal à Oui" ne s'affichait
# jamais quand il aurait dû).
e = 1 if str(expected).strip().lower() in ("1", "true", "vrai", "oui") else 0
return a != e if operator == "different" else a == e return a != e if operator == "different" else a == e
try: try:
an, en = float(actual), float(expected) an, en = float(actual), float(expected)
@@ -76,19 +118,30 @@ def _compare(actual, operator, expected, field_type):
return False return False
def filter_repeater_rows(slug, definition, rows, attrs): def _apply_one_filter(slug, definition, rows, field_name, operator, raw_value):
"""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: if not field_name:
return rows return rows
raw_value = attrs.get("_filtre_valeur") or "" expected = _resolve_filter_value(slug, raw_value or "")
expected = _resolve_filter_value(slug, raw_value)
field_type = next((f["type"] for f in definition["fields"] if f["name"] == field_name), "texte") field_type = next((f["type"] for f in definition["fields"] if f["name"] == field_name), "texte")
col = db.slugify(field_name).replace("-", "_") col = _field_column(definition, field_name)
return [r for r in rows if _compare(r.get(col), operator, expected, field_type)] return [r for r in rows if _compare(r.get(col), operator, expected, field_type)]
def filter_repeater_rows(slug, definition, rows, attrs):
"""Ne garde, parmi les lignes d'un Répéteur, que celles qui correspondent
au(x) filtre(s) optionnel(s) réglé(s) dans ses propriétés (_filtre_champ/
_filtre_operateur/_filtre_valeur, ET, si renseigné, _filtre2_champ/
_filtre2_operateur/_filtre2_valeur combiné en ET avec le premier) — 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 le niveau du parcours en cours (2 conditions : le bon
parcours ET le bon numéro de niveau) plutôt que tous les niveaux."""
rows = _apply_one_filter(
slug, definition, rows,
(attrs.get("_filtre_champ") or "").strip(), attrs.get("_filtre_operateur") or "egal", attrs.get("_filtre_valeur"),
)
rows = _apply_one_filter(
slug, definition, rows,
(attrs.get("_filtre2_champ") or "").strip(), attrs.get("_filtre2_operateur") or "egal", attrs.get("_filtre2_valeur"),
)
return rows
@@ -12,4 +12,8 @@ def _render_checkbox_or_radio(el, meta, slug=None, children_map=None, ctx=None,
attrs["type"] = input_type attrs["type"] = input_type
style = _style_string(el, " display:flex; align-items:center; gap:8px;", parent_flex_direction) style = _style_string(el, " display:flex; align-items:center; gap:8px;", parent_flex_direction)
content = _apply_ctx(el.get("content") or "", ctx) content = _apply_ctx(el.get("content") or "", ctx)
return f'<label {_attr_string({}, style)}><input {_attr_string(attrs, "")}> {html_lib.escape(content)}</label>' # <label class="checkbox"/"radio"> entourant l'input EST la façon dont
# Bulma stylise nativement une case à cocher/un bouton radio — pas de
# wrapper supplémentaire nécessaire, la structure existante correspondait
# déjà exactement à cette convention.
return f'<label {_attr_string({"class": input_type}, style)}><input {_attr_string(attrs, "")}> {html_lib.escape(content)}</label>'
+76 -10
View File
@@ -13,6 +13,11 @@ from .render_checkbox_or_radio import _render_checkbox_or_radio
from .render_repeater import _render_repeater from .render_repeater import _render_repeater
from .render_jauge import _render_jauge from .render_jauge import _render_jauge
from .render_overlay import _render_overlay from .render_overlay import _render_overlay
from .render_onglets import _render_onglets
from .render_icone import _render_icone
from .resolve_bound_row import _resolve_bound_row_ctx
from .visibility_condition import is_element_visible
from ..element_types.load_template_tree import _load_template_tree
def render_element_html(el, slug=None, children_map=None, ctx=None, parent_flex_direction=None): def render_element_html(el, slug=None, children_map=None, ctx=None, parent_flex_direction=None):
@@ -28,21 +33,58 @@ def render_element_html(el, slug=None, children_map=None, ctx=None, parent_flex_
pose physiquement cet élément, s'il y en a un — elle influence la largeur 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).""" par défaut de cet élément (voir _style_string)."""
meta = widget_meta(el.get("widget")) meta = widget_meta(el.get("widget"))
attrs_raw = el.get("attributes") or {}
# La condition de visibilité (voir VISIBILITY_CONDITION_CONTROLS) n'est
# appliquée qu'en mode JOUABLE (ctx["_forge_play_mode"], posé par
# full_game_payload.py via list_elements(..., enforce_visibility=True))
# — jamais dans l'éditeur, où l'élément doit rester visible/sélectionnable
# même si la condition serait actuellement fausse, sans quoi il
# deviendrait impossible de le retrouver pour le régler. Un commentaire
# marqueur ("visibilityGated") reste posé sur le HTML même quand la
# condition est vraie, pour que refreshRuntimeData() (play.html) sache
# qu'il faut réévaluer cet élément après toute action "Modifier une
# donnée"/"Modifier une variable" — pas seulement Jauge/Répéteur, qui ont
# déjà chacun leur propre marqueur ("jaugeBar"/"repeaterItem") couvrant
# ce cas d'office.
has_visibility_condition = attrs_raw.get("_visibilite_cond_mode") in ("variable", "objet")
play_mode = bool((ctx or {}).get("_forge_play_mode"))
if play_mode and has_visibility_condition and not is_element_visible(slug, attrs_raw, ctx):
return f'<div class="visibilityGated" data-element-id="{el["id"]}" style="display:none;"></div>'
def _mark(html_str):
return html_str + "<!--visibilityGated-->" if has_visibility_condition else html_str
bound_ctx = _resolve_bound_row_ctx(slug, attrs_raw)
if bound_ctx:
ctx = {**(ctx or {}), **bound_ctx}
# Chaque branche "special_render" passe elle aussi par _mark() : sans
# ça, un widget de ce genre (Icône, Tableau, Superposition...) actuellement
# VISIBLE sous condition ne portait aucun marqueur, et refreshRuntimeData()
# (play.html) ne savait donc jamais qu'il fallait le réévaluer après une
# action "Modifier une donnée/variable" — il restait figé dans son état
# de l'affichage précédent (ex: une icône "enveloppe fermée" restée
# visible après l'ouverture du mail, en même temps que "enveloppe
# ouverte" qui apparaît, elle, correctement puisqu'un élément CACHÉ
# porte toujours son marqueur via le placeholder ci-dessus).
special = meta.get("special_render") special = meta.get("special_render")
if special == "table": if special == "table":
return _render_table(el, meta, slug, children_map, ctx, parent_flex_direction) return _mark(_render_table(el, meta, slug, children_map, ctx, parent_flex_direction))
if special == "select_options": if special == "select_options":
return _render_select(el, meta, slug, children_map, ctx, parent_flex_direction) return _mark(_render_select(el, meta, slug, children_map, ctx, parent_flex_direction))
if special == "fieldset_legend": if special == "fieldset_legend":
return _render_fieldset(el, meta, slug, children_map, ctx, parent_flex_direction) return _mark(_render_fieldset(el, meta, slug, children_map, ctx, parent_flex_direction))
if special == "checkbox_label": if special == "checkbox_label":
return _render_checkbox_or_radio(el, meta, slug, children_map, ctx, parent_flex_direction) return _mark(_render_checkbox_or_radio(el, meta, slug, children_map, ctx, parent_flex_direction))
if special == "repeater": if special == "repeater":
return _render_repeater(el, meta, slug, children_map, ctx, parent_flex_direction) return _mark(_render_repeater(el, meta, slug, children_map, ctx, parent_flex_direction))
if special == "jauge": if special == "jauge":
return _render_jauge(el, meta, slug, children_map, ctx, parent_flex_direction) return _mark(_render_jauge(el, meta, slug, children_map, ctx, parent_flex_direction))
if special == "overlay": if special == "overlay":
return _render_overlay(el, meta, slug, children_map, ctx, parent_flex_direction) return _mark(_render_overlay(el, meta, slug, children_map, ctx, parent_flex_direction))
if special == "onglets":
return _mark(_render_onglets(el, meta, slug, children_map, ctx, parent_flex_direction))
if special == "icone":
return _mark(_render_icone(el, meta, slug, children_map, ctx, parent_flex_direction))
tag = meta.get("tag") tag = meta.get("tag")
if tag is None: # widget à balise dynamique (ex: "titre" -> h1/h2/h3) if tag is None: # widget à balise dynamique (ex: "titre" -> h1/h2/h3)
@@ -62,15 +104,39 @@ def render_element_html(el, slug=None, children_map=None, ctx=None, parent_flex_
attrs["data-hover-text"] = _apply_ctx(hover_text, ctx) attrs["data-hover-text"] = _apply_ctx(hover_text, ctx)
if meta.get("self_closing"): if meta.get("self_closing"):
return f"<{tag} {_attr_string(attrs, style)}>" return _mark(f"<{tag} {_attr_string(attrs, style)}>")
content = _apply_ctx(el.get("content") or "", ctx) content = _apply_ctx(el.get("content") or "", ctx)
child_html = _render_element_type_children(el, slug, ctx) if el.get("element_type_id") else None
if child_html is None:
child_html = _render_children(el, slug, children_map, ctx) child_html = _render_children(el, slug, children_map, ctx)
if tag in ("ul", "ol"): if tag in ("ul", "ol"):
items = [line.strip() for line in content.split("\n") if line.strip()] 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 inner = "".join(f"<li>{html_lib.escape(item)}</li>" for item in items) + child_html
return f"<{tag} {_attr_string(attrs, style)}>{inner}</{tag}>" return _mark(f"<{tag} {_attr_string(attrs, style)}>{inner}</{tag}>")
safe_content = html_lib.escape(content).replace("\n", "<br>") if content else "" safe_content = html_lib.escape(content).replace("\n", "<br>") if content else ""
return f"<{tag} {_attr_string(attrs, style)}>{safe_content}{child_html}</{tag}>" return _mark(f"<{tag} {_attr_string(attrs, style)}>{safe_content}{child_html}</{tag}>")
def _render_element_type_children(el, slug, ctx):
"""Contenu d'un exemplaire d'élément de jeu posé sur un écran (voir
add_element.py, colonne element_type_id) : rechargé EN DIRECT depuis
l'écran-modèle à chaque affichage, jamais depuis d'éventuels enfants
copiés en base — exactement le même principe qu'un modèle de ligne de
Répéteur (voir _render_repeater/_load_template_tree). Modifier
l'élément de jeu dans son propre éditeur met donc à jour tous ses
exemplaires déjà posés, sur n'importe quel écran, sans avoir à les
retoucher un par un. Renvoie None (pas "") si l'élément de jeu n'a
plus de modèle valide, pour que l'appelant retombe sur le rendu
générique (d'éventuels enfants en base issus d'une version antérieure
de ce mécanisme) plutôt que d'afficher un exemplaire silencieusement
vide."""
template_tree = _load_template_tree(slug, el["element_type_id"])
if not template_tree:
return None
return "".join(
render_element_html(c, slug, template_tree["children_map"], ctx)
for c in template_tree["top"]
)
+24
View File
@@ -0,0 +1,24 @@
from .visible_attrs import _visible_attrs
from .style_string import _style_string
from .attr_string import _attr_string
from ..widgets.fontawesome_icons import FONTAWESOME_ICONS
def _render_icone(el, meta, slug=None, children_map=None, ctx=None, parent_flex_direction=None):
"""Widget Icône — un SVG affiché en silhouette via "mask-image" (voir
.icon-svg dans style.css), pas une police d'icônes : certains
navigateurs/extensions bloquent le chargement de polices web tierces
(protection anti-fingerprinting), ce qui rendait les icônes Font Awesome
invisibles quelle que soit leur source — un SVG posé en masque n'est
jamais concerné. Se colore via "color" (couleur du texte, déjà un
réglage existant), se dimensionne via "font-size" (le masque fait 1em)."""
raw_attrs = el.get("attributes") or {}
icon_slug = raw_attrs.get("_icon_slug") or "star"
if icon_slug not in FONTAWESOME_ICONS:
icon_slug = "star" # attribut corrompu/vidé : jamais d'icône cassée
attrs = _visible_attrs(el, meta, ctx)
existing_class = attrs.pop("class", "")
attrs["class"] = (existing_class + " icon-svg").strip()
icon_url = f"/static/icons/{icon_slug}.svg"
style = _style_string(el, f" --icon-url:url('{icon_url}');", parent_flex_direction)
return f"<i {_attr_string(attrs, style)}></i>"
+83 -17
View File
@@ -22,12 +22,15 @@ def _interpolate_color(low_hex, high_hex, ratio):
return "#{:02x}{:02x}{:02x}".format(*mixed) return "#{:02x}{:02x}{:02x}".format(*mixed)
def _read_latest_field_value(slug, definition_id, field_name): def _read_field_value(slug, definition_id, row_id, field_name):
"""Lit la valeur du champ demandé sur la ligne la PLUS RÉCENTE de """Lit la valeur du champ demandé sur l'enregistrement visé (row_id) de
l'objet visé — même convention que le filtre de Répéteur (1.2) : un l'objet suivi — quand un objet contient plusieurs enregistrements (ex:
objet utilisé comme jauge de partie (Réputation, Trésorerie...) ne Réputation/Trésorerie/Confiance dans un même objet "jauge"), row_id est
garde qu'une seule ligne, mise à jour en place.""" ce qui distingue lequel suivre. Repli sur la ligne la PLUS RÉCENTE si
if not definition_id or not slug: row_id est absent ou introuvable (jauges créées avant ce réglage, ou
ligne supprimée depuis) — comportement historique, jamais de régression
pour un objet à une seule ligne."""
if not definition_id or not field_name or not slug:
return None return None
definition = db.get_definition(slug, int(definition_id)) definition = db.get_definition(slug, int(definition_id))
if not definition: if not definition:
@@ -35,8 +38,13 @@ def _read_latest_field_value(slug, definition_id, field_name):
rows = db.list_rows(slug, definition) rows = db.list_rows(slug, definition)
if not rows: if not rows:
return None return None
row = None
if row_id:
row = next((r for r in rows if str(r["id"]) == str(row_id)), None)
if row is None:
row = rows[0]
col = db.slugify(field_name).replace("-", "_") col = db.slugify(field_name).replace("-", "_")
return rows[0].get(col) return row.get(col)
def _render_jauge(el, meta, slug, children_map=None, ctx=None, parent_flex_direction=None): def _render_jauge(el, meta, slug, children_map=None, ctx=None, parent_flex_direction=None):
@@ -48,9 +56,33 @@ def _render_jauge(el, meta, slug, children_map=None, ctx=None, parent_flex_direc
"Modifier une donnée"), donc toujours à jour sans action supplémentaire "Modifier une donnée"), donc toujours à jour sans action supplémentaire
à poser dans le graphe de logique.""" à poser dans le graphe de logique."""
attrs = el.get("attributes") or {} attrs = el.get("attributes") or {}
style = _style_string(el, " overflow:hidden; position:relative; background:#1c2130; border-radius:6px;", parent_flex_direction) nom_position = attrs.get("_nom_position") or "dessus"
nom_alignement = attrs.get("_nom_alignement") or "centre"
nom_police = attrs.get("_nom_police") or "inherit"
nom_taille = attrs.get("_nom_taille") or "12"
nom_gras = attrs.get("_nom_gras") == "1"
nom_italique = attrs.get("_nom_italique") == "1"
# display:flex — le nom (optionnel) s'empile AU-DESSUS de la barre (mode
# "dessus", flex-direction:column) ou se place à sa gauche (mode "cote",
# flex-direction:row) plutôt que de se superposer dedans — voir name_html
# plus bas. "justify-content:center" recentre le bloc nom+barre quand la
# jauge se retrouve avec une hauteur supérieure à son contenu (ex. héritée
# d'un conteneur parent qui l'étire) — sans ça le bloc restait collé en
# haut de sa propre boîte, donnant une impression de centrage faussé dès
# qu'un nom (donc plus de contenu) était ajouté. Le fond/bordure/overflow
# vivent sur le wrapper de la barre (bar_wrap_style), pas ici, pour que le
# nom reste en dehors de la zone rognée par "overflow:hidden".
flex_dir = "row" if nom_position == "cote" else "column"
align_items = "center" if nom_position == "cote" else "stretch"
style = _style_string(
el,
f" display:flex; flex-direction:{flex_dir}; align-items:{align_items}; justify-content:center; gap:4px;",
parent_flex_direction,
)
definition_id = attrs.get("_definition_id") definition_id = attrs.get("_definition_id")
row_id = attrs.get("_row_id")
field_name = (attrs.get("_champ") or "").strip() field_name = (attrs.get("_champ") or "").strip()
field_name_nom = (attrs.get("_champ_nom") or "").strip()
try: try:
min_v = float(attrs.get("_min", "0") or 0) min_v = float(attrs.get("_min", "0") or 0)
except (TypeError, ValueError): except (TypeError, ValueError):
@@ -63,7 +95,7 @@ def _render_jauge(el, meta, slug, children_map=None, ctx=None, parent_flex_direc
couleur_haute = attrs.get("_couleur_haute") or "#4caf50" couleur_haute = attrs.get("_couleur_haute") or "#4caf50"
afficher_valeur = attrs.get("_afficher_valeur") == "1" afficher_valeur = attrs.get("_afficher_valeur") == "1"
raw_value = _read_latest_field_value(slug, definition_id, field_name) if field_name else None raw_value = _read_field_value(slug, definition_id, row_id, field_name) if field_name else None
try: try:
value = float(raw_value) value = float(raw_value)
except (TypeError, ValueError): except (TypeError, ValueError):
@@ -82,16 +114,50 @@ def _render_jauge(el, meta, slug, children_map=None, ctx=None, parent_flex_direc
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>' 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) name_html = ""
# pour savoir que ce wrapper doit être régénéré après toute action if field_name_nom:
# "Modifier une donnée" — sinon la jauge resterait figée sur sa valeur nom_value = _read_field_value(slug, definition_id, row_id, field_name_nom)
# de départ après un changement, exactement comme il fallait déjà if nom_value not in (None, ""):
# repérer "repeaterItem" pour les Répéteurs (voir 1.1/1.2). text_align = "left" if (nom_position == "dessus" and nom_alignement == "gauche") else "center"
name_html = (
f'<div style="flex:0 0 auto; font-family:{html_lib.escape(nom_police)}; '
f'font-size:{html_lib.escape(str(nom_taille))}px; '
f'font-weight:{"700" if nom_gras else "400"}; '
f'font-style:{"italic" if nom_italique else "normal"}; '
f'color:#e8eaf0; text-align:{text_align};">{html_lib.escape(str(nom_value))}</div>'
)
# Vraie balise <progress> Bulma ("class=progress") plutôt qu'une barre
# maison faite de <div> positionnés — la couleur dégradée bas/haut (voir
# _interpolate_color ci-dessus) reste possible nativement : Bulma pilote
# justement le remplissage via la variable CSS --bulma-progress-value-
# background-color, qu'on peut fixer en ligne à la couleur calculée sans
# sortir du système de classes de Bulma. La classe "jaugeBar" est
# repérée côté client (play.html, refreshRuntimeData) pour savoir que
# cette balise doit être régénérée 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).
extra_classes = " ".join(v for k, v in attrs.items() if k.startswith("_class_") and v)
bar_class = html_lib.escape((f"progress jaugeBar {extra_classes}").strip())
bar = ( bar = (
f'<div class="jaugeBar" style="position:absolute; left:0; top:0; bottom:0; width:{percent}%; ' f'<progress class="{bar_class}" value="{percent}" max="100" '
f'background-color:{fill_color}; transition:width .3s ease, background-color .3s ease;"></div>' f'style="--bulma-progress-value-background-color:{fill_color}; --bulma-progress-bar-background-color:#1c2130; '
f'margin:0; border-radius:6px; transition:background-color .3s ease;">{percent}%</progress>'
)
# min-height (pas 0) : un élément posé DANS un conteneur a une hauteur
# "auto" par défaut (voir _style_string) — "flex:1 1 auto" seul n'a
# alors rien à répartir (le conteneur flex lui-même n'a pas de hauteur
# définie tant que rien ne la contraint) et la barre s'effondrait à 0px,
# ne laissant plus que le nom visible. Cette valeur plancher garde la
# barre visible dans ce cas, tout en la laissant grandir avec flex:1 si
# l'élément a une vraie hauteur (posé directement sur un écran, ou
# hauteur fixée via "Position & taille").
bar_wrap = (
f'<div style="position:relative; display:flex; align-items:center; '
f'flex:1 1 auto; min-height:14px;">{bar}{label_html}</div>'
) )
return ( return (
f'<div id="{_auto_id(el)}" data-element-id="{el["id"]}" style="{html_lib.escape(style)}">' f'<div id="{_auto_id(el)}" data-element-id="{el["id"]}" style="{html_lib.escape(style)}">'
f'{bar}{label_html}</div>' f'{name_html}{bar_wrap}</div>'
) )
+66
View File
@@ -0,0 +1,66 @@
import html as html_lib
from .visible_attrs import _visible_attrs
from .style_string import _style_string
from .parent_flex_direction import _parent_flex_direction
from .auto_id import _auto_id
from .attr_string import _attr_string
def _render_onglets(el, meta, slug, children_map=None, ctx=None, parent_flex_direction=None):
"""Élément à onglets : chaque onglet est un vrai conteneur posé comme
enfant (voir screens/elements/add_tab.py) — ça réutilise tel quel tout
ce qui existe déjà pour un conteneur normal (ajouter du contenu dedans
via "Ajouter DANS ce conteneur", le renommer pour changer le libellé
affiché sur l'onglet, le supprimer via la poubelle standard) plutôt que
d'inventer un stockage séparé pour les onglets. Un seul panneau visible
à la fois, basculé par un petit script embarqué qui marche aussi bien
dans l'éditeur qu'en jeu (voir forgeShowTab dans screen_edit.html et
play.html)."""
from .render_element_html import render_element_html
tabs = (children_map or {}).get(el["id"], [])
attrs = _visible_attrs(el, meta, ctx)
# Les variantes Bulma (alignement/style/taille... voir BULMA_TABS_CONTROLS)
# calculées par _visible_attrs atterrissent dans "class" — c'est le
# <div class="tabs"> qui doit les porter, pas ce wrapper de positionnement.
tabs_extra_classes = attrs.pop("class", "")
style = _style_string(el, " display:flex; flex-direction:column;", parent_flex_direction)
group_id = _auto_id(el)
if not tabs:
return (
f'<div {_attr_string(attrs, style)}>'
f'<em style="opacity:.6;">Onglets — ajoute au moins un onglet dans ses réglages.</em></div>'
)
flex_dir = _parent_flex_direction(el)
bar_items = []
panels = []
for i, tab in enumerate(tabs):
label = tab.get("name") or f"Onglet {i + 1}"
active = i == 0
tab_html = render_element_html(tab, slug, children_map, ctx, parent_flex_direction=flex_dir)
target_id = f"tabpanel-{tab['id']}"
# <li> Bulma natif ("tabs" > "ul" > "li", "is-active" sur le <li> —
# pas le <a> — pour l'onglet en cours) : le <li> porte le clic ET les
# data-attributes ciblés par forgeShowTab (voir screen_edit.html /
# play.html), le <a> imbriqué n'est là que pour le rendu visuel Bulma.
bar_items.append(
f'<li class="{"is-active" if active else ""}" '
f'data-tab-btn="{group_id}" data-tab-target="{target_id}" '
f'onclick="forgeShowTab(this)"><a>{html_lib.escape(str(label))}</a></li>'
)
hidden_style = "" if active else ' style="display:none;"'
panels.append(
f'<div class="forgeTabPanel" id="{target_id}" data-tab-group="{group_id}"{hidden_style}>'
f'{tab_html}</div>'
)
tabs_class = html_lib.escape(("tabs " + tabs_extra_classes).strip())
return (
f'<div {_attr_string(attrs, style)}>'
f'<div class="{tabs_class}"><ul>{"".join(bar_items)}</ul></div>'
f'<div class="forgeTabPanels" style="flex:1 1 auto; min-height:0;">{"".join(panels)}</div>'
f"</div>"
)
+9 -2
View File
@@ -5,7 +5,7 @@ import db
from .style_string import _style_string from .style_string import _style_string
from .auto_id import _auto_id from .auto_id import _auto_id
from .apply_ctx import _apply_ctx from .apply_ctx import _apply_ctx
from .filter_repeater_rows import filter_repeater_rows from .filter_repeater_rows import filter_repeater_rows, _field_column
from ..element_types.load_template_tree import _load_template_tree from ..element_types.load_template_tree import _load_template_tree
@@ -32,7 +32,7 @@ def _render_repeater(el, meta, slug, children_map, ctx=None, parent_flex_directi
# qu'on sait que l'objet est valide.) # qu'on sait que l'objet est valide.)
rows = db.list_rows(slug, definition) rows = db.list_rows(slug, definition)
rows = filter_repeater_rows(slug, definition, rows, attrs_raw) rows = filter_repeater_rows(slug, definition, rows, attrs_raw)
field_cols = {f["name"]: db.slugify(f["name"]).replace("-", "_") for f in definition["fields"]} field_cols = {f["name"]: _field_column(definition, f["name"]) for f in definition["fields"]}
# Modèle de ligne = un élément de jeu (conteneur stylisé réutilisable) : # 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é # on recharge son arbre EN DIRECT à chaque ligne de données (jamais copié
@@ -59,6 +59,13 @@ def _render_repeater(el, meta, slug, children_map, ctx=None, parent_flex_directi
else: else:
line = template line = template
for fname, val in row_ctx.items(): for fname, val in row_ctx.items():
# "_forge_play_mode" (voir render_element_html.py) est un
# indicateur interne posé dans ce même ctx, pas une valeur
# de champ {{...}} — à ignorer ici (même garde que
# apply_ctx.py, qui gère le même ctx pour le chemin
# générique).
if fname == "_forge_play_mode":
continue
line = line.replace("{{" + fname + "}}", val) line = line.replace("{{" + fname + "}}", val)
inner = html_lib.escape(line) inner = html_lib.escape(line)
# data-row-id : identifiant STABLE de la vraie ligne de données # data-row-id : identifiant STABLE de la vraie ligne de données
+19 -3
View File
@@ -4,12 +4,28 @@ from .visible_attrs import _visible_attrs
from .apply_ctx import _apply_ctx from .apply_ctx import _apply_ctx
from .style_string import _style_string from .style_string import _style_string
from .attr_string import _attr_string from .attr_string import _attr_string
from .auto_id import _auto_id
def _render_select(el, meta, slug=None, children_map=None, ctx=None, parent_flex_direction=None): def _render_select(el, meta, slug=None, children_map=None, ctx=None, parent_flex_direction=None):
attrs = _visible_attrs(el, meta, ctx) # Bulma stylise un <select> via la classe "select" posée sur un <div>
style = _style_string(el, parent_flex_direction=parent_flex_direction) # QUI L'ENTOURE (le petit triangle est un ::after de ce wrapper) — la
# poser directement sur la balise <select> elle-même n'aurait aucun
# effet. Le wrapper porte donc l'id/positionnement habituel de
# l'élément (comme tous les autres widgets), le <select> reste à
# l'intérieur avec ses propres attributs de formulaire (name, required).
outer_attrs = _visible_attrs(el, meta, ctx)
# Les variantes Bulma (couleur/taille/arrondi... voir BULMA_FIELD_CONTROLS)
# calculées par _visible_attrs atterrissent normalement dans "class" —
# ici c'est le <div class="select"> qui doit les porter, pas le <select>
# ni le wrapper de positionnement, donc on les redirige à la main.
bulma_classes = outer_attrs.get("class", "")
select_attrs = {k: v for k, v in outer_attrs.items() if k not in ("id", "class", "data-element-id", "data-anim-target")}
wrapper_attrs = {k: v for k, v in outer_attrs.items() if k in ("id", "data-element-id", "data-anim-target")}
style = _style_string(el, " display:flex;", parent_flex_direction)
content = _apply_ctx(el.get("content") or "", ctx) content = _apply_ctx(el.get("content") or "", ctx)
options = [o.strip() for o in content.split("\n") if o.strip()] 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) 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>' select_html = f'<select {_attr_string(select_attrs, "")}>{opts_html}</select>'
select_wrap_class = html_lib.escape(("select is-fullwidth " + bulma_classes).strip())
return f'<div {_attr_string(wrapper_attrs, style)}><div class="{select_wrap_class}">{select_html}</div></div>'
+2 -5
View File
@@ -10,15 +10,12 @@ def _render_table(el, meta, slug=None, children_map=None, ctx=None, parent_flex_
attrs = _visible_attrs(el, meta, ctx) attrs = _visible_attrs(el, meta, ctx)
header_row = (el.get("attributes") or {}).get("_header_row") in ("1", "true", "on") header_row = (el.get("attributes") or {}).get("_header_row") in ("1", "true", "on")
content = _apply_ctx(el.get("content") or "", ctx) content = _apply_ctx(el.get("content") or "", ctx)
style = _style_string(el, " border-collapse:collapse;", parent_flex_direction) style = _style_string(el, "", parent_flex_direction)
rows = [r for r in content.split("\n") if r.strip()] rows = [r for r in content.split("\n") if r.strip()]
html_rows = [] html_rows = []
for i, r in enumerate(rows): for i, r in enumerate(rows):
cells = r.split(";") cells = r.split(";")
cell_tag = "th" if (header_row and i == 0) else "td" cell_tag = "th" if (header_row and i == 0) else "td"
cells_html = "".join( cells_html = "".join(f"<{cell_tag}>{html_lib.escape(c.strip())}</{cell_tag}>" for c in cells)
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>") html_rows.append(f"<tr>{cells_html}</tr>")
return f'<table {_attr_string(attrs, style)}>{"".join(html_rows)}</table>' return f'<table {_attr_string(attrs, style)}>{"".join(html_rows)}</table>'
+38
View File
@@ -0,0 +1,38 @@
import db
from .filter_repeater_rows import filter_repeater_rows, _field_column
def _resolve_bound_row_ctx(slug, attrs):
"""Si cet élément est lié à un objet de données (attr:_data_definition_id —
réglage "Lier à un objet de données" des widgets Texte/Titre, voir
DATA_BINDING_CONTROLS), retrouve LA ligne qui correspond aux 1 ou 2
filtres réglés (_data_filtre*/_data_filtre2*) — même moteur que le
filtre du Répéteur de données, voir filter_repeater_rows.py — et
renvoie ses champs sous forme de {nom_du_champ: valeur en texte},
exploitable par {{champ}} dans le contenu. Permet d'afficher UNE seule
valeur calculée (ex: l'objectif du niveau en cours) sans avoir besoin de
poser un Répéteur, qui affiche lui plusieurs lignes. Renvoie None si
rien n'est lié ou si aucune ligne ne correspond."""
definition_id = attrs.get("_data_definition_id")
if not definition_id or not slug:
return None
try:
definition = db.get_definition(slug, int(definition_id))
except (TypeError, ValueError):
return None
if not definition:
return None
rows = db.list_rows(slug, definition)
rows = filter_repeater_rows(slug, definition, rows, {
"_filtre_champ": attrs.get("_data_filtre_champ"),
"_filtre_operateur": attrs.get("_data_filtre_operateur"),
"_filtre_valeur": attrs.get("_data_filtre_valeur"),
"_filtre2_champ": attrs.get("_data_filtre2_champ"),
"_filtre2_operateur": attrs.get("_data_filtre2_operateur"),
"_filtre2_valeur": attrs.get("_data_filtre2_valeur"),
})
if not rows:
return None
row = rows[0]
return {f["name"]: str(row.get(_field_column(definition, f["name"]), "") or "") for f in definition["fields"]}
+10
View File
@@ -38,6 +38,16 @@ def _style_string(el, extra_base="", parent_flex_direction=None, widget_key=None
nonlocal has_size_override nonlocal has_size_override
if v in (None, ""): if v in (None, ""):
return False return False
if isinstance(v, (dict, list)):
# Ne devrait jamais arriver (chaque contrôle composite — ex.
# "shadow", voir c_shadow.py — est censé combiner ses sous-
# valeurs en une seule chaîne CSS avant d'être stocké), mais
# sert de filet de sécurité : un dict/une liste écrits tels
# quels ici donneraient une valeur CSS invalide (repr Python)
# qui cassait TOUT le style de l'élément (bug déjà corrigé côté
# écriture — voir default_style_for_widget.py — pour des
# éléments déjà créés avant ce correctif).
return False
if k == "transform" and not is_child: if k == "transform" and not is_child:
# Un élément posé directement sur un écran a déjà ce "transform" # Un élément posé directement sur un écran a déjà ce "transform"
# posé sur son cadre .canvasElementInner / .playElementInner # posé sur son cadre .canvasElementInner / .playElementInner
+60
View File
@@ -0,0 +1,60 @@
import db
from .filter_repeater_rows import _resolve_filter_value, _compare, _field_column
def is_element_visible(slug, attrs, ctx=None):
"""Condition de visibilité (panneau de propriétés, tout widget — voir
VISIBILITY_CONDITION_CONTROLS) : masque un élément selon soit une
VARIABLE GLOBALE (une seule valeur pour tout le jeu, voir
db/global_vars/), soit le champ d'un objet de données. Absente de
réglage (_visibilite_cond_mode vide) => toujours visible, comportement
inchangé pour tous les éléments déjà créés. Réutilise
_compare/_resolve_filter_value/_field_column de filter_repeater_rows.py
pour rester cohérent avec les mêmes opérateurs et la même syntaxe
"{{Objet.champ}}" pour une valeur de comparaison dynamique.
`ctx` porte les {{champ}} de la ligne en cours (voir render_repeater.py)
quand cet élément est descendant d'un Répéteur de données — en mode
"objet", si le champ réglé s'y trouve déjà, sa valeur est utilisée telle
quelle plutôt que d'aller chercher la ligne la plus récente de l'objet
en base : sans ça, TOUS les exemplaires d'un même modèle de ligne (ex.
une "Mail card" répétée pour chaque e-mail) évalueraient la même ligne
(la plus récente de l'objet) au lieu de chacun la sienne, et
n'afficheraient donc jamais rien de cohérent. Hors Répéteur (mode
"objet" sur un élément posé directement sur l'écran), le champ n'est
pas dans ctx et on retombe sur la convention "1 seule ligne = état de
partie" (déjà utilisée par la Jauge/Donnée liée) : la ligne la plus
récente de l'objet choisi."""
mode = attrs.get("_visibilite_cond_mode")
if mode not in ("variable", "objet"):
return True
operator = attrs.get("_visibilite_cond_operateur") or "egal"
expected = _resolve_filter_value(slug, attrs.get("_visibilite_cond_valeur") or "")
if mode == "variable":
var_name = attrs.get("_visibilite_cond_variable")
if not var_name:
return True
variable = db.get_global_variable(slug, var_name)
if not variable:
return True # variable supprimée depuis : ne bloque jamais l'affichage
return _compare(variable.get("value"), operator, expected, variable.get("type") or "texte")
definition_id = attrs.get("_visibilite_cond_definition_id")
field_name = attrs.get("_visibilite_cond_champ")
if not definition_id or not field_name:
return True
definition = db.get_definition(slug, int(definition_id))
if not definition:
return True
field_type = next((f["type"] for f in definition["fields"] if f["name"] == field_name), "texte")
if ctx and field_name in ctx:
return _compare(ctx.get(field_name), operator, expected, field_type)
rows = db.list_rows(slug, definition)
if not rows:
return True # objet sans donnée pour l'instant : ne bloque pas
row = rows[0] # convention "état de partie" : une seule ligne, voir data_definition_options.py
return _compare(row.get(_field_column(definition, field_name)), operator, expected, field_type)
+13 -1
View File
@@ -3,11 +3,23 @@ from .auto_id import _auto_id
def _visible_attrs(el, meta, ctx=None): def _visible_attrs(el, meta, ctx=None):
raw_attrs = el.get("attributes") or {}
attrs = { attrs = {
k: (_apply_ctx(v, ctx) if isinstance(v, str) else v) k: (_apply_ctx(v, ctx) if isinstance(v, str) else v)
for k, v in (el.get("attributes") or {}).items() if not k.startswith("_") for k, v in raw_attrs.items() if not k.startswith("_")
} }
attrs.update(meta.get("fixed_attrs", {})) attrs.update(meta.get("fixed_attrs", {}))
# Classes externes (ex. Bulma) réglées via un contrôle "class:xxx" — voir
# save_element_controls.py — chacune mémorisée sous "_class_xxx" pour ne
# pas s'écraser entre elles ; assemblées ici avec la classe de base du
# widget (posée via fixed_attrs, ex. "button"/"box"/"title") pour former
# l'attribut class final. Une classe personnalisée éventuellement tapée
# à la main (peu probable ici, mais possible via fixed_attrs) est gardée
# en tête.
extra_classes = [v for k, v in raw_attrs.items() if k.startswith("_class_") and v]
if extra_classes:
base_class = attrs.get("class", "")
attrs["class"] = " ".join(([base_class] if base_class else []) + extra_classes)
attrs.setdefault("id", _auto_id(el)) attrs.setdefault("id", _auto_id(el))
attrs["data-element-id"] = el["id"] attrs["data-element-id"] = el["id"]
if el.get("parent_id"): if el.get("parent_id"):
+14
View File
@@ -0,0 +1,14 @@
# Listes de modificateurs Bulma (https://bulma.io) réutilisées par plusieurs
# widgets pour construire leurs réglages "class:xxx" (voir
# control_groups/bulma_controls.py et save_element_controls.py) — un seul
# endroit à mettre à jour si Bulma change de version/palette.
BULMA_COLOR_OPTIONS = [
("", "Aucune (couleur par défaut)"),
("is-primary", "Primaire"), ("is-link", "Lien"), ("is-info", "Info"),
("is-success", "Succès"), ("is-warning", "Attention"), ("is-danger", "Danger"),
("is-white", "Blanc"), ("is-light", "Clair"), ("is-dark", "Sombre"), ("is-black", "Noir"), ("is-text", "Texte"),
]
BULMA_SIZE_OPTIONS = [
("", "Normale"), ("is-small", "Petite"), ("is-medium", "Moyenne"), ("is-large", "Grande"),
]
@@ -0,0 +1,65 @@
from ..controls.c_select import c_select
from ..controls.c_checkbox import c_checkbox
from ..bulma_variants import BULMA_COLOR_OPTIONS, BULMA_SIZE_OPTIONS
_GROUP = "Style Bulma"
# Chaque widget "natif Bulma" (voir fixed_attrs dans registry.py) expose ici
# TOUTES les variantes que Bulma propose pour son composant — chacune ajoute
# un jeton de classe indépendant (target "class:xxx", voir
# save_element_controls.py et _visible_attrs.py) à la classe de base déjà
# posée par fixed_attrs. Laissé à "" (par défaut) = aucun modificateur, look
# Bulma nu. Les réglages de couleur/taille "libres" du widget (curseurs,
# sélecteurs de couleur) restent utilisables par-dessus : un style en ligne
# gagne toujours contre une classe CSS, donc personnaliser une couleur ou une
# taille "à la main" continue de fonctionner exactement comme avant.
BULMA_BUTTON_CONTROLS = [
c_select("bulma_couleur", "Couleur", "class:couleur", BULMA_COLOR_OPTIONS, default="", group=_GROUP),
c_checkbox("bulma_nuance_claire", "Nuance claire (combinable avec une couleur)", "class:nuance", "is-light", "", group=_GROUP),
c_select("bulma_taille", "Taille", "class:taille", BULMA_SIZE_OPTIONS, default="", group=_GROUP),
c_checkbox("bulma_arrondi", "Coins bien arrondis", "class:arrondi", "is-rounded", "", group=_GROUP),
c_checkbox("bulma_contour", "Contour seulement (fond transparent)", "class:contour", "is-outlined", "", group=_GROUP),
c_checkbox("bulma_inverse", "Couleurs inversées", "class:inverse", "is-inverted", "", group=_GROUP),
c_checkbox("bulma_statique", "Statique (non cliquable visuellement)", "class:statique", "is-static", "", group=_GROUP),
c_checkbox("bulma_chargement", "Indicateur de chargement", "class:chargement", "is-loading", "", group=_GROUP),
c_checkbox("bulma_pleine_largeur", "Pleine largeur", "class:largeur", "is-fullwidth", "", group=_GROUP),
]
BULMA_TITLE_CONTROLS = [
c_select("bulma_taille", "Taille Bulma (indépendante du niveau de titre)", "class:taille",
[("", "Automatique"), ("is-1", "1 — la plus grande"), ("is-2", "2"), ("is-3", "3"),
("is-4", "4"), ("is-5", "5"), ("is-6", "6 — la plus petite")], default="", group=_GROUP),
c_checkbox("bulma_espace", "Garder l'espacement même sans sous-titre", "class:espace", "is-spaced", "", group=_GROUP),
]
BULMA_PROGRESS_CONTROLS = [
c_select("bulma_taille", "Taille de la barre", "class:taille", BULMA_SIZE_OPTIONS, default="", group=_GROUP),
]
BULMA_TABLE_CONTROLS = [
c_checkbox("bulma_bordure", "Bordures", "class:bordure", "is-bordered", "", default=True, group=_GROUP),
c_checkbox("bulma_pleine_largeur", "Pleine largeur", "class:largeur", "is-fullwidth", "", default=True, group=_GROUP),
c_checkbox("bulma_zebre", "Lignes zébrées", "class:zebre", "is-striped", "", group=_GROUP),
c_checkbox("bulma_survol", "Ligne surlignée au survol", "class:survol", "is-hoverable", "", group=_GROUP),
c_checkbox("bulma_compact", "Compact (cellules resserrées)", "class:compact", "is-narrow", "", group=_GROUP),
]
BULMA_TABS_CONTROLS = [
c_select("bulma_alignement", "Alignement", "class:alignement",
[("", "Gauche (par défaut)"), ("is-centered", "Centré"), ("is-right", "Droite")], default="", group=_GROUP),
c_select("bulma_style", "Style", "class:style",
[("", "Simple (soulignement)"), ("is-boxed", "Encadré"), ("is-toggle", "Boutons accolés"),
("is-toggle is-toggle-rounded", "Boutons accolés, arrondis")], default="", group=_GROUP),
c_select("bulma_taille", "Taille", "class:taille", BULMA_SIZE_OPTIONS, default="", group=_GROUP),
c_checkbox("bulma_pleine_largeur", "Pleine largeur (onglets étirés)", "class:largeur", "is-fullwidth", "", group=_GROUP),
]
# Champs de formulaire (input/textarea/select) : mêmes variantes que Bulma
# propose pour .input/.textarea/.select.
BULMA_FIELD_CONTROLS = [
c_select("bulma_couleur", "Couleur", "class:couleur", BULMA_COLOR_OPTIONS, default="", group=_GROUP),
c_select("bulma_taille", "Taille", "class:taille", BULMA_SIZE_OPTIONS, default="", group=_GROUP),
c_checkbox("bulma_arrondi", "Coins bien arrondis", "class:arrondi", "is-rounded", "", group=_GROUP),
c_checkbox("bulma_statique", "Statique (lecture seule visuelle)", "class:statique", "is-static", "", group=_GROUP),
]
@@ -0,0 +1,26 @@
from ..controls.c_text import c_text
from ..controls.c_select import c_select
from ...flow.constants import CONDITION_OPERATORS
# Permet à un Texte/Titre d'afficher directement la valeur d'un champ d'un
# AUTRE objet de données, sans avoir besoin de le poser dans un Répéteur —
# utile pour une valeur unique calculée (ex: l'objectif du niveau en cours)
# plutôt qu'une liste. La ligne affichée est celle qui correspond aux 1 ou 2
# filtres réglés ci-dessous (mêmes réglages, même moteur que ceux du
# Répéteur de données — voir filter_repeater_rows.py) ; sans filtre, c'est la
# ligne la plus récente de l'objet choisi (convention déjà utilisée par la
# Jauge pour un objet "état de partie" qui ne garde qu'une seule ligne).
DATA_BINDING_CONTROLS = [
c_select("data_definition_id", "Lier à un objet de données (pour {{champ}} ci-dessus)",
"attr:_data_definition_id", [("", "(sélectionner un objet)")], default="", group="Donnée liée"),
c_select("data_filtre_champ", "Trouver la ligne où ce champ",
"attr:_data_filtre_champ", [("", "(sélectionner un objet d'abord)")], default="", group="Donnée liée"),
c_select("data_filtre_operateur", "...est", "attr:_data_filtre_operateur", CONDITION_OPERATORS, default="egal", group="Donnée liée"),
c_text("data_filtre_valeur", "...cette valeur",
"attr:_data_filtre_valeur", placeholder="ex. 3", default="", group="Donnée liée"),
c_select("data_filtre2_champ", "ET (optionnel) : ce 2ᵉ champ",
"attr:_data_filtre2_champ", [("", "(sélectionner un objet d'abord)")], default="", group="Donnée liée"),
c_select("data_filtre2_operateur", "...est", "attr:_data_filtre2_operateur", CONDITION_OPERATORS, default="egal", group="Donnée liée"),
c_text("data_filtre2_valeur", "...cette valeur",
"attr:_data_filtre2_valeur", placeholder="ex. 1", default="", group="Donnée liée"),
]
@@ -0,0 +1,5 @@
from ..controls.c_shadow import c_shadow
SHADOW_CONTROLS = [
c_shadow("box-shadow", "Ombre portée", "style:box-shadow", group="Ombre"),
]
@@ -1,16 +1,29 @@
from ..controls.c_slider import c_slider from ..controls.c_size import c_size
# Taille fixe (en pixels), surtout utile pour un élément posé À L'INTÉRIEUR # Taille d'un élément posé À L'INTÉRIEUR d'un conteneur/répéteur/groupe de
# d'un conteneur/répéteur/groupe de champs : par défaut, ces éléments # champs : par défaut, ces éléments remplissent la largeur disponible et
# remplissent la largeur disponible et s'ajustent en hauteur à leur contenu # s'ajustent en hauteur à leur contenu (voir _style_string) — 0 garde ce
# (voir _style_string) — 0 garde ce comportement automatique ; une valeur # comportement automatique ; une valeur non nulle impose une taille fixe,
# non nulle impose une taille fixe (ex: une image de 120px de large dans une # dans l'unité choisie par l'utilisateur (% du parent, qui suit
# ligne, pour que "Espacement égal entre eux"/"autour"/"uniforme" sur le # naturellement n'importe quelle taille d'écran — ou px, pour une taille
# conteneur parent ait un effet visible, ce qui n'est pas le cas tant que # précise, ex: une image de 120px de large dans une ligne, pour que
# chaque élément remplit 100% de la largeur). # "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).
#
# UN SEUL contrôle par axe (pas un slider % + un slider px séparés) : les
# deux cibleraient la même propriété CSS "width"/"height", et le mécanisme
# de save_element_controls.py qui préserve un réglage "jamais personnalisé"
# ne protège que le PREMIER contrôle à écrire une clé donnée — un second
# contrôle non touché par l'utilisateur mais ciblant la même clé
# l'écraserait quand même à chaque sauvegarde (bug constaté en pratique).
#
# Ces réglages ne sont PAS rendus via le mécanisme générique de groupes du
# panneau de propriétés (voir screen_edit.html) : ils sont affichés à la
# main dans le bloc "Position & taille", à la place de X/Y (non applicables
# à un élément qui suit le flux de son parent plutôt qu'une position
# absolue) — voir selected_controls / size_ctrls dans le template.
SIZE_OVERRIDE_CONTROLS = [ SIZE_OVERRIDE_CONTROLS = [
c_slider("child-width", "Largeur fixe", "style:width", c_size("child-width", "Largeur", "style:width", default=0, group="Position & taille"),
min=0, max=1000, default=0, group="Taille dans le conteneur"), c_size("child-height", "Hauteur", "style:height", default=0, group="Position & taille"),
c_slider("child-height", "Hauteur fixe", "style:height",
min=0, max=1000, default=0, group="Taille dans le conteneur"),
] ]
@@ -1,8 +1,16 @@
from ..controls.c_scale import c_scale from ..controls.c_scale import c_scale
from .visibility_control import VISIBILITY_CONTROL from .visibility_control import VISIBILITY_CONTROL
from .visibility_condition_controls import VISIBILITY_CONDITION_CONTROLS
from .size_override_controls import SIZE_OVERRIDE_CONTROLS from .size_override_controls import SIZE_OVERRIDE_CONTROLS
from .spacing_controls import SPACING_CONTROLS from .spacing_controls import SPACING_CONTROLS
from .border_controls import BORDER_CONTROLS from .border_controls import BORDER_CONTROLS
from .hover_controls import HOVER_CONTROLS from .shadow_controls import SHADOW_CONTROLS
UNIVERSAL_CONTROLS = [VISIBILITY_CONTROL] + SIZE_OVERRIDE_CONTROLS + SPACING_CONTROLS + BORDER_CONTROLS + [c_scale()] + HOVER_CONTROLS # Le panneau "Survol" (HOVER_CONTROLS) a été retiré du panneau de propriétés :
# survoler un élément est conceptuellement un DÉCLENCHEUR (voir la Logique de
# la scène), pas une propriété statique de l'élément — il sera réintroduit
# là-bas. Le mécanisme data-hover-text sous-jacent (voir render_element_html
# et bindHoverTexts() dans play.html) reste fonctionnel pour les éléments qui
# en avaient déjà un, seul le moyen de le RÉGLER depuis ce panneau disparaît
# pour l'instant.
UNIVERSAL_CONTROLS = [VISIBILITY_CONTROL, c_scale()] + SIZE_OVERRIDE_CONTROLS + SPACING_CONTROLS + BORDER_CONTROLS + SHADOW_CONTROLS + VISIBILITY_CONDITION_CONTROLS
@@ -0,0 +1,28 @@
from ..controls.c_text import c_text
from ..controls.c_select import c_select
from ...flow.constants import CONDITION_OPERATORS
_GROUP = "Condition de visibilité"
# Disponible sur TOUT widget (voir UNIVERSAL_CONTROLS) — masque l'élément en
# mode jouable (jamais dans l'éditeur, voir render_element_html.py) selon
# soit une variable globale (db/global_vars/), soit le champ d'un objet de
# données (convention "1 seule ligne = état de partie", déjà utilisée par la
# Jauge/Donnée liée). "visibilite_cond_valeur" se termine par "_valeur" :
# récupère automatiquement le sélecteur "Valeur fixe / Donnée d'un autre
# objet" déjà construit pour les filtres du Répéteur (voir screen_edit.html).
VISIBILITY_CONDITION_CONTROLS = [
c_select("visibilite_cond_mode", "Afficher cet élément seulement si...", "attr:_visibilite_cond_mode",
[("", "Toujours (pas de condition)"), ("variable", "Une variable globale..."), ("objet", "Un champ d'un objet...")],
default="", group=_GROUP),
c_select("visibilite_cond_variable", "Variable", "attr:_visibilite_cond_variable",
[("", "(sélectionner une variable)")], default="", group=_GROUP),
c_select("visibilite_cond_definition_id", "Objet", "attr:_visibilite_cond_definition_id",
[("", "(sélectionner un objet)")], default="", group=_GROUP),
c_select("visibilite_cond_champ", "Champ", "attr:_visibilite_cond_champ",
[("", "(sélectionner un objet d'abord)")], default="", group=_GROUP),
c_select("visibilite_cond_operateur", "...est", "attr:_visibilite_cond_operateur",
CONDITION_OPERATORS, default="egal", group=_GROUP),
c_text("visibilite_cond_valeur", "...cette valeur", "attr:_visibilite_cond_valeur",
placeholder="ex. 1", default="", group=_GROUP),
]
+23
View File
@@ -23,6 +23,29 @@ def _control_value(el, control):
m = re.search(r"scale\(([\d.]+)\)", transform) m = re.search(r"scale\(([\d.]+)\)", transform)
return float(m.group(1)) if m else control.get("default", 1) return float(m.group(1)) if m else control.get("default", 1)
if ctype == "size":
_kind, name = control["target"].split(":", 1)
raw = style.get(name)
m = re.match(r"^([\d.]+)(px|%)$", str(raw or ""))
if m:
return {"value": float(m.group(1)), "unit": m.group(2)}
return {"value": control.get("default", 0), "unit": "%"}
if ctype == "shadow":
_kind, name = control["target"].split(":", 1)
raw = style.get(name)
m = re.match(
r"^(-?\d+)px (-?\d+)px (\d+)px (\d+)px rgba\((\d+),\s*(\d+),\s*(\d+),\s*([\d.]+)\)$",
str(raw or ""),
)
if m:
x, y, blur, spread, r, g, b, a = m.groups()
return {
"x": int(x), "y": int(y), "blur": int(blur), "spread": int(spread),
"color": "#{:02x}{:02x}{:02x}".format(int(r), int(g), int(b)), "opacity": float(a),
}
return control.get("default", {})
target = control["target"] target = control["target"]
if target == "content": if target == "content":
return el.get("content") or control.get("default", "") return el.get("content") or control.get("default", "")
+12
View File
@@ -0,0 +1,12 @@
def c_shadow(key, label, target, default=None, group=None):
"""Ombre portée composée de 6 sous-réglages (décalage X/Y, flou,
étendue, couleur, opacité) — voir save_element_controls.py (ctype
"shadow") pour comment ils se combinent en une seule valeur CSS
"box-shadow", et control_value.py pour comment elle se décompose à
nouveau pour l'affichage. Décalages/flou/étendue tous à 0 = pas
d'ombre, comme border-width à 0 = pas de bordure."""
return {
"key": key, "label": label, "type": "shadow", "target": target,
"default": default or {"x": 0, "y": 4, "blur": 12, "spread": 0, "color": "#000000", "opacity": 0.4},
"group": group,
}
+5
View File
@@ -0,0 +1,5 @@
def c_size(key, label, target, default=0, group=None):
"""Taille avec unité choisie par l'utilisateur (% ou px) — voir
size_override_controls.py. 0 = comportement automatique, quelle que soit
l'unité affichée."""
return {"key": key, "label": label, "type": "size", "target": target, "default": default, "group": group}
+11 -2
View File
@@ -1,2 +1,11 @@
def c_slider(key, label, target, unit="px", min=0, max=100, step=1, default=16, group=None): def c_slider(key, label, target, unit="px", min=0, max=100, step=1, default=16, group=None, no_freeze=False):
return {"key": key, "label": label, "type": "slider", "target": target, "unit": unit, "min": min, "max": max, "step": step, "default": default, "group": group} return {
"key": key, "label": label, "type": "slider", "target": target, "unit": unit,
"min": min, "max": max, "step": step, "default": default, "group": group,
# no_freeze : n'écrit PAS ce réglage en style au moment de la création
# de l'élément malgré sa valeur par défaut non-vide (voir
# default_style_and_attributes) — utilisé quand un widget porte déjà
# une classe CSS externe (ex. Bulma) qui doit rester visible tant que
# ce réglage n'a pas été explicitement personnalisé.
"no_freeze": no_freeze,
}
+75 -4
View File
@@ -2,23 +2,94 @@ import db
from .widget_meta import widget_meta from .widget_meta import widget_meta
from .control_value import _control_value from .control_value import _control_value
from .data_definition_options import data_definition_options
from ..element_types.list_element_types import list_element_types from ..element_types.list_element_types import list_element_types
def controls_with_values(slug, el): def _field_label(f):
"""Libellé affiché dans une liste déroulante de champs — précise l'objet
visé pour un champ "relation" (ex. "parcour (→ parcours)"), pour qu'on
sache ce qu'on choisit sans avoir à aller vérifier dans l'objet."""
if f["type"] == "relation" and f.get("relation_definition_name"):
return f"{f['name']} (→ {f['relation_definition_name']})"
return f["name"]
def controls_with_values(slug, el, template_definition_id=None):
"""Pour le panneau de propriétés : la liste des réglages du widget de """Pour le panneau de propriétés : la liste des réglages du widget de
cet élément, chacun avec sa valeur actuelle déjà résolue. Le réglage cet élément, chacun avec sa valeur actuelle déjà résolue. Le réglage
"définition_id" du Répéteur de données a besoin du jeu courant pour "définition_id" du Répéteur de données (et de la Jauge) a besoin du jeu
lister les objets disponibles — d'où le paramètre slug.""" courant pour lister les objets disponibles — d'où le paramètre slug.
`template_definition_id` : l'objet lié à l'ÉLÉMENT DE JEU en cours
d'édition (bound_definition_id, réglé à sa création — voir
element_types.html), passé par screen_edit.py seulement quand on
édite un écran-modèle qui en a un. Sert de repli pour le champ
"Contenu" (voir plus bas) : si CE widget n'a pas sa propre "Donnée
liée" réglée, ses {{champ}} font quand même référence à l'objet lié
de l'élément de jeu (c'est lui qui fournira les valeurs une fois posé
dans un Répéteur), donc le sélecteur de champ doit lister SES champs
par défaut plutôt que de rester vide."""
meta = widget_meta(el.get("widget")) meta = widget_meta(el.get("widget"))
attributes = el.get("attributes") or {}
# Pour la Jauge : quel enregistrement suivre (row_id) et quels champs
# afficher (champ/champ_nom) ne peuvent être proposés qu'une fois un
# objet choisi (definition_id, déjà sur CET élément) — voir
# render_jauge.py. La mise à jour EN DIRECT quand on change d'objet
# sans quitter le panneau est gérée côté client (onJaugeDefinitionChange
# dans screen_edit.html), ceci ne couvre que le premier affichage/une
# navigation vers un élément déjà réglé.
jauge_options = data_definition_options(slug, attributes.get("_definition_id"))
# Le Texte/Titre lié à une donnée (voir DATA_BINDING_CONTROLS) garde SON
# objet dans un attribut séparé (_data_definition_id) — jamais le même
# élément que le Répéteur/la Jauge, donc jamais besoin des deux options
# en même temps, mais un calcul dédié quand même pour rester correct si
# ça change un jour.
data_binding_options = data_definition_options(slug, attributes.get("_data_definition_id"))
# Repli pour "content" (voir plus bas) quand ce widget n'a pas sa
# propre "Donnée liée" : les champs de l'objet lié à l'élément de jeu.
template_bound_options = data_definition_options(slug, template_definition_id) if template_definition_id else None
# Condition de visibilité (voir VISIBILITY_CONDITION_CONTROLS) : elle
# aussi garde SON objet dans un attribut séparé (_visibilite_cond_
# definition_id), jamais partagé avec le Répéteur/la Jauge/Donnée liée.
visibility_cond_options = data_definition_options(slug, attributes.get("_visibilite_cond_definition_id"))
global_variables = db.list_global_variables(slug)
result = [] result = []
for control in meta.get("controls", []): for control in meta.get("controls", []):
control = dict(control) control = dict(control)
if control["key"] == "definition_id": if control["key"] in ("definition_id", "data_definition_id", "visibilite_cond_definition_id"):
defs = db.list_definitions(slug) defs = db.list_definitions(slug)
control["options"] = [("", "(sélectionner un objet)")] + [(str(d["id"]), d["name"]) for d in defs] control["options"] = [("", "(sélectionner un objet)")] + [(str(d["id"]), d["name"]) for d in defs]
if control["key"] == "visibilite_cond_champ":
control["options"] = [("", "(sélectionner un objet d'abord)")] + [(f["name"], _field_label(f)) for f in visibility_cond_options["fields"]]
if control["key"] == "visibilite_cond_variable":
control["options"] = [("", "(sélectionner une variable)")] + [(v["name"], v["name"]) for v in global_variables]
if control["key"] == "template_type_id": if control["key"] == "template_type_id":
types = list_element_types(slug) types = list_element_types(slug)
control["options"] = [("", "(aucun — utiliser le modèle de texte ci-dessous)")] + [(str(t["id"]), t["name"]) for t in types] control["options"] = [("", "(aucun — utiliser le modèle de texte ci-dessous)")] + [(str(t["id"]), t["name"]) for t in types]
if control["key"] == "row_id":
control["options"] = [("", "(sélectionner un objet d'abord)")] + [(str(r["id"]), r["label"]) for r in jauge_options["rows"]]
if control["key"] in ("champ", "champ_nom"):
# La Jauge affiche une VALEUR (champ) ou un NOM (champ_nom) tel
# quel — un champ "relation" n'y a pas de sens (c'est un id, pas
# une valeur à afficher), donc exclu ici uniquement.
non_relation_fields = [f for f in jauge_options["fields"] if f["type"] != "relation"]
empty_label = "— aucun —" if control["key"] == "champ_nom" else "(sélectionner un objet d'abord)"
control["options"] = [("", empty_label)] + [(f["name"], _field_label(f)) for f in non_relation_fields]
if control["key"] in ("filtre_champ", "filtre2_champ"):
control["options"] = [("", "(sélectionner un objet d'abord)")] + [(f["name"], _field_label(f)) for f in jauge_options["fields"]]
if control["key"] in ("data_filtre_champ", "data_filtre2_champ"):
control["options"] = [("", "(sélectionner un objet d'abord)")] + [(f["name"], _field_label(f)) for f in data_binding_options["fields"]]
if control["key"] == "content":
# Une fois "Lier à un objet de données" réglé (voir
# DATA_BINDING_CONTROLS), propose les champs de CET objet en
# liste déroulante à côté du texte — plutôt que de devoir taper
# "{{nom_du_champ}}" à la main sans savoir quels noms existent
# (voir insertFieldAtCursor() dans screen_edit.html). À défaut,
# retombe sur l'objet lié à l'élément de jeu (voir docstring).
if attributes.get("_data_definition_id"):
control["field_options"] = [(f["name"], _field_label(f)) for f in data_binding_options["fields"]]
elif template_bound_options:
control["field_options"] = [(f["name"], _field_label(f)) for f in template_bound_options["fields"]]
result.append({**control, "value": _control_value(el, control)}) result.append({**control, "value": _control_value(el, control)})
return result return result
@@ -0,0 +1,40 @@
import db
def data_definition_options(slug, definition_id):
"""Lignes et champs d'un objet de données, prêts à peupler un menu
déroulant — utilisé à la fois pour DEFINITIONS_DATA (tous les objets du
jeu, consommé côté client par les formulaires de la Logique de la
scène) et pour pré-remplir côté serveur les réglages qui dépendent d'un
objet déjà choisi sur un élément (ex: la Jauge — voir
controls_with_values.py). Chaque ligne est étiquetée avec la valeur de
son PREMIER champ (convention "premier champ = nom lisible", comme pour
un Répéteur), pour distinguer "Confiance" de "Trésorerie" plutôt que de
n'avoir que des numéros de ligne."""
if not definition_id or not slug:
return {"rows": [], "fields": []}
full = db.get_definition(slug, int(definition_id))
if not full:
return {"rows": [], "fields": []}
rows = db.list_rows(slug, full)
display_field = full["fields"][0]["name"] if full["fields"] else None
display_col = db.slugify(display_field).replace("-", "_") if display_field else None
row_options = [
{"id": r["id"], "label": f"{r.get(display_col)} (#{r['id']})" if display_col else f"Ligne #{r['id']}"}
for r in rows
]
# Les champs "relation" sont INCLUS (contrairement à avant) : un filtre
# de Répéteur/Donnée liée doit pouvoir comparer, par exemple, "le
# parcours lié" d'un niveau — ils restent identifiables via leur "type"
# pour les endroits qui n'en veulent pas (ex: le champ numérique suivi
# par une Jauge, voir controls_with_values.py). relation_definition_name
# (le nom de l'objet visé) sert uniquement à afficher un libellé clair
# dans la liste déroulante, ex. "parcour (→ parcours)".
definitions_by_id = {d["id"]: d["name"] for d in db.list_definitions(slug)}
field_options = []
for f in full["fields"]:
entry = {"name": f["name"], "type": f["type"]}
if f["type"] == "relation":
entry["relation_definition_name"] = definitions_by_id.get(f.get("relation_definition_id"))
field_options.append(entry)
return {"rows": row_options, "fields": field_options}
+50 -14
View File
@@ -1,14 +1,5 @@
from .widget_meta import widget_meta from .widget_meta import widget_meta
# Ces deux réglages utilisent 0 comme valeur sentinelle "automatique" (voir
# size_override_controls.py / _style_string) : contrairement aux autres
# sliders, 0 n'y est PAS une vraie valeur par défaut à figer, donc on ne les
# fige jamais à la création — les autres sliders à 0 par défaut (ex:
# épaisseur de bordure) doivent au contraire être figés explicitement, sinon
# un border-style baké sans border-width associé afficherait une bordure par
# défaut du navigateur (~3px, "medium") au lieu de rester invisible.
_AUTO_SIZE_KEYS = {"child-width", "child-height"}
# Réglages "par côté" (padding/margin/bordure individuels) : contrairement # Réglages "par côté" (padding/margin/bordure individuels) : contrairement
# aux réglages "tous les côtés" équivalents (qui, eux, DOIVENT être figés à # aux réglages "tous les côtés" équivalents (qui, eux, DOIVENT être figés à
# la création — voir plus bas), ceux-ci ne doivent jamais l'être. Une fois # la création — voir plus bas), ceux-ci ne doivent jamais l'être. Une fois
@@ -27,6 +18,18 @@ _PER_SIDE_KEYS = {
"border-top-width", "border-right-width", "border-bottom-width", "border-left-width", "border-top-width", "border-right-width", "border-bottom-width", "border-left-width",
} }
# Réglages "tous les côtés" dont la valeur par défaut est 0 (donc PAS filtrée
# par "default in (None, '')" ci-dessous) : figés à la création pour tout
# widget "nu" (reset volontaire, indispensable sur un canevas en positionnement
# libre — sans lui, chaque <h1>/<ul>/<p> arriverait avec les marges/paddings
# par défaut du navigateur). Mais pour un widget qui porte déjà une classe
# externe (ex. Bulma "button"/"box" — voir fixed_attrs dans registry.py), ce
# reset à 0 écraserait en permanence le padding/la bordure propres à cette
# classe (un bouton Bulma sans padding ne ressemble plus à un bouton) : on ne
# les fige donc PAS pour ces widgets-là, exactement comme les couleurs sont
# déjà exclues plus bas pour ne jamais imposer une valeur non demandée.
_ZERO_DEFAULT_RESET_KEYS = {"padding", "margin", "border-width"}
def default_style_and_attributes(widget_key): def default_style_and_attributes(widget_key):
"""Calcule le style/attributs de DÉPART d'un élément fraîchement posé sur """Calcule le style/attributs de DÉPART d'un élément fraîchement posé sur
@@ -42,10 +45,45 @@ def default_style_and_attributes(widget_key):
demandée. Voir save_element_controls.py pour la même règle appliquée demandée. Voir save_element_controls.py pour la même règle appliquée
aux modifications ultérieures via le panneau de propriétés.""" aux modifications ultérieures via le panneau de propriétés."""
meta = widget_meta(widget_key) meta = widget_meta(widget_key)
is_native_class_widget = bool(meta.get("fixed_attrs", {}).get("class"))
attributes, style = {}, {} attributes, style = {}, {}
for control in meta.get("controls", []): for control in meta.get("controls", []):
ctype = control["type"] ctype = control["type"]
if ctype in ("checkbox", "toggle_token", "preset", "scale", "color"): if ctype == "checkbox":
# Une case cochée PAR DÉFAUT (ex: "Première ligne = en-tête" du
# Tableau, ou un modificateur Bulma comme "is-bordered" activé
# d'origine) doit vraiment être active dès la création — sinon le
# panneau de propriétés affiche une case cochée qui ne correspond
# à rien de réellement posé sur l'élément. Une case décochée par
# défaut, elle, reste volontairement absente (comportement
# existant, inchangé) : rien à figer.
if control.get("default") is True and control.get("target"):
kind, name = control["target"].split(":", 1)
if kind == "class":
attributes["_class_" + name] = control["true_value"]
elif kind == "attr":
attributes[name] = control["true_value"]
else:
style[name] = control["true_value"]
continue
if ctype in ("toggle_token", "preset", "scale", "color"):
continue
if ctype == "size":
# 0 est TOUJOURS la valeur sentinelle "automatique" pour ce
# type de réglage (voir size_override_controls.py) — jamais une
# vraie valeur par défaut à figer à la création.
continue
if ctype == "shadow":
# "default" est un dict composite (voir c_shadow.py), pas une
# valeur CSS directement écrivable — sans cette exclusion, il
# était écrit TEL QUEL (repr Python d'un dict) dans le style de
# TOUT élément fraîchement créé, une valeur CSS invalide qui
# cassait le "box-shadow" de tout élément neuf. Comme pour
# "color" ci-dessus, la valeur par défaut n'est qu'une
# suggestion affichée dans le panneau, pas un réglage neutre à
# figer à la création (le neutre CSS est "pas d'ombre").
continue
if is_native_class_widget and control["key"] in _ZERO_DEFAULT_RESET_KEYS:
continue continue
target = control.get("target") target = control.get("target")
if not target or target == "content": if not target or target == "content":
@@ -54,10 +92,8 @@ def default_style_and_attributes(widget_key):
if default in (None, ""): if default in (None, ""):
continue continue
if ctype == "slider": if ctype == "slider":
if control["key"] in _AUTO_SIZE_KEYS and str(default) in ("0",): if control["key"] in _PER_SIDE_KEYS or control.get("no_freeze"):
continue # 0 = comportement automatique, rien à figer continue # réglage par côté, ou explicitement non figé (voir c_slider) : jamais figé
if control["key"] in _PER_SIDE_KEYS:
continue # réglage par côté : jamais figé (voir commentaire plus haut)
unit = control.get("unit", "") unit = control.get("unit", "")
default = f"{default}{unit}" default = f"{default}{unit}"
kind, name = target.split(":", 1) kind, name = target.split(":", 1)
+47
View File
@@ -0,0 +1,47 @@
# Sélection large mais CURATÉE d'icônes Font Awesome 5 Free (style "solid"),
# chacune un fichier SVG téléchargé une fois pour toutes dans static/icons/
# (voir render_icone.py — affichées en silhouette via "mask-image", PAS une
# police d'icônes : certains navigateurs/extensions bloquent le chargement
# de polices web tierces par protection anti-fingerprinting, ce qui rendait
# les icônes invisibles quelle que soit leur source — un SVG posé en masque
# n'est jamais concerné). Volontairement PAS le catalogue Font Awesome 5
# Free complet (~1500 icônes) : chaque entrée ici doit avoir un fichier SVG
# réellement présent dans static/icons/, donc la liste reste bornée à ce qui
# a été téléchargé plutôt que de deviner l'exhaustif.
FONTAWESOME_ICONS = [
"star", "heart", "check", "times", "plus", "minus", "search", "cog", "cogs", "wrench",
"hammer", "tools", "trash", "trash-alt", "edit", "pen", "pencil-alt", "save", "download",
"upload", "share", "share-alt", "link", "unlink", "lock", "lock-open", "key", "eye",
"eye-slash", "bell", "bell-slash", "bookmark", "flag", "flag-checkered", "thumbtack",
"filter", "sort", "sort-up", "sort-down", "list", "list-ul", "list-ol", "th", "th-list",
"th-large", "bars", "ellipsis-h", "ellipsis-v", "expand", "compress", "sync", "sync-alt",
"redo", "undo", "home", "user", "users", "user-circle", "user-plus", "user-minus",
"user-friends", "sign-in-alt", "sign-out-alt", "question-circle", "info-circle",
"exclamation-circle", "exclamation-triangle", "times-circle", "check-circle", "ban",
"shield-alt", "gem", "coins", "money-bill", "wallet", "credit-card", "gift", "trophy",
"medal", "award", "crown", "dice", "dice-d6", "dice-d20", "chess", "chess-king",
"chess-queen", "chess-knight", "chess-bishop", "chess-rook", "chess-pawn", "puzzle-piece",
"gamepad", "rocket", "bomb", "fire", "bolt", "snowflake", "sun", "moon", "cloud",
"cloud-rain", "wind", "umbrella", "tree", "leaf", "seedling", "paw", "spider", "skull",
"skull-crossbones", "map", "map-marker-alt", "map-marked-alt", "compass", "mountain",
"water", "globe", "globe-americas", "flask", "vial", "syringe", "pills", "heartbeat",
"dumbbell", "running", "walking", "swimmer", "futbol", "basketball-ball", "volleyball-ball",
"football-ball", "baseball-ball", "table-tennis", "bowling-ball", "horse", "dove", "cat",
"dog", "fish", "frog", "hippo", "otter", "kiwi-bird", "book", "book-open", "graduation-cap",
"school", "chalkboard", "chalkboard-teacher", "pencil-ruler", "ruler", "ruler-combined",
"calculator", "laptop", "desktop", "mobile-alt", "tablet-alt", "keyboard", "mouse",
"headphones", "microphone", "video", "camera", "image", "images", "film", "music", "play",
"pause", "stop", "step-forward", "step-backward", "volume-up", "volume-down", "volume-mute",
"volume-off", "envelope", "envelope-open", "comment", "comments", "comment-dots", "phone",
"phone-alt", "calendar", "calendar-alt", "calendar-check", "clock", "hourglass",
"hourglass-half", "stopwatch", "car", "car-side", "truck", "plane", "plane-departure",
"ship", "anchor", "subway", "train", "bus", "bicycle", "taxi", "road", "traffic-light",
"gas-pump", "screwdriver", "toolbox", "hard-hat", "building", "city", "industry",
"warehouse", "store", "shopping-cart", "shopping-bag", "box", "boxes", "dolly", "pallet",
"archive", "folder", "folder-open", "file", "file-alt", "file-pdf", "file-word",
"file-excel", "file-image", "file-video", "file-audio", "database", "server",
"network-wired", "wifi", "signal", "satellite", "satellite-dish", "robot", "microchip",
"memory", "hdd", "print", "cut", "paste", "copy", "clipboard", "clipboard-list",
"clipboard-check", "sticky-note", "paperclip", "paint-brush", "palette", "magic",
"fire-extinguisher", "first-aid", "briefcase", "suitcase",
]
+98 -23
View File
@@ -8,6 +8,12 @@ from .controls.c_toggle_token import c_toggle_token
from .controls.c_align import c_align from .controls.c_align import c_align
from .controls.c_select import c_select from .controls.c_select import c_select
from .control_groups.text_style_controls_extra import TEXT_STYLE_CONTROLS_EXTRA from .control_groups.text_style_controls_extra import TEXT_STYLE_CONTROLS_EXTRA
from .control_groups.data_binding_controls import DATA_BINDING_CONTROLS
from .control_groups.bulma_controls import (
BULMA_BUTTON_CONTROLS, BULMA_TITLE_CONTROLS, BULMA_PROGRESS_CONTROLS,
BULMA_TABLE_CONTROLS, BULMA_TABS_CONTROLS, BULMA_FIELD_CONTROLS,
)
from .font_options import FONT_OPTIONS
from ..flow.constants import CONDITION_OPERATORS from ..flow.constants import CONDITION_OPERATORS
WIDGETS = { WIDGETS = {
@@ -15,7 +21,8 @@ WIDGETS = {
"label": "Texte", "icon": "📝", "tag": "p", "has_content": True, "label": "Texte", "icon": "📝", "tag": "p", "has_content": True,
"w": 40, "h": 10, "default_content": "Texte du paragraphe.", "w": 40, "h": 10, "default_content": "Texte du paragraphe.",
"controls": [ "controls": [
c_textarea("content", "Texte", "content", default="Texte du paragraphe.", group="Contenu"), c_textarea("content", "Texte — {{champ}} affiche la valeur de ce champ sur la ligne trouvée ci-dessous", "content", default="Texte du paragraphe.", group="Contenu"),
] + DATA_BINDING_CONTROLS + [
c_slider("font-size", "Taille du texte", "style:font-size", min=10, max=64, default=16, group="Texte"), c_slider("font-size", "Taille du texte", "style:font-size", min=10, max=64, default=16, group="Texte"),
c_color("color", "Couleur du texte", "style:color", "#e8eaf0", group="Texte"), c_color("color", "Couleur du texte", "style:color", "#e8eaf0", group="Texte"),
c_align("text-align", "Alignement", "style:text-align", group="Texte"), c_align("text-align", "Alignement", "style:text-align", group="Texte"),
@@ -28,10 +35,15 @@ WIDGETS = {
"titre": { "titre": {
"label": "Titre", "icon": "🔠", "tag": None, "has_content": True, "label": "Titre", "icon": "🔠", "tag": None, "has_content": True,
"w": 55, "h": 10, "default_content": "Titre", "w": 55, "h": 10, "default_content": "Titre",
# class:"title" — poids de police et espacement Bulma par défaut ; la
# taille reste pilotée par le réglage "Taille du texte" ci-dessous
# comme avant (non affecté), Bulma ne fournissant ici que l'habillage.
"fixed_attrs": {"class": "title"},
"controls": [ "controls": [
c_textarea("content", "Texte", "content", default="Titre", group="Contenu"), c_textarea("content", "Texte — {{champ}} affiche la valeur de ce champ sur la ligne trouvée ci-dessous", "content", default="Titre", group="Contenu"),
c_select("niveau", "Importance du titre", "attr:_niveau", c_select("niveau", "Importance du titre", "attr:_niveau",
[("1", "Très grand — titre principal"), ("2", "Grand"), ("3", "Moyen")], default="2", group="Contenu"), [("1", "Très grand — titre principal"), ("2", "Grand"), ("3", "Moyen")], default="2", group="Contenu"),
] + DATA_BINDING_CONTROLS + BULMA_TITLE_CONTROLS + [
c_slider("font-size", "Taille du texte", "style:font-size", min=14, max=80, default=32, group="Texte"), c_slider("font-size", "Taille du texte", "style:font-size", min=14, max=80, default=32, group="Texte"),
c_color("color", "Couleur du texte", "style:color", "#e8eaf0", group="Texte"), c_color("color", "Couleur du texte", "style:color", "#e8eaf0", group="Texte"),
c_align("text-align", "Alignement", "style:text-align", default="left", group="Texte"), c_align("text-align", "Alignement", "style:text-align", default="left", group="Texte"),
@@ -42,14 +54,19 @@ WIDGETS = {
"bouton": { "bouton": {
"label": "Bouton", "icon": "🔘", "tag": "button", "has_content": True, "label": "Bouton", "icon": "🔘", "tag": "button", "has_content": True,
"w": 30, "h": 8, "default_content": "Valider", "w": 30, "h": 8, "default_content": "Valider",
# class:"button" — donne au Bouton l'apparence Bulma par défaut
# (padding, poids de police, transitions...) tant qu'aucune couleur/
# taille "libre" n'est personnalisée ci-dessous — voir
# BULMA_BUTTON_CONTROLS et _visible_attrs.py.
"fixed_attrs": {"class": "button"},
"controls": [ "controls": [
c_text("content", "Texte du bouton", "content", default="Valider", group="Contenu"), c_text("content", "Texte du bouton", "content", default="Valider", group="Contenu"),
c_color("color", "Couleur du texte", "style:color", "#ffffff", group="Texte"), c_color("color", "Couleur du texte", "style:color", "#ffffff", group="Texte"),
c_color("background", "Couleur de fond", "style:background-color", "#5b8cff", group="Texte"), c_color("background", "Couleur de fond", "style:background-color", "#5b8cff", group="Texte"),
c_slider("font-size", "Taille du texte", "style:font-size", min=10, max=40, default=15, group="Texte"), c_slider("font-size", "Taille du texte", "style:font-size", min=10, max=40, default=15, group="Texte", no_freeze=True),
c_slider("border-radius", "Arrondi des coins", "style:border-radius", min=0, max=40, default=8, group="Texte"), c_slider("border-radius", "Arrondi des coins", "style:border-radius", min=0, max=40, default=8, group="Texte", no_freeze=True),
c_checkbox("submit", "Valide un formulaire", "attr:type", "submit", "button", group="Contenu"), c_checkbox("submit", "Valide un formulaire", "attr:type", "submit", "button", group="Contenu"),
] + TEXT_STYLE_CONTROLS_EXTRA, ] + BULMA_BUTTON_CONTROLS + TEXT_STYLE_CONTROLS_EXTRA,
}, },
"lien": { "lien": {
"label": "Lien", "icon": "🔗", "tag": "a", "has_content": True, "label": "Lien", "icon": "🔗", "tag": "a", "has_content": True,
@@ -86,30 +103,30 @@ WIDGETS = {
}, },
"champ_texte": { "champ_texte": {
"label": "Champ — texte", "icon": "⌨️", "tag": "input", "has_content": False, "self_closing": True, "label": "Champ — texte", "icon": "⌨️", "tag": "input", "has_content": False, "self_closing": True,
"w": 40, "h": 8, "default_content": "", "fixed_attrs": {"type": "text"}, "w": 40, "h": 8, "default_content": "", "fixed_attrs": {"type": "text", "class": "input"},
"controls": [ "controls": [
c_text("placeholder", "Texte indicatif", "attr:placeholder", placeholder="ex. Votre réponse", group="Contenu"), c_text("placeholder", "Texte indicatif", "attr:placeholder", placeholder="ex. Votre réponse", group="Contenu"),
c_text("name", "Nom du champ", "attr:name", placeholder="ex. reponse1", group="Contenu"), c_text("name", "Nom du champ", "attr:name", placeholder="ex. reponse1", group="Contenu"),
c_checkbox("required", "Obligatoire", "attr:required", "required", "", group="Contenu"), c_checkbox("required", "Obligatoire", "attr:required", "required", "", group="Contenu"),
], ] + BULMA_FIELD_CONTROLS,
}, },
"champ_email": { "champ_email": {
"label": "Champ — email", "icon": "📧", "tag": "input", "has_content": False, "self_closing": True, "label": "Champ — email", "icon": "📧", "tag": "input", "has_content": False, "self_closing": True,
"w": 40, "h": 8, "default_content": "", "fixed_attrs": {"type": "email"}, "w": 40, "h": 8, "default_content": "", "fixed_attrs": {"type": "email", "class": "input"},
"controls": [ "controls": [
c_text("placeholder", "Texte indicatif", "attr:placeholder", placeholder="ex. votre@email.com", default="votre@email.com", group="Contenu"), c_text("placeholder", "Texte indicatif", "attr:placeholder", placeholder="ex. votre@email.com", default="votre@email.com", group="Contenu"),
c_text("name", "Nom du champ", "attr:name", placeholder="ex. email", group="Contenu"), c_text("name", "Nom du champ", "attr:name", placeholder="ex. email", group="Contenu"),
c_checkbox("required", "Obligatoire", "attr:required", "required", "", group="Contenu"), c_checkbox("required", "Obligatoire", "attr:required", "required", "", group="Contenu"),
], ] + BULMA_FIELD_CONTROLS,
}, },
"champ_mot_de_passe": { "champ_mot_de_passe": {
"label": "Champ — mot de passe", "icon": "🔒", "tag": "input", "has_content": False, "self_closing": True, "label": "Champ — mot de passe", "icon": "🔒", "tag": "input", "has_content": False, "self_closing": True,
"w": 40, "h": 8, "default_content": "", "fixed_attrs": {"type": "password"}, "w": 40, "h": 8, "default_content": "", "fixed_attrs": {"type": "password", "class": "input"},
"controls": [ "controls": [
c_text("placeholder", "Texte indicatif", "attr:placeholder", placeholder="ex. Mot de passe", group="Contenu"), c_text("placeholder", "Texte indicatif", "attr:placeholder", placeholder="ex. Mot de passe", group="Contenu"),
c_text("name", "Nom du champ", "attr:name", placeholder="ex. mot_de_passe", group="Contenu"), c_text("name", "Nom du champ", "attr:name", placeholder="ex. mot_de_passe", group="Contenu"),
c_checkbox("required", "Obligatoire", "attr:required", "required", "", group="Contenu"), c_checkbox("required", "Obligatoire", "attr:required", "required", "", group="Contenu"),
], ] + BULMA_FIELD_CONTROLS,
}, },
"case_a_cocher": { "case_a_cocher": {
"label": "Case à cocher", "icon": "☑️", "tag": "input", "has_content": True, "special_render": "checkbox_label", "label": "Case à cocher", "icon": "☑️", "tag": "input", "has_content": True, "special_render": "checkbox_label",
@@ -135,12 +152,12 @@ WIDGETS = {
}, },
"zone_texte": { "zone_texte": {
"label": "Zone de texte", "icon": "🗒️", "tag": "textarea", "has_content": False, "label": "Zone de texte", "icon": "🗒️", "tag": "textarea", "has_content": False,
"w": 40, "h": 18, "default_content": "", "w": 40, "h": 18, "default_content": "", "fixed_attrs": {"class": "textarea"},
"controls": [ "controls": [
c_text("placeholder", "Texte indicatif", "attr:placeholder", placeholder="ex. Votre message", group="Contenu"), c_text("placeholder", "Texte indicatif", "attr:placeholder", placeholder="ex. Votre message", group="Contenu"),
c_text("name", "Nom du champ", "attr:name", placeholder="ex. message", group="Contenu"), c_text("name", "Nom du champ", "attr:name", placeholder="ex. message", group="Contenu"),
c_checkbox("required", "Obligatoire", "attr:required", "required", "", group="Contenu"), c_checkbox("required", "Obligatoire", "attr:required", "required", "", group="Contenu"),
], ] + BULMA_FIELD_CONTROLS,
}, },
"liste_deroulante": { "liste_deroulante": {
"label": "Liste déroulante", "icon": "🔽", "tag": "select", "has_content": True, "special_render": "select_options", "label": "Liste déroulante", "icon": "🔽", "tag": "select", "has_content": True, "special_render": "select_options",
@@ -149,7 +166,7 @@ WIDGETS = {
c_textarea("content", "Options — une par ligne", "content", default="Option 1\nOption 2\nOption 3", group="Contenu"), c_textarea("content", "Options — une par ligne", "content", default="Option 1\nOption 2\nOption 3", group="Contenu"),
c_text("name", "Nom du champ", "attr:name", placeholder="ex. choix", group="Contenu"), c_text("name", "Nom du champ", "attr:name", placeholder="ex. choix", group="Contenu"),
c_checkbox("required", "Obligatoire", "attr:required", "required", "", group="Contenu"), c_checkbox("required", "Obligatoire", "attr:required", "required", "", group="Contenu"),
], ] + BULMA_FIELD_CONTROLS,
}, },
"groupe_champs": { "groupe_champs": {
"label": "Groupe de champs", "icon": "🗃️", "tag": "fieldset", "has_content": True, "special_render": "fieldset_legend", "label": "Groupe de champs", "icon": "🗃️", "tag": "fieldset", "has_content": True, "special_render": "fieldset_legend",
@@ -181,20 +198,27 @@ WIDGETS = {
"tableau": { "tableau": {
"label": "Tableau", "icon": "🗂️", "tag": "table", "has_content": True, "special_render": "table", "label": "Tableau", "icon": "🗂️", "tag": "table", "has_content": True, "special_render": "table",
"w": 55, "h": 25, "default_content": "Colonne 1;Colonne 2\nValeur A;Valeur B", "w": 55, "h": 25, "default_content": "Colonne 1;Colonne 2\nValeur A;Valeur B",
"fixed_attrs": {"class": "table"},
"controls": [ "controls": [
c_textarea("content", "Contenu — une ligne par rangée, colonnes séparées par ;", "content", c_textarea("content", "Contenu — une ligne par rangée, colonnes séparées par ;", "content",
default="Colonne 1;Colonne 2\nValeur A;Valeur B", group="Contenu"), default="Colonne 1;Colonne 2\nValeur A;Valeur B", group="Contenu"),
c_checkbox("header_row", "Première ligne = en-tête", "attr:_header_row", "1", "", default=True, group="Contenu"), c_checkbox("header_row", "Première ligne = en-tête", "attr:_header_row", "1", "", default=True, group="Contenu"),
c_color("color", "Couleur du texte", "style:color", "#e8eaf0", group="Texte"), c_color("color", "Couleur du texte", "style:color", "#e8eaf0", group="Texte"),
], ] + BULMA_TABLE_CONTROLS,
}, },
"conteneur": { "conteneur": {
"label": "Conteneur / décor", "icon": "⬛", "tag": "div", "has_content": True, "label": "Conteneur / décor", "icon": "⬛", "tag": "div", "has_content": True,
"w": 50, "h": 30, "default_content": "", "w": 50, "h": 30, "default_content": "",
# class:"box" — ce widget EST une carte Bulma nativement (fond clair,
# ombre, coins arrondis), pas un habillage qu'on choisit dans un
# menu : voir bouton/titre ci-dessus, même logique. Une couleur de
# fond personnalisée (réglage ci-dessous) reste prioritaire dès
# qu'elle est choisie, exactement comme avant.
"fixed_attrs": {"class": "box"},
"controls": [ "controls": [
c_text("content", "Texte", "content", group="Contenu"), c_text("content", "Texte", "content", group="Contenu"),
c_color("background", "Couleur de fond", "style:background-color", "#1f2430", group="Texte"), c_color("background", "Couleur de fond", "style:background-color", "#1f2430", group="Texte"),
c_slider("border-radius", "Arrondi des coins", "style:border-radius", min=0, max=60, default=10, group="Texte"), c_slider("border-radius", "Arrondi des coins", "style:border-radius", min=0, max=60, default=10, group="Texte", no_freeze=True),
c_slider("opacity", "Opacité", "style:opacity", unit="", min=0, max=1, step=0.05, default=1, group="Texte"), c_slider("opacity", "Opacité", "style:opacity", unit="", min=0, max=1, step=0.05, default=1, group="Texte"),
], ],
}, },
@@ -216,11 +240,16 @@ WIDGETS = {
c_textarea("content", "Modèle d'affichage texte — utilise {{nom_du_champ}}", "content", default="{{nom}}", group="Contenu"), c_textarea("content", "Modèle d'affichage texte — utilise {{nom_du_champ}}", "content", default="{{nom}}", group="Contenu"),
c_color("color", "Couleur du texte", "style:color", "#e8eaf0", group="Texte"), c_color("color", "Couleur du texte", "style:color", "#e8eaf0", group="Texte"),
c_slider("font-size", "Taille du texte", "style:font-size", min=10, max=40, default=16, group="Texte"), c_slider("font-size", "Taille du texte", "style:font-size", min=10, max=40, default=16, group="Texte"),
c_text("filtre_champ", "Filtrer : n'afficher que les lignes où ce champ", c_select("filtre_champ", "Filtrer : n'afficher que les lignes où ce champ",
"attr:_filtre_champ", placeholder="ex. niveau", default="", group="Filtre"), "attr:_filtre_champ", [("", "(sélectionner un objet d'abord)")], default="", group="Filtre"),
c_select("filtre_operateur", "...est", "attr:_filtre_operateur", CONDITION_OPERATORS, default="egal", group="Filtre"), c_select("filtre_operateur", "...est", "attr:_filtre_operateur", CONDITION_OPERATORS, default="egal", group="Filtre"),
c_text("filtre_valeur", "...cette valeur, ou {{NomObjet.champ}} pour comparer à une donnée qui change en cours de partie", c_text("filtre_valeur", "...cette valeur",
"attr:_filtre_valeur", placeholder="ex. 3 ou {{Partie.niveau_courant}}", default="", group="Filtre"), "attr:_filtre_valeur", placeholder="ex. 3", default="", group="Filtre"),
c_select("filtre2_champ", "ET (optionnel) : ce 2ᵉ champ",
"attr:_filtre2_champ", [("", "(sélectionner un objet d'abord)")], default="", group="Filtre"),
c_select("filtre2_operateur", "...est", "attr:_filtre2_operateur", CONDITION_OPERATORS, default="egal", group="Filtre"),
c_text("filtre2_valeur", "...cette valeur",
"attr:_filtre2_valeur", placeholder="ex. 1", default="", group="Filtre"),
], ],
}, },
"jauge": { "jauge": {
@@ -232,14 +261,32 @@ WIDGETS = {
# objets de données du jeu — même mécanisme que le Répéteur. # objets de données du jeu — même mécanisme que le Répéteur.
c_select("definition_id", "Objet de données à suivre", "attr:_definition_id", c_select("definition_id", "Objet de données à suivre", "attr:_definition_id",
[("", "Sélectionner un objet")], default="", group="Donnée"), [("", "Sélectionner un objet")], default="", group="Donnée"),
c_text("champ", "Champ numérique à afficher", # row_id/champ/champ_nom : options peuplées dynamiquement une
"attr:_champ", placeholder="ex. reputation", default="", group="Donnée"), # fois "definition_id" choisi — voir controls_with_values.py
# (premier affichage) et onJaugeDefinitionChange() dans
# screen_edit.html (changement en direct dans le panneau).
c_select("row_id", "Enregistrement (ligne) à suivre", "attr:_row_id",
[("", "Sélectionner un objet d'abord")], default="", group="Donnée"),
c_select("champ", "Champ numérique à afficher", "attr:_champ",
[("", "Sélectionner un objet d'abord")], default="", group="Donnée"),
c_select("champ_nom", "Champ contenant le nom (affiché au-dessus, optionnel)", "attr:_champ_nom",
[("", "— aucun —")], default="", group="Donnée"),
c_text("min", "Valeur mini", "attr:_min", default="0", group="Donnée"), c_text("min", "Valeur mini", "attr:_min", default="0", group="Donnée"),
c_text("max", "Valeur maxi", "attr:_max", default="100", group="Donnée"), c_text("max", "Valeur maxi", "attr:_max", default="100", group="Donnée"),
c_color("couleur_basse", "Couleur côté mini", "attr:_couleur_basse", "#e05252", group="Apparence"), c_color("couleur_basse", "Couleur côté mini", "attr:_couleur_basse", "#e05252", group="Apparence"),
c_color("couleur_haute", "Couleur côté maxi", "attr:_couleur_haute", "#4caf50", group="Apparence"), c_color("couleur_haute", "Couleur côté maxi", "attr:_couleur_haute", "#4caf50", group="Apparence"),
c_checkbox("afficher_valeur", "Afficher la valeur en chiffres sur la jauge", "attr:_afficher_valeur", "1", "", default=False, group="Apparence"), c_checkbox("afficher_valeur", "Afficher la valeur en chiffres sur la jauge", "attr:_afficher_valeur", "1", "", default=False, group="Apparence"),
], c_select("nom_position", "Position du nom", "attr:_nom_position",
[("dessus", "Au-dessus de la jauge"), ("cote", "À côté (à gauche) de la jauge")],
default="dessus", group="Apparence"),
c_select("nom_alignement", "Alignement du nom (si au-dessus)", "attr:_nom_alignement",
[("centre", "Centré"), ("gauche", "Aligné à gauche")],
default="centre", group="Apparence"),
c_select("nom_police", "Police du nom", "attr:_nom_police", FONT_OPTIONS, default="inherit", group="Apparence"),
c_slider("nom_taille", "Taille du texte du nom", "attr:_nom_taille", unit="", min=8, max=32, default=12, group="Apparence"),
c_checkbox("nom_gras", "Nom en gras", "attr:_nom_gras", "1", "", default=False, group="Apparence"),
c_checkbox("nom_italique", "Nom en italique", "attr:_nom_italique", "1", "", default=False, group="Apparence"),
] + BULMA_PROGRESS_CONTROLS,
}, },
"superposition": { "superposition": {
# 3.4 (Confort) — overlay/modale réutilisable : couvre TOUJOURS tout # 3.4 (Confort) — overlay/modale réutilisable : couvre TOUJOURS tout
@@ -258,4 +305,32 @@ WIDGETS = {
c_slider("arrondi", "Arrondi des coins de la boîte", "attr:_arrondi", unit="", min=0, max=40, default=12, group="Apparence"), c_slider("arrondi", "Arrondi des coins de la boîte", "attr:_arrondi", unit="", min=0, max=40, default=12, group="Apparence"),
], ],
}, },
"onglets": {
# Chaque onglet est un vrai élément "conteneur" posé comme enfant
# (voir screens/elements/add_tab.py et render_onglets.py) — pas de
# gestion dédiée ici : ajouter/renommer/supprimer un onglet se fait
# via le bloc "Onglets" du panneau de propriétés (screen_edit.html),
# qui réutilise les routes existantes (element_rename, element_delete).
"label": "Onglets", "icon": "🗂️", "tag": "div", "has_content": False, "special_render": "onglets",
"w": 60, "h": 40, "default_content": "",
"controls": BULMA_TABS_CONTROLS,
},
"icone": {
# Un SVG affiché en silhouette via "mask-image" (voir .icon-svg dans
# style.css et render_icone.py) plutôt qu'une police d'icônes —
# certains navigateurs/extensions bloquent le chargement de polices
# web tierces (protection anti-fingerprinting), ce qui rendait
# invisible toute icône Font Awesome classique quelle que soit sa
# source (CDN ou même hébergée ici). Le nom de l'icône (ex. "star")
# est posé soit en cliquant/glissant depuis la galerie du panneau de
# propriétés (voir routes/elements/element_add.py), soit choisi via
# le sélecteur visuel du réglage "Icône" — jamais tapé à la main.
"label": "Icône", "icon": "🔤", "tag": "i", "has_content": False, "special_render": "icone",
"w": 15, "h": 15, "default_content": "",
"controls": [
c_text("icon_class", "Icône", "attr:_icon_slug", default="star", group="Contenu"),
c_slider("font-size", "Taille", "style:font-size", min=10, max=160, default=32, group="Texte"),
c_color("color", "Couleur", "style:color", "#e8eaf0", group="Texte"),
],
},
} }
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 576 512"><!-- Font Awesome Free 5.15.4 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License) --><path d="M12.971 352h32.394C67.172 454.735 181.944 512 288 512c106.229 0 220.853-57.38 242.635-160h32.394c10.691 0 16.045-12.926 8.485-20.485l-67.029-67.029c-4.686-4.686-12.284-4.686-16.971 0l-67.029 67.029c-7.56 7.56-2.206 20.485 8.485 20.485h35.146c-20.29 54.317-84.963 86.588-144.117 94.015V256h52c6.627 0 12-5.373 12-12v-40c0-6.627-5.373-12-12-12h-52v-5.47c37.281-13.178 63.995-48.725 64-90.518C384.005 43.772 341.605.738 289.37.01 235.723-.739 192 42.525 192 96c0 41.798 26.716 77.35 64 90.53V192h-52c-6.627 0-12 5.373-12 12v40c0 6.627 5.373 12 12 12h52v190.015c-58.936-7.399-123.82-39.679-144.117-94.015h35.146c10.691 0 16.045-12.926 8.485-20.485l-67.029-67.029c-4.686-4.686-12.284-4.686-16.971 0L4.485 331.515C-3.074 339.074 2.28 352 12.971 352zM288 64c17.645 0 32 14.355 32 32s-14.355 32-32 32-32-14.355-32-32 14.355-32 32-32z"/></svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><!-- Font Awesome Free 5.15.4 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License) --><path d="M32 448c0 17.7 14.3 32 32 32h384c17.7 0 32-14.3 32-32V160H32v288zm160-212c0-6.6 5.4-12 12-12h104c6.6 0 12 5.4 12 12v8c0 6.6-5.4 12-12 12H204c-6.6 0-12-5.4-12-12v-8zM480 32H32C14.3 32 0 46.3 0 64v48c0 8.8 7.2 16 16 16h480c8.8 0 16-7.2 16-16V64c0-17.7-14.3-32-32-32z"/></svg>

After

Width:  |  Height:  |  Size: 524 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 384 512"><!-- Font Awesome Free 5.15.4 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License) --><path d="M97.12 362.63c-8.69-8.69-4.16-6.24-25.12-11.85-9.51-2.55-17.87-7.45-25.43-13.32L1.2 448.7c-4.39 10.77 3.81 22.47 15.43 22.03l52.69-2.01L105.56 507c8 8.44 22.04 5.81 26.43-4.96l52.05-127.62c-10.84 6.04-22.87 9.58-35.31 9.58-19.5 0-37.82-7.59-51.61-21.37zM382.8 448.7l-45.37-111.24c-7.56 5.88-15.92 10.77-25.43 13.32-21.07 5.64-16.45 3.18-25.12 11.85-13.79 13.78-32.12 21.37-51.62 21.37-12.44 0-24.47-3.55-35.31-9.58L252 502.04c4.39 10.77 18.44 13.4 26.43 4.96l36.25-38.28 52.69 2.01c11.62.44 19.82-11.27 15.43-22.03zM263 340c15.28-15.55 17.03-14.21 38.79-20.14 13.89-3.79 24.75-14.84 28.47-28.98 7.48-28.4 5.54-24.97 25.95-45.75 10.17-10.35 14.14-25.44 10.42-39.58-7.47-28.38-7.48-24.42 0-52.83 3.72-14.14-.25-29.23-10.42-39.58-20.41-20.78-18.47-17.36-25.95-45.75-3.72-14.14-14.58-25.19-28.47-28.98-27.88-7.61-24.52-5.62-44.95-26.41-10.17-10.35-25-14.4-38.89-10.61-27.87 7.6-23.98 7.61-51.9 0-13.89-3.79-28.72.25-38.89 10.61-20.41 20.78-17.05 18.8-44.94 26.41-13.89 3.79-24.75 14.84-28.47 28.98-7.47 28.39-5.54 24.97-25.95 45.75-10.17 10.35-14.15 25.44-10.42 39.58 7.47 28.36 7.48 24.4 0 52.82-3.72 14.14.25 29.23 10.42 39.59 20.41 20.78 18.47 17.35 25.95 45.75 3.72 14.14 14.58 25.19 28.47 28.98C104.6 325.96 106.27 325 121 340c13.23 13.47 33.84 15.88 49.74 5.82a39.676 39.676 0 0 1 42.53 0c15.89 10.06 36.5 7.65 49.73-5.82zM97.66 175.96c0-53.03 42.24-96.02 94.34-96.02s94.34 42.99 94.34 96.02-42.24 96.02-94.34 96.02-94.34-42.99-94.34-96.02z"/></svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><!-- Font Awesome Free 5.15.4 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License) --><path d="M256 8C119.034 8 8 119.033 8 256s111.034 248 248 248 248-111.034 248-248S392.967 8 256 8zm130.108 117.892c65.448 65.448 70 165.481 20.677 235.637L150.47 105.216c70.204-49.356 170.226-44.735 235.638 20.676zM125.892 386.108c-65.448-65.448-70-165.481-20.677-235.637L361.53 406.784c-70.203 49.356-170.226 44.736-235.638-20.676z"/></svg>

After

Width:  |  Height:  |  Size: 583 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"><!-- Font Awesome Free 5.15.4 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License) --><path d="M16 132h416c8.837 0 16-7.163 16-16V76c0-8.837-7.163-16-16-16H16C7.163 60 0 67.163 0 76v40c0 8.837 7.163 16 16 16zm0 160h416c8.837 0 16-7.163 16-16v-40c0-8.837-7.163-16-16-16H16c-8.837 0-16 7.163-16 16v40c0 8.837 7.163 16 16 16zm0 160h416c8.837 0 16-7.163 16-16v-40c0-8.837-7.163-16-16-16H16c-8.837 0-16 7.163-16 16v40c0 8.837 7.163 16 16 16z"/></svg>

After

Width:  |  Height:  |  Size: 601 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 496 512"><!-- Font Awesome Free 5.15.4 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License) --><path d="M368.5 363.9l28.8-13.9c11.1 22.9 26 43.2 44.1 60.9 34-42.5 54.5-96.3 54.5-154.9 0-58.5-20.4-112.2-54.2-154.6-17.8 17.3-32.6 37.1-43.6 59.5l-28.7-14.1c12.8-26 30-49 50.8-69C375.6 34.7 315 8 248 8 181.1 8 120.5 34.6 75.9 77.7c20.7 19.9 37.9 42.9 50.7 68.8l-28.7 14.1c-11-22.3-25.7-42.1-43.5-59.4C20.4 143.7 0 197.4 0 256c0 58.6 20.4 112.3 54.4 154.7 18.2-17.7 33.2-38 44.3-61l28.8 13.9c-12.9 26.7-30.3 50.3-51.5 70.7 44.5 43.1 105.1 69.7 172 69.7 66.8 0 127.3-26.5 171.9-69.5-21.1-20.4-38.5-43.9-51.4-70.6zm-228.3-32l-30.5-9.8c14.9-46.4 12.7-93.8-.6-134l30.4-10c15 45.6 18 99.9.7 153.8zm216.3-153.4l30.4 10c-13.2 40.1-15.5 87.5-.6 134l-30.5 9.8c-17.3-54-14.3-108.3.7-153.8z"/></svg>

After

Width:  |  Height:  |  Size: 931 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 496 512"><!-- Font Awesome Free 5.15.4 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License) --><path d="M212.3 10.3c-43.8 6.3-86.2 24.1-122.2 53.8l77.4 77.4c27.8-35.8 43.3-81.2 44.8-131.2zM248 222L405.9 64.1c-42.4-35-93.6-53.5-145.5-56.1-1.2 63.9-21.5 122.3-58.7 167.7L248 222zM56.1 98.1c-29.7 36-47.5 78.4-53.8 122.2 50-1.5 95.5-17 131.2-44.8L56.1 98.1zm272.2 204.2c45.3-37.1 103.7-57.4 167.7-58.7-2.6-51.9-21.1-103.1-56.1-145.5L282 256l46.3 46.3zM248 290L90.1 447.9c42.4 34.9 93.6 53.5 145.5 56.1 1.3-64 21.6-122.4 58.7-167.7L248 290zm191.9 123.9c29.7-36 47.5-78.4 53.8-122.2-50.1 1.6-95.5 17.1-131.2 44.8l77.4 77.4zM167.7 209.7C122.3 246.9 63.9 267.3 0 268.4c2.6 51.9 21.1 103.1 56.1 145.5L214 256l-46.3-46.3zm116 292c43.8-6.3 86.2-24.1 122.2-53.8l-77.4-77.4c-27.7 35.7-43.2 81.2-44.8 131.2z"/></svg>

After

Width:  |  Height:  |  Size: 950 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 512"><!-- Font Awesome Free 5.15.4 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License) --><path d="M633.82 458.1l-90.62-70.05c.19-1.38.8-2.66.8-4.06.05-7.55-2.61-15.27-8.61-21.71-19.32-20.76-55.47-51.99-55.47-154.29 0-77.7-54.48-139.9-127.94-155.16V32c0-17.67-14.32-32-31.98-32s-31.98 14.33-31.98 32v20.84c-40.33 8.38-74.66 31.07-97.59 62.57L45.47 3.37C38.49-2.05 28.43-.8 23.01 6.18L3.37 31.45C-2.05 38.42-.8 48.47 6.18 53.9l588.35 454.73c6.98 5.43 17.03 4.17 22.46-2.81l19.64-25.27c5.42-6.97 4.17-17.02-2.81-22.45zM157.23 251.54c-8.61 67.96-36.41 93.33-52.62 110.75-6 6.45-8.66 14.16-8.61 21.71.11 16.4 12.98 32 32.1 32h241.92L157.23 251.54zM320 512c35.32 0 63.97-28.65 63.97-64H256.03c0 35.35 28.65 64 63.97 64z"/></svg>

After

Width:  |  Height:  |  Size: 875 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"><!-- Font Awesome Free 5.15.4 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License) --><path d="M224 512c35.32 0 63.97-28.65 63.97-64H160.03c0 35.35 28.65 64 63.97 64zm215.39-149.71c-19.32-20.76-55.47-51.99-55.47-154.29 0-77.7-54.48-139.9-127.94-155.16V32c0-17.67-14.32-32-31.98-32s-31.98 14.33-31.98 32v20.84C118.56 68.1 64.08 130.3 64.08 208c0 102.3-36.15 133.53-55.47 154.29-6 6.45-8.66 14.16-8.61 21.71.11 16.4 12.98 32 32.1 32h383.8c19.12 0 32-15.6 32.1-32 .05-7.55-2.61-15.27-8.61-21.71z"/></svg>

After

Width:  |  Height:  |  Size: 657 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 512"><!-- Font Awesome Free 5.15.4 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License) --><path d="M512.509 192.001c-16.373-.064-32.03 2.955-46.436 8.495l-77.68-125.153A24 24 0 0 0 368.001 64h-64c-8.837 0-16 7.163-16 16v16c0 8.837 7.163 16 16 16h50.649l14.896 24H256.002v-16c0-8.837-7.163-16-16-16h-87.459c-13.441 0-24.777 10.999-24.536 24.437.232 13.044 10.876 23.563 23.995 23.563h48.726l-29.417 47.52c-13.433-4.83-27.904-7.483-42.992-7.52C58.094 191.83.412 249.012.002 319.236-.413 390.279 57.055 448 128.002 448c59.642 0 109.758-40.793 123.967-96h52.033a24 24 0 0 0 20.406-11.367L410.37 201.77l14.938 24.067c-25.455 23.448-41.385 57.081-41.307 94.437.145 68.833 57.899 127.051 126.729 127.719 70.606.685 128.181-55.803 129.255-125.996 1.086-70.941-56.526-129.72-127.476-129.996zM186.75 265.772c9.727 10.529 16.673 23.661 19.642 38.228h-43.306l23.664-38.228zM128.002 400c-44.112 0-80-35.888-80-80s35.888-80 80-80c5.869 0 11.586.653 17.099 1.859l-45.505 73.509C89.715 331.327 101.213 352 120.002 352h81.3c-12.37 28.225-40.562 48-73.3 48zm162.63-96h-35.624c-3.96-31.756-19.556-59.894-42.383-80.026L237.371 184h127.547l-74.286 120zm217.057 95.886c-41.036-2.165-74.049-35.692-75.627-76.755-.812-21.121 6.633-40.518 19.335-55.263l44.433 71.586c4.66 7.508 14.524 9.816 22.032 5.156l13.594-8.437c7.508-4.66 9.817-14.524 5.156-22.032l-44.468-71.643a79.901 79.901 0 0 1 19.858-2.497c44.112 0 80 35.888 80 80-.001 45.54-38.252 82.316-84.313 79.885z"/></svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 512"><!-- Font Awesome Free 5.15.4 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License) --><path d="M296 160H180.6l42.6-129.8C227.2 15 215.7 0 200 0H56C44 0 33.8 8.9 32.2 20.8l-32 240C-1.7 275.2 9.5 288 24 288h118.7L96.6 482.5c-3.6 15.2 8 29.5 23.3 29.5 8.4 0 16.4-4.4 20.8-12l176-304c9.3-15.9-2.2-36-20.7-36z"/></svg>

After

Width:  |  Height:  |  Size: 469 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><!-- Font Awesome Free 5.15.4 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License) --><path d="M440.5 88.5l-52 52L415 167c9.4 9.4 9.4 24.6 0 33.9l-17.4 17.4c11.8 26.1 18.4 55.1 18.4 85.6 0 114.9-93.1 208-208 208S0 418.9 0 304 93.1 96 208 96c30.5 0 59.5 6.6 85.6 18.4L311 97c9.4-9.4 24.6-9.4 33.9 0l26.5 26.5 52-52 17.1 17zM500 60h-24c-6.6 0-12 5.4-12 12s5.4 12 12 12h24c6.6 0 12-5.4 12-12s-5.4-12-12-12zM440 0c-6.6 0-12 5.4-12 12v24c0 6.6 5.4 12 12 12s12-5.4 12-12V12c0-6.6-5.4-12-12-12zm33.9 55l17-17c4.7-4.7 4.7-12.3 0-17-4.7-4.7-12.3-4.7-17 0l-17 17c-4.7 4.7-4.7 12.3 0 17 4.8 4.7 12.4 4.7 17 0zm-67.8 0c4.7 4.7 12.3 4.7 17 0 4.7-4.7 4.7-12.3 0-17l-17-17c-4.7-4.7-12.3-4.7-17 0-4.7 4.7-4.7 12.3 0 17l17 17zm67.8 34c-4.7-4.7-12.3-4.7-17 0-4.7 4.7-4.7 12.3 0 17l17 17c4.7 4.7 12.3 4.7 17 0 4.7-4.7 4.7-12.3 0-17l-17-17zM112 272c0-35.3 28.7-64 64-64 8.8 0 16-7.2 16-16s-7.2-16-16-16c-52.9 0-96 43.1-96 96 0 8.8 7.2 16 16 16s16-7.2 16-16z"/></svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 576 512"><!-- Font Awesome Free 5.15.4 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License) --><path d="M542.22 32.05c-54.8 3.11-163.72 14.43-230.96 55.59-4.64 2.84-7.27 7.89-7.27 13.17v363.87c0 11.55 12.63 18.85 23.28 13.49 69.18-34.82 169.23-44.32 218.7-46.92 16.89-.89 30.02-14.43 30.02-30.66V62.75c.01-17.71-15.35-31.74-33.77-30.7zM264.73 87.64C197.5 46.48 88.58 35.17 33.78 32.05 15.36 31.01 0 45.04 0 62.75V400.6c0 16.24 13.13 29.78 30.02 30.66 49.49 2.6 149.59 12.11 218.77 46.95 10.62 5.35 23.21-1.94 23.21-13.46V100.63c0-5.29-2.62-10.14-7.27-12.99z"/></svg>

After

Width:  |  Height:  |  Size: 713 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"><!-- Font Awesome Free 5.15.4 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License) --><path d="M448 360V24c0-13.3-10.7-24-24-24H96C43 0 0 43 0 96v320c0 53 43 96 96 96h328c13.3 0 24-10.7 24-24v-16c0-7.5-3.5-14.3-8.9-18.7-4.2-15.4-4.2-59.3 0-74.7 5.4-4.3 8.9-11.1 8.9-18.6zM128 134c0-3.3 2.7-6 6-6h212c3.3 0 6 2.7 6 6v20c0 3.3-2.7 6-6 6H134c-3.3 0-6-2.7-6-6v-20zm0 64c0-3.3 2.7-6 6-6h212c3.3 0 6 2.7 6 6v20c0 3.3-2.7 6-6 6H134c-3.3 0-6-2.7-6-6v-20zm253.4 250H96c-17.7 0-32-14.3-32-32 0-17.6 14.4-32 32-32h285.4c-1.9 17.1-1.9 46.9 0 64z"/></svg>

After

Width:  |  Height:  |  Size: 698 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 384 512"><!-- Font Awesome Free 5.15.4 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License) --><path d="M0 512V48C0 21.49 21.49 0 48 0h288c26.51 0 48 21.49 48 48v464L192 400 0 512z"/></svg>

After

Width:  |  Height:  |  Size: 336 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 496 512"><!-- Font Awesome Free 5.15.4 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License) --><path d="M248 8C111 8 0 119 0 256s111 248 248 248 248-111 248-248S385 8 248 8zM120 192c-17.7 0-32-14.3-32-32s14.3-32 32-32 32 14.3 32 32-14.3 32-32 32zm64-96c0-17.7 14.3-32 32-32s32 14.3 32 32-14.3 32-32 32-32-14.3-32-32zm48 144c-17.7 0-32-14.3-32-32s14.3-32 32-32 32 14.3 32 32-14.3 32-32 32z"/></svg>

After

Width:  |  Height:  |  Size: 544 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><!-- Font Awesome Free 5.15.4 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License) --><path d="M509.5 184.6L458.9 32.8C452.4 13.2 434.1 0 413.4 0H272v192h238.7c-.4-2.5-.4-5-1.2-7.4zM240 0H98.6c-20.7 0-39 13.2-45.5 32.8L2.5 184.6c-.8 2.4-.8 4.9-1.2 7.4H240V0zM0 224v240c0 26.5 21.5 48 48 48h416c26.5 0 48-21.5 48-48V224H0z"/></svg>

After

Width:  |  Height:  |  Size: 486 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 576 512"><!-- Font Awesome Free 5.15.4 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License) --><path d="M560 288h-80v96l-32-21.3-32 21.3v-96h-80c-8.8 0-16 7.2-16 16v192c0 8.8 7.2 16 16 16h224c8.8 0 16-7.2 16-16V304c0-8.8-7.2-16-16-16zm-384-64h224c8.8 0 16-7.2 16-16V16c0-8.8-7.2-16-16-16h-80v96l-32-21.3L256 96V0h-80c-8.8 0-16 7.2-16 16v192c0 8.8 7.2 16 16 16zm64 64h-80v96l-32-21.3L96 384v-96H16c-8.8 0-16 7.2-16 16v192c0 8.8 7.2 16 16 16h224c8.8 0 16-7.2 16-16V304c0-8.8-7.2-16-16-16z"/></svg>

After

Width:  |  Height:  |  Size: 642 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><!-- Font Awesome Free 5.15.4 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License) --><path d="M320 336c0 8.84-7.16 16-16 16h-96c-8.84 0-16-7.16-16-16v-48H0v144c0 25.6 22.4 48 48 48h416c25.6 0 48-22.4 48-48V288H320v48zm144-208h-80V80c0-25.6-22.4-48-48-48H176c-25.6 0-48 22.4-48 48v48H48c-25.6 0-48 22.4-48 48v80h512v-80c0-25.6-22.4-48-48-48zm-144 0H192V96h128v32z"/></svg>

After

Width:  |  Height:  |  Size: 528 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"><!-- Font Awesome Free 5.15.4 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License) --><path d="M436 480h-20V24c0-13.255-10.745-24-24-24H56C42.745 0 32 10.745 32 24v456H12c-6.627 0-12 5.373-12 12v20h448v-20c0-6.627-5.373-12-12-12zM128 76c0-6.627 5.373-12 12-12h40c6.627 0 12 5.373 12 12v40c0 6.627-5.373 12-12 12h-40c-6.627 0-12-5.373-12-12V76zm0 96c0-6.627 5.373-12 12-12h40c6.627 0 12 5.373 12 12v40c0 6.627-5.373 12-12 12h-40c-6.627 0-12-5.373-12-12v-40zm52 148h-40c-6.627 0-12-5.373-12-12v-40c0-6.627 5.373-12 12-12h40c6.627 0 12 5.373 12 12v40c0 6.627-5.373 12-12 12zm76 160h-64v-84c0-6.627 5.373-12 12-12h40c6.627 0 12 5.373 12 12v84zm64-172c0 6.627-5.373 12-12 12h-40c-6.627 0-12-5.373-12-12v-40c0-6.627 5.373-12 12-12h40c6.627 0 12 5.373 12 12v40zm0-96c0 6.627-5.373 12-12 12h-40c-6.627 0-12-5.373-12-12v-40c0-6.627 5.373-12 12-12h40c6.627 0 12 5.373 12 12v40zm0-96c0 6.627-5.373 12-12 12h-40c-6.627 0-12-5.373-12-12V76c0-6.627 5.373-12 12-12h40c6.627 0 12 5.373 12 12v40z"/></svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

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