#!/usr/bin/env python
# encoding: utf-8
# api: inkscape
##type: effect
# category: animation
# title: Animate Yo
# description: Attach an ❮animate❯ tag to selected object
# id: org.include-once.inkscape.animate-yo
# license: MITL
# version: 0.5
# state: alpha
# depends: bin:inkscape (>= 1.2), python (>= 3.6)
# pylint: disable=line-too-long, missing-module-docstring, bad-whitespace
# config:
#    {  xml: <hbox><vbox> }
#    { name: general, type: notebook, value: time, description: Timing and control settings }
#    { class: timing, name: start, type: str, value: "0s", description: Start time, help: "Can be a numeric `1s` or an expression and reference events `click + 1s` or other animations `animXY.end - 1s`." }
#    { class: timing, name: duration, type: str, value: "5s", description: Duration, help: "Static value or expression again." }
#    { class: repeat, name: repeat_count, type: select, select: "indefinite|0|1|2|3|4|5|10|15|20|25|50|100", value: style, description: repeatCount, help: "How often this animation loops. (Ignored by gif_export, where animations are generally per-frame.)" }
#    { class: repeat, name: repeat_fill, type: select, select: "freeze|remove", value: freeze, translatable: no, description: repeatFill, help: "Animation can either stay at last state, or reset to value before animation. (gif_export always reverts to previous slide state)" }
#    { class: calc, name: calc_mode, type: select, select: "linear|discrete|paced|spline", value: "linear", description: "calcMode", help: "Defines if values in animation are traversed in a straight from-to progression (linear), jump from start to end (discrete), slowly speed up (paced), or use a list of values/keyTimes (spline)." }
#    { class: calc, name: key_values, type: str, value: "", description: "(key)values", help: "Spline mode: Instead of a single to= attribute, can give a list of values `12px;20px;50px` or `#f33;#77cc99;#03F` for use with keyTimes/Splines." }
#    { class: calc, name: key_times, type: str, value: "", description: "keyTimes", help: "Spline mode: Lists the time lapse positions `0.0; 0.3; 0.9; 1.0` when point in values= list should be approached." }
#    { class: calc, name: key_splines, type: str, value: "", description: "keySplines", help: "Spline mode: defines bezier curves for use with keyTimes. (Ignored by gif_export)" }
#    { class: id, name: set_id, type: str, value: "", description: "Assign #id", help: "Animation itself can have an #id for referencing." }
#    { name: remove_child, type: bool, value: 0, description: "Strip previous animation", help: "Remove any existing ﹤animate*﹥ tags from selected object. (Else use the XML editor Ctrl+Shift+X.)" }
#    {  xml: </vbox><image width='220' height='220'>animate_yo.svg</image></hbox> }
#    { name: mode, type: notebook, select: "style|scale|rotate|translate|skewxy|attrib|move", value: style, description: Animation mode, help: Animation }
#    { class: style,  name: style, type: select, select: "fill|stroke|fill-opacity|stroke-opacity|stroke-width", value: style, description: Attribute, help: "Can change either colors, opacity, or width.", translatable: no }
#    { class: style,  name: color, type: color, appearance: colorbutton, value: "#ff5555", description: Color, help: Target color, translatable: no }
#    { class: style,  name: style_val, type: str, value: "", description: Opacity/width, help: "Use a fraction 0.75 for opacity. Or an integer 25 for widths." }
#    { class: scale,  name: scale, type: str, value: "1.0 1.0", description: Scaling, help: "Scales both width and height. Specify fractions each. For example 0.0 will vanish the object, or 2.0 double it per dimension. Beware that this transform can 'scale' x= and y= positions alongside. Can also be a single value `2.5` to scale both width and height." }
#    { class: rotate, name: rotate, type: int, value: 0, min: -360, max: 360, appearance: full, description: Rotation, help: "Just in degrees." }
#    { class: translate, label: Arguments for translate(x y) or matrix(a b c d e f) }
#    { class: translate, name: translate, type: str, value: "", description: Translate, help: "Should usually be a tuple like 20 30.", translatable: no }
#    { class: skewxy, label: Either or, tab_desc: skewXY }
#    { class: skewxy, name: skew_x, type: str, value: "", description: X direction, help: "numeric value, like -20 or 50" }
#    { class: skewxy, name: skew_y, type: str, value: "", description: Y direction, help: "only used if _x is empty" }
#    { class: attrib, name: attr_name, type: str, value: "x", description: Attribute name, help: "Could be any of x, cy, or height, or other SVG tag attributes." }
#    { class: attrib, name: attr_from, type: str, value: "", description: Start value, help: "numeric or string" }
#    { class: attrib, name: attr_to, type: str, value: "", description: Final value, help: "numeric or string" }
#    { class: move,   label: Select a path alongside the main object. }
#    { class: move,   label: It’ll be applied as transition path. }
#    { class: move,   name: move_relative, type: bool, value: 0, description: "Convert to relative path. (No displacement in export_gif).", help: "Usually the animation path is best drawn at page origin (top left, x=0 y=0). This option tries to make it relative (to top left corner or center of animated object). // Alternatively to a selected path, you can define a `M0,0 L20,20` in the [translate] tab." }
# architecture: all
# pack: animate_yo.svg, animate_yo.inx
# format: off
# author: mario#include-once:org
#
# Allows to add trivial <animate> tags to a SVG document. Embedded animations
# work for standalone SVG graphics. Some instructions are understood by the
# GIF interpolation in ★export_gif (which this was originally intended for).
#
# Usually requires selecting one object/shape/path, invoking the module in
# Extension➜Animation➜Animate-Yo, and activating one of the tabs to inject an
# <animate*> tag.  The [move] option additionally requires a selected path
# (second argument) from transitioning the main object on.
#
# More timing attributes:
#  · fill, end, min, max, restart, repeatDur,
#  · calcMode, values, by
#  · keyTimes, keyPoints, keySplines
#  · additive, accumulate


