From bb79f2f93d8a2cf9514f4c5d62dac6421e78e043 Mon Sep 17 00:00:00 2001
From: william
Date: Sun, 23 Aug 2026 19:53:38 +0200
Subject: [PATCH] Redesign the screen editor's element tree and add duplication
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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
---
routes/elements/__init__.py | 1 +
routes/elements/element_duplicate.py | 14 +++
screens/__init__.py | 1 +
screens/elements/duplicate_element.py | 60 ++++++++++++
static/style.css | 27 ++++++
templates/screen_edit.html | 126 +++++++++++++++++---------
tests/test_screens_and_elements.py | 64 +++++++++++++
7 files changed, 249 insertions(+), 44 deletions(-)
create mode 100644 routes/elements/element_duplicate.py
create mode 100644 screens/elements/duplicate_element.py
diff --git a/routes/elements/__init__.py b/routes/elements/__init__.py
index 4a687a8c..c9c16444 100644
--- a/routes/elements/__init__.py
+++ b/routes/elements/__init__.py
@@ -4,5 +4,6 @@ from . import (
element_geometry,
element_save,
element_delete,
+ element_duplicate,
element_rename,
)
diff --git a/routes/elements/element_duplicate.py b/routes/elements/element_duplicate.py
new file mode 100644
index 00000000..bca9841b
--- /dev/null
+++ b/routes/elements/element_duplicate.py
@@ -0,0 +1,14 @@
+from flask import redirect, url_for, abort
+
+import screens
+
+from core.flask_app import app
+
+
+@app.route("/game//elements//duplicate", methods=["POST"])
+def element_duplicate(slug, element_id):
+ element = screens.get_element(slug, element_id)
+ if not element:
+ abort(404)
+ new_id = screens.duplicate_element(slug, element_id)
+ return redirect(url_for("screen_edit", slug=slug, screen_id=element["screen_id"], selected=new_id))
diff --git a/screens/__init__.py b/screens/__init__.py
index 44014358..d06ae73c 100644
--- a/screens/__init__.py
+++ b/screens/__init__.py
@@ -76,6 +76,7 @@ from .elements.add_element import add_element
from .elements.update_element_geometry import update_element_geometry
from .elements.save_element_controls import save_element_controls
from .elements.delete_element import delete_element
+from .elements.duplicate_element import duplicate_element
from .elements.rename_element import rename_element
from .elements.element_label import element_label
diff --git a/screens/elements/duplicate_element.py b/screens/elements/duplicate_element.py
new file mode 100644
index 00000000..ecd20e0b
--- /dev/null
+++ b/screens/elements/duplicate_element.py
@@ -0,0 +1,60 @@
+import db
+
+
+def duplicate_element(slug, element_id):
+ """Duplique un élément et tout son sous-arbre DANS SON CONTENEUR ACTUEL
+ (même écran, même parent_id que l'original) — le clic droit "Dupliquer"
+ de l'arborescence n'a pas à faire choisir où poser la copie, elle
+ apparaît juste à côté de l'original. Renvoie l'id de la copie (celui à
+ sélectionner ensuite), ou None si l'élément n'existe pas."""
+ conn = db.connect(slug)
+ root_row = conn.execute("SELECT * FROM _screen_elements WHERE id = ?", (element_id,)).fetchone()
+ if not root_row:
+ conn.close()
+ return None
+ root = dict(root_row)
+ source = [
+ dict(r) for r in conn.execute(
+ "SELECT * FROM _screen_elements WHERE screen_id = ?", (root["screen_id"],)
+ ).fetchall()
+ ]
+ conn.close()
+
+ def copy_one(src, new_parent_id, is_root):
+ conn = db.connect(slug)
+ max_z = conn.execute(
+ "SELECT MAX(z_index) AS m FROM _screen_elements WHERE screen_id = ?", (src["screen_id"],)
+ ).fetchone()["m"]
+ x, y = src["x"], src["y"]
+ if is_root and not new_parent_id:
+ # Petit décalage pour que la copie ne recouvre pas exactement
+ # l'original (qui serait alors invisible en dessous).
+ x = min(100, (x or 0) + 4)
+ y = min(100, (y or 0) + 4)
+ name = src.get("name")
+ if is_root and name:
+ name = f"{name} (copie)"
+ conn.execute(
+ """INSERT INTO _screen_elements
+ (screen_id, parent_id, widget, element_type_id, name, content, attributes,
+ x, y, width, height, z_index, style)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
+ (
+ src["screen_id"], new_parent_id, src["widget"], src.get("element_type_id"), name,
+ src["content"], src["attributes"], x, y, src["width"], src["height"],
+ (max_z or 0) + 1, src["style"],
+ ),
+ )
+ new_id = conn.execute("SELECT last_insert_rowid() AS id").fetchone()["id"]
+ conn.commit()
+ conn.close()
+ return new_id
+
+ def walk(src_parent_id, new_parent_id):
+ for child in [s for s in source if s.get("parent_id") == src_parent_id]:
+ new_child_id = copy_one(child, new_parent_id, is_root=False)
+ walk(child["id"], new_child_id)
+
+ new_root_id = copy_one(root, root.get("parent_id"), is_root=True)
+ walk(root["id"], new_root_id)
+ return new_root_id
diff --git a/static/style.css b/static/style.css
index a6b89ccc..79169b6b 100644
--- a/static/style.css
+++ b/static/style.css
@@ -154,6 +154,33 @@ body.builderBody > main.content{ flex:1 1 auto; min-height:0; overflow:hidden; d
.childRow{ padding:7px 10px; font-size:12px; }
.childRow .rowTitle{ font-size:12px; }
+/* Arborescence compacte des éléments de l'écran (panneau gauche) — une
+ ligne par élément, indentation par imbrication réelle (profondeur
+ illimitée) plutôt qu'un seul niveau d'enfants comme l'ancienne .childList,
+ pour rester lisible même avec beaucoup d'éléments. */
+.elementTree{ display:flex; flex-direction:column; gap:1px; max-height:55vh; overflow-y:auto; margin-bottom:14px; }
+.treeNode{ display:flex; flex-direction:column; }
+.treeRowLink{
+ display:flex; align-items:center; gap:6px; padding:4px 6px; border-radius:5px;
+ text-decoration:none; color:var(--text); font-size:12.5px; line-height:1.5;
+ white-space:nowrap; overflow:hidden;
+}
+.treeRowLink:hover{ background:var(--panel2); }
+.treeRowLink.selected{ background:var(--accent); color:#0b0e14; font-weight:600; }
+.treeIcon{ flex:0 0 auto; font-size:12px; }
+.treeLabel{ overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
+.treeChildren{ display:flex; flex-direction:column; margin-left:10px; padding-left:8px; border-left:1px dashed var(--border); }
+
+.contextMenu{
+ position:fixed; z-index:500; background:var(--panel); border:1px solid var(--border);
+ border-radius:8px; box-shadow:0 8px 24px rgba(0,0,0,.4); padding:4px; min-width:160px;
+}
+.contextMenu button{
+ display:block; width:100%; text-align:left; background:none; border:none; color:var(--text);
+ padding:7px 10px; border-radius:5px; font-size:13px; cursor:pointer;
+}
+.contextMenu button:hover{ background:var(--panel2); }
+
.propGroup{ border:1px solid var(--border); border-radius:8px; margin-bottom:8px; background:var(--panel); }
.propGroup > summary{
list-style:none; cursor:pointer; padding:9px 12px; font-size:13px; color:var(--accent);
diff --git a/templates/screen_edit.html b/templates/screen_edit.html
index 953404b1..f5e39591 100644
--- a/templates/screen_edit.html
+++ b/templates/screen_edit.html
@@ -29,16 +29,31 @@
{% endif %}
+{% macro render_tree_node(el) %}
+
+{% endmacro %}
+
{% set el = elements|selectattr("id", "equalto", selected_id)|first if selected_id else none %}
- {% if selected_id and el and el.widget in container_widgets %}
-
- ⚠️ Un conteneur est sélectionné. La grille ci-dessous pose l'élément directement sur l'écran, PAS à l'intérieur du conteneur — pour poser un élément dans le conteneur sélectionné, utilise plutôt « 📥 Ajouter DANS ce conteneur » dans le panneau de droite.
-
- {% endif %}
-
+
Ajouter un élément sur l'écran