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>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
9c9e3c5379
commit
bda082bffd
@@ -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
|
||||
# <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
|
||||
|
||||
@@ -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"),
|
||||
]
|
||||
@@ -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
|
||||
|
||||
@@ -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", "")
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
@@ -730,6 +730,26 @@
|
||||
oninput="this.nextElementSibling.textContent = 'x' + this.value">
|
||||
<span class="sliderValue tag">x{{ c.value }}</span>
|
||||
</div>
|
||||
{% elif c.type == 'shadow' %}
|
||||
<div class="control">
|
||||
<div class="fieldRow">
|
||||
<div class="formField" style="flex:1;"><label class="label is-small">Décalage X</label><input class="input is-small" type="number" name="ctrl_{{ c.key }}_x" value="{{ c.value.x }}"></div>
|
||||
<div class="formField" style="flex:1;"><label class="label is-small">Décalage Y</label><input class="input is-small" type="number" name="ctrl_{{ c.key }}_y" value="{{ c.value.y }}"></div>
|
||||
</div>
|
||||
<div class="fieldRow" style="margin-top:6px;">
|
||||
<div class="formField" style="flex:1;"><label class="label is-small">Flou</label><input class="input is-small" type="number" min="0" name="ctrl_{{ c.key }}_blur" value="{{ c.value.blur }}"></div>
|
||||
<div class="formField" style="flex:1;"><label class="label is-small">Étendue</label><input class="input is-small" type="number" min="0" name="ctrl_{{ c.key }}_spread" value="{{ c.value.spread }}"></div>
|
||||
</div>
|
||||
<div class="fieldRow" style="margin-top:6px; align-items:center;">
|
||||
<input class="input" type="color" style="flex:0 0 60px; padding:2px;" name="ctrl_{{ c.key }}_color" value="{{ c.value.color }}">
|
||||
<div class="sliderRow" style="flex:1;">
|
||||
<input type="range" name="ctrl_{{ c.key }}_opacity" min="0" max="1" step="0.05" value="{{ c.value.opacity }}"
|
||||
oninput="this.nextElementSibling.textContent = Math.round(this.value * 100) + '%'">
|
||||
<span class="sliderValue tag">{{ (c.value.opacity * 100)|round|int }}%</span>
|
||||
</div>
|
||||
</div>
|
||||
<p class="help">Décalages, flou et étendue tous à 0 = pas d'ombre.</p>
|
||||
</div>
|
||||
{% elif c.type == 'align' %}
|
||||
<div class="control">
|
||||
<div class="buttons has-addons alignGroup">
|
||||
|
||||
@@ -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"]
|
||||
Reference in New Issue
Block a user