Merge pull request 'Dev' (#1) from dev into main
Reviewed-on: #1
@@ -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.
|
||||
"""
|
||||
|
||||
from .constants import PROJECTS_DIR, FIELD_TYPES
|
||||
from .constants import PROJECTS_DIR, FIELD_TYPES, GLOBAL_VARIABLE_TYPES
|
||||
from .slugify import slugify
|
||||
from .table_name_for import table_name_for
|
||||
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.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__ = [
|
||||
"PROJECTS_DIR", "FIELD_TYPES",
|
||||
"PROJECTS_DIR", "FIELD_TYPES", "GLOBAL_VARIABLE_TYPES",
|
||||
"slugify", "table_name_for", "game_dir", "db_path", "connect",
|
||||
"list_games", "game_meta", "create_game", "update_game_name", "delete_game",
|
||||
"list_definitions", "get_definition", "create_definition", "rename_definition",
|
||||
@@ -61,4 +69,7 @@ __all__ = [
|
||||
"delete_definition",
|
||||
"list_rows", "relation_options", "insert_row", "get_row", "update_row",
|
||||
"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",
|
||||
]
|
||||
|
||||
@@ -17,4 +17,56 @@ def connect(slug):
|
||||
conn.execute("PRAGMA foreign_keys = ON")
|
||||
conn.execute("PRAGMA journal_mode = WAL")
|
||||
conn.execute("PRAGMA busy_timeout = 8000")
|
||||
_track_for_teardown(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()
|
||||
|
||||
@@ -14,3 +14,14 @@ FIELD_TYPES = {
|
||||
"date": {"label": "Date", "sql": "TEXT"},
|
||||
"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"],
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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]
|
||||
@@ -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 @@
|
||||
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
|
||||
|
||||
@@ -2,7 +2,12 @@ from . import (
|
||||
element_add,
|
||||
element_add_child,
|
||||
element_geometry,
|
||||
element_reorder,
|
||||
element_move_to_container,
|
||||
element_save,
|
||||
element_delete,
|
||||
element_delete_impact,
|
||||
element_duplicate,
|
||||
element_rename,
|
||||
element_tab_add,
|
||||
)
|
||||
|
||||
@@ -17,4 +17,11 @@ def element_add(slug, screen_id):
|
||||
et = types.get(int(element_type_id))
|
||||
widget = et["default_widget"] if et else "conteneur"
|
||||
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))
|
||||
|
||||
@@ -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)})
|
||||
@@ -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})
|
||||
@@ -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})
|
||||
@@ -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))
|
||||
@@ -5,5 +5,6 @@ from . import (
|
||||
flow_edge_add,
|
||||
flow_edge_delete,
|
||||
flow_node_run_data,
|
||||
flow_node_run_variable,
|
||||
runtime_payload,
|
||||
)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from flask import jsonify
|
||||
from flask import jsonify, request
|
||||
|
||||
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
|
||||
Action de type "Modifier une donnée d'un objet" — identique au
|
||||
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)
|
||||
if not node or node.get("node_type") != "action" or node.get("action_type") != "modifier_donnee":
|
||||
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)
|
||||
return jsonify({"ok": ok})
|
||||
|
||||
@@ -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})
|
||||
@@ -7,7 +7,7 @@ from core.flask_app import app
|
||||
|
||||
@app.route("/game/<slug>/rename", methods=["POST"])
|
||||
def game_rename(slug):
|
||||
new_name = request.form.get("name", "").strip()
|
||||
new_name = request.form.get("game_name", "").strip()
|
||||
if new_name:
|
||||
db.update_game_name(slug, new_name)
|
||||
return redirect(url_for("game_dashboard", slug=slug))
|
||||
|
||||
@@ -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
|
||||
@@ -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))
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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))
|
||||
@@ -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))
|
||||
@@ -22,7 +22,12 @@ def screen_edit(slug, screen_id):
|
||||
if selected_id:
|
||||
selected_el = next((e for e in elements if e["id"] == selected_id), None)
|
||||
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 = {}
|
||||
for el in elements:
|
||||
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}
|
||||
|
||||
definitions = db.list_definitions(slug)
|
||||
definitions_data = {}
|
||||
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}
|
||||
definitions_data = {str(d["id"]): screens.data_definition_options(slug, d["id"]) for d in definitions}
|
||||
|
||||
flow_nodes = screens.list_flow_nodes(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}
|
||||
screens_map = {s["id"]: s["name"] for s in all_screens}
|
||||
animation_clips = screens.list_animation_clips(slug, screen_id)
|
||||
global_variables = db.list_global_variables(slug)
|
||||
|
||||
return render_template(
|
||||
"screen_edit.html",
|
||||
@@ -87,4 +79,6 @@ def screen_edit(slug, screen_id):
|
||||
animation_clips=animation_clips, animation_clips_json=json.dumps(animation_clips),
|
||||
animate_css_catalog=screens.ANIMATE_CSS_CATALOG,
|
||||
animate_css_labels_json=json.dumps(screens.ANIMATE_CSS_LABELS),
|
||||
fontawesome_icons=screens.FONTAWESOME_ICONS,
|
||||
global_variables=global_variables,
|
||||
)
|
||||
|
||||
@@ -41,8 +41,10 @@ from .widgets.registry import WIDGETS
|
||||
from .widgets.container_widgets import CONTAINER_WIDGETS
|
||||
from .widgets.layout_capable_widgets import LAYOUT_CAPABLE_WIDGETS
|
||||
from .widgets.font_options import GOOGLE_FONTS_LINK
|
||||
from .widgets.fontawesome_icons import FONTAWESOME_ICONS
|
||||
from .widgets.widget_meta import widget_meta
|
||||
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.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.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.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.get_element import get_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.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.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.element_label import element_label
|
||||
|
||||
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.update_element_type import update_element_type
|
||||
from .element_types.element_type_usage_count import element_type_usage_count
|
||||
from .element_types.delete_element_type import delete_element_type
|
||||
from .element_types.instantiate_template_tree import instantiate_template_tree
|
||||
|
||||
from .legacy_actions.list_actions import list_actions
|
||||
from .legacy_actions.add_action import add_action
|
||||
@@ -101,6 +108,7 @@ from .flow.add_flow_edge import add_flow_edge
|
||||
from .flow.delete_flow_edge import delete_flow_edge
|
||||
|
||||
from .data_actions.apply_data_action import apply_data_action
|
||||
from .data_actions.apply_variable_action import apply_variable_action
|
||||
|
||||
from .animations.ensure_animation_schema import ensure_animation_schema
|
||||
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
|
||||
@@ -6,7 +6,6 @@ from ..widgets.registry import WIDGETS
|
||||
from ..widgets.widget_meta import widget_meta
|
||||
from ..widgets.default_style_for_widget import default_style_and_attributes
|
||||
from ..element_types.list_element_types import list_element_types
|
||||
from ..element_types.instantiate_template_tree import instantiate_template_tree
|
||||
|
||||
|
||||
def add_element(slug, screen_id, widget_key, element_type_id=None, parent_id=None):
|
||||
@@ -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
|
||||
défaut pour rester cohérent si jamais il en sort un jour.
|
||||
|
||||
element_type_id : si fourni ET que cet élément de jeu a un écran-modèle
|
||||
(le cas normal désormais — voir create_element_type), tout son arbre est
|
||||
COPIÉ en profondeur ici plutôt que de créer un seul élément plat : c'est
|
||||
ce qui permet à un élément de jeu d'être un mini-conteneur réutilisable
|
||||
avec plusieurs éléments imbriqués à l'intérieur, pas juste un widget
|
||||
unique avec un contenu par défaut."""
|
||||
element_type_id : si fourni, un SEUL élément est créé ici (ligne plate,
|
||||
comme n'importe quel widget), qui garde juste une référence vers cet
|
||||
élément de jeu — son contenu (l'arbre de son écran-modèle) n'est PAS
|
||||
copié en base : il est rechargé EN DIRECT à chaque affichage (voir
|
||||
render_element_html.py), exactement comme un modèle de ligne de
|
||||
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:
|
||||
types = {t["id"]: t for t in list_element_types(slug)}
|
||||
et = types.get(int(element_type_id))
|
||||
if et and et.get("template_screen_id"):
|
||||
created = instantiate_template_tree(slug, et["template_screen_id"], screen_id, parent_id)
|
||||
if created:
|
||||
conn = db.connect(slug)
|
||||
conn.execute(
|
||||
"UPDATE _screen_elements SET element_type_id = ? WHERE id = ?", (et["id"], created[0])
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return created[0]
|
||||
# Modèle vide (aucun élément dedans pour l'instant) : on retombe
|
||||
# sur l'ancien comportement plat ci-dessous, pour ne jamais
|
||||
# échouer silencieusement à poser quelque chose.
|
||||
|
||||
widget_key = widget_key if widget_key in WIDGETS else "conteneur"
|
||||
if et:
|
||||
widget_key = et.get("default_widget") or widget_key
|
||||
meta = widget_meta(widget_key)
|
||||
# Réglages dont la valeur par défaut a un effet visuel voulu dès la
|
||||
# création (ex: une image démarre avec "cover" pour se recadrer
|
||||
|
||||
@@ -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
|
||||
@@ -1,8 +1,35 @@
|
||||
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):
|
||||
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
|
||||
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.commit()
|
||||
conn.close()
|
||||
|
||||
@@ -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
|
||||
@@ -5,14 +5,20 @@ import db
|
||||
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
|
||||
l'imbrication) — pratique pour construire l'arbre côté appelant. Seuls
|
||||
les éléments de premier niveau (parent_id NULL) reçoivent un
|
||||
"rendered_html" : celui d'un conteneur/répéteur/groupe de champs inclut
|
||||
déjà, en le générant récursivement, le HTML de tout ce qui est posé à
|
||||
l'intérieur — les enfants n'ont donc pas besoin d'être dessinés une
|
||||
deuxième fois séparément."""
|
||||
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)
|
||||
rows = conn.execute(
|
||||
"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["attributes"] = json.loads(d["attributes"] or "{}")
|
||||
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 = {}
|
||||
for d in result:
|
||||
if d.get("parent_id"):
|
||||
children_map.setdefault(d["parent_id"], []).append(d)
|
||||
play_ctx = {"_forge_play_mode": True} if enforce_visibility else None
|
||||
for d in result:
|
||||
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
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -6,6 +6,16 @@ from ..widgets.widget_meta import widget_meta
|
||||
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):
|
||||
"""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
|
||||
@@ -64,6 +74,56 @@ def save_element_controls(slug, element_id, form):
|
||||
style["transform"] = f"scale({val})"
|
||||
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"]
|
||||
if ctype == "checkbox":
|
||||
checked = form.get(field_name) is not None
|
||||
@@ -78,6 +138,14 @@ def save_element_controls(slug, element_id, form):
|
||||
content = raw_value
|
||||
else:
|
||||
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
|
||||
if raw_value in (None, ""):
|
||||
store.pop(name, None)
|
||||
|
||||
@@ -10,9 +10,29 @@
|
||||
# casser sur les jeux déjà créés, mais n'est plus exposé dans l'éditeur ni
|
||||
# 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 = [
|
||||
("clic", "Au clic"),
|
||||
("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 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
|
||||
@@ -44,4 +64,5 @@ FLOW_NODE_FIELDS = {
|
||||
"cond_clauses", "cond_combinator",
|
||||
"action_type", "target_screen_id", "target_element_id", "element_property", "element_value",
|
||||
"target_definition_id", "target_row_id", "target_field", "data_operation", "data_value",
|
||||
"target_variable",
|
||||
}
|
||||
|
||||
@@ -51,5 +51,10 @@ def ensure_flow_schema(slug):
|
||||
conn.execute("ALTER TABLE _flow_nodes ADD COLUMN cond_clauses TEXT")
|
||||
if "cond_combinator" not in cols:
|
||||
conn.execute("ALTER TABLE _flow_nodes ADD COLUMN cond_combinator TEXT NOT NULL DEFAULT 'et'")
|
||||
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.close()
|
||||
|
||||
@@ -4,6 +4,7 @@ ACTION_TYPE_LABELS = {
|
||||
"aller_a": "Aller à un écran précis",
|
||||
"modifier_element": "Modifier un élément",
|
||||
"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",
|
||||
"activer_onglet": "Afficher cet élément, masquer tous ses frères",
|
||||
"attendre": "Attendre quelques secondes avant de continuer",
|
||||
|
||||
@@ -2,6 +2,14 @@
|
||||
|
||||
ELEMENT_ACTION_PROPERTIES = [
|
||||
("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"),
|
||||
("border_color", "Couleur de bordure"),
|
||||
("width", "Largeur en %"),
|
||||
|
||||
@@ -2,4 +2,6 @@ TRIGGER_LABELS = {
|
||||
"clic": "Au clic",
|
||||
"soumission": "À la soumission",
|
||||
"affichage": "À l'affichage de l'écran",
|
||||
"survol": "Au survol",
|
||||
"fin_survol": "Fin du survol",
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ def full_game_payload(slug):
|
||||
flows = {}
|
||||
animations = {}
|
||||
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})
|
||||
flows[str(s["id"])] = {
|
||||
"nodes": list_flow_nodes(slug, s["id"]),
|
||||
|
||||
@@ -5,5 +5,10 @@ def _apply_ctx(text, ctx):
|
||||
if not ctx or not text:
|
||||
return text
|
||||
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)
|
||||
return text
|
||||
|
||||
@@ -9,6 +9,25 @@ import db
|
||||
# d'un Répéteur, voir _apply_ctx / applyOpenRowBindings côté JS).
|
||||
_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):
|
||||
"""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,
|
||||
outils débloqués... — ne garde qu'une seule ligne, mise à jour en place
|
||||
par des actions "Modifier une donnée" plutôt que d'en créer une
|
||||
nouvelle à chaque fois). Sinon, renvoie raw_value tel quel (valeur
|
||||
fixe)."""
|
||||
nouvelle à chaque fois). Si c'est une référence "{{$nom_variable}}", va
|
||||
lire la valeur ACTUELLE de cette variable globale (voir
|
||||
db/global_vars/). Sinon, renvoie raw_value tel quel (valeur fixe)."""
|
||||
if not 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:
|
||||
return raw_value
|
||||
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"])
|
||||
rows = db.list_rows(slug, full)
|
||||
if rows:
|
||||
return rows[0].get(db.slugify(field_name).replace("-", "_"))
|
||||
return rows[0].get(_field_column(full, field_name))
|
||||
return None
|
||||
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
|
||||
deux doivent rester alignées si on ajoute un opérateur."""
|
||||
if field_type == "booleen":
|
||||
a = 1 if actual else 0
|
||||
e = 1 if str(expected).strip().lower() in ("1", "true", "vrai") else 0
|
||||
# "actual" est un int SQLite (0/1) pour un champ d'objet, mais une
|
||||
# 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
|
||||
try:
|
||||
an, en = float(actual), float(expected)
|
||||
@@ -76,19 +118,30 @@ def _compare(actual, operator, expected, field_type):
|
||||
return False
|
||||
|
||||
|
||||
def filter_repeater_rows(slug, definition, rows, attrs):
|
||||
"""Ne garde, parmi les lignes d'un Répéteur, que celles qui correspondent
|
||||
au filtre optionnel réglé dans ses propriétés (_filtre_champ /
|
||||
_filtre_operateur / _filtre_valeur) — voir 1.2 dans la doc de cadrage
|
||||
Forge Engine ("affichage piloté par la donnée sur un écran unique") :
|
||||
c'est ce qui permet à un seul écran de n'afficher, par exemple, que les
|
||||
mails du niveau en cours plutôt que la boîte de réception entière."""
|
||||
field_name = (attrs.get("_filtre_champ") or "").strip()
|
||||
operator = attrs.get("_filtre_operateur") or "egal"
|
||||
def _apply_one_filter(slug, definition, rows, field_name, operator, raw_value):
|
||||
if not field_name:
|
||||
return rows
|
||||
raw_value = attrs.get("_filtre_valeur") or ""
|
||||
expected = _resolve_filter_value(slug, raw_value)
|
||||
expected = _resolve_filter_value(slug, raw_value or "")
|
||||
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)]
|
||||
|
||||
|
||||
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
|
||||
style = _style_string(el, " display:flex; align-items:center; gap:8px;", parent_flex_direction)
|
||||
content = _apply_ctx(el.get("content") or "", ctx)
|
||||
return f'<label {_attr_string({}, style)}><input {_attr_string(attrs, "")}> {html_lib.escape(content)}</label>'
|
||||
# <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>'
|
||||
|
||||
@@ -13,6 +13,11 @@ from .render_checkbox_or_radio import _render_checkbox_or_radio
|
||||
from .render_repeater import _render_repeater
|
||||
from .render_jauge import _render_jauge
|
||||
from .render_overlay import _render_overlay
|
||||
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):
|
||||
@@ -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
|
||||
par défaut de cet élément (voir _style_string)."""
|
||||
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")
|
||||
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":
|
||||
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":
|
||||
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":
|
||||
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":
|
||||
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":
|
||||
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":
|
||||
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")
|
||||
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)
|
||||
|
||||
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)
|
||||
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)
|
||||
|
||||
if tag in ("ul", "ol"):
|
||||
items = [line.strip() for line in content.split("\n") if line.strip()]
|
||||
inner = "".join(f"<li>{html_lib.escape(item)}</li>" for item in items) + child_html
|
||||
return f"<{tag} {_attr_string(attrs, style)}>{inner}</{tag}>"
|
||||
return _mark(f"<{tag} {_attr_string(attrs, style)}>{inner}</{tag}>")
|
||||
|
||||
safe_content = html_lib.escape(content).replace("\n", "<br>") if content else ""
|
||||
return f"<{tag} {_attr_string(attrs, style)}>{safe_content}{child_html}</{tag}>"
|
||||
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"]
|
||||
)
|
||||
|
||||
@@ -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>"
|
||||
@@ -22,12 +22,15 @@ def _interpolate_color(low_hex, high_hex, ratio):
|
||||
return "#{:02x}{:02x}{:02x}".format(*mixed)
|
||||
|
||||
|
||||
def _read_latest_field_value(slug, definition_id, field_name):
|
||||
"""Lit la valeur du champ demandé sur la ligne la PLUS RÉCENTE de
|
||||
l'objet visé — même convention que le filtre de Répéteur (1.2) : un
|
||||
objet utilisé comme jauge de partie (Réputation, Trésorerie...) ne
|
||||
garde qu'une seule ligne, mise à jour en place."""
|
||||
if not definition_id or not slug:
|
||||
def _read_field_value(slug, definition_id, row_id, field_name):
|
||||
"""Lit la valeur du champ demandé sur l'enregistrement visé (row_id) de
|
||||
l'objet suivi — quand un objet contient plusieurs enregistrements (ex:
|
||||
Réputation/Trésorerie/Confiance dans un même objet "jauge"), row_id est
|
||||
ce qui distingue lequel suivre. Repli sur la ligne la PLUS RÉCENTE si
|
||||
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
|
||||
definition = db.get_definition(slug, int(definition_id))
|
||||
if not definition:
|
||||
@@ -35,8 +38,13 @@ def _read_latest_field_value(slug, definition_id, field_name):
|
||||
rows = db.list_rows(slug, definition)
|
||||
if not rows:
|
||||
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("-", "_")
|
||||
return rows[0].get(col)
|
||||
return row.get(col)
|
||||
|
||||
|
||||
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
|
||||
à poser dans le graphe de logique."""
|
||||
attrs = el.get("attributes") or {}
|
||||
style = _style_string(el, " overflow:hidden; position:relative; background:#1c2130; border-radius:6px;", parent_flex_direction)
|
||||
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")
|
||||
row_id = attrs.get("_row_id")
|
||||
field_name = (attrs.get("_champ") or "").strip()
|
||||
field_name_nom = (attrs.get("_champ_nom") or "").strip()
|
||||
try:
|
||||
min_v = float(attrs.get("_min", "0") or 0)
|
||||
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"
|
||||
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:
|
||||
value = float(raw_value)
|
||||
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>'
|
||||
)
|
||||
|
||||
# La classe "jaugeBar" est repérée côté client (play.html, refreshRuntimeData)
|
||||
# pour savoir que ce wrapper doit être régénéré après toute action
|
||||
# "Modifier une donnée" — sinon la jauge resterait figée sur sa valeur
|
||||
# de départ après un changement, exactement comme il fallait déjà
|
||||
# repérer "repeaterItem" pour les Répéteurs (voir 1.1/1.2).
|
||||
name_html = ""
|
||||
if field_name_nom:
|
||||
nom_value = _read_field_value(slug, definition_id, row_id, field_name_nom)
|
||||
if nom_value not in (None, ""):
|
||||
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 = (
|
||||
f'<div class="jaugeBar" style="position:absolute; left:0; top:0; bottom:0; width:{percent}%; '
|
||||
f'background-color:{fill_color}; transition:width .3s ease, background-color .3s ease;"></div>'
|
||||
f'<progress class="{bar_class}" value="{percent}" max="100" '
|
||||
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 (
|
||||
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>'
|
||||
)
|
||||
|
||||
@@ -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>"
|
||||
)
|
||||
@@ -5,7 +5,7 @@ import db
|
||||
from .style_string import _style_string
|
||||
from .auto_id import _auto_id
|
||||
from .apply_ctx import _apply_ctx
|
||||
from .filter_repeater_rows import filter_repeater_rows
|
||||
from .filter_repeater_rows import filter_repeater_rows, _field_column
|
||||
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.)
|
||||
rows = db.list_rows(slug, definition)
|
||||
rows = filter_repeater_rows(slug, definition, rows, attrs_raw)
|
||||
field_cols = {f["name"]: db.slugify(f["name"]).replace("-", "_") for f in definition["fields"]}
|
||||
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) :
|
||||
# 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:
|
||||
line = template
|
||||
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)
|
||||
inner = html_lib.escape(line)
|
||||
# data-row-id : identifiant STABLE de la vraie ligne de données
|
||||
|
||||
@@ -4,12 +4,28 @@ from .visible_attrs import _visible_attrs
|
||||
from .apply_ctx import _apply_ctx
|
||||
from .style_string import _style_string
|
||||
from .attr_string import _attr_string
|
||||
from .auto_id import _auto_id
|
||||
|
||||
|
||||
def _render_select(el, meta, slug=None, children_map=None, ctx=None, parent_flex_direction=None):
|
||||
attrs = _visible_attrs(el, meta, ctx)
|
||||
style = _style_string(el, parent_flex_direction=parent_flex_direction)
|
||||
# Bulma stylise un <select> via la classe "select" posée sur un <div>
|
||||
# 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)
|
||||
options = [o.strip() for o in content.split("\n") if o.strip()]
|
||||
opts_html = "".join(f'<option value="{html_lib.escape(o)}">{html_lib.escape(o)}</option>' for o in options)
|
||||
return f'<select {_attr_string(attrs, style)}>{opts_html}</select>'
|
||||
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>'
|
||||
|
||||
@@ -10,15 +10,12 @@ def _render_table(el, meta, slug=None, children_map=None, ctx=None, parent_flex_
|
||||
attrs = _visible_attrs(el, meta, ctx)
|
||||
header_row = (el.get("attributes") or {}).get("_header_row") in ("1", "true", "on")
|
||||
content = _apply_ctx(el.get("content") or "", ctx)
|
||||
style = _style_string(el, " border-collapse:collapse;", parent_flex_direction)
|
||||
style = _style_string(el, "", parent_flex_direction)
|
||||
rows = [r for r in content.split("\n") if r.strip()]
|
||||
html_rows = []
|
||||
for i, r in enumerate(rows):
|
||||
cells = r.split(";")
|
||||
cell_tag = "th" if (header_row and i == 0) else "td"
|
||||
cells_html = "".join(
|
||||
f'<{cell_tag} style="border:1px solid #2b3140; padding:6px 8px;">{html_lib.escape(c.strip())}</{cell_tag}>'
|
||||
for c in cells
|
||||
)
|
||||
cells_html = "".join(f"<{cell_tag}>{html_lib.escape(c.strip())}</{cell_tag}>" for c in cells)
|
||||
html_rows.append(f"<tr>{cells_html}</tr>")
|
||||
return f'<table {_attr_string(attrs, style)}>{"".join(html_rows)}</table>'
|
||||
|
||||
@@ -0,0 +1,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"]}
|
||||
@@ -38,6 +38,16 @@ def _style_string(el, extra_base="", parent_flex_direction=None, widget_key=None
|
||||
nonlocal has_size_override
|
||||
if v in (None, ""):
|
||||
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:
|
||||
# Un élément posé directement sur un écran a déjà ce "transform"
|
||||
# posé sur son cadre .canvasElementInner / .playElementInner
|
||||
|
||||
@@ -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)
|
||||
@@ -3,11 +3,23 @@ from .auto_id import _auto_id
|
||||
|
||||
|
||||
def _visible_attrs(el, meta, ctx=None):
|
||||
raw_attrs = el.get("attributes") or {}
|
||||
attrs = {
|
||||
k: (_apply_ctx(v, ctx) if isinstance(v, str) else v)
|
||||
for k, v in (el.get("attributes") or {}).items() if not k.startswith("_")
|
||||
for k, v in raw_attrs.items() if not k.startswith("_")
|
||||
}
|
||||
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["data-element-id"] = el["id"]
|
||||
if el.get("parent_id"):
|
||||
|
||||
@@ -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
|
||||
# d'un conteneur/répéteur/groupe de champs : par défaut, ces éléments
|
||||
# remplissent la largeur disponible et s'ajustent en hauteur à leur contenu
|
||||
# (voir _style_string) — 0 garde ce comportement automatique ; une valeur
|
||||
# non nulle impose une taille fixe (ex: une image de 120px de large dans une
|
||||
# ligne, pour que "Espacement égal entre eux"/"autour"/"uniforme" sur le
|
||||
# conteneur parent ait un effet visible, ce qui n'est pas le cas tant que
|
||||
# chaque élément remplit 100% de la largeur).
|
||||
# Taille d'un élément posé À L'INTÉRIEUR d'un conteneur/répéteur/groupe de
|
||||
# champs : par défaut, ces éléments remplissent la largeur disponible et
|
||||
# s'ajustent en hauteur à leur contenu (voir _style_string) — 0 garde ce
|
||||
# comportement automatique ; une valeur non nulle impose une taille fixe,
|
||||
# dans l'unité choisie par l'utilisateur (% du parent, qui suit
|
||||
# naturellement n'importe quelle taille d'écran — ou px, pour une taille
|
||||
# précise, ex: une image de 120px de large dans une ligne, pour que
|
||||
# "Espacement égal entre eux"/"autour"/"uniforme" sur le conteneur parent
|
||||
# ait un effet visible, ce qui n'est pas le cas tant que chaque élément
|
||||
# remplit 100% de la largeur).
|
||||
#
|
||||
# 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 = [
|
||||
c_slider("child-width", "Largeur fixe", "style:width",
|
||||
min=0, max=1000, default=0, group="Taille dans le conteneur"),
|
||||
c_slider("child-height", "Hauteur fixe", "style:height",
|
||||
min=0, max=1000, default=0, group="Taille dans le conteneur"),
|
||||
c_size("child-width", "Largeur", "style:width", default=0, group="Position & taille"),
|
||||
c_size("child-height", "Hauteur", "style:height", default=0, group="Position & taille"),
|
||||
]
|
||||
|
||||
@@ -1,8 +1,16 @@
|
||||
from ..controls.c_scale import c_scale
|
||||
from .visibility_control import VISIBILITY_CONTROL
|
||||
from .visibility_condition_controls import VISIBILITY_CONDITION_CONTROLS
|
||||
from .size_override_controls import SIZE_OVERRIDE_CONTROLS
|
||||
from .spacing_controls import SPACING_CONTROLS
|
||||
from .border_controls import BORDER_CONTROLS
|
||||
from .hover_controls import HOVER_CONTROLS
|
||||
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,6 +23,29 @@ def _control_value(el, control):
|
||||
m = re.search(r"scale\(([\d.]+)\)", transform)
|
||||
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"]
|
||||
if target == "content":
|
||||
return el.get("content") or control.get("default", "")
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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}
|
||||
@@ -1,2 +1,11 @@
|
||||
def c_slider(key, label, target, unit="px", min=0, max=100, step=1, default=16, group=None):
|
||||
return {"key": key, "label": label, "type": "slider", "target": target, "unit": unit, "min": min, "max": max, "step": step, "default": default, "group": group}
|
||||
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,
|
||||
# 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,
|
||||
}
|
||||
|
||||
@@ -2,23 +2,94 @@ import db
|
||||
|
||||
from .widget_meta import widget_meta
|
||||
from .control_value import _control_value
|
||||
from .data_definition_options import data_definition_options
|
||||
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
|
||||
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
|
||||
lister les objets disponibles — d'où le paramètre slug."""
|
||||
"définition_id" du Répéteur de données (et de la Jauge) a besoin du jeu
|
||||
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"))
|
||||
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 = []
|
||||
for control in meta.get("controls", []):
|
||||
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)
|
||||
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":
|
||||
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]
|
||||
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)})
|
||||
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}
|
||||
@@ -1,14 +1,5 @@
|
||||
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
|
||||
# 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
|
||||
@@ -27,6 +18,18 @@ _PER_SIDE_KEYS = {
|
||||
"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):
|
||||
"""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
|
||||
aux modifications ultérieures via le panneau de propriétés."""
|
||||
meta = widget_meta(widget_key)
|
||||
is_native_class_widget = bool(meta.get("fixed_attrs", {}).get("class"))
|
||||
attributes, style = {}, {}
|
||||
for control in meta.get("controls", []):
|
||||
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
|
||||
target = control.get("target")
|
||||
if not target or target == "content":
|
||||
@@ -54,10 +92,8 @@ def default_style_and_attributes(widget_key):
|
||||
if default in (None, ""):
|
||||
continue
|
||||
if ctype == "slider":
|
||||
if control["key"] in _AUTO_SIZE_KEYS and str(default) in ("0",):
|
||||
continue # 0 = comportement automatique, rien à figer
|
||||
if control["key"] in _PER_SIDE_KEYS:
|
||||
continue # réglage par côté : jamais figé (voir commentaire plus haut)
|
||||
if control["key"] in _PER_SIDE_KEYS or control.get("no_freeze"):
|
||||
continue # réglage par côté, ou explicitement non figé (voir c_slider) : jamais figé
|
||||
unit = control.get("unit", "")
|
||||
default = f"{default}{unit}"
|
||||
kind, name = target.split(":", 1)
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
@@ -8,6 +8,12 @@ from .controls.c_toggle_token import c_toggle_token
|
||||
from .controls.c_align import c_align
|
||||
from .controls.c_select import c_select
|
||||
from .control_groups.text_style_controls_extra import TEXT_STYLE_CONTROLS_EXTRA
|
||||
from .control_groups.data_binding_controls import DATA_BINDING_CONTROLS
|
||||
from .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
|
||||
|
||||
WIDGETS = {
|
||||
@@ -15,7 +21,8 @@ WIDGETS = {
|
||||
"label": "Texte", "icon": "📝", "tag": "p", "has_content": True,
|
||||
"w": 40, "h": 10, "default_content": "Texte du paragraphe.",
|
||||
"controls": [
|
||||
c_textarea("content", "Texte", "content", default="Texte du paragraphe.", group="Contenu"),
|
||||
c_textarea("content", "Texte — {{champ}} affiche la valeur de ce champ sur la ligne trouvée ci-dessous", "content", default="Texte du paragraphe.", group="Contenu"),
|
||||
] + DATA_BINDING_CONTROLS + [
|
||||
c_slider("font-size", "Taille du texte", "style:font-size", min=10, max=64, default=16, group="Texte"),
|
||||
c_color("color", "Couleur du texte", "style:color", "#e8eaf0", group="Texte"),
|
||||
c_align("text-align", "Alignement", "style:text-align", group="Texte"),
|
||||
@@ -28,10 +35,15 @@ WIDGETS = {
|
||||
"titre": {
|
||||
"label": "Titre", "icon": "🔠", "tag": None, "has_content": True,
|
||||
"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": [
|
||||
c_textarea("content", "Texte", "content", default="Titre", group="Contenu"),
|
||||
c_textarea("content", "Texte — {{champ}} affiche la valeur de ce champ sur la ligne trouvée ci-dessous", "content", default="Titre", group="Contenu"),
|
||||
c_select("niveau", "Importance du titre", "attr:_niveau",
|
||||
[("1", "Très grand — titre principal"), ("2", "Grand"), ("3", "Moyen")], default="2", group="Contenu"),
|
||||
] + DATA_BINDING_CONTROLS + BULMA_TITLE_CONTROLS + [
|
||||
c_slider("font-size", "Taille du texte", "style:font-size", min=14, max=80, default=32, group="Texte"),
|
||||
c_color("color", "Couleur du texte", "style:color", "#e8eaf0", group="Texte"),
|
||||
c_align("text-align", "Alignement", "style:text-align", default="left", group="Texte"),
|
||||
@@ -42,14 +54,19 @@ WIDGETS = {
|
||||
"bouton": {
|
||||
"label": "Bouton", "icon": "🔘", "tag": "button", "has_content": True,
|
||||
"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": [
|
||||
c_text("content", "Texte du bouton", "content", default="Valider", group="Contenu"),
|
||||
c_color("color", "Couleur du texte", "style:color", "#ffffff", 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("border-radius", "Arrondi des coins", "style:border-radius", min=0, max=40, default=8, 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", no_freeze=True),
|
||||
c_checkbox("submit", "Valide un formulaire", "attr:type", "submit", "button", group="Contenu"),
|
||||
] + TEXT_STYLE_CONTROLS_EXTRA,
|
||||
] + BULMA_BUTTON_CONTROLS + TEXT_STYLE_CONTROLS_EXTRA,
|
||||
},
|
||||
"lien": {
|
||||
"label": "Lien", "icon": "🔗", "tag": "a", "has_content": True,
|
||||
@@ -86,30 +103,30 @@ WIDGETS = {
|
||||
},
|
||||
"champ_texte": {
|
||||
"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": [
|
||||
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_checkbox("required", "Obligatoire", "attr:required", "required", "", group="Contenu"),
|
||||
],
|
||||
] + BULMA_FIELD_CONTROLS,
|
||||
},
|
||||
"champ_email": {
|
||||
"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": [
|
||||
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_checkbox("required", "Obligatoire", "attr:required", "required", "", group="Contenu"),
|
||||
],
|
||||
] + BULMA_FIELD_CONTROLS,
|
||||
},
|
||||
"champ_mot_de_passe": {
|
||||
"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": [
|
||||
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_checkbox("required", "Obligatoire", "attr:required", "required", "", group="Contenu"),
|
||||
],
|
||||
] + BULMA_FIELD_CONTROLS,
|
||||
},
|
||||
"case_a_cocher": {
|
||||
"label": "Case à cocher", "icon": "☑️", "tag": "input", "has_content": True, "special_render": "checkbox_label",
|
||||
@@ -135,12 +152,12 @@ WIDGETS = {
|
||||
},
|
||||
"zone_texte": {
|
||||
"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": [
|
||||
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_checkbox("required", "Obligatoire", "attr:required", "required", "", group="Contenu"),
|
||||
],
|
||||
] + BULMA_FIELD_CONTROLS,
|
||||
},
|
||||
"liste_deroulante": {
|
||||
"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_text("name", "Nom du champ", "attr:name", placeholder="ex. choix", group="Contenu"),
|
||||
c_checkbox("required", "Obligatoire", "attr:required", "required", "", group="Contenu"),
|
||||
],
|
||||
] + BULMA_FIELD_CONTROLS,
|
||||
},
|
||||
"groupe_champs": {
|
||||
"label": "Groupe de champs", "icon": "🗃️", "tag": "fieldset", "has_content": True, "special_render": "fieldset_legend",
|
||||
@@ -181,20 +198,27 @@ WIDGETS = {
|
||||
"tableau": {
|
||||
"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",
|
||||
"fixed_attrs": {"class": "table"},
|
||||
"controls": [
|
||||
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"),
|
||||
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"),
|
||||
],
|
||||
] + BULMA_TABLE_CONTROLS,
|
||||
},
|
||||
"conteneur": {
|
||||
"label": "Conteneur / décor", "icon": "⬛", "tag": "div", "has_content": True,
|
||||
"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": [
|
||||
c_text("content", "Texte", "content", group="Contenu"),
|
||||
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"),
|
||||
],
|
||||
},
|
||||
@@ -216,11 +240,16 @@ WIDGETS = {
|
||||
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_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",
|
||||
"attr:_filtre_champ", placeholder="ex. niveau", default="", group="Filtre"),
|
||||
c_select("filtre_champ", "Filtrer : n'afficher que les lignes où ce champ",
|
||||
"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_text("filtre_valeur", "...cette valeur, ou {{NomObjet.champ}} pour comparer à une donnée qui change en cours de partie",
|
||||
"attr:_filtre_valeur", placeholder="ex. 3 ou {{Partie.niveau_courant}}", default="", group="Filtre"),
|
||||
c_text("filtre_valeur", "...cette valeur",
|
||||
"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": {
|
||||
@@ -232,14 +261,32 @@ WIDGETS = {
|
||||
# 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",
|
||||
[("", "Sélectionner un objet")], default="", group="Donnée"),
|
||||
c_text("champ", "Champ numérique à afficher",
|
||||
"attr:_champ", placeholder="ex. reputation", default="", group="Donnée"),
|
||||
# row_id/champ/champ_nom : options peuplées dynamiquement une
|
||||
# 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("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_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_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": {
|
||||
# 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"),
|
||||
],
|
||||
},
|
||||
"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"),
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |