diff --git a/routes/elements/__init__.py b/routes/elements/__init__.py index c9c16444..673286f5 100644 --- a/routes/elements/__init__.py +++ b/routes/elements/__init__.py @@ -6,4 +6,5 @@ from . import ( element_delete, element_duplicate, element_rename, + element_tab_add, ) diff --git a/routes/elements/element_tab_add.py b/routes/elements/element_tab_add.py new file mode 100644 index 00000000..4dffd447 --- /dev/null +++ b/routes/elements/element_tab_add.py @@ -0,0 +1,14 @@ +from flask import redirect, url_for, abort + +import screens + +from core.flask_app import app + + +@app.route("/game//elements//tabs/add", methods=["POST"]) +def element_tab_add(slug, element_id): + onglets = screens.get_element(slug, element_id) + if not onglets: + abort(404) + screens.add_tab(slug, element_id, onglets["screen_id"]) + return redirect(url_for("screen_edit", slug=slug, screen_id=onglets["screen_id"], selected=element_id)) diff --git a/screens/__init__.py b/screens/__init__.py index d06ae73c..75af2633 100644 --- a/screens/__init__.py +++ b/screens/__init__.py @@ -73,6 +73,7 @@ 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.save_element_controls import save_element_controls from .elements.delete_element import delete_element diff --git a/screens/elements/add_tab.py b/screens/elements/add_tab.py new file mode 100644 index 00000000..8b0036d1 --- /dev/null +++ b/screens/elements/add_tab.py @@ -0,0 +1,19 @@ +import db + +from .add_element import add_element +from .rename_element import rename_element + + +def add_tab(slug, onglets_id, screen_id): + """Ajoute un nouvel onglet à un élément "onglets" : un simple conteneur + posé comme enfant (voir render_onglets.py), nommé "Onglet N" par défaut + (N = son rang) — ce nom est directement ce qui s'affiche sur l'onglet, + modifiable ensuite comme celui de n'importe quel élément.""" + conn = db.connect(slug) + count = conn.execute( + "SELECT COUNT(*) AS c FROM _screen_elements WHERE parent_id = ?", (onglets_id,) + ).fetchone()["c"] + conn.close() + tab_id = add_element(slug, screen_id, "conteneur", parent_id=onglets_id) + rename_element(slug, tab_id, f"Onglet {count + 1}") + return tab_id diff --git a/screens/rendering/render_element_html.py b/screens/rendering/render_element_html.py index bbed65eb..bd978cae 100644 --- a/screens/rendering/render_element_html.py +++ b/screens/rendering/render_element_html.py @@ -13,6 +13,7 @@ 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 def render_element_html(el, slug=None, children_map=None, ctx=None, parent_flex_direction=None): @@ -43,6 +44,8 @@ def render_element_html(el, slug=None, children_map=None, ctx=None, parent_flex_ return _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) + if special == "onglets": + return _render_onglets(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) diff --git a/screens/rendering/render_onglets.py b/screens/rendering/render_onglets.py new file mode 100644 index 00000000..02e1262f --- /dev/null +++ b/screens/rendering/render_onglets.py @@ -0,0 +1,57 @@ +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) + style = _style_string(el, " display:flex; flex-direction:column;", parent_flex_direction) + group_id = _auto_id(el) + + if not tabs: + return ( + f'
' + f'Onglets — ajoute au moins un onglet dans ses réglages.
' + ) + + 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']}" + bar_items.append( + f'' + ) + hidden_style = "" if active else ' style="display:none;"' + panels.append( + f'
' + f'{tab_html}
' + ) + + return ( + f'
' + f'
{"".join(bar_items)}
' + f'
{"".join(panels)}
' + f"
" + ) diff --git a/screens/widgets/registry.py b/screens/widgets/registry.py index 076ba9c0..23fd4756 100644 --- a/screens/widgets/registry.py +++ b/screens/widgets/registry.py @@ -277,4 +277,14 @@ 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": [], + }, } diff --git a/static/style.css b/static/style.css index bd06d6bb..f5b76177 100644 --- a/static/style.css +++ b/static/style.css @@ -206,6 +206,17 @@ body.builderBody > main.content{ flex:1 1 auto; min-height:0; overflow:hidden; d } .contextMenu button:hover{ background:var(--panel2); } +/* Élément à onglets (voir render_onglets.py) */ +.forgeTabBar{ display:flex; flex-wrap:wrap; gap:2px; border-bottom:1px solid var(--border); } +.forgeTabBtn{ + background:none; border:none; border-bottom:2px solid transparent; color:var(--text-dim); + padding:8px 14px; font-size:14px; font-family:inherit; cursor:pointer; white-space:nowrap; +} +.forgeTabBtn:hover{ color:var(--text); } +.forgeTabBtn.active{ color:var(--text); border-bottom-color:var(--accent); font-weight:600; } +.forgeTabPanels{ min-width:0; } +.forgeTabPanel{ padding-top:10px; } + .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/play.html b/templates/play.html index 1dc8e9c2..ab1f2067 100644 --- a/templates/play.html +++ b/templates/play.html @@ -74,6 +74,20 @@ let gameData = {{ payload_json|safe }}; let screensData = gameData.screens; + // Élément à onglets (voir render_onglets.py) : bascule le panneau visible + // sans aller-retour serveur — même fonction que dans screen_edit.html, + // pour un comportement identique éditeur/jeu. + function forgeShowTab(btn) { + var groupId = btn.dataset.tabBtn; + var targetId = btn.dataset.tabTarget; + document.querySelectorAll('[data-tab-btn="' + groupId + '"]').forEach(function (b) { + b.classList.toggle('active', b === btn); + }); + document.querySelectorAll('[data-tab-group="' + groupId + '"]').forEach(function (p) { + p.style.display = (p.id === targetId) ? '' : 'none'; + }); + } + function showScreen(id) { document.querySelectorAll('.playScreen').forEach(s => s.style.display = 'none'); const el = document.querySelector('.playScreen[data-screen-id="' + id + '"]'); diff --git a/templates/screen_edit.html b/templates/screen_edit.html index 38b61775..b834aa96 100644 --- a/templates/screen_edit.html +++ b/templates/screen_edit.html @@ -186,6 +186,38 @@ {% endif %} + {% if el.widget == 'onglets' %} + {% set tabs = children_of.get(el.id, []) %} +
+ 🗂️ Onglets{{ ' (' ~ tabs|length ~ ')' if tabs else '' }} +
+ {% if tabs %} +
+ {% for tab in tabs %} +
+
+ + +
+
+ 📥 Contenu de l'onglet +
+ +
+
+
+ {% endfor %} +
+ {% else %} +

