first commit
Build and deploy / build-and-push (push) Successful in 17s
Build and deploy / deploy (push) Successful in 10s

This commit is contained in:
william
2026-08-21 16:23:49 +02:00
committed by william
commit 3f4ebc4527
248 changed files with 10180 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
"""Éléments posés sur un écran (_screen_elements)."""
+84
View File
@@ -0,0 +1,84 @@
import json
import db
from ..widgets.registry import WIDGETS
from ..widgets.widget_meta import widget_meta
from ..widgets.default_style_for_widget import default_style_and_attributes
from ..element_types.list_element_types import list_element_types
from ..element_types.instantiate_template_tree import instantiate_template_tree
def add_element(slug, screen_id, widget_key, element_type_id=None, parent_id=None):
"""parent_id : si fourni, l'élément est posé PHYSIQUEMENT à l'intérieur
de cet élément parent (qui doit être un conteneur/répéteur/groupe de
champs) plutôt que directement sur l'écran — sa position/taille ne sert
alors plus (il suit le flux du parent), mais on garde des valeurs par
défaut pour rester cohérent si jamais il en sort un jour.
element_type_id : si fourni ET que cet élément de jeu a un écran-modèle
(le cas normal désormais — voir create_element_type), tout son arbre est
COPIÉ en profondeur ici plutôt que de créer un seul élément plat : c'est
ce qui permet à un élément de jeu d'être un mini-conteneur réutilisable
avec plusieurs éléments imbriqués à l'intérieur, pas juste un widget
unique avec un contenu par défaut."""
if element_type_id:
types = {t["id"]: t for t in list_element_types(slug)}
et = types.get(int(element_type_id))
if et and et.get("template_screen_id"):
created = instantiate_template_tree(slug, et["template_screen_id"], screen_id, parent_id)
if created:
conn = db.connect(slug)
conn.execute(
"UPDATE _screen_elements SET element_type_id = ? WHERE id = ?", (et["id"], created[0])
)
conn.commit()
conn.close()
return created[0]
# Modèle vide (aucun élément dedans pour l'instant) : on retombe
# sur l'ancien comportement plat ci-dessous, pour ne jamais
# échouer silencieusement à poser quelque chose.
widget_key = widget_key if widget_key in WIDGETS else "conteneur"
meta = widget_meta(widget_key)
# Réglages dont la valeur par défaut a un effet visuel voulu dès la
# création (ex: une image démarre avec "cover" pour se recadrer
# proprement) — hors couleurs, qui restent volontairement transparentes/
# sans bordure tant qu'on ne les choisit pas explicitement.
attributes, style = default_style_and_attributes(widget_key)
content = meta.get("default_content", "")
width, height = meta.get("w", 30), meta.get("h", 10)
conn = db.connect(slug)
max_z = conn.execute(
"SELECT MAX(z_index) AS m FROM _screen_elements WHERE screen_id = ?", (screen_id,)
).fetchone()["m"]
# Décalage en cascade : sans ça, chaque nouvel élément posé DIRECTEMENT
# SUR L'ÉCRAN atterrit exactement au même endroit (10%, 10%) que le
# précédent et le recouvre entièrement — ce qui peut donner l'impression
# trompeuse qu'un "élément indésirable" vient d'apparaître et d'en
# cacher un autre (le plus souvent après une confusion entre "Ajouter un
# élément sur l'écran" et "Ajouter DANS ce conteneur", qui posent tous
# les deux un nouvel élément mais pas au même endroit dans l'arbre).
x = y = 10
if not parent_id:
top_level_count = conn.execute(
"SELECT COUNT(*) AS c FROM _screen_elements WHERE screen_id = ? AND parent_id IS NULL",
(screen_id,),
).fetchone()["c"]
offset = (top_level_count % 8) * 4
x = y = 10 + offset
conn.execute(
"""INSERT INTO _screen_elements
(screen_id, parent_id, widget, element_type_id, content, attributes, x, y, width, height, z_index, style)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
(
screen_id, parent_id or None, widget_key, element_type_id or None, content, json.dumps(attributes),
x, y, width, height, (max_z or 0) + 1,
json.dumps(style),
),
)
element_id = conn.execute("SELECT last_insert_rowid() AS id").fetchone()["id"]
conn.commit()
conn.close()
return element_id
+8
View File
@@ -0,0 +1,8 @@
import db
def delete_element(slug, element_id):
conn = db.connect(slug)
conn.execute("DELETE FROM _screen_elements WHERE id = ?", (element_id,))
conn.commit()
conn.close()
+10
View File
@@ -0,0 +1,10 @@
from ..widgets.registry import WIDGETS
def element_label(el, widgets_catalog=None):
"""Nom lisible d'un élément pour les listes/menus : son nom personnalisé
s'il en a un, sinon le libellé de son widget (ex: "Image")."""
catalog = widgets_catalog if widgets_catalog is not None else WIDGETS
if el.get("name"):
return el["name"]
return catalog.get(el.get("widget"), {}).get("label", el.get("widget", "?"))
+15
View File
@@ -0,0 +1,15 @@
import json
import db
def get_element(slug, element_id):
conn = db.connect(slug)
row = conn.execute("SELECT * FROM _screen_elements WHERE id = ?", (element_id,)).fetchone()
conn.close()
if not row:
return None
d = dict(row)
d["style"] = json.loads(d["style"] or "{}")
d["attributes"] = json.loads(d["attributes"] or "{}")
return d
+34
View File
@@ -0,0 +1,34 @@
import json
import db
from ..rendering.render_element_html import render_element_html
def list_elements(slug, screen_id):
"""Renvoie TOUS les éléments de l'écran, à plat (parent_id indique
l'imbrication) — pratique pour construire l'arbre côté appelant. Seuls
les éléments de premier niveau (parent_id NULL) reçoivent un
"rendered_html" : celui d'un conteneur/répéteur/groupe de champs inclut
déjà, en le générant récursivement, le HTML de tout ce qui est posé à
l'intérieur — les enfants n'ont donc pas besoin d'être dessinés une
deuxième fois séparément."""
conn = db.connect(slug)
rows = conn.execute(
"SELECT * FROM _screen_elements WHERE screen_id = ? ORDER BY z_index, id", (screen_id,)
).fetchall()
conn.close()
result = []
for r in rows:
d = dict(r)
d["style"] = json.loads(d["style"] or "{}")
d["attributes"] = json.loads(d["attributes"] or "{}")
result.append(d)
children_map = {}
for d in result:
if d.get("parent_id"):
children_map.setdefault(d["parent_id"], []).append(d)
for d in result:
if not d.get("parent_id"):
d["rendered_html"] = render_element_html(d, slug, children_map)
return result
+8
View File
@@ -0,0 +1,8 @@
import db
def rename_element(slug, element_id, name):
conn = db.connect(slug)
conn.execute("UPDATE _screen_elements SET name = ? WHERE id = ?", ((name or "").strip() or None, element_id))
conn.commit()
conn.close()
+113
View File
@@ -0,0 +1,113 @@
import json
import db
from ..widgets.widget_meta import widget_meta
from .get_element import get_element
def _is_default_value(control, raw_value):
"""Compare la valeur reçue du formulaire à la valeur par défaut du
réglage (en tenant compte de l'unité ajoutée pour les sliders), pour
savoir si le champ a simplement été renvoyé tel quel sans être
personnalisé."""
default = control.get("default", "")
if control["type"] == "slider":
unit = control.get("unit", "")
default = f"{default}{unit}" if default not in (None, "") else ""
return str(raw_value) == str(default)
def save_element_controls(slug, element_id, form):
"""Lit, pour chaque réglage du widget de cet élément, la valeur envoyée
par le panneau de propriétés (un seul formulaire, un champ par réglage)
et met à jour content / attributs / style en conséquence."""
el = get_element(slug, element_id)
if not el:
return
meta = widget_meta(el["widget"])
content = el["content"]
attributes = dict(el["attributes"])
style = dict(el["style"])
toggle_groups = {} # propriété CSS -> ensemble des "tokens" cochés (ex: text-decoration)
for control in meta.get("controls", []):
ctype = control["type"]
field_name = "ctrl_" + control["key"]
if ctype == "toggle_token":
_kind, name = control["target"].split(":", 1)
toggle_groups.setdefault(name, set())
if form.get(field_name) is not None:
toggle_groups[name].add(control["token"])
continue
if ctype == "preset":
chosen = form.get(field_name, control.get("default"))
opt = next((o for o in control["options"] if o[0] == chosen), None)
if opt:
for k, v in opt[2].items():
if v in (None, ""):
style.pop(k, None)
else:
style[k] = v
continue
if ctype == "scale":
try:
val = float(form.get(field_name, control.get("default", 1)))
except (TypeError, ValueError):
val = 1.0
if abs(val - 1) < 1e-6:
style.pop("transform", None)
else:
style["transform"] = f"scale({val})"
continue
target = control["target"]
if ctype == "checkbox":
checked = form.get(field_name) is not None
raw_value = control["true_value"] if checked else control["false_value"]
else:
raw_value = form.get(field_name, control.get("default", ""))
if ctype == "slider":
unit = control.get("unit", "")
raw_value = f"{raw_value}{unit}" if raw_value not in (None, "") else ""
if target == "content":
content = raw_value
else:
kind, name = target.split(":", 1)
store = attributes if kind == "attr" else style
if raw_value in (None, ""):
store.pop(name, None)
elif ctype != "checkbox" and name not in store and _is_default_value(control, raw_value):
# Ce réglage n'a jamais été personnalisé (la clé est absente
# du style/des attributs stockés) et la valeur reçue est
# simplement celle par défaut affichée dans le champ (un
# <input type="color"> ou un slider a TOUJOURS une valeur à
# l'écran, même quand rien n'a été choisi). Comme le
# panneau de propriétés envoie TOUS les réglages du widget
# en un seul formulaire, sauvegarder n'importe quel autre
# champ (ex. changer la disposition) écrirait sinon cette
# valeur par défaut "en dur" dans l'élément — ce qui faisait
# apparaître par exemple une couleur de fond qui n'avait
# jamais été demandée. On laisse donc ce réglage "non
# défini" tant qu'il n'a pas été explicitement changé.
pass
else:
store[name] = raw_value
for prop, tokens in toggle_groups.items():
if tokens:
style[prop] = " ".join(sorted(tokens))
else:
style.pop(prop, None)
conn = db.connect(slug)
conn.execute(
"UPDATE _screen_elements SET content = ?, attributes = ?, style = ? WHERE id = ?",
(content, json.dumps(attributes), json.dumps(style), element_id),
)
conn.commit()
conn.close()
@@ -0,0 +1,14 @@
import db
def update_element_geometry(slug, element_id, x, y, width, height):
"""Utilisé par le glisser-déposer / redimensionnement (et par les champs
Position & taille du panneau de droite) — ne touche qu'à la position et
à la taille, rien d'autre."""
conn = db.connect(slug)
conn.execute(
"UPDATE _screen_elements SET x = ?, y = ?, width = ?, height = ? WHERE id = ?",
(x, y, width, height, element_id),
)
conn.commit()
conn.close()