diff --git a/db/__init__.py b/db/__init__.py index 80714494..02ce34f0 100644 --- a/db/__init__.py +++ b/db/__init__.py @@ -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", ] diff --git a/db/connection.py b/db/connection.py index 5eff1c3a..21805dae 100644 --- a/db/connection.py +++ b/db/connection.py @@ -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() diff --git a/db/constants.py b/db/constants.py index 43b5a49b..184f754c 100644 --- a/db/constants.py +++ b/db/constants.py @@ -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"], +} diff --git a/db/global_vars/__init__.py b/db/global_vars/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/db/global_vars/create_global_variable.py b/db/global_vars/create_global_variable.py new file mode 100644 index 00000000..29501df1 --- /dev/null +++ b/db/global_vars/create_global_variable.py @@ -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 diff --git a/db/global_vars/delete_global_variable.py b/db/global_vars/delete_global_variable.py new file mode 100644 index 00000000..2525bee3 --- /dev/null +++ b/db/global_vars/delete_global_variable.py @@ -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() diff --git a/db/global_vars/delete_global_variable_by_id.py b/db/global_vars/delete_global_variable_by_id.py new file mode 100644 index 00000000..8117cbeb --- /dev/null +++ b/db/global_vars/delete_global_variable_by_id.py @@ -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() diff --git a/db/global_vars/ensure_global_vars_schema.py b/db/global_vars/ensure_global_vars_schema.py new file mode 100644 index 00000000..c9a01899 --- /dev/null +++ b/db/global_vars/ensure_global_vars_schema.py @@ -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() diff --git a/db/global_vars/get_global_variable.py b/db/global_vars/get_global_variable.py new file mode 100644 index 00000000..e0102a70 --- /dev/null +++ b/db/global_vars/get_global_variable.py @@ -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 diff --git a/db/global_vars/list_global_variables.py b/db/global_vars/list_global_variables.py new file mode 100644 index 00000000..0bffbe63 --- /dev/null +++ b/db/global_vars/list_global_variables.py @@ -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] diff --git a/db/global_vars/update_global_variable.py b/db/global_vars/update_global_variable.py new file mode 100644 index 00000000..98c26831 --- /dev/null +++ b/db/global_vars/update_global_variable.py @@ -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() diff --git a/db/global_vars/update_global_variable_value.py b/db/global_vars/update_global_variable_value.py new file mode 100644 index 00000000..a6d85ff3 --- /dev/null +++ b/db/global_vars/update_global_variable_value.py @@ -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 diff --git a/routes/__init__.py b/routes/__init__.py index a82f793e..a4b6f4b4 100644 --- a/routes/__init__.py +++ b/routes/__init__.py @@ -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 diff --git a/routes/elements/__init__.py b/routes/elements/__init__.py index 4a687a8c..ad59e67d 100644 --- a/routes/elements/__init__.py +++ b/routes/elements/__init__.py @@ -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, ) diff --git a/routes/elements/element_add.py b/routes/elements/element_add.py index 40786900..02bfab4c 100644 --- a/routes/elements/element_add.py +++ b/routes/elements/element_add.py @@ -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)) diff --git a/routes/elements/element_delete_impact.py b/routes/elements/element_delete_impact.py new file mode 100644 index 00000000..025adefe --- /dev/null +++ b/routes/elements/element_delete_impact.py @@ -0,0 +1,16 @@ +from flask import jsonify + +import screens + +from core.flask_app import app + + +@app.route("/game//elements//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)}) diff --git a/routes/elements/element_duplicate.py b/routes/elements/element_duplicate.py new file mode 100644 index 00000000..bca9841b --- /dev/null +++ b/routes/elements/element_duplicate.py @@ -0,0 +1,14 @@ +from flask import redirect, url_for, abort + +import screens + +from core.flask_app import app + + +@app.route("/game//elements//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)) diff --git a/routes/elements/element_move_to_container.py b/routes/elements/element_move_to_container.py new file mode 100644 index 00000000..9b265527 --- /dev/null +++ b/routes/elements/element_move_to_container.py @@ -0,0 +1,21 @@ +from flask import request, jsonify, abort + +import screens + +from core.flask_app import app + + +@app.route("/game//elements//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}) diff --git a/routes/elements/element_reorder.py b/routes/elements/element_reorder.py new file mode 100644 index 00000000..a3125505 --- /dev/null +++ b/routes/elements/element_reorder.py @@ -0,0 +1,22 @@ +from flask import request, jsonify, abort + +import screens + +from core.flask_app import app + + +@app.route("/game//elements//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}) diff --git a/routes/elements/element_tab_add.py b/routes/elements/element_tab_add.py new file mode 100644 index 00000000..4dffd447 --- /dev/null +++ b/routes/elements/element_tab_add.py @@ -0,0 +1,14 @@ +from flask import redirect, url_for, abort + +import screens + +from core.flask_app import app + + +@app.route("/game//elements//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)) diff --git a/routes/flow/__init__.py b/routes/flow/__init__.py index 2524b08e..d28a34e3 100644 --- a/routes/flow/__init__.py +++ b/routes/flow/__init__.py @@ -5,5 +5,6 @@ from . import ( flow_edge_add, flow_edge_delete, flow_node_run_data, + flow_node_run_variable, runtime_payload, ) diff --git a/routes/flow/flow_node_run_data.py b/routes/flow/flow_node_run_data.py index 63d24434..0d362df8 100644 --- a/routes/flow/flow_node_run_data.py +++ b/routes/flow/flow_node_run_data.py @@ -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}) diff --git a/routes/flow/flow_node_run_variable.py b/routes/flow/flow_node_run_variable.py new file mode 100644 index 00000000..ba9dedf2 --- /dev/null +++ b/routes/flow/flow_node_run_variable.py @@ -0,0 +1,17 @@ +from flask import jsonify + +import screens + +from core.flask_app import app + + +@app.route("/game//flow/nodes//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}) diff --git a/routes/games/game_rename.py b/routes/games/game_rename.py index 64477672..1fdd5730 100644 --- a/routes/games/game_rename.py +++ b/routes/games/game_rename.py @@ -7,7 +7,7 @@ from core.flask_app import app @app.route("/game//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)) diff --git a/routes/global_vars/__init__.py b/routes/global_vars/__init__.py new file mode 100644 index 00000000..e076e9b1 --- /dev/null +++ b/routes/global_vars/__init__.py @@ -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 diff --git a/routes/global_vars/create_global_var.py b/routes/global_vars/create_global_var.py new file mode 100644 index 00000000..38e88827 --- /dev/null +++ b/routes/global_vars/create_global_var.py @@ -0,0 +1,18 @@ +from flask import request, redirect, url_for + +import db + +from core.flask_app import app + + +@app.route("/game//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)) diff --git a/routes/global_vars/game_variables.py b/routes/global_vars/game_variables.py new file mode 100644 index 00000000..80ae9ff0 --- /dev/null +++ b/routes/global_vars/game_variables.py @@ -0,0 +1,19 @@ +from flask import render_template + +import db + +from core.flask_app import app + + +@app.route("/game//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, + ) diff --git a/routes/global_vars/global_var_delete.py b/routes/global_vars/global_var_delete.py new file mode 100644 index 00000000..4aa8920d --- /dev/null +++ b/routes/global_vars/global_var_delete.py @@ -0,0 +1,11 @@ +from flask import redirect, url_for + +import db + +from core.flask_app import app + + +@app.route("/game//variables//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)) diff --git a/routes/global_vars/global_var_edit.py b/routes/global_vars/global_var_edit.py new file mode 100644 index 00000000..d09ef66f --- /dev/null +++ b/routes/global_vars/global_var_edit.py @@ -0,0 +1,13 @@ +from flask import request, redirect, url_for + +import db + +from core.flask_app import app + + +@app.route("/game//variables//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)) diff --git a/routes/screens/screen_edit.py b/routes/screens/screen_edit.py index 6e994def..886b4793 100644 --- a/routes/screens/screen_edit.py +++ b/routes/screens/screen_edit.py @@ -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, ) diff --git a/screens/__init__.py b/screens/__init__.py index 5313fb71..3ad5dc2e 100644 --- a/screens/__init__.py +++ b/screens/__init__.py @@ -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 diff --git a/screens/data_actions/apply_variable_action.py b/screens/data_actions/apply_variable_action.py new file mode 100644 index 00000000..f3d37704 --- /dev/null +++ b/screens/data_actions/apply_variable_action.py @@ -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) diff --git a/screens/element_types/get_element_type_by_template_screen.py b/screens/element_types/get_element_type_by_template_screen.py new file mode 100644 index 00000000..1533cced --- /dev/null +++ b/screens/element_types/get_element_type_by_template_screen.py @@ -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 diff --git a/screens/element_types/instantiate_template_tree.py b/screens/element_types/instantiate_template_tree.py deleted file mode 100644 index f9d31382..00000000 --- a/screens/element_types/instantiate_template_tree.py +++ /dev/null @@ -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 diff --git a/screens/elements/add_element.py b/screens/elements/add_element.py index ef7b3b0f..111093ca 100644 --- a/screens/elements/add_element.py +++ b/screens/elements/add_element.py @@ -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 diff --git a/screens/elements/add_tab.py b/screens/elements/add_tab.py new file mode 100644 index 00000000..8b0036d1 --- /dev/null +++ b/screens/elements/add_tab.py @@ -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 diff --git a/screens/elements/delete_element.py b/screens/elements/delete_element.py index 5aaaa178..b1d37aac 100644 --- a/screens/elements/delete_element.py +++ b/screens/elements/delete_element.py @@ -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() diff --git a/screens/elements/duplicate_element.py b/screens/elements/duplicate_element.py new file mode 100644 index 00000000..ecd20e0b --- /dev/null +++ b/screens/elements/duplicate_element.py @@ -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 diff --git a/screens/elements/element_descendant_ids.py b/screens/elements/element_descendant_ids.py new file mode 100644 index 00000000..aa10b3cb --- /dev/null +++ b/screens/elements/element_descendant_ids.py @@ -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 diff --git a/screens/elements/flow_nodes_referencing_element.py b/screens/elements/flow_nodes_referencing_element.py new file mode 100644 index 00000000..ec7257f4 --- /dev/null +++ b/screens/elements/flow_nodes_referencing_element.py @@ -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 diff --git a/screens/elements/list_elements.py b/screens/elements/list_elements.py index 11471e5c..9025346b 100644 --- a/screens/elements/list_elements.py +++ b/screens/elements/list_elements.py @@ -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 diff --git a/screens/elements/move_element_to_container.py b/screens/elements/move_element_to_container.py new file mode 100644 index 00000000..17e6bf67 --- /dev/null +++ b/screens/elements/move_element_to_container.py @@ -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() diff --git a/screens/elements/reorder_element.py b/screens/elements/reorder_element.py new file mode 100644 index 00000000..5c926e56 --- /dev/null +++ b/screens/elements/reorder_element.py @@ -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() diff --git a/screens/elements/save_element_controls.py b/screens/elements/save_element_controls.py index 7df352b4..a5c66542 100644 --- a/screens/elements/save_element_controls.py +++ b/screens/elements/save_element_controls.py @@ -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 + # 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,7 +138,15 @@ def save_element_controls(slug, element_id, form): content = raw_value else: kind, name = target.split(":", 1) - store = attributes if kind == "attr" else style + 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) elif ctype != "checkbox" and name not in store and _is_default_value(control, raw_value): diff --git a/screens/flow/constants.py b/screens/flow/constants.py index 84fe0536..89050b47 100644 --- a/screens/flow/constants.py +++ b/screens/flow/constants.py @@ -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", } diff --git a/screens/flow/ensure_flow_schema.py b/screens/flow/ensure_flow_schema.py index f0347d37..a796fe91 100644 --- a/screens/flow/ensure_flow_schema.py +++ b/screens/flow/ensure_flow_schema.py @@ -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() diff --git a/screens/labels/action_type_labels.py b/screens/labels/action_type_labels.py index 28e02ae9..cd0f65ef 100644 --- a/screens/labels/action_type_labels.py +++ b/screens/labels/action_type_labels.py @@ -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", diff --git a/screens/labels/element_action_properties.py b/screens/labels/element_action_properties.py index 0a479aa3..84da90b2 100644 --- a/screens/labels/element_action_properties.py +++ b/screens/labels/element_action_properties.py @@ -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 %"), diff --git a/screens/labels/trigger_labels.py b/screens/labels/trigger_labels.py index c72a0e5d..49bc3531 100644 --- a/screens/labels/trigger_labels.py +++ b/screens/labels/trigger_labels.py @@ -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", } diff --git a/screens/payload/full_game_payload.py b/screens/payload/full_game_payload.py index 5d90b790..9f4cc791 100644 --- a/screens/payload/full_game_payload.py +++ b/screens/payload/full_game_payload.py @@ -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"]), diff --git a/screens/rendering/apply_ctx.py b/screens/rendering/apply_ctx.py index 8eb5e7fc..428b26a7 100644 --- a/screens/rendering/apply_ctx.py +++ b/screens/rendering/apply_ctx.py @@ -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 diff --git a/screens/rendering/filter_repeater_rows.py b/screens/rendering/filter_repeater_rows.py index f3dfbaaf..314ac28f 100644 --- a/screens/rendering/filter_repeater_rows.py +++ b/screens/rendering/filter_repeater_rows.py @@ -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 "_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 diff --git a/screens/rendering/render_checkbox_or_radio.py b/screens/rendering/render_checkbox_or_radio.py index 63b8eb04..e97eddd8 100644 --- a/screens/rendering/render_checkbox_or_radio.py +++ b/screens/rendering/render_checkbox_or_radio.py @@ -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'' + #