Bloques del DSL con closure: cómo se construyen
Tutorial práctico para añadir al DSL formas del tipo
bloque(params) { ... }, que cambian lo que le pasa al código escrito dentro.El caso que se desarrolla de principio a fin es
defaults(...), un bloque que da valores por defecto a todas las animaciones que contiene:defaults(runtime: 2, lambda: "smooth", effects: [alpha: 0.5]) { animShift(obj: a, dx: 2) animRotate(obj: b, angle: PI) play { appear(obj: c); appear(obj: d) } }Todas las referencias apuntan a
jmathanim-core/src/main/groovy/com/jmathanim/core/groovy/dsl/.
1. Por qué un closure y no un mapa
Es la misma razón por la que existe play { } y está explicada en la cabecera de
BlockDSL.groovy:10-52, pero conviene tenerla delante porque condiciona todo el
diseño.
Groovy evalúa los argumentos antes de invocar el método. Si defaults fuese
una llamada normal con una lista:
defaults(runtime: 2, anims: [animShift(obj: a, dx: 2), animRotate(obj: b, angle: PI)])
para cuando el cuerpo de defaults empieza a ejecutarse, las dos animaciones ya
están construidas y reproducidas, con su runtime de 1 segundo. No hay bandera
ni parámetro que arregle eso desde dentro: el daño ocurrió durante la evaluación
de la lista de argumentos.
Un closure es un valor que todavía no se ha ejecutado. Al pasarlo, el bloque
recibe el control de cuándo se construye lo de dentro, y por lo tanto puede
preparar el contexto antes y limpiarlo después. Esa es la única herramienta que
da Groovy para esto, y es la que usan play, sequence, skip, section y
harryhausen.
2. Anatomía de un bloque
Todo bloque del DSL está hecho de las mismas cuatro piezas:
| Pieza | Dónde | Para qué |
|---|---|---|
| Método de fachada | JMAnimBaseScript.groovy |
Es lo que el script puede escribir. Delega en el *DSL |
| Espejo estático | JMA.java |
El mismo método para código que no es cuerpo de script (clases Groovy propias, helpers) |
| Implementación | un *DSL.groovy (normalmente BlockDSL) |
La lógica: abrir contexto, llamar al closure, cerrarlo |
| Contexto | pila ThreadLocal en AbstractAnimDSL |
El estado que el bloque instala y que el resto del DSL consulta |
Y hay tres arquetipos, según lo que el bloque haga con el closure:
| Arquetipo | Ejemplo | Qué hace | Contexto que instala |
|---|---|---|---|
| Recolector | play, sequence |
Ejecuta el closure una vez y se queda con lo que se construyó dentro para reproducirlo junto | Una lista donde finish() deposita en vez de reproducir |
| Modificador | defaults, skip |
Ejecuta el closure una vez y cambia cómo se comporta lo de dentro | Un mapa/bandera que se consulta en el camino normal |
| Repetidor | harryhausen |
Ejecuta el closure N veces, haciendo algo entre pasada y pasada | Un objeto que hace de delegate del closure (dt, t, frame) |
defaults es del segundo tipo, el más barato de implementar y el que más
cuidado exige al elegir dónde se consulta el contexto.
3. El recorrido de una animación del DSL
Antes de enganchar nada hay que saber por dónde pasa una llamada. Todas las
animaciones siguen exactamente este camino (ejemplo con appear,
AppearDSL.groovy:59-88):
script: appear(obj: s, runtime: 2)
|
v
JMAnimBaseScript.appear(Map) fachada, delega
|
v
AppearDSL.make(Map params)
| 1. params = normalizeKeys(params) BaseDSL.groovy:32 claves a minúsculas
| 2. warnUnknownAnimParams("appear", ...) AbstractAnimDSL:55 avisa de claves raras
| 3. resolveAnimObjects(params.obj) resuelve 'obj'
| 4. double runtime = resolveRuntime(params) BaseDSL.groovy:77 <-- consume 'runtime'
| 5. anim = Commands.fadeIn(runtime, objs) construye la animación
| 6. return finish(anim, params) AbstractAnimDSL:143
|
v
finish(anim, params)
applyEffects / applyDelay / applyAdvanced / noLogs / lambda
si hay bloque abierto -> lo recolecta o lo salta
si no -> play.run(anim)
Los dos únicos sitios por los que pasa el mapa de parámetros son el paso 1-2
(entrada) y el paso 6 (finish). Son los dos candidatos a punto de enganche.
4. Elegir el punto de enganche
La tentación es hacerlo todo en finish(), porque ya es "el sitio único" donde
se aplican los parámetros comunes y donde se consulta la pila de bloques
(AbstractAnimDSL.groovy:143-168). Tres líneas y listo:
// TRAMPA: parece que basta, y no basta
protected static Animation finish(Animation anim, Map params) {
params = fillWithDefaults(params, currentDefaults()) // <-- aquí
applyEffects(anim, params.effects)
...
}
No basta, y el motivo es el paso 4 del diagrama: runtime ya se ha consumido
cuando finish recibe el mapa. La animación se construyó con
Commands.fadeIn(1.0, objs) y el default de 2 segundos llega tarde. Lo mismo
pasaría con cualquier clave que el builder lea por su cuenta (type, from,
orientation).
El resultado sería el peor tipo de fallo: lambda y effects funcionarían,
runtime se ignoraría en silencio, y el usuario no tendría forma de adivinar la
regla.
Conclusión: el enganche va en la entrada, no en la salida. Los defaults se mezclan en el mapa antes de que el builder lea nada.
Segunda decisión, derivada de la misma idea: el enganche va en
AbstractAnimDSL, no en BaseDSL.normalizeKeys. normalizeKeys lo usan también
los constructores de objetos (shape, latex, group), y meterles un
runtime: 2 les provocaría un aviso de parámetro desconocido en cada llamada.
5. Implementación paso a paso
Paso 1: la pila de contexto
En AbstractAnimDSL.groovy, junto a la pila BLOCKS que ya existe
(AbstractAnimDSL.groovy:74). Se usa ThreadLocal por la misma razón: un script
corre en un hilo y un bloque no debe filtrarse a nada más.
/**
* Los mapas de defaults actualmente abiertos, el más interno primero. Cada
* marco ya lleva mezclados los de fuera, así que consultar la cima basta.
*/
private static final ThreadLocal<Deque<Map>> DEFAULTS =
ThreadLocal.withInitial({ new ArrayDeque<Map>() } as Supplier)
/** Los defaults en vigor, un mapa vacío cuando no hay ningún bloque abierto. */
protected static Map currentDefaults() {
Map top = DEFAULTS.get().peek()
return (top != null) ? top : Collections.EMPTY_MAP
}
/**
* Abre un bloque de defaults. Siempre emparejado con {@link #closeDefaults} en
* un finally: un marco que se quede abierto contamina el resto de la escena.
*
* @param params Parámetros del bloque, sin normalizar
*/
protected static void openDefaults(Map params) {
// El marco nuevo hereda del que lo envuelve, y gana sobre él
DEFAULTS.get().push(fillWithDefaults(normalizeKeys(params), currentDefaults()))
}
/** Cierra el bloque de defaults más interno. */
protected static void closeDefaults() {
DEFAULTS.get().pop()
}
Fijarse en que openDefaults fusiona con lo que ya había. Así los bloques
anidados acumulan en vez de sustituir, que es lo que se espera de unos valores
por defecto:
defaults(runtime: 2, lambda: "smooth") {
defaults(runtime: 5) {
appear(obj: a) // runtime 5, lambda "smooth"
}
}
Esto es distinto de la regla de play/skip, donde gana el bloque más
interno y punto (AbstractAnimDSL.groovy:153). Ahí la regla es "quién decide
reproducir", que solo puede ser uno; aquí es "de dónde salen los valores que
faltan", que se puede repartir.
Paso 2: la fusión
Una sola función sirve para los dos usos (rellenar una llamada y apilar un marco), porque en ambos la regla es la misma: lo más específico gana.
/**
* Rellena con 'defaults' los huecos de 'explicit'. Lo escrito en la llamada
* siempre gana. Los valores que son mapas (effects, advanced) se combinan clave
* a clave en vez de sustituirse enteros, de modo que un default
* effects:[alpha: 0.5] y una llamada con effects:[jump: 1] dan las dos cosas.
*
* @param explicit Mapa normalizado de la llamada
* @param defaults Mapa normalizado de defaults en vigor
* @return Un mapa nuevo; ni explicit ni defaults se modifican
*/
protected static Map fillWithDefaults(Map explicit, Map defaults) {
if (defaults == null || defaults.isEmpty()) return explicit
Map result = new LinkedHashMap(explicit)
defaults.each { k, v ->
def own = result[k]
if (own == null) {
// Copia del submapa: si no, dos llamadas comparten el mismo objeto y
// la primera que lo modifique afecta a la segunda
result[k] = (v instanceof Map) ? new LinkedHashMap(v as Map) : v
} else if (own instanceof Map && v instanceof Map) {
result[k] = fillWithDefaults(normalizeKeys(own as Map), normalizeKeys(v as Map))
}
}
return result
}
Tres detalles que parecen menores y no lo son:
- Se devuelve un mapa nuevo. El mapa del marco lo comparten todas las llamadas del bloque; mutarlo desde una de ellas rompería las siguientes.
- Los submapas se copian. Mismo motivo, un nivel más abajo.
- La comprobación es
own == null, no la verdad de Groovy. Con truthiness, unruntime: 0o unnologs: falseexplícitos se considerarían ausentes y el default los pisaría. Es exactamente la trampa recogida en el barrido de julio de 2026 sobre los setters numéricos del DSL.
Paso 3: el punto de entrada único
Ahora hay que hacer que los 21 sitios donde arranca un builder de animación pasen por la fusión. Hoy todos empiezan igual:
params = normalizeKeys(params)
warnUnknownAnimParams("appear", params, KNOWN_KEYS)
Se sustituyen esas dos líneas por una, que hace las tres cosas en el orden correcto:
/**
* Prepara el mapa de parámetros de un builder de animación: lo normaliza, le
* aplica los defaults del bloque 'defaults' en vigor y avisa de las claves que
* el builder no entiende. Sustituye al par normalizeKeys/warnUnknownAnimParams
* y es el único sitio por el que entran los defaults, que por eso llegan a
* tiempo para 'runtime' y para las claves propias de cada builder.
*
* @param context Nombre del builder, para los mensajes (p.ej. "appear")
* @param params Mapa de parámetros tal como lo escribió el usuario
* @param ownKeys Claves propias del builder, en minúsculas
* @return El mapa normalizado y completado, listo para leer
*/
protected static Map animParams(String context, Map params, List<String> ownKeys) {
Map p = normalizeKeys(params)
Map defs = currentDefaults()
if (!defs.isEmpty()) {
// Sólo las claves que ESTE builder entiende: un defaults(type: "fadein")
// debe llegar a appear y no colarse en animShift, que avisaría de una
// clave desconocida en cada llamada
List<String> accepted = ownKeys + COMMON_ANIM_KEYS
p = fillWithDefaults(p, defs.findAll { k, v -> accepted.contains(k) })
}
warnUnknownAnimParams(context, p, ownKeys)
return p
}
Ese filtrado por ownKeys es lo que hace que el bloque sea utilizable con
claves no comunes. defaults(type: "fadein") afecta a appear y disappear, y
es invisible para animShift. Sin el filtro habría que restringir defaults a
COMMON_ANIM_KEYS y perder la mitad de la gracia.
Cada builder queda así (AppearDSL.groovy:59-61):
static Animation make(Map params) {
params = animParams("appear", params, KNOWN_KEYS)
...
Son 21 llamadas en 17 ficheros (AlignDSL, AnimStateDSL, AnimStyleDSL,
AnimationGroupDSL, AppearDSL, DisappearDSL, HighlightDSL,
JoinAnimationDSL, MathTransformDSL, MorphDSL, MoveAlongPathDSL,
ScalarAnimDSL, SetLayoutDSL, SoundDSL, StackToDSL, TransformAnimDSL
con cinco, WaitAnimDSL). Todas tienen la misma forma, así que la migración es
mecánica.
Variante de bajo coste, si no se quieren tocar los 17 ficheros: como
warnUnknownAnimParamsya la llaman los 21 sitios con el mapa recién normalizado, y los mapas de Groovy son mutables, se pueden inyectar ahí los defaults conparams.putAll(...)sobre las claves ausentes. Funciona, pero deja un método llamado "warn" que además modifica su argumento. Merece la pena el rato de sustitución.
Paso 4: el bloque
En BlockDSL.groovy, junto a los demás:
/** Parámetros que documenta {@code defaults}; acepta además las propias de cada builder. */
protected static final List<String> DEFAULTS_KEYS = COMMON_ANIM_KEYS
/**
* Da valores por defecto a todas las animaciones construidas dentro del bloque.
* Lo que la llamada escriba gana siempre sobre el default, y los bloques se
* anidan acumulando: el interno hereda del externo y lo sobrescribe.
*
* <pre>
* defaults(runtime: 2, lambda: "smooth", effects: [alpha: 0.5]) {
* animShift(obj: a, dx: 2) // runtime 2
* animRotate(obj: b, angle: PI, runtime: 0.5) // runtime 0.5, gana lo escrito
* play { appear(obj: c); appear(obj: d) } // los dos appear, runtime 2
* }
* </pre>
*
* Los defaults se aplican a cada animación que se construye, no al contenedor
* que un play/sequence de dentro fabrique con ellas. Para un grupo con su propio
* runtime o lambda está animGroup(...).
*
* Una clave que no sea común se aplica sólo a los builders que la entienden, así
* que defaults(type: "fadein") afecta a appear y disappear y no molesta al resto.
*
* @param params Parámetros por defecto, los mismos que acepta una animación
* @param body El bloque de llamadas
* @return Lo que devuelva el bloque
*/
static Object defaults(Map params, Closure body) {
requireBody(body, "defaults")
if (params == null || params.isEmpty()) {
JMathAnimScene.logger.warn(
"'defaults': the block sets no default at all, so it changes nothing.")
return body.call()
}
openDefaults(params)
try {
return body.call()
} finally {
// En un finally: un marco que quede abierto porque el bloque falló
// seguiría aplicándose al resto de la escena
closeDefaults()
}
}
El finally es el mismo salvavidas que en BlockDSL.collect
(BlockDSL.groovy:279-283), y por el mismo motivo: el fallo sería silencioso y
aparecería lejos del sitio que lo causó.
Paso 5: exponerlo
JMAnimBaseScript.groovy, junto a play/sequence/skip:
/**
* Da valores por defecto a todas las animaciones escritas dentro del bloque.
* Lo escrito en cada llamada gana, y los bloques anidados acumulan.
*
* <pre>
* defaults(runtime: 2, lambda: "smooth") {
* animShift(obj: a, dx: 2)
* play { appear(obj: c); appear(obj: d) }
* }
* </pre>
*
* @param params Parámetros por defecto, los mismos que acepta una animación
* @param body El bloque de llamadas
* @return Lo que devuelva el bloque
*/
Object defaults(Map params, Closure body) {
return BlockDSL.defaults(params, body)
}
Y su espejo en JMA.java (JMA.java:313 para el modelo con harryhausen):
/** Defaults for every animation built inside the block. */
public static Object defaults(Map params, groovy.lang.Closure body) {
return BlockDSL.defaults(params, body);
}
Sin el espejo, DslConsistencyTest.jmaMirrorsTheScriptFacade falla: comprueba
que todo método público de JMAnimBaseScript existe también en JMA.
6. Semántica: las decisiones y su porqué
Un bloque de este tipo tiene una docena de casos frontera. Estas son las respuestas que hacen que el conjunto sea coherente:
| Caso | Decisión | Motivo |
|---|---|---|
appear(runtime: 0.5) dentro de defaults(runtime: 2) |
Gana 0.5 | Un default que pise lo escrito no es un default |
defaults dentro de defaults |
Acumulan, el interno gana clave a clave | Es "de dónde salen los valores que faltan", no "quién manda" |
effects en el default y en la llamada |
Se fusionan clave a clave | Si no, poner un jump perdería el alpha heredado sin avisar |
play { } dentro de defaults |
Los hijos reciben los defaults; el AnimationGroup que fabrica play, no |
collect llama a finish(anim, [:]) (BlockDSL.groovy:305) y ese mapa no pasa por animParams. Si pasara, la lambda se aplicaría dos veces, al hijo y al grupo |
defaults(run: false) |
Se permite | run es una clave común más; sirve para construir en lote y combinar después |
defaults(obj: a) |
Se permite, obj está en ownKeys de todos |
Discutible; si molesta, basta con excluir obj en animParams |
harryhausen dentro de defaults |
No le afecta | No pasa por animParams, usa warnUnknownParams. Su runtime es la duración del bloque, no la de una animación: heredarlo sería una sorpresa desagradable |
defaults dentro de skip |
Compatible, son ejes distintos | skip decide qué se reproduce, defaults con qué parámetros se construye |
| Bloque sin ninguna clave | Aviso y se ejecuta igual | Un bloque que no hace nada es casi siempre un error de escritura |
Merece la pena escribir la cuarta fila en el javadoc del bloque: es la única que un usuario no puede deducir mirando el resultado.
7. Trampas de Groovy que aparecen en el camino
La firma (Map, Closure) no es negociable. Groovy empaqueta los argumentos
con nombre en un Map que pasa como primer parámetro, y el closure escrito
detrás del paréntesis va como último. Por eso
defaults(runtime: 2) { ... } casa con defaults(Map, Closure) y con nada más.
Si se declara defaults(Closure, Map) no compila la llamada, y si se declara
solo defaults(Map) el closure se pierde.
El closure va fuera del paréntesis o no es un bloque. defaults(runtime: 2,
{ ... }) es un mapa con dos entradas raras, no una llamada con bloque. Es la
misma trampa del parser que aparece al portar scripts antiguos.
Collections.EMPTY_MAP es inmutable a propósito. Devolverlo desde
currentDefaults() garantiza que ningún sitio lo mute por accidente creyendo que
apila algo.
ThreadLocal sin remove() está bien aquí. El Deque queda vacío tras cada
bloque y el hilo del script es de vida corta; es el mismo tratamiento que recibe
BLOCKS.
Cuidado con dar de alta el nombre. defaults no choca con nada del binding
(scene, play, camera, config) ni con un método de Script. Para un
nombre nuevo, comprobarlo con GroovyShell antes: play sí colisiona y funciona
por un detalle de resolución (método frente a variable) que no conviene repetir a
ciegas.
8. Cómo se comprueba
No hace falta renderizar nada. Con un script corto se ve todo, porque los builders devuelven la animación:
defaults(runtime: 2, lambda: "smooth", effects: [alpha: 0.5]) {
def a1 = animShift(obj: a, dx: 2, run: false)
def a2 = animShift(obj: b, dx: 2, runtime: 0.5, run: false)
println "${a1.getRunTime()} ${a2.getRunTime()}" // 2.0 0.5
defaults(runtime: 5) {
println animShift(obj: c, dx: 1, run: false).getRunTime() // 5.0
}
println animShift(obj: d, dx: 1, run: false).getRunTime() // 2.0, el marco se cerró
}
println animShift(obj: e, dx: 1, run: false).getRunTime() // 1.0, fuera del bloque
Los cuatro casos que hay que ver pasar sí o sí:
- El default llega (2.0).
- Lo explícito gana (0.5).
- El anidamiento acumula y se deshace (5.0 y luego 2.0).
- Nada se filtra fuera del bloque (1.0). Repetir provocando una excepción
dentro del bloque, para ejercitar el
finally.
Y una comprobación de que no se aplica dos veces: defaults(lambda: { t -> t * t })
{ play { appear(obj: a); appear(obj: b) } } debe dar un AnimationGroup sin
lambda propia y dos hijos con la suya.
9. Checklist de integración
El bloque no está terminado hasta que el editor y la documentación lo conocen.
Es la lista de new-dsl-definition-checklist aplicada a este caso:
| # | Fichero | Qué añadir |
|---|---|---|
| 1 | BlockDSL.groovy |
defaults(Map, Closure) y DEFAULTS_KEYS |
| 2 | AbstractAnimDSL.groovy |
La pila, currentDefaults, fillWithDefaults, animParams |
| 3 | Los 17 builders | params = animParams(...) en vez del par de líneas |
| 4 | JMAnimBaseScript.groovy |
La fachada |
| 5 | JMA.java |
El espejo estático |
| 6 | dsl-completions.yaml |
Entrada - name: defaults con las claves comunes y su descripción |
| 7 | DslConsistencyTest.java |
BLOCK_CLASSES.put("defaults", "BlockDSL"), si no el test avisa de un bloque del catálogo que no conoce |
| 8 | snippets.yaml |
Snippet kind: groovy en Animations > Containers, junto a "Play together" |
| 9 | docs/manual/cheatsheet/main.typ |
Entrada == defaults(...) con su tabla |
| 10 | docs/manual/05_Animations/*.md |
Mención en prosa, es una función de usuario, no una variante |
Sobre el punto 7: acceptedKeysOf reúne todos los campos estáticos que
acaban en KEYS de la clase y de sus superclases del paquete, así que
BlockDSL acabará aceptando la unión de HARRYHAUSEN_KEYS, DEFAULTS_KEYS y
COMMON_ANIM_KEYS. Ser generoso ahí no produce falsos avisos; quedarse corto,
sí.
10. Plantilla para el siguiente bloque
Resumen operativo, para cuando toque el bloque número seis:
- Decidir el arquetipo (recolector, modificador, repetidor) con la tabla de la sección 2. Cambia qué contexto hay que instalar, no la estructura.
- Seguir el recorrido de la sección 3 y marcar dónde se consulta el contexto. Es la única decisión difícil. La pregunta que la resuelve es: ¿qué parámetros se consumen antes de llegar a mi punto de enganche? Si la respuesta no es "ninguno", el enganche está demasiado tarde.
- Escribir el contexto como pila
ThreadLocalenAbstractAnimDSL, con suopenX/closeX. - Escribir el bloque en
BlockDSLconrequireBody,tryyfinally. - Definir la regla de anidamiento y escribirla en el javadoc. "Gana el más interno" para lo que solo puede decidir uno; "acumulan" para lo que se reparte.
- Comprobar con un script los cuatro casos de la sección 8: llega, se sobrescribe, anida, no se filtra.
- Recorrer el checklist de la sección 9.
Y la regla que resume el diseño de defaults, la que conviene tener presente
antes de escribir una línea: un bloque que cambia parámetros engancha a la
entrada; uno que cambia lo que se reproduce engancha en finish.