import sys
import re
import random
import inkex
from inkex import AbortExtension


class AnimateYo(inkex.EffectExtension):
    """ Add an <animate*> tag to selected object/path """

    def __init__(self):
        self.obj = None
        self.path = None
        self.bbox = None
        self.center = ""
        self.has_transform = ""
        super().__init__()

    def add_arguments(self, pars):
        """ populate self.options from script args """
        pars.add_argument("--general", type=str, dest="general", default="time", help="Timing and control settings")
        pars.add_argument("--start", type=str, dest="start", default="0s", help="Start time")
        pars.add_argument("--duration", type=str, dest="duration", default="5s", help="Duration")
        pars.add_argument("--repeat_count", type=str, dest="repeat_count", default="style", help="repeatCount")
        pars.add_argument("--repeat_fill", type=str, dest="repeat_fill", default="freeze", help="fill")
        pars.add_argument("--calc_mode", type=str, dest="calc_mode", default="linear", help="calcMode")
        pars.add_argument("--key_values", type=str, dest="key_values", default="", help="(key)values")
        pars.add_argument("--key_times", type=str, dest="key_times", default="", help="keyTimes")
        pars.add_argument("--key_splines", type=str, dest="key_splines", default="", help="keySplines")
        pars.add_argument("--set_id", type=str, dest="set_id", default="", help="Assign #id")
        pars.add_argument("--remove_child", type=inkex.Boolean, dest="remove_child", default=False, help="Strip previous animations")
        pars.add_argument("--mode", type=str, dest="mode", default="style", help="Animation mode")
        pars.add_argument("--style", type=str, dest="style", default="style", help="Attribute")
        pars.add_argument("--color", type=inkex.Color, dest="color", default="#ff5555", help="Color")
        pars.add_argument("--style_val", type=str, dest="style_val", default="", help="Opacity/width")
        pars.add_argument("--scale", type=str, dest="scale", default="1.0 1.0", help="Scaling")
        pars.add_argument("--rotate", type=int, dest="rotate", default=0, help="Rotation")
        pars.add_argument("--translate", type=str, dest="translate", default="", help="Translate")
        pars.add_argument("--skew_x", type=str, dest="skew_x", default="", help="X direction")
        pars.add_argument("--skew_y", type=str, dest="skew_y", default="", help="Y direction")
        pars.add_argument("--attr_name", type=str, dest="attr_name", default="x", help="Attribute name")
        pars.add_argument("--attr_from", type=str, dest="attr_from", default="", help="Start value")
        pars.add_argument("--attr_to", type=str, dest="attr_to", default="", help="Final value")
        pars.add_argument("--move_relative", type=inkex.Boolean, dest="move_relative", default=False, help="Convert to relative path. (No displacement in export_gif).")

    def effect(self):
        """ attach something to tree """

        # check params
        if not self.svg.selection or len(self.svg.selection) < 1:
            raise AbortExtension("Requires a selected object to work on.")
        if len(self.svg.selection) > 2:
            raise AbortExtension("Can only work with one or two objects selected.")
        if len(self.svg.selection) == 2:
            self.options.mode = "move"
            obj, path = self.svg.selection
            if obj.tag_name == "path" and path.tag_name != "path":
                obj, path = path, obj
            elif path.tag_name != "path":
                raise AbortExtension(f"One of the two selections must be a path to move on. ({obj.tag_name}, {path.tag_name} given)")
        else:
            obj, path = self.svg.selection[0], None

        # prepare more arguments
        self.obj = obj
        self.path = path
        self.bbox = obj.bounding_box()
        self.has_transform=str(obj.transform) or ""

        ink_ns = "{" + inkex.NSS["inkscape"] + "}"
        if obj.attrib.get(f"{ink_ns}transform-center-x"):
            self.center = [
                self.bbox.center.x + float(obj.attrib[f"{ink_ns}transform-center-x"]),
                self.bbox.center.y + float(obj.attrib[f"{ink_ns}transform-center-y"])
            ]
        else:
            self.center = [self.bbox.center.x, self.bbox.center.y]
        self.center = " ".join(str(s) for s in self.center)

        # should generally abort?
        #if obj.transform:
        #    raise Exception("Object already has a transformation. Animations will be wonky at best.")

        # dispatch & apply
        method = getattr(self, self.options.mode)
        creator, args = method()
        args.update(self.default_args())
        self.remove_previous_tags(obj, creator.tag_name)
        self.obj.append(creator(**args))

    def remove_previous_tags(self, parent, tag_name):
        """ strip any existing <animate*> tag in children """
        if not self.options.remove_child:
            return
        for child in parent.getchildren():
            if not re.match("^animate(Motion|Transform)?$", child.tag_name):
                continue
            if child.tag_name != tag_name: # only remove identically-named
                continue
            parent.remove(child)

    def style(self):
        """ <animate attributeName=color to=red> """
        if self.options.style in ("fill", "stroke"):
            val = str(self.options.color)
        else:
            val = self.options.style_val
        return Animate, {
            "attributeName": self.options.style,
            "attributeType": "CSS",
            #"from": "",
            "to": val,
        }
    def scale(self):
        """ <animateTransform attributeName=transform to=scale(2,5)> """
        return AnimateTransform, {
            "attributeName": "transform",
            "attributeType": "XML",
            "type": self.options.mode, # scale
            "from": "1.0 1.0",
            "to": self.options.scale,
        }

    def translate(self):
        """ <animateTransform attributeName=transform to=translate(20 30 50)> """
        nums = re.findall(r"(-?\d+(?:\.\d+)?)", self.options.translate)
        if len(nums) == 6:
            func = "matrix"
        elif len(nums) == 2:
            func = "translate"
        else:
            raise AbortExtension(f"Requires either two params for translate(x y) or six for matrix(a b c d e f) - received '{nums}'")
        return AnimateTransform,  {
            "attributeName": "transform",
            "attributeType": "XML",
            "type": func, # translate / matrix
            #"from": "0 0",
            "to": " ".join(nums),
        }

    def rotate(self):
        """ <animateTransform attributeName=transform to=rotate(360)> """
        if re.search(r"rotate\(.*\)", self.has_transform):
            raise AbortExtension("Can't realistically calculate center point with pre-existing rotation.")
            # obj.transform-origin=center ?
        return AnimateTransform, {
            "attributeName": "transform",
            "attributeType": "XML",
            "type": self.options.mode, # rotate
            "from": f"0 {self.center}",
            "to": f"{self.options.rotate} {self.center}",
        }

    def skewxy(self):
        """ <animateTransform attributeName=skewX to=-50> """
        return AnimateTransform,  {
            "attributeName": "transform",
            "attributeType": "XML",
            "type": "skewX" if self.options.skew_x else "skewY",
            #"from": "0",
            "to": self.options.skew_x or self.options.skew_y,
        }

    def attrib(self):
        """ <animate attributeName=width to=20px> """
        return Animate, {
            "attributeName": self.options.attr_name,
            "attributeType": "auto", # XML
            "from": self.options.attr_from,
            "to": self.options.attr_to,
        }

    def move(self):
        """ <animateMotion path=m0,0 c50,20,10,50,30,10 l100,200 z> """
        if self.path is not None and self.path.attrib.get("d"):
            data = self.path.attrib["d"]
        else:
            data = self.options.translate

        if self.options.move_relative:
            path = inkex.paths.Path(data).to_relative()
            if isinstance(path[0], (inkex.paths.Move, inkex.paths.move)):
                path[0] = inkex.paths.move(0, 0)
            data = str(path)

        return AnimateMotion, {
            "path": data,
        }

    def default_args(self):
        """ add some default attributes """
        args = {
            "id": re.sub(r"^#|\s+", "", self.options.set_id),
            "begin": self.options.start,
            "dur": self.options.duration,
            "repeatCount": self.options.repeat_count,
            "fill": self.options.repeat_fill,
            "calcMode": self.options.calc_mode,
        }
        if self.options.calc_mode == "spline":
            args.update({
                "values": self.options.key_values,
                "keyTimes": self.options.key_times,
                "keySplines": self.options.key_splines,
            })

        while args["id"] and self.svg.getElementById(args["id"]):
            args["id"] = "anim" + str(random.randint(100, 10000))

        return {
            key: val for key, val in args.items() if val
        }

class Animate(inkex.BaseElement):
    """Title element"""
    tag_name = "animate"

class AnimateTransform(inkex.BaseElement):
    """Title element"""
    tag_name = "animateTransform"

class AnimateMotion(inkex.BaseElement):
    """Title element"""
    tag_name = "animateMotion"


if __name__ == "__main__":
    AnimateYo().run()
    sys.exit(0)