Aucun onglet pour l'instant.

+ {% endif %} +
+ +
+
+
+ {% endif %} + {% set size_ctrl_keys = ['child-width', 'child-height'] %} {% set size_ctrls = {} %} {% for c in selected_controls if c.key in size_ctrl_keys %}{% set _ = size_ctrls.update({c.key: c}) %}{% endfor %} @@ -675,6 +707,22 @@ function applySelectionHighlight(id) { if (node) node.classList.add('elementHighlighted'); } +// ---------- Élément à onglets : bascule d'onglet côté client ---------- +// +// Un seul panneau visible à la fois (voir render_onglets.py) — basculé ici +// plutôt que par un aller-retour serveur, pour un rendu instantané aussi +// bien dans l'éditeur qu'en jeu (voir la même fonction dans play.html). +function forgeShowTab(btn) { + var groupId = btn.dataset.tabBtn; + var targetId = btn.dataset.tabTarget; + document.querySelectorAll('[data-tab-btn="' + groupId + '"]').forEach(function (b) { + b.classList.toggle('active', b === btn); + }); + document.querySelectorAll('[data-tab-group="' + groupId + '"]').forEach(function (p) { + p.style.display = (p.id === targetId) ? '' : 'none'; + }); +} + if (!window.__forgeTreeToggleBound) { window.__forgeTreeToggleBound = true; document.addEventListener('click', function (e) { diff --git a/tests/test_onglets_widget.py b/tests/test_onglets_widget.py new file mode 100644 index 00000000..b4b825c9 --- /dev/null +++ b/tests/test_onglets_widget.py @@ -0,0 +1,121 @@ +"""Tests du widget Onglets — ajouter/renommer/supprimer un onglet, contenu +par onglet (un vrai conteneur enfant, voir screens/elements/add_tab.py). + +À ne pas confondre avec tests/test_onglets.py, qui teste l'action de flux +existante "activer_onglet" (2.3, montrer un élément et masquer ses frères) — +un mécanisme manuel plus bas niveau, toujours disponible séparément, que ce +widget ne remplace pas.""" +import re + + +def _create_screen(client, slug, name="Accueil"): + resp = client.post(f"/game/{slug}/screens/new", data={"name": name}, follow_redirects=False) + return int(re.search(r"/screens/(\d+)/edit", resp.headers["Location"]).group(1)) + + +def _create_onglets(client, slug, screen_id): + resp = client.post(f"/game/{slug}/screens/{screen_id}/elements/add", data={"widget": "onglets"}, follow_redirects=False) + return int(re.search(r"selected=(\d+)", resp.headers["Location"]).group(1)) + + +def _tab_rename_ids(html): + """Ids des formulaires de renommage DANS le bloc "Onglets" du panneau de + propriétés — pas le premier /elements//rename de la page, qui est le + champ "Nommer" générique de l'élément actuellement sélectionné (pas + forcément un onglet, ex. l'élément Onglets lui-même).""" + block = html.split("Onglets (", 1)[-1] + return [int(x) for x in re.findall(r"/elements/(\d+)/rename", block)] + + +def test_new_tabs_widget_has_no_tabs_yet(client, game): + screen_id = _create_screen(client, game) + _create_onglets(client, game, screen_id) + html = client.get(f"/game/{game}/play").data.decode() + assert "forgeTabBtn" not in html + assert "ajoute au moins un onglet" in html + + +def test_add_tab_creates_a_named_child_container(client, game): + screen_id = _create_screen(client, game) + onglets_id = _create_onglets(client, game, screen_id) + + resp = client.post(f"/game/{game}/elements/{onglets_id}/tabs/add", follow_redirects=False) + assert resp.status_code == 302 + assert f"selected={onglets_id}" in resp.headers["Location"] # reste sur le widget Onglets, pas le nouvel onglet + + html = client.get(f"/game/{game}/screens/{screen_id}/edit").data.decode() + assert ">Onglet 1<" in html # nom par défaut, affiché tel quel comme libellé de l'onglet + + +def test_second_tab_is_named_onglet_2_and_starts_hidden(client, game): + screen_id = _create_screen(client, game) + onglets_id = _create_onglets(client, game, screen_id) + client.post(f"/game/{game}/elements/{onglets_id}/tabs/add") + client.post(f"/game/{game}/elements/{onglets_id}/tabs/add") + + html = client.get(f"/game/{game}/screens/{screen_id}/edit").data.decode() + assert ">Onglet 1<" in html + assert ">Onglet 2<" in html + # Seul le premier onglet est visible par défaut (le second est en display:none). + assert 'style="display:none;"' in html + + +def test_renaming_a_tab_changes_the_visible_label(client, game): + screen_id = _create_screen(client, game) + onglets_id = _create_onglets(client, game, screen_id) + client.post(f"/game/{game}/elements/{onglets_id}/tabs/add") + + html = client.get(f"/game/{game}/screens/{screen_id}/edit?selected={onglets_id}").data.decode() + tab_id = _tab_rename_ids(html)[0] + + client.post(f"/game/{game}/elements/{tab_id}/rename", data={"name": "Statistiques"}) + html = client.get(f"/game/{game}/screens/{screen_id}/edit").data.decode() + assert ">Statistiques<" in html + assert ">Onglet 1<" not in html + + +def test_deleting_a_tab_removes_it_and_reselects_the_tabs_widget(client, game): + screen_id = _create_screen(client, game) + onglets_id = _create_onglets(client, game, screen_id) + client.post(f"/game/{game}/elements/{onglets_id}/tabs/add") + client.post(f"/game/{game}/elements/{onglets_id}/tabs/add", follow_redirects=False) + html = client.get(f"/game/{game}/screens/{screen_id}/edit?selected={onglets_id}").data.decode() + tab_ids = _tab_rename_ids(html) + assert len(tab_ids) == 2 + + resp = client.post(f"/game/{game}/elements/{tab_ids[1]}/delete", follow_redirects=False) + assert resp.status_code == 302 + assert f"selected={onglets_id}" in resp.headers["Location"] + + html = client.get(f"/game/{game}/screens/{screen_id}/edit").data.decode() + assert html.count("forgeTabBtn") == 1 + + +def test_tab_content_holds_a_real_container_children_can_be_added_to(client, game): + """Régression : chaque onglet doit se comporter comme un vrai conteneur + — on doit pouvoir y poser un Répéteur/Conteneur/etc. via "Ajouter DANS + ce conteneur", exactement comme pour n'importe quel autre conteneur.""" + screen_id = _create_screen(client, game) + onglets_id = _create_onglets(client, game, screen_id) + client.post(f"/game/{game}/elements/{onglets_id}/tabs/add") + html = client.get(f"/game/{game}/screens/{screen_id}/edit?selected={onglets_id}").data.decode() + tab_id = _tab_rename_ids(html)[0] + + resp = client.post(f"/game/{game}/elements/{tab_id}/children/add", data={"widget": "titre"}, follow_redirects=False) + assert resp.status_code == 302 + child_id = int(re.search(r"selected=(\d+)", resp.headers["Location"]).group(1)) + client.post(f"/game/{game}/elements/{child_id}/save", data={"ctrl_content": "Bienvenue dans cet onglet"}) + + html = client.get(f"/game/{game}/screens/{screen_id}/edit").data.decode() + assert "Bienvenue dans cet onglet" in html + + +def test_deleting_the_tabs_widget_cascades_to_its_tabs(client, game): + screen_id = _create_screen(client, game) + onglets_id = _create_onglets(client, game, screen_id) + client.post(f"/game/{game}/elements/{onglets_id}/tabs/add") + + client.post(f"/game/{game}/elements/{onglets_id}/delete") + html = client.get(f"/game/{game}/screens/{screen_id}/edit").data.decode() + assert "forgeTabBtn" not in html + assert ">Onglet 1<" not in html