Compare commits

...
63 Commits
Author SHA1 Message Date
w-vandal 4fc2b492b1 Merge pull request 'Dev' (#1) from dev into main
Build and deploy / deploy (push) Successful in 12s
Build and deploy / build-and-push (push) Successful in 1m17s
Reviewed-on: #1
2026-08-26 04:56:43 +00:00
williamandClaude Sonnet 5 438e561b45 Préserver l'état visuel transitoire lors du rafraîchissement des données
Cause réelle du bug rapporté ("clic sur une ligne = comme un rechargement,
impossible de cliquer sur une deuxième ligne") : applyOpenRowBindings()
regénère entièrement le sous-arbre d'un élément contenant des {{champ}}
(ex. "mail content") à partir de son HTML D'ORIGINE, tel que rendu par le
serveur — donc avec son style PAR DÉFAUT (ici "invisible" via le réglage
Disposition > Visibilité). Cette fonction est appelée après CHAQUE
rafraîchissement de données (refreshRuntimeData), y compris pour un
changement de donnée sans rapport avec ce panneau.

Or une action "Modifier un élément → Rendre visible" ne modifie JAMAIS la
base : c'est un changement DOM transitoire (style.visibility = ''). Quand
un clic sur une ligne de Répéteur déclenche EN PARALLÈLE "Ouvrir la ligne
cliquée" + "Rendre visible" + "Modifier une donnée", la branche
"Modifier une donnée" est asynchrone (aller-retour serveur) et termine
après les deux autres, synchrones. Son refreshRuntimeData() qui suit
regénère alors "mail content" depuis son état par défaut, écrasant le
"Rendre visible" qui venait tout juste d'être posé — le panneau redevient
invisible. Un second clic sur le MÊME mail "corrige" l'affichage car la
donnée est déjà à jour, donc la Condition ne redéclenche plus l'action de
modification, plus de refresh, plus d'écrasement ; mais ouvrir un AUTRE
mail reproduisait le même écrasement.

Fix : avant de remplacer wrapper.innerHTML, sauvegarder le style inline,
la classe et les data-* de chaque élément du sous-arbre, puis les
réappliquer juste après la regénération — la résolution des {{champ}}
reste correcte (c'est le but premier de la fonction) sans plus annuler
les changements posés par une action "Modifier un élément" au même clic.

Les trois actions du graphe (ouvrir la ligne, rendre visible, modifier la
donnée) restent connectées telles quelles, sans aucun retrait.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-25 18:17:35 +02:00
williamandClaude Sonnet 5 96d017c21f Évite de rejouer les déclencheurs "affichage"/l'animation quand on est déjà sur l'écran ciblé
Bug remonté : cliquer sur une ligne de Répéteur "semblait recharger la
page", et devenait impossible à recliquer une deuxième fois - alors que
le graphe de logique voulu (modifier la donnée + rendre visible un détail
+ "Ouvrir la ligne cliquée") reste sur le MÊME écran que le Répéteur.

Cause : showScreen() rejouait INCONDITIONNELLEMENT les déclencheurs "À
l'affichage de l'écran" et relançait la timeline d'animation depuis le
début à chaque appel - même quand l'écran cible est déjà celui affiché
(le cas normal pour "Ouvrir la ligne cliquée" combinée à une action
"Modifier un élément → Visibilité" sur le même clic, pensées pour
fonctionner ensemble SUR le même écran qu'un Répéteur). Ça rejouait donc
les animations d'entrée et pouvait faire repasser la visibilité à son
état initial via un déclencheur "affichage", entrant en conflit avec
l'action "Rendre visible" du même clic - d'où l'impression de
rechargement, et le blocage : reflow/re-rendu qui se disputent avec
l'état attendu.

Fix : showScreen() ne fait plus rien du tout si l'écran ciblé est déjà
celui affiché - aucun changement visuel à faire, donc aucune raison de
rejouer son "premier affichage". Changer vers un écran DIFFÉRENT continue
de tout rejouer normalement. Les trois actions du graphe restent
déclenchées à chaque clic, sans plus se marcher dessus.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-25 17:47:25 +02:00
williamandClaude Sonnet 5 b094342097 Ajoute "Ligne cliquée (Répéteur)" comme cible pour une Condition/action de la Logique de la scène
Problème remonté : un déclencheur "Au clic" posé sur un Répéteur exécute
le MÊME graphe pour n'importe quelle ligne cliquée - or une Condition
("Si is_opened est égal à Non") ou une action "Modifier une donnée" ne
pouvaient viser qu'une ligne FIXE, choisie à la création du nœud dans
l'éditeur. Impossible donc de dire "modifie le champ DE LA LIGNE QUE JE
VIENS DE CLIQUER", puisque cette ligne n'est justement jamais connue à
l'avance.

Nouvelle valeur sentinelle CLICKED_ROW_ID (-1, screens/flow/constants.py,
ne collisionne jamais avec un vrai id de ligne) proposée en tête de TOUTE
liste déroulante "Ligne concernée" (clause principale et clauses
supplémentaires d'un nœud Condition, cible d'une action "Modifier une
donnée") : "🖱️ Ligne cliquée (Répéteur)".

Résolution au moment de l'exécution, pas à la création du nœud :
- Condition (évaluée côté client) : readFieldValue() (play.html) résout
  -1 en window.lastClickedRowId, déjà capturé par bindClicks() au clic sur
  une ligne de Répéteur (déjà utilisé par "Ouvrir la ligne cliquée").
- Action "Modifier une donnée" (exécutée côté serveur) : le client envoie
  clicked_row_id dans le corps de la requête POST ; flow_node_run_data.py
  ne s'en sert que si le nœud vise justement CLICKED_ROW_ID, sinon la
  ligne fixe stockée sur le nœud reste utilisée normalement.

Ajoute tests/test_flow_clicked_row.py (ligne cliquée seule modifiée,
absence de clic = no-op plutôt que plantage, non-régression d'une cible
fixe, présence de l'option dans l'éditeur).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-25 17:19:41 +02:00
williamandClaude Sonnet 5 7d48445443 Ajoute "Variable globale" au sélecteur "Valeur fixe / Donnée d'un autre objet"
Le sélecteur de valeur de comparaison (tout contrôle "..._valeur" : filtre
du Répéteur, Donnée liée, condition de visibilité) proposait déjà une
valeur fixe ou le champ d'un AUTRE objet - manquait la possibilité de
comparer à une variable globale (voir db/global_vars/), qui change elle
aussi en cours de partie mais n'est rattachée à aucun objet précis.

Nouvelle syntaxe interne "{{$nom_variable}}" (le "$" la distingue sans
ambiguïté de "{{Objet.champ}}", qui attend toujours un point) :
_resolve_filter_value (filter_repeater_rows.py) va lire sa valeur actuelle
via db.get_global_variable, comme "{{Objet.champ}}" le fait déjà pour un
champ d'objet. Le panneau de propriétés gagne un troisième mode
"Variable globale" à côté de "Valeur fixe"/"Donnée d'un autre objet",
avec la liste déroulante des variables existantes.

Corrige au passage un bug latent découvert en testant bout en bout : un
Répéteur SANS modèle de ligne (texte brut avec {{champ}}) plantait en
mode jouable avec TypeError - render_repeater.py substitue lui aussi
directement les {{champ}} du ctx dans ce cas (repli), et ce ctx porte
aussi _forge_play_mode (un booléen, voir render_element_html.py) depuis
l'ajout de la condition de visibilité - déjà corrigé pour le chemin
générique (apply_ctx.py) mais pas pour ce chemin séparé.

Le sélecteur de champ pour insérer {{champ}} dans "Contenu" (demandé dans
le même message) existe déjà depuis un tour précédent (voir
insertFieldAtCursor()) - vérifié toujours fonctionnel.

Ajoute tests/test_filter_value_global_variable.py.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-25 16:51:18 +02:00
williamandClaude Sonnet 5 6894c5fc95 Remplace le confirm() natif par une modale custom qui avertit des suppressions en cascade
Depuis le dernier correctif, supprimer un élément supprime aussi en
silence tout nœud de la Logique de la scène qui le référence (déclencheur/
action, voir delete_element.py) - nécessaire pour éviter le plantage
"FOREIGN KEY constraint failed", mais l'utilisateur n'était jamais prévenu
qu'un bout de sa logique disparaissait en même temps.

Nouvelle route GET .../elements/<id>/delete-impact (element_delete_
impact.py) : calcule, sans rien supprimer, combien de nœuds de la Logique
de la scène référencent cet élément OU l'un de ses descendants (partage
element_descendant_ids.py avec delete_element.py, pour rester exactement
cohérent avec ce qui sera réellement supprimé).

Les deux boutons "supprimer" de screen_edit.html (élément sélectionné, et
onglet d'un widget Onglets) ouvrent maintenant une modale custom
(deleteConfirmModal, même famille que le sélecteur d'icônes) au lieu du
confirm() natif du navigateur : elle interroge cette route juste après
ouverture et affiche un avertissement dédié si le nombre remonté est non
nul, avant que l'utilisateur ne confirme quoi que ce soit - impossible à
faire avec confirm(), dont le texte est figé au moment du rendu de la
page. La confirmation soumet ensuite le formulaire normalement (via
requestSubmit(), intercepté par pjax.js comme n'importe quel autre
formulaire).

Ajoute deux tests pour la nouvelle route (impact nul, impact non nul sans
rien supprimer).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-25 16:27:34 +02:00
williamandClaude Sonnet 5 1e86077e45 Corrige un plantage à la suppression d'un élément référencé par la Logique de la scène
Bug remonté : supprimer un élément plantait avec sqlite3.IntegrityError:
FOREIGN KEY constraint failed.

Cause : trigger_element_id/target_element_id (_flow_nodes, voir
ensure_flow_schema.py - un nœud "clic sur cet élément" ou "Modifier cet
élément"/"Activer cet onglet") et target_element_id (l'ancien système
_actions, conservé pour compatibilité) référencent _screen_elements(id)
SANS ON DELETE CASCADE - volontairement, un élément ne doit pas pouvoir
disparaître "par erreur" en cascade depuis un nœud de logique qu'on
modifie ailleurs. Mais ça veut dire que delete_element.py, qui ne
supprimait jusqu'ici que la ligne elle-même, faisait échouer PRAGMA
foreign_keys=ON (db/connection.py) dès qu'un nœud de logique existant
référençait encore l'élément.

Fix : delete_element.py nettoie maintenant ces références AVANT de
supprimer l'élément - pas seulement pour l'élément explicitement supprimé,
mais pour tous ses DESCENDANTS aussi (leur suppression est cascadée
automatiquement au niveau SQL via parent_id, sans repasser par ce
fichier, donc sans ce nettoyage si on ne le fait pas explicitement).

Ajoute tests/test_delete_element_referenced_by_flow.py (élément
référencé comme déclencheur, comme cible d'action, et cas d'un conteneur
supprimé dont un descendant est référencé).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-25 16:19:52 +02:00
williamandClaude Sonnet 5 4dc42a7f3b Corrige les réglages fantômes d'un exemplaire d'élément de jeu déjà posé
Bug remonté : la taille d'un Titre réglée à 18px dans le modèle affichait
21px "sur l'écran". Cause : un exemplaire posé AVANT le passage en mode
"toujours lié au modèle" (tour précédent) avait été créé par l'ancien
mécanisme instantiate_template_tree (retiré), qui copiait tout l'arbre en
base - ces lignes copiées (l'ancien enfant "Titre", encore à 21px depuis
avant le changement) ne sont plus jamais RENDUES (le contenu vient
désormais toujours en direct du modèle, voir le tour précédent), mais
restaient toujours sélectionnables dans l'arborescence de l'éditeur, avec
leurs propres réglages jamais synchronisés. Cliquer dessus dans l'arbre
affichait donc ses vieux réglages (21px) dans le panneau de propriétés,
donnant l'impression trompeuse que le modèle (18px) n'était pas pris en
compte - alors que le rendu réel utilisait déjà correctement 18px.

Fix, dans list_elements.py : tout élément dont un ANCÊTRE a
element_type_id réglé est maintenant exclu de la liste renvoyée à
l'éditeur (arborescence, sélection, panneau de propriétés) - son contenu
n'a plus aucune existence propre, seul l'écran-modèle fait foi. Les
lignes elles-mêmes restent en base (pas de suppression, un simple filtre
en lecture) mais ne sont plus jamais atteignables depuis l'éditeur.

Ajoute un test de régression qui simule exactement ce scénario (ligne
orpheline avec un ancien réglage figé) et vérifie qu'elle n'apparaît plus
nulle part - ni dans le canevas, ni via une sélection directe par id.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-25 16:10:30 +02:00
williamandClaude Sonnet 5 94f1414506 Corrige un bug important : le style de TOUT élément neuf était cassé depuis l'ajout de l'Ombre
Bug remonté ("toutes les propriétés ne sont pas prises en compte") : la
vraie cause n'avait rien à voir avec les éléments de jeu — le contrôle
"Ombre" (voir le tour précédent) a une valeur par défaut composite (un
dict Python, {"x":0,"y":4,...}, pas une chaîne CSS). default_style_for_
widget.py, qui fige les réglages "dont la valeur par défaut a un effet
visuel voulu dès la création" pour chaque widget neuf, n'excluait pas ce
nouveau type de contrôle — il écrivait donc ce dict TEL QUEL (repr Python)
dans le style de CHAQUE élément fraîchement créé depuis ce commit, quel
que soit son widget. Une valeur CSS invalide au milieu du style pouvait
donner l'impression que "plein de propriétés" ne s'appliquaient plus.

Deux correctifs :
1. default_style_for_widget.py exclut maintenant "shadow" du gel à la
   création (même raisonnement déjà appliqué à "color" juste au-dessus :
   la valeur par défaut n'est qu'une suggestion affichée dans le panneau,
   pas un réglage neutre à figer - le neutre CSS est "pas d'ombre").
2. style_string.py ignore désormais toute valeur non scalaire (dict/liste)
   au moment de construire l'attribut style - filet de sécurité pour les
   éléments déjà créés AVANT ce correctif, qui portent encore ce dict figé
   en base et continueraient sinon à s'afficher cassés.

Ajoute deux tests de régression dans test_shadow_controls.py (aucune ombre
au premier rendu d'un élément neuf ; un élément déjà corrompu avant ce
correctif continue de s'afficher normalement).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-25 15:54:06 +02:00
williamandClaude Sonnet 5 e9a991ed13 Un exemplaire d'élément de jeu posé sur un écran reste maintenant lié à son modèle
Jusqu'ici, poser un élément de jeu depuis le catalogue ("Mes éléments de
jeu") copiait tout son arbre en base (instantiate_template_tree) : chaque
exemplaire devenait indépendant, y compris de son propre modèle - modifier
l'élément de jeu dans son éditeur n'avait plus aucun effet sur les
exemplaires déjà posés ailleurs.

Change ce comportement pour qu'un exemplaire reste TOUJOURS lié à son
modèle, sur le même principe déjà utilisé par un modèle de ligne de
Répéteur (jamais copié, rechargé en direct à chaque affichage - voir
_load_template_tree/_render_repeater) : add_element.py ne crée plus
qu'UNE SEULE ligne plate (avec sa position/taille propres à cet
exemplaire) au lieu de copier tout l'arbre, et render_element_html.py
recharge le contenu depuis l'écran-modèle à chaque rendu quand
element_type_id est réglé. 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 (y compris ceux placés AVANT ce correctif, qui portaient déjà
element_type_id sur leur ligne de premier niveau), sans avoir à les
retoucher un par un.

Contrepartie assumée (discutée avec l'utilisateur avant ce changement) :
un exemplaire ne peut plus être personnalisé individuellement à
l'INTÉRIEUR (texte, couleur d'un enfant précis...) - seules sa position et
sa taille sur l'écran restent propres à chaque exemplaire. Pour changer le
contenu, il faut désormais passer par l'éditeur de l'élément de jeu
lui-même.

instantiate_template_tree.py, devenu inutilisé, est supprimé.

Ajoute tests/test_element_type_live_instances.py (mise à jour d'un
exemplaire déjà posé, propagation jusqu'à "Jouer", position toujours
indépendante par exemplaire) et met à jour un commentaire de test devenu
obsolète dans test_screens_and_elements.py.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-25 15:26:45 +02:00
williamandClaude Sonnet 5 79fe04c6ac Corrige "database is locked" causé par une connexion SQLite qui fuit après un plantage
Bug remonté : suppression d'un élément échouant avec
sqlite3.OperationalError: database is locked, exactement sur le
conn.execute() de delete_element.py. La trace complète montrait que la
connexion attendait puis lâchait après le timeout (10s) - pas une simple
collision passagère entre deux requêtes (déjà gérée par WAL + busy_timeout,
voir les commentaires existants de connect()), mais un verrou tenu bien
plus longtemps : une connexion ouverte par une requête ANTÉRIEURE qui a
planté, jamais fermée.

Cause de fond : chaque fonction de db/ (~80 d'entre elles) ouvre sa propre
connexion et est censée la fermer elle-même avant de rendre la main - si
une exception survient entre l'ouverture et cette fermeture, le
conn.close() prévu n'est jamais atteint. En mode debug (voir app.py), le
débogueur Werkzeug garde la trace complète de l'erreur en mémoire pour
l'inspection interactive, ce qui inclut la variable locale `conn` :
empêchée d'être ramassée par le GC, elle ne libère jamais son verrou
d'écriture SQLite - bloquant TOUTE écriture suivante jusqu'au redémarrage
du serveur, même longtemps après l'erreur d'origine et sans lien apparent
avec elle (d'où la confusion : l'erreur semble venir de l'action qui
échoue, alors qu'elle est victime d'une fuite antérieure).

Fix, dans db/connection.py, sans toucher aux ~80 fonctions existantes :
connect() enregistre maintenant chaque connexion sur le contexte de la
requête Flask en cours (flask.g, uniquement quand il y en a un - un appel
direct hors requête, scripts/tests, n'est pas concerné) ; un
teardown_request ferme toute connexion encore ouverte à la fin de CHAQUE
requête, qu'elle ait réussi ou planté (garanti par Flask, contrairement à
after_request). Fermer une connexion déjà fermée normalement ne fait
rien, donc aucun changement de comportement pour le cas normal.

Ajoute tests/test_db_connection_leak_safety_net.py, qui reproduit le
scénario exact (connexion ouverte puis exception avant fermeture) et
vérifie qu'une écriture suivante ne bloque plus - désactivé temporairement
pour confirmer que le test échoue bien (et de la même façon) sans le fix.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-25 15:06:16 +02:00
williamandClaude Sonnet 5 bda082bffd Ajoute une propriété "Ombre portée" (box-shadow), disponible sur tout widget
Nouveau groupe "Ombre" dans le panneau de propriétés, à côté de "Bordure"
(même universalité — voir UNIVERSAL_CONTROLS) : décalage X/Y, flou,
étendue, couleur et opacité, combinés en une seule valeur CSS box-shadow
(couleur+opacité fusionnées en rgba(), un <input type="color"> seul ne
portant pas de canal alpha). Décalages/flou/étendue tous à 0 = pas
d'ombre, même convention que border-width à 0 = pas de bordure.

Nouveau ctype "shadow" (c_shadow.py), suit exactement le même principe
que le ctype "size" déjà existant (size_override_controls.py) : plusieurs
entrées de formulaire pour un seul réglage, composées/décomposées dans
save_element_controls.py et control_value.py plutôt que passées par le
chemin générique clé->valeur.

Ajoute tests/test_shadow_controls.py (présence dans le panneau,
composition de la valeur CSS, aller-retour dans le formulaire, remise à
zéro qui retire l'ombre).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-25 14:37:38 +02:00
williamandClaude Sonnet 5 9c9e3c5379 Le sélecteur de champ apparaît maintenant sur tout nouveau widget d'un élément de jeu lié à un objet
Le tour précédent exigeait que CHAQUE widget règle sa propre "Donnée
liée" (_data_definition_id) pour voir apparaître le sélecteur de champ -
mais un élément de jeu ("Mail card"...) créé avec un "Objet lié" (voir
element_types.html, bound_definition_id) a précisément pour but d'éviter
ce réglage widget par widget : ses {{champ}} sont censés venir de CET
objet, fourni plus tard par le Répéteur qui l'utilisera comme modèle de
ligne. D'où le bug remonté : un nouveau Titre/Texte posé dans un tel
élément de jeu n'affichait jamais le sélecteur.

Ajoute get_element_type_by_template_screen(slug, screen_id), pour
retrouver depuis l'éditeur d'un écran-modèle l'entrée du catalogue (et
donc l'objet lié) dont il est la recette. screen_edit.py le calcule pour
le panneau de propriétés et le passe à controls_with_values(), qui
l'utilise comme repli pour le champ "Contenu" SEULEMENT si ce widget n'a
pas déjà sa propre "Donnée liée" réglée (priorité conservée au réglage le
plus spécifique).

Ajoute tests/test_element_type_bound_field_picker.py (apparition sans
réglage supplémentaire, absence sans objet lié, priorité à la "Donnée
liée" du widget si réglée).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-25 13:38:10 +02:00
williamandClaude Sonnet 5 7e80ff952a Ajoute un sélecteur de champ pour insérer {{champ}} dans le Contenu
Une fois "Lier à un objet de données" réglé (widget Texte/Titre...),
propose maintenant les champs de CET objet en liste déroulante juste sous
le champ "Contenu", avec un bouton "+ Ajouter" qui insère "{{nom_du_champ}}"
à l'emplacement du curseur - plutôt que d'avoir à taper cette syntaxe à
la main sans savoir quels noms de champs existent réellement.

controls_with_values.py pose field_options sur le contrôle "content"
quand _data_definition_id est réglé (réutilise data_binding_options, déjà
calculé pour data_filtre_champ/data_filtre2_champ) ; insertFieldAtCursor()
(screen_edit.html) fait l'insertion via selectionStart/selectionEnd puis
déclenche un événement "input" pour que l'enregistrement automatique se
déclenche normalement, comme une saisie manuelle.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-25 13:18:12 +02:00
williamandClaude Sonnet 5 baa3da1035 Corrige la comparaison booléenne : "Oui"/"Non" n'était pas reconnu comme valeur vraie/fausse
Bug remonté avec une "Mail card" (icône enveloppe fermée si is_opened
est à "Non", ouverte si "Oui") : les DEUX variantes s'affichaient (ou
aucune), selon la ligne.

Cause : _compare() (filter_repeater_rows.py, utilisé par la condition de
visibilité, le Répéteur et Donnée liée) ne reconnaissait "1"/"true"/"vrai"
comme valeur vraie pour un champ booléen — jamais "oui", pourtant le SEUL
vocabulaire que l'app affiche elle-même pour ce type de champ partout
ailleurs (voir data_list.html : "Oui" si vrai sinon "Non"). Une valeur de
comparaison fixe tapée "Oui" retombait donc silencieusement à "faux",
et comme l'opérateur et le champ étaient par ailleurs corrects, ça
donnait l'impression que la condition "ne voyait" rien : sur la ligne où
is_opened=faux, les DEUX cartes ("égal à Oui" et "égal à Non", toutes
deux évaluées comme "égal à faux") s'affichaient ensemble ; sur la ligne
où is_opened=vrai, aucune des deux.

Fix : "oui" ajouté à l'ensemble des valeurs reconnues comme vraies,
côté Python (_compare) ET côté JS (compareValues() dans play.html, qui
doit rester alignée — utilisée par les nœuds Condition de la Logique de
la scène), cette dernière au passage rendue insensible à la casse comme
son équivalent Python (elle ne l'était pas du tout).

Ajoute un test de régression dédié à ce cas précis.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-25 12:52:27 +02:00
williamandClaude Sonnet 5 a28cc5881e Corrige la condition de visibilité : un widget "special_render" visible ne se rafraîchissait jamais en jeu
Bug remonté avec deux icônes (enveloppe fermée / ouverte) posées
directement sur l'écran, chacune conditionnée sur is_opened : après avoir
ouvert le mail (une action "Modifier une donnée"), les DEUX icônes
restaient affichées en même temps au lieu que l'ouverte remplace la fermée.

Cause : refreshRuntimeData() (play.html) ne réévalue, après une action,
que les éléments dont le HTML porte un marqueur ("visibilityGated"/
"repeaterItem"/"jaugeBar"). render_element_html() posait bien ce marqueur
quand un élément sous condition est actuellement visible - mais seulement
sur le chemin de rendu GÉNÉRIQUE (texte, titre, conteneur...), jamais sur
les 9 widgets "special_render" (Icône, Tableau, Superposition, Onglets,
Case à cocher, Liste déroulante, Groupe de champs, Répéteur, Jauge) - un
élément CACHÉ portait toujours son marqueur (via son placeholder), mais un
élément VISIBLE de ce type non. Résultat : l'icône "fermée", visible au
premier chargement, ne portait aucun marqueur et restait donc figée dans
son état d'origine après toute action suivante, pendant que l'icône
"ouverte" (cachée au départ, donc marquée) se mettait, elle, correctement
à jour - d'où les deux affichées ensemble.

Fix : les 9 branches special_render passent maintenant, elles aussi, par
_mark() comme le chemin générique.

Ajoute un test de régression dédié (icône visible sous condition = doit
porter le marqueur).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-25 12:31:50 +02:00
williamandClaude Sonnet 5 ff01b3903c Corrige la condition de visibilité (mode "objet") à l'intérieur d'un Répéteur
Bug remonté : dans une "Mail card" (élément de jeu réutilisable, deux
icônes - enveloppe fermée/ouverte - conditionnées sur le champ is_opened
de l'objet Email) posée dans un Répéteur de données, rien ne s'affichait
jamais correctement.

Cause : is_element_visible() (mode "objet") allait toujours chercher en
base la ligne la plus récente de l'objet ciblé (convention "1 seule ligne
= état de partie", correcte pour une Jauge suivant un état de partie),
sans jamais tenir compte de la ligne EN COURS DE RENDU dans un Répéteur -
donc tous les exemplaires du même modèle de ligne évaluaient la MÊME
ligne (la plus récente de tout l'objet Email) au lieu de chacun la
sienne, et affichaient donc tous exactement le même résultat.

Fix : is_element_visible() reçoit maintenant le ctx de rendu (les
{{champ}} de la ligne en cours, déjà posés par render_repeater.py) et,
si le champ réglé s'y trouve, utilise directement cette valeur plutôt que
d'interroger la base - un exemplaire de Répéteur voit donc bien SA propre
ligne. Hors Répéteur, le comportement (ligne la plus récente de l'objet)
est inchangé.

Ajoute tests/test_visibility_condition.py (mode variable, mode objet hors
Répéteur, absence dans l'éditeur, et ce cas précis dans un Répéteur) -
cette fonctionnalité n'avait jusqu'ici aucun test persistant, seulement
des scripts ad-hoc jetés après vérification.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-25 12:17:40 +02:00
williamandClaude Sonnet 5 11b08c8503 Éditeur de scène : le canevas remplit exactement l'espace disponible, plus de marges
Le calcul JS "object-fit:contain" du tour précédent gardait l'aspect-ratio
(Portrait/Paysage/Carré) au prix de marges vides sur les côtés dès que la
fenêtre n'avait pas exactement ce ratio - "prendre toute la place
disponible" et "garder l'aspect-ratio" sont deux exigences contradictoires
dans ce cas, et c'est la première qui doit l'emporter dans l'éditeur.

Le canevas (#canvas) remplit donc maintenant .canvasFrame à 100% x 100%,
sans plus tenir compte de l'aspect-ratio choisi dans l'éditeur - ce
réglage continue de s'appliquer normalement à l'aperçu jouable ("Jouer",
voir screen_set_aspect.py/play.html), qui reste la référence pour le
rendu final. Padding de .canvasFrame réduit au minimum. fitCanvasToFrame()
et son calcul en pixels n'ont plus lieu d'être - retirés entièrement.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-25 11:50:50 +02:00
williamandClaude Sonnet 5 14deba943a Éditeur de scène : le canevas remplit vraiment tout l'espace disponible
Le calcul purement CSS du tour précédent (aspect-ratio + height:100% +
max-width:100%, pour que le canevas garde ses proportions tout en tenant
dans la zone visible) laissait en pratique le canevas bien plus petit que
l'espace réellement disponible - le calcul de taille "auto" d'un élément
non remplacé dans ce contexte flex n'est pas fiable.

Remplacé par un calcul en JavaScript (fitCanvasToFrame()) : mesure la
taille réelle de .canvasFrame et calcule la plus grande taille en pixels
qui tient à la fois en largeur ET en hauteur pour l'aspect-ratio courant
(l'équivalent d'un "object-fit:contain"), posée directement en style
inline sur #canvas. Recalculé à l'ouverture de l'écran, au changement de
format (Portrait/Paysage/Carré), au redimensionnement de la fenêtre, et à
chaque rafraîchissement du panneau (#canvas étant recréé à chaque
sélection d'élément, sa taille calculée était perdue à chaque fois).
Sous 1300px (mise en page empilée), le style inline est explicitement
effacé pour laisser la règle CSS de repli (pleine largeur, page qui
défile normalement) reprendre la main.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-25 11:44:40 +02:00
williamandClaude Sonnet 5 dae6da166e Éditeur de scène : supprime le fil d'Ariane, canevas remonté, défilement propre au canevas
Le fil d'Ariane disparaît entièrement sur cette page (breadcrumb_wrap vide)
- le nom de l'écran est de toute façon déjà éditable juste au-dessus, dans
le panneau "Éléments" (voir le tour précédent) - et .content-builder perd
son padding par défaut hérité de .content, pour que le canevas commence le
plus haut possible.

Change aussi la façon dont le canevas est dimensionné : il était
jusqu'ici contraint par la LARGEUR (width:100%), ce qui pouvait le rendre
bien plus haut que la fenêtre pour un format Portrait - obligeant à
défiler .builderCanvasArea (toolbar/onglets compris) pour voir le bas de
l'écran. Il est maintenant contraint par la HAUTEUR disponible
(height:100%, la largeur se déduisant de l'aspect-ratio), avec
max-width:100% en secours si c'est la largeur qui manque en premier -
l'écran entier reste donc visible sans défiler. Le défilement, s'il reste
nécessaire (fenêtre très basse), se fait maintenant sur .canvasFrame
lui-même, jamais sur .builderCanvasArea (repassé à overflow:hidden) : la
barre d'onglets et la barre d'outils restent toujours fixes en haut.

Ajoute le pendant pour le repli en page empilée (< 1300px, une seule
colonne) : le "letterboxing" par hauteur suppose une chaîne de hauteurs
définies qui n'existe plus une fois empilé - revient alors à un
dimensionnement par largeur, cohérent avec une page qui défile normalement.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-25 11:38:29 +02:00
williamandClaude Sonnet 5 ea9f91fc19 Éditeur de scène : Logique/Timeline en onglets plein écran, réglages d'écran déplacés dans le panneau Éléments
Remplace l'ancien panneau du bas rétractable/redimensionnable à la souris
(Logique de la scène, Timeline d'animation) par un système d'onglets au
centre de l'éditeur - Écran / Logique de la scène / Timeline d'animation
- un seul visible à la fois, occupant systématiquement tout l'espace
disponible (switchBuilderTab() dans screen_edit.html). Changer d'onglet
équivaut à "fermer" celui qu'on quitte ; plus besoin d'une poignée de
redimensionnement séparée puisque l'onglet actif prend déjà toute la
place. Supprime au passage tout l'ancien mécanisme (toggleFlowPanel/
toggleAnimPanel, poignées flowResizeHandle/animResizeHandle, classe CSS
.flowPanel) devenu inutile.

Déplace aussi le renommage de l'écran, le bouton "Jouer depuis le début"
et le choix du format d'aperçu (Portrait/Paysage/Carré) - jusqu'ici
au-dessus du canevas - dans le panneau flottant "Éléments" (celui qui
porte déjà ce nom, à gauche) : des réglages qu'on touche rarement une
fois l'écran en construction, qui n'ont plus besoin de rester en
permanence visibles au-dessus de la zone de travail.

Corrige au passage un bug découvert pendant ce tour : sur la page "Nouvel
objet" (2 colonnes), le bouton "+ Ajouter un champ" avait disparu -
placé APRÈS la zone de liste à défilement (flex-grow:1) dans la colonne
de droite, un flex-grow imprévisible dans ce contexte le poussait hors de
vue. Déplacé avant la liste (statique, toujours visible), pattern déjà
éprouvé ailleurs sur cette même page.

Deux tests mis à jour pour refléter intentionnellement la nouvelle
structure : la présence de "animTabPanel" (au lieu de l'ancien
"animPanel"), et un marqueur plus précis pour distinguer le bloc de
propriétés "Onglets" d'une simple occurrence du même texte dans une liste
déroulante de la Logique de la scène (qui apparaît désormais plus tôt
dans le document, cet éditeur de flow étant maintenant un onglet du
centre plutôt qu'un panneau tout en bas de page).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-25 11:27:50 +02:00
williamandClaude Sonnet 5 2051b375ac Passe toutes les pages restantes en deux colonnes (créer à gauche, contenu à droite)
Généralise le principe déjà utilisé pour le tableau de bord et l'édition
d'objet à toutes les pages restantes : liste des jeux, écrans, éléments de
jeu, variables, nouvel objet, formulaire de données, tableau des données
d'un objet - colonne de gauche pour créer/agir, colonne de droite pour ce
qui existe déjà, chaque colonne défilant pour son propre compte.

Pour un formulaire qui doit rester UN SEUL <form> à cheval sur les deux
colonnes (nouvel objet : nom à gauche, champs à droite ; formulaire de
données : bouton Enregistrer à gauche, champs à droite), nouvelle classe
.formPassthrough ("display:contents") : le <form> ne devient pas lui-même
une boîte dans la mise en page flex, seul .twoCol à l'intérieur compte.

Supprime au passage .content-page/.formScroll/body.pageBody (le gabarit à
une colonne introduit au tour précédent, plus utilisé nulle part) et
.list/.listRow/.listRowFlex/.rowTitle/.rowSub/.rowActions/.addBtn (les
cartes à deux lignes remplacées par les tableaux denses et la nav
compacte) - du CSS mort plutôt que deux systèmes qui se chevauchent.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-25 10:58:49 +02:00
williamandClaude Sonnet 5 59247694d6 Applique le gabarit sobre/compact/sans défilement à toutes les pages hors éditeur
Généralise le principe déjà en place pour l'édition d'objet (la page
n'occupe jamais plus que la hauteur de la fenêtre, seules ses zones
internes défilent) à toutes les pages restantes : liste des jeux, tableau
de bord d'un jeu, écrans, éléments de jeu, variables, nouvel objet,
formulaire de données, tableau des données d'un objet. Seuls l'éditeur
d'écran/d'élément et l'aperçu jouable restent en dehors (déjà exclus par
leur propre gabarit plein écran, ou pas concernés).

Nouveau gabarit générique à une colonne (body.pageBody + .content-page +
.scrollArea) sur le même principe que .content-objectEdit, plus une
variante .formScroll pour un formulaire long à défilement interne (liste
de champs d'un nouvel objet, formulaire de données) tout en gardant les
boutons d'action toujours visibles. Les listes/tableaux remplacés par le
format dense .fieldsTable (déjà utilisé pour le tableau de bord) pour
rester cohérent et afficher plus de lignes à l'écran.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-25 10:45:42 +02:00
williamandClaude Sonnet 5 ac0f003663 Rend le tableau de bord du jeu plus compact, sobre et sans défilement de page
Réutilise le système déjà en place pour l'édition d'objet (body.objectEditBody
+ .content-objectEdit) : la page occupe exactement la hauteur de la fenêtre,
seules les deux colonnes défilent chacune de leur côté si besoin - plus la
page elle-même, qui ne doit jamais défiler.

Colonne de gauche ("Créer") : nouvelle nav à une ligne (.navRow/.navList,
icône + libellé, sous-titre en info-bulle) plutôt que des cartes à deux
lignes - plus dense, plus sobre.

Colonne de droite ("Objets définis") : remplace les cartes par le tableau
compact à en-tête collant déjà utilisé pour les champs d'un objet
(.fieldsTable) - format nettement plus adapté à beaucoup de lignes.

Réduit aussi le padding par défaut de .listRow/.dangerZone (encore utilisés
par la page Variables), dans le même esprit.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-25 10:31:43 +02:00
williamandClaude Sonnet 5 20ef86e039 Aligne le style du bouton "nouvel objet" sur les autres actions du tableau de bord
Remplace le bouton en pointillés ("+ Définir un nouvel objet") par une
entrée de liste identique aux autres actions (Écrans, Variables, Jouer...)
— plus cohérent visuellement, comme demandé après revue du rendu réel.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-25 10:24:07 +02:00
williamandClaude Sonnet 5 11a2d397e8 Remplace la création inline de variable par une page de gestion dédiée, redesign du tableau de bord du jeu
Retrait de la création rapide de variable globale depuis le sélecteur de
la Condition de visibilité (bouton "+ Créer") : une variable globale est
désormais gérée comme un objet "jeu" à part entière, avec une vraie page
CRUD ("Variables", nouvelle entrée du menu de gauche) - création, édition
du type/valeur, suppression. 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 - la renommer casserait ces réglages
en silence), d'où db.update_global_variable qui ne touche que type/valeur.

Redesign du tableau de bord du jeu (game_dashboard.html) en deux
colonnes : à gauche tout ce qu'on peut créer (écrans, éléments de jeu,
variables, jouer, nouvel objet) plus les paramètres du jeu (renommer/
supprimer) ; à droite ce qui a déjà été créé (objets définis). Remplace
les cartes Bulma par le système de mise en page compact déjà défini dans
style.css (.twoCol/.listRow/.dangerZone/.addBtn) mais jamais utilisé
jusqu'ici - plus dense et cohérent avec le reste de l'éditeur.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-25 10:00:35 +02:00
williamandClaude Sonnet 5 c04bc0b926 Ajoute la condition de visibilité et les variables globales
Nouveau panneau "Condition de visibilité" disponible dans les propriétés
de TOUT élément (widget) : permet de masquer un élément en mode jouable
selon deux moyens, au choix -
  - une variable globale (nom + type + valeur, une seule par jeu, stockée
    dans une nouvelle table _global_variables) ;
  - le champ d'un objet de données existant (même convention "état de
    partie" - une seule ligne - déjà utilisée par la Jauge).

Une variable ne servant à rien si elle ne peut jamais changer en cours de
partie, ajoute aussi une nouvelle action de flow "Modifier une variable
globale" (parallèle à "Modifier une donnée"), avec sa propre route
d'exécution serveur et son sous-formulaire dans l'éditeur de logique de
scène. Une variable peut aussi se créer à la volée depuis le sélecteur du
panneau de visibilité, sans quitter les propriétés de l'élément.

La condition n'est évaluée qu'en mode jouable (/game/<slug>/play), jamais
dans l'éditeur, pour que l'élément reste toujours sélectionnable. Un
élément masqué se réévalue en direct après toute action "Modifier une
donnée/variable", via le même mécanisme de rafraîchissement déjà utilisé
par la Jauge et le Répéteur.

Corrige au passage deux bugs découverts en testant bout en bout : (1)
apply_ctx plantait sur le nouveau marqueur interne _forge_play_mode (un
booléen parmi les {{champ}} à substituer, qui attend des chaînes) ; (2)
_compare traitait toute valeur booléenne stockée en chaîne ("0" inclus,
donc toujours vraie en Python) comme vraie - correct pour les champs
d'objet (entiers SQLite) mais faux pour les variables globales (toujours
stockées en texte).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-25 09:34:10 +02:00
william 3c6d07cb3a Remove the "Ajouter DANS ce conteneur" properties-panel menu
Redundant since dragging an element onto a container row in the tree now does the same thing (see move_element_to_container.py). The element_add_child route itself stays (still used by tests and available as an API), only the UI entry point is gone. Removed the now-dead .containerContentGroup CSS along with it.
2026-08-25 07:22:09 +02:00
william aa3be503ce Expose relation fields in the champ pickers and fix their column resolution
data_definition_options() used to exclude relation-type fields from its field list entirely, so a filter/binding could never reference "the linked object" of a row — and even when a relation field's clean name was typed manually (as the earlier "level.parcour" example needed), it silently matched nothing: every column lookup for a filter/repeater field used the field's own name, but a relation is actually stored in a "<field>_id" column (see create_definition.py), so the lookup always missed.

Relation fields now appear in the champ dropdowns (Répéteur's filtre_champ/filtre2_champ, Donnée liée's data_filtre_champ/data_filtre2_champ) labeled with the object they point to (e.g. "parcour (→ parcours)"), and a new _field_column() helper in filter_repeater_rows.py resolves the right "<field>_id" column whenever the field turns out to be a relation — used consistently by the filter comparison itself, the "{{Objet.champ}}" dynamic-value resolver, the repeater's row content ({{champ}}), and the Donnée liée row context. Jauge's own champ/champ_nom pickers (which need an actual displayable value, not an id) still exclude relations, both server- and client-side.

Verified end to end: the dropdown shows the relation field with its target-object label, and filtering "level" rows by the clean relation field name "parcour" (not "parcour_id") against a dynamic {{game.current_parcours}} reference now actually matches, alongside the existing "number" filter. Full suite green (89).
2026-08-25 06:48:04 +02:00
william 343732d7aa Turn free-typed field/object names into dropdowns in the Répéteur and Donnée liée filters
Two settings previously required typing an exact field or object name by hand:

- "champ" (Répéteur's filtre_champ/filtre2_champ, Donnée liée's data_filtre_champ/data_filtre2_champ) is now a dropdown of the actual fields on the object already chosen for that widget — pre-populated server-side (controls_with_values.py) and live-updated client-side without a reload when you change the object (bindDefinitionFieldSelects(), generalizing the existing Jauge champ/row_id pattern).

- "valeur" (the comparison value, which could already reference another object's field via a "{{Objet.champ}}" string typed by hand) is now a "Valeur fixe" / "Donnée d'un autre objet" toggle — the dynamic mode shows an object dropdown and a dependent field dropdown, and picking from them reconstructs that internal reference string automatically (initFilterValuePickers()/updateFilterValueFromDynamic()). The underlying stored value and _resolve_filter_value's parsing are unchanged, so existing elements using the old typed syntax still load correctly and populate the pickers on open.

Guards against writing a literal "{{...}}" pair directly in the Jinja template source (caught by a real render error while building this — Jinja parses {{/}} anywhere in the file, including inside comments) by building that syntax from split string literals in JS instead.

Verified end to end: repeater panel renders the pickers, saving with a dynamic reference filters correctly against live data (current_level changing which row shows), and reloading pre-selects the right object/field in the dropdowns. Full suite green (89).
2026-08-25 06:32:21 +02:00
william 33a887e7ba Move icon gallery to the left panel, drop the redundant Icône tile, add drag-into-container in the tree
- The "icone" widget no longer appears as a generic tile in "Ajouter un élément sur l'écran" / "Ajouter DANS ce conteneur" — the icon gallery (now living under "Ajouter un élément sur l'écran" in the left panel instead of the right one) is the only way to add one, always pre-set to the icon you picked.
- The element tree now supports dragging an element onto a container row to move it inside (last child), from anywhere in the tree — not just reordering within the same parent. Hovering a container row splits it into before/after/inside zones (thirds) when dragging a sibling, or "inside only" when dragging from elsewhere in the tree. New move_element_to_container() rejects non-container targets and cycles (dropping a container into itself or one of its own descendants) silently, mirroring reorder_element's existing safety pattern.

Verified end to end: gallery renders in the left panel with no icone tile in the widget grids, and the move endpoint correctly reparents, rejects a cycle, and rejects a non-container target. Full suite green (89).
2026-08-25 06:03:02 +02:00
william 6d80ed6958 Switch icons from a webfont to self-hosted SVG masks — the webfont was the actual problem
Self-hosting Font Awesome's font files (previous commit) didn't fix it either: every icon in the picker still failed identically. That rules out CDN/network blocking specifically and points to something in the browser blocking custom webfont loading altogether regardless of origin (common with strict anti-fingerprinting protection, e.g. Brave's font blocking or certain privacy extensions — @font-face loading is a well-known fingerprinting vector).

Replaced the whole mechanism: each of the 258 curated icons is now a real downloaded SVG file (static/icons/*.svg, sourced from Font Awesome 5 Free's official SVG package) displayed via CSS mask-image (.icon-svg in style.css) instead of a font glyph. A masked SVG isn't a font resource at all, so it isn't subject to webfont-blocking — and it still colors via the existing "color" style property (background-color:currentColor) and sizes via font-size (1em), so no change to how the widget's other controls work.

The "Icône" widget now stores just the icon's slug (e.g. "trophy") in a dedicated attribute instead of a full CSS class string, rendered by a new special_render (render_icone.py). Updated the add-gallery and the properties-panel picker modal to use the same mask technique for their previews, and element_add.py to validate the slug against the curated list. Removed the now-unused self-hosted Font Awesome CSS/webfont files and the <link> tags — no font dependency left for icons at all.

Verified end to end (gallery renders with real SVG previews, simulated add creates a correctly-attributed element, the SVG file is actually served, the per-element picker reflects the current icon). Full suite green (89).
2026-08-24 20:11:49 +02:00
william 15cad6c50f Self-host Font Awesome instead of loading it from a CDN
Switching CDN provider (cdnjs -> jsdelivr) didn't fix icons rendering as fallback glyph-code text — every icon in the picker still failed the same way, which points to something in the user's browser/network blocking cross-origin webfont loading specifically (common with strict tracking/fingerprinting protection, e.g. Brave's font-blocking or an ad-blocker's remote-font rule), not the CDN itself.

Downloaded Font Awesome 5.15.4's CSS and the solid-weight webfont files (the only style this app's icon classes actually use) into static/fontawesome/, served same-origin like style.css and pjax.js already are. Removes the CDN dependency entirely for icons rather than betting on a second provider.
2026-08-24 20:01:39 +02:00
william 675316cb3d Switch Font Awesome CDN from cdnjs to jsdelivr
Icons weren't rendering (glyph placeholder text like "F085" showing instead of the actual icon — the browser's font-fallback behavior when no font can render that codepoint, meaning the FA webfont never loaded). Both CDN URLs resolve fine from here, but Bulma (already jsdelivr) renders correctly for the user while cdnjs-hosted Font Awesome didn't — switching to the same jsdelivr provider removes that difference as a variable.
2026-08-24 19:57:31 +02:00
william 9cc6704e08 Replace the icon widget's plain text field with a visual searchable picker
The properties panel's "Icône" setting was a raw text input expecting a Font Awesome class name typed by hand — not something a non-developer using Forge Engine should have to know. It's now a button showing the current icon that opens a modal with the same searchable icon grid as the add-gallery; picking one replaces the selected element's icon and autosaves immediately, no typing required. The class name is still stored the same way (a hidden input feeding the existing generic attr:class control), so no backend changes were needed.
2026-08-24 19:26:35 +02:00
william cc0442ef5f Add a Font Awesome icon gallery to the properties panel, draggable onto the screen
New "Icône" widget (<i class="...">, color/size customizable exactly like any other widget) plus a curated set of ~250 verified Font Awesome 5 Free solid icon names (fontawesome_icons.py) — not the full ~1500-icon catalog, since an embedded list needs to be guaranteed accurate (a wrong class name silently renders as a blank glyph); any other valid FA5 class still works by typing it directly into the widget's "Icône" setting.

The gallery lives in the (now always-visible) top of the right floating panel, searchable, with each tile both clickable and HTML5-draggable onto the canvas — either action posts to element_add with the chosen icon_class, which now seeds the new element's class instead of leaving it on the generic default. Available in the element-type template editor too, since it reuses screen_edit.html.

Loads Font Awesome 5.15.4 (cdnjs) alongside the existing animate.css/Bulma links, in both the editor and /play.

Verified end to end: gallery renders with real icon glyphs, clicking/simulated-drop creates a correctly-classed <i> element, and the actual /play route renders it with Font Awesome loaded. Full suite green (89).
2026-08-24 19:10:25 +02:00
william 850242d1d4 Make the left element-tree panel floating/draggable/closable too, same as the right one
Generalized the floating-panel mechanism from the properties panel (props/propsPanelFloat/propsPanelReopenBtn) into reusable functions keyed by a short id, and applied it to the left "Éléments de cet écran" panel (key "left", panelLeftFloat, panelLeftReopenBtn). Both panels can now be closed independently, letting the canvas take the screen's full width for the most faithful possible preview against the actual /play rendering. Shared CSS (.floatPanel/.floatPanelHeader/.floatPanelBody/.floatPanelReopenBtn with --left/--right position variants) replaces the props-panel-only rules from the previous commit.

Fixed the two querySelector('.builderPanel .elementTree') call sites (used to patch the tree preview into the DOM after an unrelated canvas-only refresh) that would have silently stopped finding the tree once its wrapper's class changed.
2026-08-24 15:33:47 +02:00
william 03ca2723c5 Make the screen editor's properties panel floating, draggable, and closable
The right-hand properties panel was a fixed 320px flex column, permanently shrinking the canvas — on a screen with real content (tabs, gauges), this made the editor's preview visibly narrower than the actual /play rendering, to the point of wrapping tab labels differently. It's now position:fixed with a drag handle header, moved out of .builder3's flex flow so .builderCanvasArea automatically reclaims the space; closing it (✕, replaced by a small "⚙️ Propriétés" reopen button) frees the full width for a more faithful preview. Position and open/closed state persist per screen in localStorage and get reapplied after every selection change (which replaces #builder3 wholesale). Falls back to a normal stacked column below 1300px width, where a floating panel wouldn't fit usefully.
2026-08-24 15:12:50 +02:00
william a7518355ed Expose every Bulma variant as a choosable option, not just a curated few
Adds a shared bulma_variants.py (color/size option lists) and expands BULMA_*_CONTROLS in bulma_controls.py to cover each component's full modifier set: Bouton (colour incl. white/light/dark/black/text, light-shade toggle, size, rounded, outlined, inverted, static, loading, fullwidth), Titre (Bulma size is-1..is-6 independent of heading level, is-spaced), Jauge (size), Tableau (bordered/fullwidth/striped/hoverable/narrow, now toggleable instead of hardcoded), Onglets (alignment, boxed/toggle/toggle-rounded style, size, fullwidth), and every form field — champ_texte/email/mot_de_passe, zone_texte, liste_deroulante (colour, size, rounded, static).

render_select.py/render_onglets.py/render_jauge.py now redirect the merged "class" from _visible_attrs to the actual Bulma sub-element (the .select wrapper, the .tabs div, the <progress> tag) instead of the outer positioning wrapper, since that's what needs to carry the modifier classes.

Also fixed a real latent bug found while wiring this up: a checkbox control with default=True (e.g. Tableau's "Première ligne = en-tête") was never actually applied on a freshly created element — default_style_and_attributes unconditionally skipped ALL checkbox types at creation, so the panel showed it checked while the element itself had nothing set. Now a checkbox's default=True is frozen at creation like any other meaningful default; default=False (the common case) is unaffected.

Verified end to end: every widget's variant controls save and render the right class tokens (spot-checked titre/tableau/onglets/champ_texte/liste_deroulante/jauge), full suite green (89 passed).
2026-08-24 14:50:44 +02:00
william bb01e9fdf1 Make Jauge, Onglets, and every form-field widget real native Bulma elements
- Jauge now renders a real <progress class="progress"> instead of a hand-built pair of absolutely-positioned divs. The bas/haut color interpolation still works, set via Bulma's own --bulma-progress-value-background-color CSS custom property rather than fighting the class.
- Onglets' tab strip is now genuine Bulma tabs markup (tabs > ul > li, is-active on the li) instead of custom forgeTabBar/forgeTabBtn classes; forgeShowTab (duplicated in screen_edit.html and play.html) now toggles is-active to match.
- Champ texte/email/mot de passe get class="input", Zone de texte gets class="textarea", Case à cocher/Bouton radio's existing <label> wrapper gets class="checkbox"/"radio" (Bulma's own convention — the structure already matched, just needed the class), Liste déroulante is wrapped in Bulma's required <div class="select"> (a bare class on the <select> itself has zero effect in Bulma), Tableau gets class="table is-bordered is-fullwidth" with the per-cell inline borders removed so Bulma's own table styling applies.
- Removed the now-dead .forgeTabBar/.forgeTabBtn CSS.

Updated tests/test_jauge.py and tests/test_onglets_widget.py assertions to match the new markup (value="X" attribute instead of width:X% inline style, --bulma-progress-value-background-color instead of background-color, forgeShowTab(this) marker instead of the removed forgeTabBtn class) — same behavior, different rendering mechanism. Full suite green (89 passed), verified end to end against the real "test" project via the actual /play route.
2026-08-24 14:31:55 +02:00
william 24ced267cb Stop zeroing out padding/margin/border-width on native-Bulma widgets
Every widget gets padding/margin/border-width reset to 0 inline at creation (needed for a free-positioning canvas, otherwise every <h1>/<ul>/<p> would carry the browser's default spacing) — but for a widget that carries an external class like Bulma's button/box (see fixed_attrs in registry.py), inline style always wins over the class, so this reset silently stripped Bulma's own padding and border, making them look nothing like the real components. Bouton/Conteneur/Titre now skip this reset for those three properties, letting Bulma's own box model show through until a value is actually customized.

Also cleaned the frozen 0px values out of existing elements in the two live project databases (projects/test, projects/test-2) — only removed values that exactly matched the old frozen default, any genuinely customized non-zero padding/margin/border-width was left untouched.
2026-08-24 14:01:38 +02:00
william 2a673e023c Make Conteneur a native Bulma box by default instead of an opt-in style picker
Dropped the "Style Bulma" select that required manually choosing "Carte" to get Bulma's box class — the widget now always carries class="box", same unconditional pattern as Bouton (button) and Titre (title). A custom background color still overrides it the moment it's set, same as before.
2026-08-24 13:45:29 +02:00
william 8da5715e5b Make the screen editor's properties panel use native Bulma elements, not just Bulma-styled buttons
The element header/rename/id row, tabs list, position & size grid, and — most importantly — the generic control-rendering loop (used by every widget's properties panel: text, color, slider, checkbox, align, select...) now emit real field/control/label/select/checkbox/buttons-has-addons Bulma markup instead of the old custom controlRow/sliderRow/alignGroup/posGrid CSS. Removed the now-redundant custom CSS those classes used to carry.

Verified structurally safe: bindPropsAutosave/submitPropsForm already used querySelectorAll/FormData (structure-agnostic), so wrapping inputs in field/control divs doesn't affect autosave; the one sibling-dependent bit (slider oninput reading nextElementSibling) was kept adjacent inside its wrapper. Confirmed end-to-end with a live save round-trip through the actual route (Bulma modifier + free-style values both persist correctly) and every widget type's panel still renders 200 OK.

The flow-graph node editor (bottom panel) is not converted yet — part of its markup is built dynamically in JS (renderFlowConditionClauses), so reskinning it means updating the JS templates in lockstep with the HTML, which is more work/risk than this pass; noting it as the next piece.
2026-08-24 13:30:17 +02:00
william 25a331830b Reskin the whole admin UI (not just game widgets) with Bulma
Loads Bulma 1.0.2 in base.html (data-theme="dark" for its native dark palette) for every page that extends it, plus play.html directly. Converts every admin template — index, game dashboard, screens list, element types, object new/edit, data form/list — to real Bulma markup: navbar, breadcrumb component, box/card, field/control/input/select, table, notification, buttons, and a native Bulma modal for the data-row detail popup. Forge's own style.css keeps only what Bulma doesn't cover (the fixed-viewport builder/object-edit layouts, the element tree, canvas, flow-graph editor) and now acts as a secondary/override layer rather than a competing design system, matching how per-element inline customization already overrides widget defaults.

screen_edit.html (the 3-panel screen builder) gets the same navbar/breadcrumb/button treatment plus its top rename form, but its flow-graph node editor, canvas and element tree keep their existing custom styling — several of their inputs have JS relying on exact DOM sibling structure (e.g. slider oninput reading nextElementSibling) or Bulma's own select/wrapper requirement, and reskinning them without a browser to verify against risked silently breaking the app's most complex feature. Buttons and headings there are still fully converted (safe, purely additive class changes).

Verified: full test suite green, every route smoke-rendered 200 OK via the test client after the change.
2026-08-24 13:19:02 +02:00
william 38f33d88d9 Make Bulma the default style engine for Bouton/Titre/Conteneur, layered under existing inline customization
Adds a new "class:" control-target kind (save_element_controls.py, _visible_attrs.py) alongside the existing content/attr/style ones, so a widget can carry CSS classes built from independent named slots (color, size, shape...) without them overwriting each other. Bouton and Titre get their Bulma base class (button/title) via fixed_attrs; Conteneur gets an opt-in "Carte (Bulma)" preset instead of a forced default, since it's also used as an invisible layout wrapper. Bouton's font-size/border-radius sliders no longer freeze their default value into inline style at creation (new c_slider no_freeze flag), so Bulma's own button styling shows through until a user actually customizes it — inline style still wins over any class the moment it's set, exactly like today.

Loads bulma.min.css via CDN in screen_edit.html and play.html, same pattern as animate.css.
2026-08-24 12:54:50 +02:00
william 26cf245ffe Truncate long cells in the data table and add a row-detail popup
Cells now clip with an ellipsis instead of wrapping and inflating row height. A new eye button before the pencil opens a popup showing every field of that row in full, label above value, for comfortable reading.
2026-08-24 11:33:29 +02:00
william 0e5db97180 Let Texte/Titre elements bind directly to a single row of another object
Adds a "Donnée liée" settings group to Texte/Titre: pick an object, optionally match it to the current game state via 1-2 filter conditions (same engine as the data-repeater's filter, including {{Objet.champ}} cross-references), and use {{champ}} in the text content to show a field from that one matching row. Unlike the data repeater — built for showing a list of rows — this covers displaying a single computed value (e.g. the objective of the level matching the game's current parcours/level) without wrapping it in a repeater.
2026-08-24 11:02:54 +02:00
william f58fb2bc3d Support a second AND condition on the data-repeater filter
Needed to filter a "level" object by both its parcour_id and its number at once (e.g. show the level matching the game's current_parcours AND current_level) — the previous filter only supported a single condition.
2026-08-24 10:48:52 +02:00
william d8a80b8a8a Show existing fields as a scrollable table on the object-edit page
Replaces the stacked per-field cards with a compact table (one row per field) for a clearer overview when an object has many fields. The page itself no longer scrolls — only the table body and the left column do, mirroring the screen editor's fixed-viewport layout. Each row stays independently editable/deletable via hidden forms targeted with the HTML form= attribute.
2026-08-24 10:28:03 +02:00
william 2bb0c253f9 Fix object-form breaking on repeat pjax visits, and stray autofill in game rename field
object_form.js declared top-level const bindings, which pjax replays verbatim on every visit — the second visit threw "already declared" and silently broke "Ajouter un champ"/"Créer l'objet". Wrapped it in an IIFE. Also renamed the generic name="name" rename-game field to name="game_name" with autocomplete off, since browsers were autofilling it with unrelated previously-typed values.
2026-08-24 09:21:19 +02:00
william 4b05301e2e Add drag-and-drop reordering of sibling elements in the tree panel
Elements can now be reordered within the same container by dragging a row above or below another in the left-hand element tree.
2026-08-24 08:43:36 +02:00
williamandClaude Sonnet 5 17fa4cf087 Add an Onglets (Tabs) widget
New widget where each tab is a real "conteneur" element posed as a child
(see screens/elements/add_tab.py) — this reuses everything that already
exists for a normal container (adding a Répéteur/Conteneur/etc. inside via
"Ajouter DANS ce conteneur", renaming to change the tab's visible label,
deleting via the standard trash icon) instead of inventing a separate
storage format for tabs.

The widget's own properties panel gets a dedicated "Onglets" section to
add a tab, rename one, jump to its content, or delete it. Rendering
(render_onglets.py) builds a tab bar + one panel per tab, switched
client-side (forgeShowTab, in both screen_edit.html and play.html) with
only one panel visible at a time.

Distinct from the existing "activer_onglet" flow action (2.3, manual
show-one/hide-siblings) — that stays available for custom show/hide
wiring; this widget is the turnkey version with tab management built in.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-23 21:42:39 +02:00
williamandClaude Sonnet 5 2b2619d1c4 Fix selection highlight getting clipped when flush against a container edge
The positive outline-offset from the previous fix pushed the highlight
outside the element's own box — which gets clipped by the first
overflow:hidden ancestor (the canvas itself, or a rounded-corner
container) on whichever sides touch that ancestor's edge. A row of
gauges flush against the top/left of its container showed only a
stray line on the untouched side instead of a full frame.

Switch to an inset box-shadow: it always stays inside the element's own
box, so it can never be clipped by an ancestor, and it naturally follows
the element's own border-radius.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-23 20:53:48 +02:00
williamandClaude Sonnet 5 3228460f1c Give the selected-element canvas outline breathing room
A compact element like a Jauge (name label + thin bar, no padding) got
an outline pulled 1px INSIDE its box (outline-offset:-1px), which hugged
the content so tightly it looked like it was slicing through the bar
instead of framing it. Push the outline outward instead (+3px) with a
soft glow, so the selection frame reads clearly regardless of how
tightly the underlying element is sized.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-23 20:34:48 +02:00
williamandClaude Sonnet 5 d8ab71ddeb Highlight the selected element on the canvas even when it's nested
A top-level element already got a selection border via its
.canvasElement wrapper, but a child posed inside a container/répéteur/
groupe de champs has no such separate frame, so selecting it from the
tree gave no visual feedback on the canvas at all.

applySelectionHighlight() now targets the element's own tag directly via
data-element-id (present on every rendered element, nested or not) and
outlines it, re-run after every canvas refresh path (initial load, full
pjax navigation, partial panel refresh, and the autosave-only canvas
refresh).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-23 20:25:05 +02:00
williamandClaude Sonnet 5 386e93d9c6 Move element tree above the add-element menus, make containers collapsible
The "Éléments de cet écran" tree now sits above "Ajouter un élément sur
l'écran" / "Mes éléments de jeu" so it's the first thing visible in the
left panel.

Each container row gets a disclosure triangle that collapses/expands its
children, independent from the row's link (which still selects the
element and shows its properties whether collapsed or expanded). The
collapsed state is remembered per element in localStorage.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-23 20:17:21 +02:00
williamandClaude Sonnet 5 bb79f2f93d Redesign the screen editor's element tree and add duplication
The "Éléments de cet écran" tree now renders every level of nesting
(previously stopped after one level of children) as a compact single-line
list, and right-clicking a row opens a context menu to duplicate the
element (and its full subtree) in place, in its current container.

Also: all property panels start collapsed instead of some being open by
default, the redundant nested element list inside "Ajouter DANS ce
conteneur" is removed (it only needs the widget picker), and the
now-unneeded "a container is selected" warning banner is gone.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-23 19:53:38 +02:00
williamandClaude Sonnet 5 3039489e39 Fix Jauge name centering and add name appearance controls
Center the name+bar block vertically within the Jauge's own box
(justify-content:center) so it no longer looks pinned to the top once a
name label adds extra content height.

Add appearance settings for the name: position (above the bar, or beside
it on the left), alignment when above (centered or left-aligned), font
family, font size, bold, and italic.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-23 19:23:19 +02:00
william 7e4df3fa3a Fix Jauge bar collapsing to 0px when nested with a name label
A Jauge posée dans un conteneur a une hauteur "auto" (voir
_style_string) — "flex:1 1 auto" seul n'avait alors rien à répartir
(le conteneur flex lui-même n'a pas de hauteur définie), donc le
wrapper de la barre s'effondrait à 0px : seul le nom restait visible,
la jauge elle-même disparaissait. Ajout d'un "min-height" plancher
sur ce wrapper, qui laisse toujours la barre visible dans ce cas tout
en la laissant grandir avec flex:1 si l'élément a une vraie hauteur.
2026-08-23 18:55:44 +02:00
william c9d3069f47 Fix Jauge always reading the object's most recent row
A Jauge could only target a whole object, not a specific record —
three gauges pointing at the same "value" field (e.g. Réputation/
Trésorerie/Confiance in one "jauge" object) all silently showed the
most recent row's value, with no way to tell them apart.

Add "Enregistrement (ligne) à suivre" (row_id) so a Jauge targets one
specific row, and "Champ contenant le nom" (champ_nom) to show a
label above the bar — both as dropdowns populated from the object's
actual fields/rows (previously "Champ numérique à afficher" was free
text the user had to type correctly by hand). No regression: without
row_id the widget still falls back to the latest row, exactly as
before.

Extract data_definition_options() (rows+fields for a definition) out
of routes/screens/screen_edit.py so controls_with_values.py can reuse
it server-side for the initial render; a small client-side handler
(bindJaugeDefinitionSelect) repopulates the same selects live when
the tracked object is changed without leaving the panel.

Verified live with Playwright: picking "jauge" then "Confiance" then
"value"/"name" in the panel renders an 80%-filled, green-leaning bar
labelled "Confiance" on /play — not the 20%/50% of the other rows.
2026-08-23 18:02:02 +02:00
william 4eac20a464 Rewire hover as a flow trigger
Add "survol"/"fin_survol" trigger events (mouseenter/mouseleave,
same anti-doublon pattern as bindClicks()) so hovering an element can
run flow nodes, instead of the old static hover-text-only control
removed from the properties panel. Also add "Contenu" as a settable
"Modifier un élément" property so an action can display a defined
text on another element on hover — the concrete use case that
motivated this.

Verified live with Playwright: a "Au survol" trigger on one element
correctly updates another element's text via the action, and text
stays put with no "Fin du survol" wired (explicit, no implicit
revert, consistent with "Au clic").
2026-08-23 17:16:48 +02:00
william 4a593d07e4 Consolidate the properties panel: Position & taille, no more Taille/Survol/duplicate Disposition
- Merge "Taille dans le conteneur" into "Position & taille": a child
  element (posé dans un conteneur/répéteur/groupe de champs) now shows
  greyed-out X/Y (not applicable, it follows its parent's layout) and
  a single Largeur/Hauteur field per axis with a unit selector (%/px)
  instead of the old fixed-px-only sliders capped at 1000 — which was
  the root cause of a landscape-mode bug where a child couldn't be
  made wider than 1000px even though the actual screen was wider.
  New c_size control type (one style key per axis, not two competing
  sliders — an earlier px+% two-slider attempt let the untouched
  slider silently clobber the other's value on every autosave).
- Merge the two "Disposition" groups (visibility + scale, previously
  split apart in UNIVERSAL_CONTROLS by unrelated groups) into one.
- Remove the "Survol" panel: hovering is conceptually a flow trigger,
  not a static element property — to be reintroduced there. The
  underlying data-hover-text/bindHoverTexts runtime is untouched.
2026-08-23 12:14:00 +02:00
william d86b1a60c7 Fix builder canvas resizing on element selection
main.content (.content-builder) inherited "margin:0 auto" from the
base .content class. On this page body is a column flex container, so
that auto margin disabled cross-axis stretch and made the element
shrink-to-fit instead — sized via an algorithm that runs back down
through the flex/%/aspect-ratio chain to the canvas, and shifts
slightly whenever sibling panel content changes (e.g. selecting an
element). Give it an explicit width:100% so max-width/auto-margin
centering still work but sizing is stable and fills the available
space. Also add min-width:0 to .builderPanelRight, matching its
siblings, as defense against the same class of bug.

Verified with Playwright: canvas box was previously not filling
available width (918px expected vs 379px actual at a 1920px viewport)
and shifted between steps; both are fixed.
2026-08-23 11:00:09 +02:00
365 changed files with 6030 additions and 1258 deletions
+13 -2
View File
@@ -20,7 +20,7 @@ exécute un vrai CREATE TABLE, et remplir le formulaire généré exécute un
vrai INSERT dans cette table.
"""
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",
]
+52
View File
@@ -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()
+11
View File
@@ -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"],
}
View File
+27
View File
@@ -0,0 +1,27 @@
from ..connection import connect
from .ensure_global_vars_schema import ensure_global_vars_schema
def create_global_variable(slug, name, var_type="texte", default_value=""):
"""Crée une variable globale si son nom n'existe pas déjà (les noms sont
uniques — voir ensure_global_vars_schema.py) ; si elle existe déjà, ne
touche à rien et renvoie simplement son id existant, pour que "créer"
depuis le sélecteur de condition de visibilité (voir screen_edit.html)
reste sans risque même en cas de double clic/soumission."""
name = (name or "").strip()
if not name:
return None
ensure_global_vars_schema(slug)
conn = connect(slug)
existing = conn.execute("SELECT id FROM _global_variables WHERE name = ?", (name,)).fetchone()
if existing:
conn.close()
return existing["id"]
conn.execute(
"INSERT INTO _global_variables (name, type, value) VALUES (?, ?, ?)",
(name, var_type, default_value),
)
new_id = conn.execute("SELECT last_insert_rowid() AS id").fetchone()["id"]
conn.commit()
conn.close()
return new_id
+10
View File
@@ -0,0 +1,10 @@
from ..connection import connect
from .ensure_global_vars_schema import ensure_global_vars_schema
def delete_global_variable(slug, name):
ensure_global_vars_schema(slug)
conn = connect(slug)
conn.execute("DELETE FROM _global_variables WHERE name = ?", (name,))
conn.commit()
conn.close()
@@ -0,0 +1,14 @@
from ..connection import connect
from .ensure_global_vars_schema import ensure_global_vars_schema
def delete_global_variable_by_id(slug, var_id):
"""Version par id de delete_global_variable — utilisée par la page de
gestion des variables (game_variables.html), où chaque ligne connaît son
id technique mais pas nécessairement un nom encore garanti unique côté
formulaire d'URL (espaces, accents...)."""
ensure_global_vars_schema(slug)
conn = connect(slug)
conn.execute("DELETE FROM _global_variables WHERE id = ?", (var_id,))
conn.commit()
conn.close()
@@ -0,0 +1,23 @@
from ..connection import connect
def ensure_global_vars_schema(slug):
"""Migration légère (même principe que ensure_field_bounds_schema.py) :
crée _global_variables si absente — aucune migration manuelle nécessaire
pour les jeux créés avant cette fonctionnalité, la prochaine lecture
suffit. Une variable globale vit pour TOUT le jeu (pas par écran, pas
par ligne) : "nom" est donc UNIQUE, une seule valeur actuelle par nom."""
conn = connect(slug)
conn.execute(
"""
CREATE TABLE IF NOT EXISTS _global_variables (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
type TEXT NOT NULL DEFAULT 'texte',
value TEXT,
created_at TEXT DEFAULT CURRENT_TIMESTAMP
);
"""
)
conn.commit()
conn.close()
+16
View File
@@ -0,0 +1,16 @@
from ..connection import connect
from .ensure_global_vars_schema import ensure_global_vars_schema
def get_global_variable(slug, name):
"""Par NOM (unique), pas par id — c'est comme ça qu'une variable est
référencée partout ailleurs (condition de visibilité, action "Modifier
une variable"), jamais par un id technique que l'utilisateur ne voit
pas."""
if not name:
return None
ensure_global_vars_schema(slug)
conn = connect(slug)
row = conn.execute("SELECT * FROM _global_variables WHERE name = ?", (name,)).fetchone()
conn.close()
return dict(row) if row else None
+10
View File
@@ -0,0 +1,10 @@
from ..connection import connect
from .ensure_global_vars_schema import ensure_global_vars_schema
def list_global_variables(slug):
ensure_global_vars_schema(slug)
conn = connect(slug)
rows = conn.execute("SELECT * FROM _global_variables ORDER BY name").fetchall()
conn.close()
return [dict(r) for r in rows]
+16
View File
@@ -0,0 +1,16 @@
from ..connection import connect
from .ensure_global_vars_schema import ensure_global_vars_schema
def update_global_variable(slug, var_id, var_type, value):
"""Modifie le type et la valeur d'une variable existante depuis la page
de gestion (voir game_variables.html) — le NOM reste volontairement
immuable après création : c'est par ce nom qu'une condition de
visibilité ou une action "Modifier une variable" la référence (voir
get_global_variable.py), la renommer casserait ces réglages en
silence."""
ensure_global_vars_schema(slug)
conn = connect(slug)
conn.execute("UPDATE _global_variables SET type = ?, value = ? WHERE id = ?", (var_type, str(value), var_id))
conn.commit()
conn.close()
@@ -0,0 +1,18 @@
from ..connection import connect
from .ensure_global_vars_schema import ensure_global_vars_schema
from .get_global_variable import get_global_variable
def update_global_variable_value(slug, name, new_value):
"""Écrit une nouvelle valeur BRUTE (déjà calculée par l'appelant) sur la
variable — utilisé aussi bien par l'action "Modifier une variable" (voir
screens/global_vars/apply_variable_action.py) que par une éventuelle
modification directe (page de gestion des variables)."""
if not get_global_variable(slug, name):
return False
ensure_global_vars_schema(slug)
conn = connect(slug)
conn.execute("UPDATE _global_variables SET value = ? WHERE name = ?", (str(new_value), name))
conn.commit()
conn.close()
return True
+1 -1
View File
@@ -1 +1 @@
from . import games, objects, screens, elements, legacy_actions, flow, element_types, uploads, play, animations
from . import games, objects, screens, elements, legacy_actions, flow, element_types, uploads, play, animations, global_vars
+5
View File
@@ -2,7 +2,12 @@ from . import (
element_add,
element_add_child,
element_geometry,
element_reorder,
element_move_to_container,
element_save,
element_delete,
element_delete_impact,
element_duplicate,
element_rename,
element_tab_add,
)
+7
View File
@@ -17,4 +17,11 @@ def element_add(slug, screen_id):
et = types.get(int(element_type_id))
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))
+16
View File
@@ -0,0 +1,16 @@
from flask import jsonify
import screens
from core.flask_app import app
@app.route("/game/<slug>/elements/<int:element_id>/delete-impact")
def element_delete_impact(slug, element_id):
"""Appelé par la modale de confirmation de suppression (screen_edit.html)
AVANT que l'utilisateur ne confirme, pour l'avertir si des nœuds de la
Logique de la scène (déclencheur/action) seraient aussi supprimés en
même temps que l'élément — voir delete_element.py, qui les supprime
silencieusement sinon (nécessaire pour éviter un plantage "FOREIGN KEY
constraint failed", voir son commentaire)."""
return jsonify({"flow_node_count": screens.flow_nodes_referencing_element(slug, element_id)})
+14
View File
@@ -0,0 +1,14 @@
from flask import redirect, url_for, abort
import screens
from core.flask_app import app
@app.route("/game/<slug>/elements/<int:element_id>/duplicate", methods=["POST"])
def element_duplicate(slug, element_id):
element = screens.get_element(slug, element_id)
if not element:
abort(404)
new_id = screens.duplicate_element(slug, element_id)
return redirect(url_for("screen_edit", slug=slug, screen_id=element["screen_id"], selected=new_id))
@@ -0,0 +1,21 @@
from flask import request, jsonify, abort
import screens
from core.flask_app import app
@app.route("/game/<slug>/elements/<int:element_id>/move-to-container", methods=["POST"])
def element_move_to_container(slug, element_id):
"""Appelé en AJAX quand on glisse un élément DANS l'arborescence pour le
déposer sur un autre élément (un conteneur) plutôt qu'au-dessus/en
dessous — le fait passer enfant de ce conteneur, en dernière position
(voir move_element_to_container, qui refuse aussi silencieusement les
cas invalides : cible non-conteneur, ou cycle)."""
element = screens.get_element(slug, element_id)
if not element:
abort(404)
data = request.get_json(force=True)
new_parent_id = int(data["parent_id"])
screens.move_element_to_container(slug, element_id, new_parent_id)
return jsonify({"ok": True})
+22
View File
@@ -0,0 +1,22 @@
from flask import request, jsonify, abort
import screens
from core.flask_app import app
@app.route("/game/<slug>/elements/<int:element_id>/reorder", methods=["POST"])
def element_reorder(slug, element_id):
"""Appelé en AJAX quand on glisse un élément au-dessus/en dessous d'un
autre DANS LA MÊME ARBORESCENCE (panneau de gauche) pour changer l'ordre
des éléments d'un même conteneur — voir reorder_element."""
element = screens.get_element(slug, element_id)
if not element:
abort(404)
data = request.get_json(force=True)
target_id = int(data["target_id"])
position = data.get("position")
if position not in ("before", "after"):
abort(400)
screens.reorder_element(slug, element["screen_id"], element_id, target_id, position)
return jsonify({"ok": True})
+14
View File
@@ -0,0 +1,14 @@
from flask import redirect, url_for, abort
import screens
from core.flask_app import app
@app.route("/game/<slug>/elements/<int:element_id>/tabs/add", methods=["POST"])
def element_tab_add(slug, element_id):
onglets = screens.get_element(slug, element_id)
if not onglets:
abort(404)
screens.add_tab(slug, element_id, onglets["screen_id"])
return redirect(url_for("screen_edit", slug=slug, screen_id=onglets["screen_id"], selected=element_id))
+1
View File
@@ -5,5 +5,6 @@ from . import (
flow_edge_add,
flow_edge_delete,
flow_node_run_data,
flow_node_run_variable,
runtime_payload,
)
+10 -2
View File
@@ -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})
+17
View File
@@ -0,0 +1,17 @@
from flask import jsonify
import screens
from core.flask_app import app
@app.route("/game/<slug>/flow/nodes/<int:node_id>/run-variable", methods=["POST"])
def flow_node_run_variable(slug, node_id):
"""Exécuté côté serveur au moment du clic, en mode jouable, pour un nœud
Action de type "Modifier une variable globale" — même principe que
flow_node_run_data.py, pour une variable plutôt qu'un champ d'objet."""
node = screens.get_flow_node(slug, node_id)
if not node or node.get("node_type") != "action" or node.get("action_type") != "modifier_variable":
return jsonify({"ok": False}), 400
ok = screens.apply_variable_action(slug, node)
return jsonify({"ok": ok})
+1 -1
View File
@@ -7,7 +7,7 @@ from core.flask_app import app
@app.route("/game/<slug>/rename", methods=["POST"])
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))
+4
View File
@@ -0,0 +1,4 @@
from .create_global_var import create_global_var
from .game_variables import game_variables
from .global_var_edit import global_var_edit
from .global_var_delete import global_var_delete
+18
View File
@@ -0,0 +1,18 @@
from flask import request, redirect, url_for
import db
from core.flask_app import app
@app.route("/game/<slug>/variables/new", methods=["POST"])
def create_global_var(slug):
"""Depuis la page de gestion des variables (game_variables.html) —
idempotent par nom (voir db.create_global_variable), donc sans risque en
cas de double soumission."""
name = (request.form.get("name") or "").strip()
var_type = request.form.get("type") or "texte"
default_value = request.form.get("default_value") or ""
if name:
db.create_global_variable(slug, name, var_type, default_value)
return redirect(url_for("game_variables", slug=slug))
+19
View File
@@ -0,0 +1,19 @@
from flask import render_template
import db
from core.flask_app import app
@app.route("/game/<slug>/variables")
def game_variables(slug):
"""Page de gestion des variables globales (voir db/global_vars/) : un
objet "jeu" implicite dont les propriétés/valeurs sont visibles partout
(condition de visibilité, action "Modifier une variable"), plutôt
qu'attachées à un objet de données précis."""
game = db.game_meta(slug)
variables = db.list_global_variables(slug)
return render_template(
"game_variables.html", game=game, variables=variables,
variable_types=db.GLOBAL_VARIABLE_TYPES,
)
+11
View File
@@ -0,0 +1,11 @@
from flask import redirect, url_for
import db
from core.flask_app import app
@app.route("/game/<slug>/variables/<int:var_id>/delete", methods=["POST"])
def global_var_delete(slug, var_id):
db.delete_global_variable_by_id(slug, var_id)
return redirect(url_for("game_variables", slug=slug))
+13
View File
@@ -0,0 +1,13 @@
from flask import request, redirect, url_for
import db
from core.flask_app import app
@app.route("/game/<slug>/variables/<int:var_id>/edit", methods=["POST"])
def global_var_edit(slug, var_id):
var_type = request.form.get("type") or "texte"
value = request.form.get("value") or ""
db.update_global_variable(slug, var_id, var_type, value)
return redirect(url_for("game_variables", slug=slug))
+10 -16
View File
@@ -22,7 +22,12 @@ def screen_edit(slug, screen_id):
if selected_id:
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,
)
+10 -2
View File
@@ -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
+15 -22
View File
@@ -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
+19
View File
@@ -0,0 +1,19 @@
import db
from .add_element import add_element
from .rename_element import rename_element
def add_tab(slug, onglets_id, screen_id):
"""Ajoute un nouvel onglet à un élément "onglets" : un simple conteneur
posé comme enfant (voir render_onglets.py), nommé "Onglet N" par défaut
(N = son rang) — ce nom est directement ce qui s'affiche sur l'onglet,
modifiable ensuite comme celui de n'importe quel élément."""
conn = db.connect(slug)
count = conn.execute(
"SELECT COUNT(*) AS c FROM _screen_elements WHERE parent_id = ?", (onglets_id,)
).fetchone()["c"]
conn.close()
tab_id = add_element(slug, screen_id, "conteneur", parent_id=onglets_id)
rename_element(slug, tab_id, f"Onglet {count + 1}")
return tab_id
+27
View File
@@ -1,8 +1,35 @@
import db
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()
+60
View File
@@ -0,0 +1,60 @@
import db
def duplicate_element(slug, element_id):
"""Duplique un élément et tout son sous-arbre DANS SON CONTENEUR ACTUEL
(même écran, même parent_id que l'original) — le clic droit "Dupliquer"
de l'arborescence n'a pas à faire choisir où poser la copie, elle
apparaît juste à côté de l'original. Renvoie l'id de la copie (celui à
sélectionner ensuite), ou None si l'élément n'existe pas."""
conn = db.connect(slug)
root_row = conn.execute("SELECT * FROM _screen_elements WHERE id = ?", (element_id,)).fetchone()
if not root_row:
conn.close()
return None
root = dict(root_row)
source = [
dict(r) for r in conn.execute(
"SELECT * FROM _screen_elements WHERE screen_id = ?", (root["screen_id"],)
).fetchall()
]
conn.close()
def copy_one(src, new_parent_id, is_root):
conn = db.connect(slug)
max_z = conn.execute(
"SELECT MAX(z_index) AS m FROM _screen_elements WHERE screen_id = ?", (src["screen_id"],)
).fetchone()["m"]
x, y = src["x"], src["y"]
if is_root and not new_parent_id:
# Petit décalage pour que la copie ne recouvre pas exactement
# l'original (qui serait alors invisible en dessous).
x = min(100, (x or 0) + 4)
y = min(100, (y or 0) + 4)
name = src.get("name")
if is_root and name:
name = f"{name} (copie)"
conn.execute(
"""INSERT INTO _screen_elements
(screen_id, parent_id, widget, element_type_id, name, content, attributes,
x, y, width, height, z_index, style)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
(
src["screen_id"], new_parent_id, src["widget"], src.get("element_type_id"), name,
src["content"], src["attributes"], x, y, src["width"], src["height"],
(max_z or 0) + 1, src["style"],
),
)
new_id = conn.execute("SELECT last_insert_rowid() AS id").fetchone()["id"]
conn.commit()
conn.close()
return new_id
def walk(src_parent_id, new_parent_id):
for child in [s for s in source if s.get("parent_id") == src_parent_id]:
new_child_id = copy_one(child, new_parent_id, is_root=False)
walk(child["id"], new_child_id)
new_root_id = copy_one(root, root.get("parent_id"), is_root=True)
walk(root["id"], new_root_id)
return new_root_id
@@ -0,0 +1,23 @@
def element_descendant_ids(conn, screen_id, root_id):
"""Id de cet élément et de TOUS ses descendants — ceux qu'une
suppression en cascade (parent_id ON DELETE CASCADE, voir
ensure_schema.py) supprime automatiquement en même temps que lui.
Partagé entre delete_element.py (nettoyage des références de la
Logique de la scène avant suppression) et flow_nodes_referencing_
element.py (pour prévenir l'utilisateur AVANT qu'il confirme la
suppression, voir routes/elements/element_delete_impact.py)."""
rows = conn.execute(
"SELECT id, parent_id FROM _screen_elements WHERE screen_id = ?", (screen_id,)
).fetchall()
children_of = {}
for r in rows:
if r["parent_id"]:
children_of.setdefault(r["parent_id"], []).append(r["id"])
ids = [root_id]
stack = [root_id]
while stack:
current = stack.pop()
for child_id in children_of.get(current, []):
ids.append(child_id)
stack.append(child_id)
return ids
@@ -0,0 +1,28 @@
import db
from ..flow.ensure_flow_schema import ensure_flow_schema
from .element_descendant_ids import element_descendant_ids
def flow_nodes_referencing_element(slug, element_id):
"""Nombre de nœuds de la Logique de la scène (déclencheur "clic sur cet
élément", action "Modifier cet élément"/"Activer cet onglet"...) qui
référencent cet élément OU L'UN DE SES DESCENDANTS — donc qui seraient
supprimés SI on supprimait cet élément (voir delete_element.py). Utilisé
pour prévenir l'utilisateur AVANT qu'il confirme la suppression (voir
la modale de confirmation dans screen_edit.html), plutôt que de les
supprimer en silence."""
ensure_flow_schema(slug)
conn = db.connect(slug)
row = conn.execute("SELECT screen_id FROM _screen_elements WHERE id = ?", (element_id,)).fetchone()
if not row:
conn.close()
return 0
ids = element_descendant_ids(conn, row["screen_id"], element_id)
placeholders = ",".join("?" * len(ids))
count = conn.execute(
f"SELECT COUNT(*) AS c FROM _flow_nodes WHERE trigger_element_id IN ({placeholders}) OR target_element_id IN ({placeholders})",
ids + ids,
).fetchone()["c"]
conn.close()
return count
+40 -3
View File
@@ -5,14 +5,20 @@ import db
from ..rendering.render_element_html import render_element_html
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()
+37
View File
@@ -0,0 +1,37 @@
import db
def reorder_element(slug, screen_id, element_id, target_id, position):
"""Déplace element_id juste avant ("before") ou juste après ("after")
target_id, DANS LE MÊME CONTENEUR (les deux doivent avoir le même
parent_id — sinon on ne touche à rien, pour ne jamais reparenter par
erreur via le glisser-déposer de l'arborescence). L'ordre est porté par
z_index, partagé par tous les éléments de l'écran (pas juste les frères
et sœurs) : on renumérote donc l'écran entier à partir de l'ordre actuel
(z_index, id) pour rester cohérent partout, pas seulement dans ce
conteneur."""
conn = db.connect(slug)
rows = conn.execute(
"SELECT id, parent_id FROM _screen_elements WHERE screen_id = ? ORDER BY z_index, id",
(screen_id,),
).fetchall()
by_id = {r["id"]: r["parent_id"] for r in rows}
if element_id not in by_id or target_id not in by_id:
conn.close()
return
if by_id[element_id] != by_id[target_id]:
conn.close()
return
order = [r["id"] for r in rows]
order.remove(element_id)
target_index = order.index(target_id)
insert_at = target_index + 1 if position == "after" else target_index
order.insert(insert_at, element_id)
conn.executemany(
"UPDATE _screen_elements SET z_index = ? WHERE id = ?",
[(i + 1, eid) for i, eid in enumerate(order)],
)
conn.commit()
conn.close()
+69 -1
View File
@@ -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,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):
+21
View File
@@ -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",
}
+5
View File
@@ -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()
+1
View File
@@ -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
View File
@@ -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",
}
+1 -1
View File
@@ -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
View File
@@ -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
+71 -18
View File
@@ -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>'
+77 -11
View File
@@ -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_children(el, slug, children_map, 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"]
)
+24
View File
@@ -0,0 +1,24 @@
from .visible_attrs import _visible_attrs
from .style_string import _style_string
from .attr_string import _attr_string
from ..widgets.fontawesome_icons import FONTAWESOME_ICONS
def _render_icone(el, meta, slug=None, children_map=None, ctx=None, parent_flex_direction=None):
"""Widget Icône — un SVG affiché en silhouette via "mask-image" (voir
.icon-svg dans style.css), pas une police d'icônes : certains
navigateurs/extensions bloquent le chargement de polices web tierces
(protection anti-fingerprinting), ce qui rendait les icônes Font Awesome
invisibles quelle que soit leur source — un SVG posé en masque n'est
jamais concerné. Se colore via "color" (couleur du texte, déjà un
réglage existant), se dimensionne via "font-size" (le masque fait 1em)."""
raw_attrs = el.get("attributes") or {}
icon_slug = raw_attrs.get("_icon_slug") or "star"
if icon_slug not in FONTAWESOME_ICONS:
icon_slug = "star" # attribut corrompu/vidé : jamais d'icône cassée
attrs = _visible_attrs(el, meta, ctx)
existing_class = attrs.pop("class", "")
attrs["class"] = (existing_class + " icon-svg").strip()
icon_url = f"/static/icons/{icon_slug}.svg"
style = _style_string(el, f" --icon-url:url('{icon_url}');", parent_flex_direction)
return f"<i {_attr_string(attrs, style)}></i>"
+83 -17
View File
@@ -22,12 +22,15 @@ def _interpolate_color(low_hex, high_hex, ratio):
return "#{:02x}{:02x}{:02x}".format(*mixed)
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>'
)
+66
View File
@@ -0,0 +1,66 @@
import html as html_lib
from .visible_attrs import _visible_attrs
from .style_string import _style_string
from .parent_flex_direction import _parent_flex_direction
from .auto_id import _auto_id
from .attr_string import _attr_string
def _render_onglets(el, meta, slug, children_map=None, ctx=None, parent_flex_direction=None):
"""Élément à onglets : chaque onglet est un vrai conteneur posé comme
enfant (voir screens/elements/add_tab.py) — ça réutilise tel quel tout
ce qui existe déjà pour un conteneur normal (ajouter du contenu dedans
via "Ajouter DANS ce conteneur", le renommer pour changer le libellé
affiché sur l'onglet, le supprimer via la poubelle standard) plutôt que
d'inventer un stockage séparé pour les onglets. Un seul panneau visible
à la fois, basculé par un petit script embarqué qui marche aussi bien
dans l'éditeur qu'en jeu (voir forgeShowTab dans screen_edit.html et
play.html)."""
from .render_element_html import render_element_html
tabs = (children_map or {}).get(el["id"], [])
attrs = _visible_attrs(el, meta, ctx)
# Les variantes Bulma (alignement/style/taille... voir BULMA_TABS_CONTROLS)
# calculées par _visible_attrs atterrissent dans "class" — c'est le
# <div class="tabs"> qui doit les porter, pas ce wrapper de positionnement.
tabs_extra_classes = attrs.pop("class", "")
style = _style_string(el, " display:flex; flex-direction:column;", parent_flex_direction)
group_id = _auto_id(el)
if not tabs:
return (
f'<div {_attr_string(attrs, style)}>'
f'<em style="opacity:.6;">Onglets — ajoute au moins un onglet dans ses réglages.</em></div>'
)
flex_dir = _parent_flex_direction(el)
bar_items = []
panels = []
for i, tab in enumerate(tabs):
label = tab.get("name") or f"Onglet {i + 1}"
active = i == 0
tab_html = render_element_html(tab, slug, children_map, ctx, parent_flex_direction=flex_dir)
target_id = f"tabpanel-{tab['id']}"
# <li> Bulma natif ("tabs" > "ul" > "li", "is-active" sur le <li> —
# pas le <a> — pour l'onglet en cours) : le <li> porte le clic ET les
# data-attributes ciblés par forgeShowTab (voir screen_edit.html /
# play.html), le <a> imbriqué n'est là que pour le rendu visuel Bulma.
bar_items.append(
f'<li class="{"is-active" if active else ""}" '
f'data-tab-btn="{group_id}" data-tab-target="{target_id}" '
f'onclick="forgeShowTab(this)"><a>{html_lib.escape(str(label))}</a></li>'
)
hidden_style = "" if active else ' style="display:none;"'
panels.append(
f'<div class="forgeTabPanel" id="{target_id}" data-tab-group="{group_id}"{hidden_style}>'
f'{tab_html}</div>'
)
tabs_class = html_lib.escape(("tabs " + tabs_extra_classes).strip())
return (
f'<div {_attr_string(attrs, style)}>'
f'<div class="{tabs_class}"><ul>{"".join(bar_items)}</ul></div>'
f'<div class="forgeTabPanels" style="flex:1 1 auto; min-height:0;">{"".join(panels)}</div>'
f"</div>"
)
+9 -2
View File
@@ -5,7 +5,7 @@ import db
from .style_string import _style_string
from .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
+19 -3
View File
@@ -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>'
+2 -5
View File
@@ -10,15 +10,12 @@ def _render_table(el, meta, slug=None, children_map=None, ctx=None, parent_flex_
attrs = _visible_attrs(el, meta, ctx)
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>'
+38
View File
@@ -0,0 +1,38 @@
import db
from .filter_repeater_rows import filter_repeater_rows, _field_column
def _resolve_bound_row_ctx(slug, attrs):
"""Si cet élément est lié à un objet de données (attr:_data_definition_id —
réglage "Lier à un objet de données" des widgets Texte/Titre, voir
DATA_BINDING_CONTROLS), retrouve LA ligne qui correspond aux 1 ou 2
filtres réglés (_data_filtre*/_data_filtre2*) — même moteur que le
filtre du Répéteur de données, voir filter_repeater_rows.py — et
renvoie ses champs sous forme de {nom_du_champ: valeur en texte},
exploitable par {{champ}} dans le contenu. Permet d'afficher UNE seule
valeur calculée (ex: l'objectif du niveau en cours) sans avoir besoin de
poser un Répéteur, qui affiche lui plusieurs lignes. Renvoie None si
rien n'est lié ou si aucune ligne ne correspond."""
definition_id = attrs.get("_data_definition_id")
if not definition_id or not slug:
return None
try:
definition = db.get_definition(slug, int(definition_id))
except (TypeError, ValueError):
return None
if not definition:
return None
rows = db.list_rows(slug, definition)
rows = filter_repeater_rows(slug, definition, rows, {
"_filtre_champ": attrs.get("_data_filtre_champ"),
"_filtre_operateur": attrs.get("_data_filtre_operateur"),
"_filtre_valeur": attrs.get("_data_filtre_valeur"),
"_filtre2_champ": attrs.get("_data_filtre2_champ"),
"_filtre2_operateur": attrs.get("_data_filtre2_operateur"),
"_filtre2_valeur": attrs.get("_data_filtre2_valeur"),
})
if not rows:
return None
row = rows[0]
return {f["name"]: str(row.get(_field_column(definition, f["name"]), "") or "") for f in definition["fields"]}
+10
View File
@@ -38,6 +38,16 @@ def _style_string(el, extra_base="", parent_flex_direction=None, widget_key=None
nonlocal has_size_override
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
+60
View File
@@ -0,0 +1,60 @@
import db
from .filter_repeater_rows import _resolve_filter_value, _compare, _field_column
def is_element_visible(slug, attrs, ctx=None):
"""Condition de visibilité (panneau de propriétés, tout widget — voir
VISIBILITY_CONDITION_CONTROLS) : masque un élément selon soit une
VARIABLE GLOBALE (une seule valeur pour tout le jeu, voir
db/global_vars/), soit le champ d'un objet de données. Absente de
réglage (_visibilite_cond_mode vide) => toujours visible, comportement
inchangé pour tous les éléments déjà créés. Réutilise
_compare/_resolve_filter_value/_field_column de filter_repeater_rows.py
pour rester cohérent avec les mêmes opérateurs et la même syntaxe
"{{Objet.champ}}" pour une valeur de comparaison dynamique.
`ctx` porte les {{champ}} de la ligne en cours (voir render_repeater.py)
quand cet élément est descendant d'un Répéteur de données — en mode
"objet", si le champ réglé s'y trouve déjà, sa valeur est utilisée telle
quelle plutôt que d'aller chercher la ligne la plus récente de l'objet
en base : sans ça, TOUS les exemplaires d'un même modèle de ligne (ex.
une "Mail card" répétée pour chaque e-mail) évalueraient la même ligne
(la plus récente de l'objet) au lieu de chacun la sienne, et
n'afficheraient donc jamais rien de cohérent. Hors Répéteur (mode
"objet" sur un élément posé directement sur l'écran), le champ n'est
pas dans ctx et on retombe sur la convention "1 seule ligne = état de
partie" (déjà utilisée par la Jauge/Donnée liée) : la ligne la plus
récente de l'objet choisi."""
mode = attrs.get("_visibilite_cond_mode")
if mode not in ("variable", "objet"):
return True
operator = attrs.get("_visibilite_cond_operateur") or "egal"
expected = _resolve_filter_value(slug, attrs.get("_visibilite_cond_valeur") or "")
if mode == "variable":
var_name = attrs.get("_visibilite_cond_variable")
if not var_name:
return True
variable = db.get_global_variable(slug, var_name)
if not variable:
return True # variable supprimée depuis : ne bloque jamais l'affichage
return _compare(variable.get("value"), operator, expected, variable.get("type") or "texte")
definition_id = attrs.get("_visibilite_cond_definition_id")
field_name = attrs.get("_visibilite_cond_champ")
if not definition_id or not field_name:
return True
definition = db.get_definition(slug, int(definition_id))
if not definition:
return True
field_type = next((f["type"] for f in definition["fields"] if f["name"] == field_name), "texte")
if ctx and field_name in ctx:
return _compare(ctx.get(field_name), operator, expected, field_type)
rows = db.list_rows(slug, definition)
if not rows:
return True # objet sans donnée pour l'instant : ne bloque pas
row = rows[0] # convention "état de partie" : une seule ligne, voir data_definition_options.py
return _compare(row.get(_field_column(definition, field_name)), operator, expected, field_type)
+13 -1
View File
@@ -3,11 +3,23 @@ from .auto_id import _auto_id
def _visible_attrs(el, meta, ctx=None):
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"):
+14
View File
@@ -0,0 +1,14 @@
# Listes de modificateurs Bulma (https://bulma.io) réutilisées par plusieurs
# widgets pour construire leurs réglages "class:xxx" (voir
# control_groups/bulma_controls.py et save_element_controls.py) — un seul
# endroit à mettre à jour si Bulma change de version/palette.
BULMA_COLOR_OPTIONS = [
("", "Aucune (couleur par défaut)"),
("is-primary", "Primaire"), ("is-link", "Lien"), ("is-info", "Info"),
("is-success", "Succès"), ("is-warning", "Attention"), ("is-danger", "Danger"),
("is-white", "Blanc"), ("is-light", "Clair"), ("is-dark", "Sombre"), ("is-black", "Noir"), ("is-text", "Texte"),
]
BULMA_SIZE_OPTIONS = [
("", "Normale"), ("is-small", "Petite"), ("is-medium", "Moyenne"), ("is-large", "Grande"),
]
@@ -0,0 +1,65 @@
from ..controls.c_select import c_select
from ..controls.c_checkbox import c_checkbox
from ..bulma_variants import BULMA_COLOR_OPTIONS, BULMA_SIZE_OPTIONS
_GROUP = "Style Bulma"
# Chaque widget "natif Bulma" (voir fixed_attrs dans registry.py) expose ici
# TOUTES les variantes que Bulma propose pour son composant — chacune ajoute
# un jeton de classe indépendant (target "class:xxx", voir
# save_element_controls.py et _visible_attrs.py) à la classe de base déjà
# posée par fixed_attrs. Laissé à "" (par défaut) = aucun modificateur, look
# Bulma nu. Les réglages de couleur/taille "libres" du widget (curseurs,
# sélecteurs de couleur) restent utilisables par-dessus : un style en ligne
# gagne toujours contre une classe CSS, donc personnaliser une couleur ou une
# taille "à la main" continue de fonctionner exactement comme avant.
BULMA_BUTTON_CONTROLS = [
c_select("bulma_couleur", "Couleur", "class:couleur", BULMA_COLOR_OPTIONS, default="", group=_GROUP),
c_checkbox("bulma_nuance_claire", "Nuance claire (combinable avec une couleur)", "class:nuance", "is-light", "", group=_GROUP),
c_select("bulma_taille", "Taille", "class:taille", BULMA_SIZE_OPTIONS, default="", group=_GROUP),
c_checkbox("bulma_arrondi", "Coins bien arrondis", "class:arrondi", "is-rounded", "", group=_GROUP),
c_checkbox("bulma_contour", "Contour seulement (fond transparent)", "class:contour", "is-outlined", "", group=_GROUP),
c_checkbox("bulma_inverse", "Couleurs inversées", "class:inverse", "is-inverted", "", group=_GROUP),
c_checkbox("bulma_statique", "Statique (non cliquable visuellement)", "class:statique", "is-static", "", group=_GROUP),
c_checkbox("bulma_chargement", "Indicateur de chargement", "class:chargement", "is-loading", "", group=_GROUP),
c_checkbox("bulma_pleine_largeur", "Pleine largeur", "class:largeur", "is-fullwidth", "", group=_GROUP),
]
BULMA_TITLE_CONTROLS = [
c_select("bulma_taille", "Taille Bulma (indépendante du niveau de titre)", "class:taille",
[("", "Automatique"), ("is-1", "1 — la plus grande"), ("is-2", "2"), ("is-3", "3"),
("is-4", "4"), ("is-5", "5"), ("is-6", "6 — la plus petite")], default="", group=_GROUP),
c_checkbox("bulma_espace", "Garder l'espacement même sans sous-titre", "class:espace", "is-spaced", "", group=_GROUP),
]
BULMA_PROGRESS_CONTROLS = [
c_select("bulma_taille", "Taille de la barre", "class:taille", BULMA_SIZE_OPTIONS, default="", group=_GROUP),
]
BULMA_TABLE_CONTROLS = [
c_checkbox("bulma_bordure", "Bordures", "class:bordure", "is-bordered", "", default=True, group=_GROUP),
c_checkbox("bulma_pleine_largeur", "Pleine largeur", "class:largeur", "is-fullwidth", "", default=True, group=_GROUP),
c_checkbox("bulma_zebre", "Lignes zébrées", "class:zebre", "is-striped", "", group=_GROUP),
c_checkbox("bulma_survol", "Ligne surlignée au survol", "class:survol", "is-hoverable", "", group=_GROUP),
c_checkbox("bulma_compact", "Compact (cellules resserrées)", "class:compact", "is-narrow", "", group=_GROUP),
]
BULMA_TABS_CONTROLS = [
c_select("bulma_alignement", "Alignement", "class:alignement",
[("", "Gauche (par défaut)"), ("is-centered", "Centré"), ("is-right", "Droite")], default="", group=_GROUP),
c_select("bulma_style", "Style", "class:style",
[("", "Simple (soulignement)"), ("is-boxed", "Encadré"), ("is-toggle", "Boutons accolés"),
("is-toggle is-toggle-rounded", "Boutons accolés, arrondis")], default="", group=_GROUP),
c_select("bulma_taille", "Taille", "class:taille", BULMA_SIZE_OPTIONS, default="", group=_GROUP),
c_checkbox("bulma_pleine_largeur", "Pleine largeur (onglets étirés)", "class:largeur", "is-fullwidth", "", group=_GROUP),
]
# Champs de formulaire (input/textarea/select) : mêmes variantes que Bulma
# propose pour .input/.textarea/.select.
BULMA_FIELD_CONTROLS = [
c_select("bulma_couleur", "Couleur", "class:couleur", BULMA_COLOR_OPTIONS, default="", group=_GROUP),
c_select("bulma_taille", "Taille", "class:taille", BULMA_SIZE_OPTIONS, default="", group=_GROUP),
c_checkbox("bulma_arrondi", "Coins bien arrondis", "class:arrondi", "is-rounded", "", group=_GROUP),
c_checkbox("bulma_statique", "Statique (lecture seule visuelle)", "class:statique", "is-static", "", group=_GROUP),
]
@@ -0,0 +1,26 @@
from ..controls.c_text import c_text
from ..controls.c_select import c_select
from ...flow.constants import CONDITION_OPERATORS
# Permet à un Texte/Titre d'afficher directement la valeur d'un champ d'un
# AUTRE objet de données, sans avoir besoin de le poser dans un Répéteur —
# utile pour une valeur unique calculée (ex: l'objectif du niveau en cours)
# plutôt qu'une liste. La ligne affichée est celle qui correspond aux 1 ou 2
# filtres réglés ci-dessous (mêmes réglages, même moteur que ceux du
# Répéteur de données — voir filter_repeater_rows.py) ; sans filtre, c'est la
# ligne la plus récente de l'objet choisi (convention déjà utilisée par la
# Jauge pour un objet "état de partie" qui ne garde qu'une seule ligne).
DATA_BINDING_CONTROLS = [
c_select("data_definition_id", "Lier à un objet de données (pour {{champ}} ci-dessus)",
"attr:_data_definition_id", [("", "(sélectionner un objet)")], default="", group="Donnée liée"),
c_select("data_filtre_champ", "Trouver la ligne où ce champ",
"attr:_data_filtre_champ", [("", "(sélectionner un objet d'abord)")], default="", group="Donnée liée"),
c_select("data_filtre_operateur", "...est", "attr:_data_filtre_operateur", CONDITION_OPERATORS, default="egal", group="Donnée liée"),
c_text("data_filtre_valeur", "...cette valeur",
"attr:_data_filtre_valeur", placeholder="ex. 3", default="", group="Donnée liée"),
c_select("data_filtre2_champ", "ET (optionnel) : ce 2ᵉ champ",
"attr:_data_filtre2_champ", [("", "(sélectionner un objet d'abord)")], default="", group="Donnée liée"),
c_select("data_filtre2_operateur", "...est", "attr:_data_filtre2_operateur", CONDITION_OPERATORS, default="egal", group="Donnée liée"),
c_text("data_filtre2_valeur", "...cette valeur",
"attr:_data_filtre2_valeur", placeholder="ex. 1", default="", group="Donnée liée"),
]
@@ -0,0 +1,5 @@
from ..controls.c_shadow import c_shadow
SHADOW_CONTROLS = [
c_shadow("box-shadow", "Ombre portée", "style:box-shadow", group="Ombre"),
]
@@ -1,16 +1,29 @@
from ..controls.c_slider import c_slider
from ..controls.c_size import c_size
# Taille fixe (en pixels), surtout utile pour un élément posé À L'INTÉRIEUR
# 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
View File
@@ -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", "")
+12
View File
@@ -0,0 +1,12 @@
def c_shadow(key, label, target, default=None, group=None):
"""Ombre portée composée de 6 sous-réglages (décalage X/Y, flou,
étendue, couleur, opacité) — voir save_element_controls.py (ctype
"shadow") pour comment ils se combinent en une seule valeur CSS
"box-shadow", et control_value.py pour comment elle se décompose à
nouveau pour l'affichage. Décalages/flou/étendue tous à 0 = pas
d'ombre, comme border-width à 0 = pas de bordure."""
return {
"key": key, "label": label, "type": "shadow", "target": target,
"default": default or {"x": 0, "y": 4, "blur": 12, "spread": 0, "color": "#000000", "opacity": 0.4},
"group": group,
}
+5
View File
@@ -0,0 +1,5 @@
def c_size(key, label, target, default=0, group=None):
"""Taille avec unité choisie par l'utilisateur (% ou px) — voir
size_override_controls.py. 0 = comportement automatique, quelle que soit
l'unité affichée."""
return {"key": key, "label": label, "type": "size", "target": target, "default": default, "group": group}
+11 -2
View File
@@ -1,2 +1,11 @@
def c_slider(key, label, target, unit="px", min=0, max=100, step=1, default=16, group=None):
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,
}
+75 -4
View File
@@ -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}
+50 -14
View File
@@ -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)
+47
View File
@@ -0,0 +1,47 @@
# Sélection large mais CURATÉE d'icônes Font Awesome 5 Free (style "solid"),
# chacune un fichier SVG téléchargé une fois pour toutes dans static/icons/
# (voir render_icone.py — affichées en silhouette via "mask-image", PAS une
# police d'icônes : certains navigateurs/extensions bloquent le chargement
# de polices web tierces par protection anti-fingerprinting, ce qui rendait
# les icônes invisibles quelle que soit leur source — un SVG posé en masque
# n'est jamais concerné). Volontairement PAS le catalogue Font Awesome 5
# Free complet (~1500 icônes) : chaque entrée ici doit avoir un fichier SVG
# réellement présent dans static/icons/, donc la liste reste bornée à ce qui
# a été téléchargé plutôt que de deviner l'exhaustif.
FONTAWESOME_ICONS = [
"star", "heart", "check", "times", "plus", "minus", "search", "cog", "cogs", "wrench",
"hammer", "tools", "trash", "trash-alt", "edit", "pen", "pencil-alt", "save", "download",
"upload", "share", "share-alt", "link", "unlink", "lock", "lock-open", "key", "eye",
"eye-slash", "bell", "bell-slash", "bookmark", "flag", "flag-checkered", "thumbtack",
"filter", "sort", "sort-up", "sort-down", "list", "list-ul", "list-ol", "th", "th-list",
"th-large", "bars", "ellipsis-h", "ellipsis-v", "expand", "compress", "sync", "sync-alt",
"redo", "undo", "home", "user", "users", "user-circle", "user-plus", "user-minus",
"user-friends", "sign-in-alt", "sign-out-alt", "question-circle", "info-circle",
"exclamation-circle", "exclamation-triangle", "times-circle", "check-circle", "ban",
"shield-alt", "gem", "coins", "money-bill", "wallet", "credit-card", "gift", "trophy",
"medal", "award", "crown", "dice", "dice-d6", "dice-d20", "chess", "chess-king",
"chess-queen", "chess-knight", "chess-bishop", "chess-rook", "chess-pawn", "puzzle-piece",
"gamepad", "rocket", "bomb", "fire", "bolt", "snowflake", "sun", "moon", "cloud",
"cloud-rain", "wind", "umbrella", "tree", "leaf", "seedling", "paw", "spider", "skull",
"skull-crossbones", "map", "map-marker-alt", "map-marked-alt", "compass", "mountain",
"water", "globe", "globe-americas", "flask", "vial", "syringe", "pills", "heartbeat",
"dumbbell", "running", "walking", "swimmer", "futbol", "basketball-ball", "volleyball-ball",
"football-ball", "baseball-ball", "table-tennis", "bowling-ball", "horse", "dove", "cat",
"dog", "fish", "frog", "hippo", "otter", "kiwi-bird", "book", "book-open", "graduation-cap",
"school", "chalkboard", "chalkboard-teacher", "pencil-ruler", "ruler", "ruler-combined",
"calculator", "laptop", "desktop", "mobile-alt", "tablet-alt", "keyboard", "mouse",
"headphones", "microphone", "video", "camera", "image", "images", "film", "music", "play",
"pause", "stop", "step-forward", "step-backward", "volume-up", "volume-down", "volume-mute",
"volume-off", "envelope", "envelope-open", "comment", "comments", "comment-dots", "phone",
"phone-alt", "calendar", "calendar-alt", "calendar-check", "clock", "hourglass",
"hourglass-half", "stopwatch", "car", "car-side", "truck", "plane", "plane-departure",
"ship", "anchor", "subway", "train", "bus", "bicycle", "taxi", "road", "traffic-light",
"gas-pump", "screwdriver", "toolbox", "hard-hat", "building", "city", "industry",
"warehouse", "store", "shopping-cart", "shopping-bag", "box", "boxes", "dolly", "pallet",
"archive", "folder", "folder-open", "file", "file-alt", "file-pdf", "file-word",
"file-excel", "file-image", "file-video", "file-audio", "database", "server",
"network-wired", "wifi", "signal", "satellite", "satellite-dish", "robot", "microchip",
"memory", "hdd", "print", "cut", "paste", "copy", "clipboard", "clipboard-list",
"clipboard-check", "sticky-note", "paperclip", "paint-brush", "palette", "magic",
"fire-extinguisher", "first-aid", "briefcase", "suitcase",
]
+98 -23
View File
@@ -8,6 +8,12 @@ from .controls.c_toggle_token import c_toggle_token
from .controls.c_align import c_align
from .controls.c_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"),
],
},
}
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 576 512"><!-- Font Awesome Free 5.15.4 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License) --><path d="M12.971 352h32.394C67.172 454.735 181.944 512 288 512c106.229 0 220.853-57.38 242.635-160h32.394c10.691 0 16.045-12.926 8.485-20.485l-67.029-67.029c-4.686-4.686-12.284-4.686-16.971 0l-67.029 67.029c-7.56 7.56-2.206 20.485 8.485 20.485h35.146c-20.29 54.317-84.963 86.588-144.117 94.015V256h52c6.627 0 12-5.373 12-12v-40c0-6.627-5.373-12-12-12h-52v-5.47c37.281-13.178 63.995-48.725 64-90.518C384.005 43.772 341.605.738 289.37.01 235.723-.739 192 42.525 192 96c0 41.798 26.716 77.35 64 90.53V192h-52c-6.627 0-12 5.373-12 12v40c0 6.627 5.373 12 12 12h52v190.015c-58.936-7.399-123.82-39.679-144.117-94.015h35.146c10.691 0 16.045-12.926 8.485-20.485l-67.029-67.029c-4.686-4.686-12.284-4.686-16.971 0L4.485 331.515C-3.074 339.074 2.28 352 12.971 352zM288 64c17.645 0 32 14.355 32 32s-14.355 32-32 32-32-14.355-32-32 14.355-32 32-32z"/></svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

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

After

Width:  |  Height:  |  Size: 524 B

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

After

Width:  |  Height:  |  Size: 1.7 KiB

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

After

Width:  |  Height:  |  Size: 583 B

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

After

Width:  |  Height:  |  Size: 601 B

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

After

Width:  |  Height:  |  Size: 931 B

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

After

Width:  |  Height:  |  Size: 950 B

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

After

Width:  |  Height:  |  Size: 875 B

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

After

Width:  |  Height:  |  Size: 657 B

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

After

Width:  |  Height:  |  Size: 1.6 KiB

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

After

Width:  |  Height:  |  Size: 469 B

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

After

Width:  |  Height:  |  Size: 1.1 KiB

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

After

Width:  |  Height:  |  Size: 713 B

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

After

Width:  |  Height:  |  Size: 698 B

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

After

Width:  |  Height:  |  Size: 336 B

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

After

Width:  |  Height:  |  Size: 544 B

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

After

Width:  |  Height:  |  Size: 486 B

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

After

Width:  |  Height:  |  Size: 642 B

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

After

Width:  |  Height:  |  Size: 528 B

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

After

Width:  |  Height:  |  Size: 1.1 KiB

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