diff --git a/screens/elements/save_element_controls.py b/screens/elements/save_element_controls.py index 142d34b7..a5c66542 100644 --- a/screens/elements/save_element_controls.py +++ b/screens/elements/save_element_controls.py @@ -6,6 +6,16 @@ from ..widgets.widget_meta import widget_meta from .get_element import get_element +def _hex_to_rgb(hex_color): + h = (hex_color or "").lstrip("#") + if len(h) != 6: + return (0, 0, 0) + try: + return tuple(int(h[i:i + 2], 16) for i in (0, 2, 4)) + except ValueError: + return (0, 0, 0) + + def _is_default_value(control, raw_value): """Compare la valeur reçue du formulaire à la valeur par défaut du réglage (en tenant compte de l'unité ajoutée pour les sliders), pour @@ -83,6 +93,37 @@ def save_element_controls(slug, element_id, form): style[name] = f"{num_str}{unit}" continue + if ctype == "shadow": + # 6 entrées de formulaire (décalage X/Y, flou, étendue, couleur, + # opacité — voir c_shadow.py) combinées en une seule valeur CSS + # "box-shadow" (couleur+opacité fusionnées en rgba(), un + # seul ne portant pas de canal alpha). + _kind, name = control["target"].split(":", 1) + defaults = control.get("default", {}) + + def _num(suffix, fallback): + try: + return float(form.get(field_name + suffix, fallback)) + except (TypeError, ValueError): + return fallback + + x = _num("_x", defaults.get("x", 0)) + y = _num("_y", defaults.get("y", 4)) + blur = _num("_blur", defaults.get("blur", 12)) + spread = _num("_spread", defaults.get("spread", 0)) + opacity = _num("_opacity", defaults.get("opacity", 0.4)) + color = form.get(field_name + "_color") or defaults.get("color", "#000000") + if x == 0 and y == 0 and blur == 0 and spread == 0: + style.pop(name, None) + else: + r, g, b = _hex_to_rgb(color) + + def _px(n): + return str(int(n)) if n == int(n) else str(n) + + style[name] = f"{_px(x)}px {_px(y)}px {_px(blur)}px {_px(spread)}px rgba({r}, {g}, {b}, {opacity})" + continue + target = control["target"] if ctype == "checkbox": checked = form.get(field_name) is not None diff --git a/screens/widgets/control_groups/shadow_controls.py b/screens/widgets/control_groups/shadow_controls.py new file mode 100644 index 00000000..78abee16 --- /dev/null +++ b/screens/widgets/control_groups/shadow_controls.py @@ -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"), +] diff --git a/screens/widgets/control_groups/universal_controls.py b/screens/widgets/control_groups/universal_controls.py index 9c26e5d8..b6dadfdc 100644 --- a/screens/widgets/control_groups/universal_controls.py +++ b/screens/widgets/control_groups/universal_controls.py @@ -4,6 +4,7 @@ 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 .shadow_controls import SHADOW_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 @@ -12,4 +13,4 @@ from .border_controls import BORDER_CONTROLS # 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 + VISIBILITY_CONDITION_CONTROLS +UNIVERSAL_CONTROLS = [VISIBILITY_CONTROL, c_scale()] + SIZE_OVERRIDE_CONTROLS + SPACING_CONTROLS + BORDER_CONTROLS + SHADOW_CONTROLS + VISIBILITY_CONDITION_CONTROLS diff --git a/screens/widgets/control_value.py b/screens/widgets/control_value.py index d6418b07..b20aa867 100644 --- a/screens/widgets/control_value.py +++ b/screens/widgets/control_value.py @@ -31,6 +31,21 @@ def _control_value(el, control): 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", "") diff --git a/screens/widgets/controls/c_shadow.py b/screens/widgets/controls/c_shadow.py new file mode 100644 index 00000000..db26b671 --- /dev/null +++ b/screens/widgets/controls/c_shadow.py @@ -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, + } diff --git a/templates/screen_edit.html b/templates/screen_edit.html index e9176906..11b7fb91 100644 --- a/templates/screen_edit.html +++ b/templates/screen_edit.html @@ -730,6 +730,26 @@ oninput="this.nextElementSibling.textContent = 'x' + this.value"> x{{ c.value }} + {% elif c.type == 'shadow' %} +
+
+
+
+
+
+
+
+
+
+ +
+ + {{ (c.value.opacity * 100)|round|int }}% +
+
+

Décalages, flou et étendue tous à 0 = pas d'ombre.

+
{% elif c.type == 'align' %}
diff --git a/tests/test_shadow_controls.py b/tests/test_shadow_controls.py new file mode 100644 index 00000000..253952a8 --- /dev/null +++ b/tests/test_shadow_controls.py @@ -0,0 +1,56 @@ +"""Ombre portée (panneau de propriétés, tout widget) : 6 sous-réglages +(décalage X/Y, flou, étendue, couleur, opacité) combinés en une seule +valeur CSS box-shadow.""" +import re + +import screens + + +def _create_element(client, slug, widget="conteneur"): + resp = client.post(f"/game/{slug}/screens/new", data={"name": "Accueil"}, follow_redirects=False) + screen_id = int(re.search(r"/screens/(\d+)/edit", resp.headers["Location"]).group(1)) + resp = client.post(f"/game/{slug}/screens/{screen_id}/elements/add", data={"widget": widget}, follow_redirects=False) + element_id = int(re.search(r"selected=(\d+)", resp.headers["Location"]).group(1)) + return screen_id, element_id + + +def test_shadow_controls_appear_in_the_properties_panel(client, game): + screen_id, element_id = _create_element(client, game) + html = client.get(f"/game/{game}/screens/{screen_id}/edit?selected={element_id}").data.decode() + assert "ctrl_box-shadow_x" in html + assert "ctrl_box-shadow_color" in html + + +def test_saving_a_shadow_composes_box_shadow_css(client, game): + screen_id, element_id = _create_element(client, game) + client.post(f"/game/{game}/elements/{element_id}/save", data={ + "ctrl_box-shadow_x": "2", "ctrl_box-shadow_y": "6", "ctrl_box-shadow_blur": "20", + "ctrl_box-shadow_spread": "3", "ctrl_box-shadow_color": "#ff0000", "ctrl_box-shadow_opacity": "0.5", + }) + el = screens.get_element(game, element_id) + assert el["style"]["box-shadow"] == "2px 6px 20px 3px rgba(255, 0, 0, 0.5)" + + +def test_shadow_value_round_trips_back_into_the_form(client, game): + screen_id, element_id = _create_element(client, game) + client.post(f"/game/{game}/elements/{element_id}/save", data={ + "ctrl_box-shadow_x": "2", "ctrl_box-shadow_y": "6", "ctrl_box-shadow_blur": "20", + "ctrl_box-shadow_spread": "3", "ctrl_box-shadow_color": "#ff0000", "ctrl_box-shadow_opacity": "0.5", + }) + html = client.get(f"/game/{game}/screens/{screen_id}/edit?selected={element_id}").data.decode() + assert 'name="ctrl_box-shadow_x" value="2.0"' in html or 'name="ctrl_box-shadow_x" value="2"' in html + assert 'name="ctrl_box-shadow_color" value="#ff0000"' in html + + +def test_all_zero_offsets_remove_the_shadow(client, game): + screen_id, element_id = _create_element(client, game) + client.post(f"/game/{game}/elements/{element_id}/save", data={ + "ctrl_box-shadow_x": "2", "ctrl_box-shadow_y": "6", "ctrl_box-shadow_blur": "20", + "ctrl_box-shadow_spread": "3", "ctrl_box-shadow_color": "#ff0000", "ctrl_box-shadow_opacity": "0.5", + }) + client.post(f"/game/{game}/elements/{element_id}/save", data={ + "ctrl_box-shadow_x": "0", "ctrl_box-shadow_y": "0", "ctrl_box-shadow_blur": "0", + "ctrl_box-shadow_spread": "0", "ctrl_box-shadow_color": "#ff0000", "ctrl_box-shadow_opacity": "0.5", + }) + el = screens.get_element(game, element_id) + assert "box-shadow" not in el["style"]