/*
    Lazy Animator 1.2
    Nine everyday animation shortcuts in one dockable After Effects panel.

    Keyframes
      Easy Ease            eases the selected keyframes in and out, like F9
      Reverse Keyframes    flips the values of the selected keyframes end to end
      loopOut              adds loopOut() to the selected properties
    Rigging
      Controller Parent    adds a "C_" null for each selected layer and parents the layer to it
      World Parent         makes a Position follow another layer's anchor point
      Puppet Pin Renamer   renames the selected Puppet Pins Name_1, Name_2 and so on
    Layers
      Centered Null        adds a null in the middle of the comp
      Mask Crop            swaps a masked solid for one cropped to the mask
      Sequence Layers      trims the selected layers so they play one after another

    Hover over a tool to see what to select, or click ? at the top of the panel for the
    full list. Each tool is one undo (Ctrl+Z, or Cmd+Z on a Mac).

    Install as a dockable panel
      Windows: C:\Program Files\Adobe\Adobe After Effects <version>\Support Files\Scripts\ScriptUI Panels
      macOS:   /Applications/Adobe After Effects <version>/Scripts/ScriptUI Panels
      Copy this file there, restart After Effects and open it from the Window menu, then dock it
      wherever you like. (File > Scripts > Run Script File opens it as a floating window instead.)
*/

(function LazyAnimator(thisObj) {
    var NAME = "Lazy Animator";
    var VERSION = "1.2";
    var TAGLINE = "Work less, animate more";
    var SETTINGS_SECTION = "Lazy Animator";
    var IDLE_TEXT = "Select what to work on, then click a tool. Hover over a tool to see what it needs.";
    var TILE_SIZE = [88, 62];
    var ICON_SIZE = 20;
    var RADIUS = 5;
    var MAX_FRAMES = 9999;

    var state = { frames: 1 };

    // The panel's sections and tools, in the order they appear. The tip shows up when
    // hovering over a tool, and in Help.
    var SECTIONS = [
        {
            title: "KEYFRAMES",
            tools: [
                { label: "Easy Ease", icon: "ease", run: easyEase,
                    tip: "Select keyframes. Eases them in and out (speed 0, 33% influence), the same as F9." },
                { label: "Reverse Keyframes", icon: "reverse", run: reverseKeyframes,
                    tip: "Select 2 or more keyframes on a property. Their values flip end to end, and the keyframes stay where they are in time." },
                { label: "loopOut", icon: "loop", run: addLoopOut,
                    tip: "Select properties, like Position or Rotation. Adds loopOut() so their keyframes repeat." }
            ]
        },
        {
            title: "RIGGING",
            tools: [
                { label: "Controller Parent", icon: "parent", run: controllerParent,
                    tip: "Select layers. Each one gets a \"C_\" null at its position and is parented to it." },
                { label: "World Parent", icon: "world", run: worldParent,
                    tip: "Select the Position of the layer that should follow, then Ctrl-click (Cmd-click on a Mac) the layer to follow. The Position then sticks to that layer's anchor point." },
                { label: "Puppet Pin Renamer", icon: "pins", run: puppetPinRenamer, ownUndo: true,
                    tip: "Select Puppet Pins (Puppet > Mesh > Deform), click, then type a base name. They become Name_1, Name_2 and so on." }
            ]
        },
        {
            title: "LAYERS",
            sequenceStep: true,
            tools: [
                { label: "Centered Null", icon: "centerNull", run: centeredNull,
                    tip: "Adds a null in the middle of the comp." },
                { label: "Mask Crop", icon: "crop", run: maskCrop,
                    tip: "Select one solid with a mask. It's swapped for a solid cropped to the mask, with the same name and timing. Effects and keyframes on the old solid aren't carried over." },
                { label: "Sequence Layers", icon: "sequence", run: function () { return sequenceLayers(state.frames); },
                    tip: "Select layers. Starting at the playhead, each one plays for the Sequence step, one after another." }
            ]
        }
    ];

    // ================================================================== helpers

    function userError(message) {
        var e = new Error(message);
        e.isUser = true;
        return e;
    }

    function trimString(s) {
        return String(s).replace(/^\s+|\s+$/g, "");
    }

    function readNumber(text) {
        var s = trimString(text).replace(",", ".");
        if (s === "") return null;
        var v = parseFloat(s);
        return isNaN(v) ? null : v;
    }

    function clamp(v, lo, hi) {
        return Math.min(hi, Math.max(lo, v));
    }

    function quoted(name) {
        return "\"" + name + "\"";
    }

    function plural(n, one, many) {
        return n + " " + (n === 1 ? one : many);
    }

    function loadSetting(key, fallback) {
        try {
            if (app.settings.haveSetting(SETTINGS_SECTION, key)) return app.settings.getSetting(SETTINGS_SECTION, key);
        } catch (e) { }
        return fallback;
    }

    function saveSetting(key, value) {
        try { app.settings.saveSetting(SETTINGS_SECTION, key, String(value)); } catch (e) { }
    }

    function activeComp(message) {
        var comp = app.project.activeItem;
        if (!(comp instanceof CompItem)) throw userError(message);
        return comp;
    }

    // The owning layer's index plus the index path down to a property, so a property
    // reached through two selections is only handled once.
    function propertyKey(prop) {
        var path = [];
        var p = prop;
        for (var d = prop.propertyDepth; d > 0; d--) {
            path.unshift(p.propertyIndex);
            p = p.parentProperty;
        }
        return p.index + ":" + path.join(".");
    }

    // ================================================================== tools: keyframes

    // Speed 0 and 33.33% influence on both sides, the same as F9.
    function easyEase() {
        var comp = activeComp("Open a composition and select the keyframes to ease.");
        var props = comp.selectedProperties;
        var eased = 0;
        for (var i = 0; i < props.length; i++) {
            var prop = props[i];
            if (prop.propertyType !== PropertyType.PROPERTY || !prop.isTimeVarying) continue;
            var keys = prop.selectedKeys;
            for (var j = 0; j < keys.length; j++) {
                // One ease per dimension: Scale takes two or three, Position and Opacity take one.
                var dimensions = prop.keyInTemporalEase(keys[j]).length;
                var ease = [];
                for (var d = 0; d < dimensions; d++) ease.push(new KeyframeEase(0, 33.33));
                prop.setTemporalEaseAtKey(keys[j], ease, ease);
                eased++;
            }
        }
        if (eased === 0) throw userError("Select the keyframes to ease.");
        return { text: "Eased " + plural(eased, "keyframe", "keyframes") + "." };
    }

    // Keeps the selected keyframes where they are in time and gives each one the value
    // (and interpolation) of the keyframe at the opposite end.
    function reverseKeyframes() {
        var comp = activeComp("Open a composition and select the keyframes to reverse.");
        var selectedProperties = comp.selectedProperties;
        var done = {};
        var totalReversed = 0;

        function processProperty(prop) {
            if (!prop) return;

            if (prop.propertyType === PropertyType.PROPERTY) {
                var key = null;
                try { key = propertyKey(prop); } catch (eKey) { key = null; }
                if (key !== null) {
                    if (done.hasOwnProperty(key)) return;
                    done[key] = true;
                }

                var selected = [];
                try {
                    for (var i = 1; i <= prop.numKeys; i++) {
                        if (prop.keySelected(i)) selected.push(i);
                    }
                } catch (e) {
                    return;
                }
                if (selected.length < 2) return;

                // Store everything before changing any keyframes.
                var data = [];
                for (var j = 0; j < selected.length; j++) {
                    var index = selected[j];
                    var keyData = {
                        value: prop.keyValue(index),
                        inType: null,
                        outType: null,
                        inEase: null,
                        outEase: null,
                        spatialContinuous: null,
                        spatialAutoBezier: null,
                        roving: null
                    };
                    try {
                        keyData.inType = prop.keyInInterpolationType(index);
                        keyData.outType = prop.keyOutInterpolationType(index);
                    } catch (e1) { }
                    try {
                        keyData.inEase = prop.keyInTemporalEase(index);
                        keyData.outEase = prop.keyOutTemporalEase(index);
                    } catch (e2) { }
                    try {
                        keyData.spatialContinuous = prop.keySpatialContinuous(index);
                        keyData.spatialAutoBezier = prop.keySpatialAutoBezier(index);
                    } catch (e3) { }
                    try {
                        keyData.roving = prop.keyRoving(index);
                    } catch (e4) { }
                    data.push(keyData);
                }

                // Keep the keyframe positions and give them the values from the opposite end.
                for (var r = 0; r < selected.length; r++) {
                    var targetIndex = selected[r];
                    var sourceData = data[data.length - 1 - r];
                    try {
                        prop.setValueAtKey(targetIndex, sourceData.value);
                    } catch (e5) {
                        continue;
                    }
                    try {
                        if (sourceData.inType !== null && sourceData.outType !== null) {
                            prop.setInterpolationTypeAtKey(targetIndex, sourceData.inType, sourceData.outType);
                        }
                    } catch (e6) { }
                    try {
                        if (sourceData.inEase && sourceData.outEase) {
                            prop.setTemporalEaseAtKey(targetIndex, sourceData.inEase, sourceData.outEase);
                        }
                    } catch (e7) { }
                    try {
                        if (sourceData.spatialContinuous !== null) prop.setSpatialContinuousAtKey(targetIndex, sourceData.spatialContinuous);
                    } catch (e8) { }
                    try {
                        if (sourceData.spatialAutoBezier !== null) prop.setSpatialAutoBezierAtKey(targetIndex, sourceData.spatialAutoBezier);
                    } catch (e9) { }
                    try {
                        if (sourceData.roving !== null) prop.setRovingAtKey(targetIndex, sourceData.roving);
                    } catch (e10) { }
                    totalReversed++;
                }
                return;
            }

            if (prop.numProperties) {
                for (var p = 1; p <= prop.numProperties; p++) {
                    try { processProperty(prop.property(p)); } catch (e11) { }
                }
            }
        }

        // Selected properties and the properties inside selected groups.
        for (var k = 0; k < selectedProperties.length; k++) {
            try { processProperty(selectedProperties[k]); } catch (e12) { }
        }
        if (totalReversed === 0) throw userError("Select at least 2 keyframes on a property.");
        return { text: "Reversed " + plural(totalReversed, "keyframe", "keyframes") + "." };
    }

    function addLoopOut() {
        var comp = activeComp("Open a composition and select the properties to loop.");
        var props = comp.selectedProperties;
        if (props.length === 0) throw userError("Select the properties to loop, like Position or Rotation.");
        var added = 0;
        for (var i = 0; i < props.length; i++) {
            if (props[i].canSetExpression) {
                props[i].expression = "loopOut()";
                added++;
            }
        }
        if (added === 0) throw userError("None of the selected properties can take an expression.");
        return { text: "Added loopOut() to " + plural(added, "property", "properties") + "." };
    }

    // ================================================================== tools: rigging

    function controllerParent() {
        var comp = activeComp("Open a composition and select the layers that need a controller.");
        var layers = comp.selectedLayers;
        if (layers.length === 0) throw userError("Select the layers that need a controller.");
        for (var i = 0; i < layers.length; i++) {
            var layer = layers[i];
            var nullLayer = comp.layers.addNull();
            nullLayer.name = "C_" + layer.name;
            nullLayer.transform.position.setValue(layer.transform.position.value);
            nullLayer.transform.anchorPoint.setValue([nullLayer.width / 2, nullLayer.height / 2]);
            layer.parent = nullLayer;
        }
        if (layers.length === 1) {
            return { text: "Parented " + quoted(layers[0].name) + " to " + quoted("C_" + layers[0].name) + "." };
        }
        return { text: "Added " + layers.length + " controller nulls and parented each layer to its own." };
    }

    // Puts an expression on the selected Position that sticks it to another
    // layer's anchor point in world space.
    function worldParent() {
        var comp = activeComp("Open a composition first.");
        var targetProp = null;
        var selectedProps = comp.selectedProperties;
        for (var i = 0; i < selectedProps.length; i++) {
            if (selectedProps[i].matchName === "ADBE Position") {
                if (targetProp !== null) throw userError("Select only one Position property.");
                targetProp = selectedProps[i];
            }
        }
        if (targetProp === null) {
            throw userError("Select the Position of the layer that should follow, then Ctrl-click (Cmd-click on a Mac) the layer to follow.");
        }
        if (!targetProp.canSetExpression) throw userError("The selected Position property can't take an expression.");

        var ownerLayer = targetProp.propertyGroup(targetProp.propertyDepth);
        var selectedLayers = comp.selectedLayers;
        var sourceLayer = null;
        for (var j = 0; j < selectedLayers.length; j++) {
            if (selectedLayers[j].index !== ownerLayer.index) {
                if (sourceLayer !== null) throw userError("Select only one layer to follow.");
                sourceLayer = selectedLayers[j];
            }
        }
        if (sourceLayer === null) throw userError("Now also Ctrl-click (Cmd-click on a Mac) the layer to follow.");

        var sourceName = sourceLayer.name.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
        targetProp.expression =
            "/****Parenting****/\r\r" +
            "//Vars\r" +
            "offset =    value;\r" +
            'ref =      thisComp.layer("' + sourceName + '");\r' +
            "target =     ref.toWorld(ref.anchorPoint);\r\r" +
            "//Execution\r" +
            "temp = target;\r" +
            "temp";
        return { text: quoted(ownerLayer.name) + " now follows " + quoted(sourceLayer.name) + "." };
    }

    function pinNames(base, count) {
        if (count === 1) return base + "_1";
        if (count === 2) return base + "_1 and " + base + "_2";
        return base + "_1 to " + base + "_" + count;
    }

    // Asks for the name first, so cancelling leaves no undo step behind.
    function puppetPinRenamer() {
        var comp = activeComp("Open a composition and select the Puppet Pins to rename.");
        // After Effects lists selected Puppet Pin rows as ADBE FreePin3 PosPin Atom.
        var selectedProps = comp.selectedProperties;
        var pins = [];
        for (var i = 0; i < selectedProps.length; i++) {
            if (selectedProps[i].matchName === "ADBE FreePin3 PosPin Atom") pins.push(selectedProps[i]);
        }
        if (pins.length === 0) throw userError("Select the Puppet Pins to rename. They're under Puppet > Mesh > Deform.");

        var baseName = askBaseName(pins.length);
        if (baseName === null) return { kind: "idle", text: "Renaming cancelled. Nothing changed." };

        app.beginUndoGroup(NAME + ": Puppet Pin Renamer");
        try {
            for (var p = 0; p < pins.length; p++) pins[p].name = baseName + "_" + (p + 1);
        } finally {
            app.endUndoGroup();
        }
        return { text: "Renamed " + plural(pins.length, "Puppet Pin", "Puppet Pins") + ": " + pinNames(baseName, pins.length) + "." };
    }

    // ================================================================== tools: layers

    function centeredNull() {
        var comp = activeComp("Open a composition first.");
        var nullLayer = comp.layers.addNull();
        nullLayer.name = "Centered Null";
        nullLayer.transform.position.setValue([comp.width / 2, comp.height / 2]);
        nullLayer.transform.anchorPoint.setValue([nullLayer.width / 2, nullLayer.height / 2]);
        return { text: "Added \"Centered Null\" in the middle of the comp." };
    }

    // Swaps the selected solid for a new one the size of its mask's bounding box,
    // with the same name, timing, blending mode and opacity.
    function maskCrop() {
        function fail(m) { throw userError(m); }

        var comp = activeComp("Open a composition and select one solid layer.");
        var sel = comp.selectedLayers;
        if (!sel || sel.length !== 1) fail("Select exactly one solid layer.");

        var src = sel[0];
        if (src.threeDLayer) fail("The selected layer is 3D. Turn off 3D first.");

        (function checkParents(l) {
            var p = l.parent;
            while (p) {
                if (p.threeDLayer) fail("Parent chain contains a 3D layer (" + quoted(p.name) + "). Unparent it or turn off 3D first.");
                p = p.parent;
            }
        })(src);

        var t = comp.time;

        function pickMask(layer) {
            var masks = layer.property("ADBE Mask Parade");
            if (!masks || masks.numProperties === 0) fail("The layer has no masks.");
            for (var i = 1; i <= masks.numProperties; i++) {
                var m = masks.property(i);
                if (m.selected && m.enabled) return m;
            }
            for (var j = 1; j <= masks.numProperties; j++) {
                var m2 = masks.property(j);
                if (m2.enabled) return m2;
            }
            return masks.property(1);
        }
        var mask = pickMask(src);
        var shp = mask.property("ADBE Mask Shape").valueAtTime(t, false);
        var verts = shp.vertices;
        if (!verts || !verts.length) fail("The mask has no points.");

        function deg2rad(d) { return d * Math.PI / 180; }
        function rot2(a, p) { var c = Math.cos(a), s = Math.sin(a); return [p[0] * c - p[1] * s, p[0] * s + p[1] * c]; }
        function add2(a, b) { return [a[0] + b[0], a[1] + b[1]]; }
        function sub2(a, b) { return [a[0] - b[0], a[1] - b[1]]; }
        function mul2(a, sx, sy) { return [a[0] * sx, a[1] * sy]; }

        function read2DTransform(layer, time) {
            var xf = layer.property("ADBE Transform Group");
            if (!xf) fail("Missing Transform group on " + quoted(layer.name) + ".");
            var posProp = xf.property("ADBE Position");
            var pos = posProp.valueAtTime(time, false);
            if (!(pos instanceof Array) || pos.length < 2) {
                var px = xf.property("ADBE Position_0"), py = xf.property("ADBE Position_1");
                if (!px || !py) fail("Can't read the 2D Position of " + quoted(layer.name) + ".");
                pos = [px.valueAtTime(time, false), py.valueAtTime(time, false)];
            }
            var ap = xf.property("ADBE Anchor Point").valueAtTime(time, false);
            var sc = xf.property("ADBE Scale").valueAtTime(time, false);
            var sx = sc[0] / 100, sy = sc[1] / 100;
            var rotProp = xf.property("ADBE Rotation");
            var rad = deg2rad(rotProp ? rotProp.valueAtTime(time, false) : 0);
            return { pos: pos, ap: ap, sx: sx, sy: sy, rad: rad };
        }

        function applyLayer2D(layer, ptLS, time) {
            var tr = read2DTransform(layer, time);
            var p = sub2(ptLS, tr.ap);
            p = mul2(p, tr.sx, tr.sy);
            p = rot2(tr.rad, p);
            p = add2(p, tr.pos);
            return p; // parent space (or comp space if there's no parent)
        }

        function layerToComp2D(layer, ptLS, time) {
            var p = [ptLS[0], ptLS[1]];
            var cur = layer;
            while (cur) {
                p = applyLayer2D(cur, p, time);
                cur = cur.parent; // null means comp space
            }
            return p;
        }

        var minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
        for (var k = 0; k < verts.length; k++) {
            var c = layerToComp2D(src, verts[k], t);
            if (c[0] < minX) minX = c[0];
            if (c[0] > maxX) maxX = c[0];
            if (c[1] < minY) minY = c[1];
            if (c[1] > maxY) maxY = c[1];
        }
        var bboxW = Math.max(1, Math.round(maxX - minX));
        var bboxH = Math.max(1, Math.round(maxY - minY));
        var centerCS = [(minX + maxX) / 2, (minY + maxY) / 2];

        var solidColor = [1, 1, 1];
        try {
            if (src.source && src.source.mainSource && src.source.mainSource.color) solidColor = src.source.mainSource.color;
        } catch (e) { }

        // The new solid keeps the original's name.
        var name = src.name;
        var dst = comp.layers.addSolid(solidColor, name, bboxW, bboxH, comp.pixelAspect);
        dst.moveAfter(src);

        var xf = dst.property("ADBE Transform Group");
        xf.property("ADBE Position").setValue(centerCS);
        xf.property("ADBE Anchor Point").setValue([bboxW / 2, bboxH / 2]);

        // Start time first, so the in and out points land where they were.
        dst.startTime = src.startTime;
        dst.inPoint = src.inPoint;
        dst.outPoint = src.outPoint;
        try { dst.blendingMode = src.blendingMode; } catch (e2) { }
        try {
            var srcOp = src.property("ADBE Transform Group").property("ADBE Opacity").valueAtTime(t, false);
            dst.property("ADBE Transform Group").property("ADBE Opacity").setValue(srcOp);
        } catch (e3) { }

        // Delete the original masked layer.
        src.remove();
        return { text: "Cropped " + quoted(name) + " to its mask: " + bboxW + " x " + bboxH + " px." };
    }

    // Starting at the playhead, each selected layer plays for the step, one after another.
    function sequenceLayers(frames) {
        var comp = activeComp("Open a composition and select the layers to sequence.");
        var layers = comp.selectedLayers;
        if (layers.length === 0) throw userError("Select the layers to sequence.");
        var step = frames * comp.frameDuration;
        for (var i = 0; i < layers.length; i++) {
            var layer = layers[i];
            layer.inPoint = comp.time;
            layer.outPoint = layer.inPoint + step;
            comp.time += step;
        }
        return { text: "Sequenced " + plural(layers.length, "layer", "layers") + ", " +
            plural(frames, "frame", "frames") + " each, from the playhead." };
    }

    // ================================================================== look

    // Painted areas carry their own colors, so they read the same at any After Effects
    // brightness. Plain labels keep the host's text color.
    var C = {
        accentA: [0.557, 0.361, 1.0],          // #8E5CFF, the Lazy Animator purple
        accentB: [0.30, 0.62, 1.0],            // the blue its gradients blend into
        white: [1, 1, 1],
        header: [0.078, 0.071, 0.102],
        title: [0.96, 0.95, 0.99],
        subtitle: [0.62, 0.60, 0.71],
        pill: [0.19, 0.14, 0.30],
        pillInk: [0.78, 0.70, 1.0],
        section: [0.67, 0.57, 1.0],
        hairline: [0.27, 0.26, 0.33],
        tile: [0.16, 0.153, 0.192],
        tileHover: [0.205, 0.192, 0.255],
        tileEdge: [0.235, 0.224, 0.29],
        label: [0.84, 0.83, 0.89],
        icon: [0.72, 0.63, 1.0],
        iconAccent: [0.42, 0.72, 1.0],
        iconHover: [0.86, 0.81, 1.0],
        iconAccentHover: [0.66, 0.85, 1.0],
        secondary: [0.22, 0.21, 0.26],
        secondaryHover: [0.28, 0.27, 0.33],
        secondaryPressed: [0.17, 0.16, 0.20],
        secondaryInk: [0.91, 0.90, 0.94],
        idle: [0.58, 0.56, 0.64],
        ok: [0.36, 0.84, 0.52],
        warn: [0.98, 0.74, 0.30],
        error: [0.95, 0.40, 0.38]
    };
    var PRIMARY = { primary: true, ink: C.white };
    var SECONDARY = { base: C.secondary, hover: C.secondaryHover, pressed: C.secondaryPressed, ink: C.secondaryInk };
    var fontName = "dialog";   // replaced by the font After Effects uses for panels
    var fonts = {};
    var linesByWidth = {};

    // Fonts are made once and reused.
    function makeFont(bold, size) {
        var key = (bold ? "b" : "r") + size;
        if (!fonts.hasOwnProperty(key)) {
            try {
                fonts[key] = ScriptUI.newFont(fontName, bold ? ScriptUI.FontStyle.BOLD : ScriptUI.FontStyle.REGULAR, size);
            } catch (e) {
                fonts[key] = null;
            }
        }
        return fonts[key];
    }

    function setFont(control, bold, size) {
        var font = makeFont(bold, size);
        if (font === null) return;
        try { control.graphics.font = font; } catch (e) { }
    }

    function setTextColor(control, rgb) {
        try {
            var g = control.graphics;
            g.foregroundColor = g.newPen(g.PenType.SOLID_COLOR, rgb, 1);
        } catch (e) { }
    }

    function setBackground(control, rgb) {
        try {
            var g = control.graphics;
            g.backgroundColor = g.newBrush(g.BrushType.SOLID_COLOR, rgb);
        } catch (e) { }
    }

    function addText(parent, text, bold, size, rgb) {
        var label = parent.add("statictext", undefined, text);
        setFont(label, bold, size);
        if (rgb) setTextColor(label, rgb);
        return label;
    }

    // Wrapped text. ScriptUI needs a height for it. When measureString wraps the text,
    // that height is used (measured a little narrower than the label, to be safe);
    // otherwise the height is estimated from the width.
    function addParagraph(parent, text, width) {
        var perLine = Math.max(10, Math.floor(width / 6.6));
        var lines = 0;
        var paragraphs = text.split("\n");
        for (var i = 0; i < paragraphs.length; i++) {
            lines += Math.max(1, Math.ceil(paragraphs[i].length / perLine));
        }
        var label = parent.add("statictext", undefined, text, { multiline: true });
        var height = lines * 16 + 4;
        try {
            var g = label.graphics;
            var one = g.measureString("Ag", g.font)[1];
            var wrapped = g.measureString(text, g.font, width - 12)[1];
            if (wrapped > one * 1.5 || lines === 1) height = Math.ceil(wrapped) + 6;
        } catch (e) { }
        label.preferredSize = [width, height];
        return label;
    }

    function mix(a, b, t) {
        return [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t, a[2] + (b[2] - a[2]) * t];
    }

    function solidBrush(g, rgb) {
        return g.newBrush(g.BrushType.SOLID_COLOR, rgb);
    }

    function solidPen(g, rgb, width) {
        return g.newPen(g.PenType.SOLID_COLOR, rgb, width);
    }

    // Starts a new path around a rectangle with rounded corners. Fill or stroke it after.
    function roundedPath(g, x, y, w, h, r) {
        r = Math.max(0, Math.min(r, w / 2, h / 2));
        var centers = [[x + w - r, y + r], [x + w - r, y + h - r], [x + r, y + h - r], [x + r, y + r]];
        var first = null;
        g.newPath();
        for (var c = 0; c < 4; c++) {
            for (var s = 0; s <= 4; s++) {
                var a = (c * 90 - 90 + s * 22.5) * Math.PI / 180;
                var px = centers[c][0] + r * Math.cos(a);
                var py = centers[c][1] + r * Math.sin(a);
                if (first === null) {
                    first = [px, py];
                    g.moveTo(px, py);
                } else {
                    g.lineTo(px, py);
                }
            }
        }
        g.lineTo(first[0], first[1]);
    }

    // How far the rounded corners cut into the top and bottom edges at px from the left.
    function cornerInset(px, w, r) {
        var d = (px < r) ? r - px : (px > w - r) ? px - (w - r) : 0;
        return (d > 0) ? r - Math.sqrt(Math.max(0, r * r - d * d)) : 0;
    }

    // Fills a rounded rectangle in one color, or blended left to right from one color to another.
    // A blend is painted as thin strips, finer around the corners so they stay round.
    function fillRounded(g, x, y, w, h, r, from, to) {
        r = Math.max(0, Math.min(r, w / 2, h / 2));
        if (!to) {
            roundedPath(g, x, y, w, h, r);
            g.fillPath(solidBrush(g, from));
            return;
        }
        var edges = [];
        var i;
        var fine = Math.ceil(r);
        var middle = w - 2 * r;
        var coarse = Math.max(1, Math.ceil(middle / 3));
        for (i = 0; i < fine; i++) edges.push(i * r / fine);
        for (i = 0; i < coarse; i++) edges.push(r + i * middle / coarse);
        for (i = 0; i < fine; i++) edges.push(w - r + i * r / fine);
        edges.push(w);
        for (i = 0; i < edges.length - 1; i++) {
            var x0 = edges[i];
            var x1 = edges[i + 1];
            if (x1 <= x0) continue;
            var t0 = cornerInset(x0, w, r);
            var t1 = cornerInset(x1, w, r);
            var overlap = (i < edges.length - 2) ? 0.5 : 0;   // hides seams between strips
            g.newPath();
            g.moveTo(x + x0, y + t0);
            g.lineTo(x + x1 + overlap, y + t1);
            g.lineTo(x + x1 + overlap, y + h - t1);
            g.lineTo(x + x0, y + h - t0);
            g.lineTo(x + x0, y + t0);
            g.fillPath(solidBrush(g, mix(from, to, (x0 + x1) / 2 / w)));
        }
    }

    function strokeRounded(g, x, y, w, h, r, rgb, width) {
        roundedPath(g, x, y, w, h, r);
        g.strokePath(solidPen(g, rgb, width));
    }

    // The accent blend, lighter on hover and darker while pressed.
    function accentFill(g, w, h, ds, r) {
        var a = C.accentA;
        var b = C.accentB;
        if (ds && ds.leftButtonPressed) {
            a = mix(a, [0, 0, 0], 0.2);
            b = mix(b, [0, 0, 0], 0.2);
        } else if (ds && ds.mouseOver) {
            a = mix(a, [1, 1, 1], 0.14);
            b = mix(b, [1, 1, 1], 0.14);
        }
        fillRounded(g, 0, 0, w, h, r, a, b);
    }

    function fillRect(g, x, y, w, h, rgb) {
        g.newPath();
        g.rectPath(x, y, w, h);
        g.fillPath(solidBrush(g, rgb));
    }

    function strokeRect(g, x, y, w, h, rgb, width) {
        g.newPath();
        g.rectPath(x, y, w, h);
        g.strokePath(solidPen(g, rgb, width));
    }

    function fillDot(g, x, y, r, rgb) {
        g.newPath();
        g.ellipsePath(x - r, y - r, 2 * r, 2 * r);
        g.fillPath(solidBrush(g, rgb));
    }

    function strokeEllipse(g, x, y, w, h, rgb, width) {
        g.newPath();
        g.ellipsePath(x, y, w, h);
        g.strokePath(solidPen(g, rgb, width));
    }

    function strokeLine(g, pts, pen) {
        if (pts.length < 2) return;
        g.newPath();
        g.moveTo(pts[0][0], pts[0][1]);
        for (var i = 1; i < pts.length; i++) g.lineTo(pts[i][0], pts[i][1]);
        g.strokePath(pen);
    }

    function fillShape(g, pts, rgb) {
        g.newPath();
        g.moveTo(pts[0][0], pts[0][1]);
        for (var i = 1; i < pts.length; i++) g.lineTo(pts[i][0], pts[i][1]);
        g.lineTo(pts[0][0], pts[0][1]);
        g.fillPath(solidBrush(g, rgb));
    }

    // A keyframe diamond.
    function fillDiamond(g, cx, cy, r, rgb) {
        fillShape(g, [[cx, cy - r], [cx + r, cy], [cx, cy + r], [cx - r, cy]], rgb);
    }

    function textSize(g, text, font) {
        return g.measureString(text, font !== null ? font : g.font);
    }

    function drawText(g, text, font, rgb, x, y) {
        g.drawString(text, solidPen(g, rgb, 1), x, y, font !== null ? font : g.font);
    }

    function drawCentered(g, text, font, rgb, w, h) {
        var m = textSize(g, text, font);
        drawText(g, text, font, rgb, Math.round((w - m[0]) / 2), Math.round((h - m[1]) / 2));
    }

    // Draws text one letter at a time with extra space between them. Returns where it ends.
    function drawTracked(g, text, font, rgb, x, y, tracking) {
        var pen = solidPen(g, rgb, 1);
        var f = (font !== null) ? font : g.font;
        for (var i = 0; i < text.length; i++) {
            var ch = text.charAt(i);
            g.drawString(ch, pen, x, y, f);
            x += g.measureString(ch, f)[0] + tracking;
        }
        return x - tracking;
    }

    // Shortens text with an ellipsis until it fits.
    function fitText(g, text, font, maxWidth) {
        if (textSize(g, text, font)[0] <= maxWidth) return text;
        var s = text;
        while (s.length > 1 && textSize(g, s + "\u2026", font)[0] > maxWidth) s = s.substring(0, s.length - 1);
        return s + "\u2026";
    }

    // Splits a label over two lines when it doesn't fit, at the space that balances them best.
    function wrapLabel(g, text, font, maxWidth) {
        if (textSize(g, text, font)[0] <= maxWidth) return [text];
        var words = text.split(" ");
        if (words.length < 2) return [fitText(g, text, font, maxWidth)];
        var best = null;
        var bestWidth = 0;
        for (var i = 1; i < words.length; i++) {
            var a = words.slice(0, i).join(" ");
            var b = words.slice(i).join(" ");
            var wide = Math.max(textSize(g, a, font)[0], textSize(g, b, font)[0]);
            if (best === null || wide < bestWidth) {
                best = [a, b];
                bestWidth = wide;
            }
        }
        return [fitText(g, best[0], font, maxWidth), fitText(g, best[1], font, maxWidth)];
    }

    // The most lines any tool label needs at this width, so every icon sits at the same height.
    function mostLabelLines(g, font, maxWidth) {
        var key = String(Math.round(maxWidth));
        if (!linesByWidth.hasOwnProperty(key)) {
            var most = 1;
            for (var s = 0; s < SECTIONS.length; s++) {
                var tools = SECTIONS[s].tools;
                for (var t = 0; t < tools.length; t++) {
                    most = Math.max(most, wrapLabel(g, tools[t].label, font, maxWidth).length);
                }
            }
            linesByWidth[key] = most;
        }
        return linesByWidth[key];
    }

    // If custom drawing ever fails, the control falls back to its standard look.
    function drawFailed(control, err) {
        try { control.onDraw = undefined; } catch (e) { }
    }

    // ScriptUI has no redraw call: hiding and showing a control repaints it.
    function repaint(control) {
        if (!control) return;
        try {
            control.visible = false;
            control.visible = true;
        } catch (e) { }
    }

    // ------------------------------------------------------------------ icons

    // Tool icons, drawn in a 20 x 20 box at x, y. main and accent are the two inks.
    var ICONS = {
        // An ease in-out curve between two keyframes
        ease: function (g, x, y, main, accent) {
            var pts = [];
            for (var i = 0; i <= 20; i++) {
                var t = i / 20;
                var s = Math.pow(t, 3) / (Math.pow(t, 3) + Math.pow(1 - t, 3));
                pts.push([x + 3 + 14 * t, y + 16 - 12 * s]);
            }
            strokeLine(g, pts, solidPen(g, main, 2));
            fillDiamond(g, x + 3, y + 16, 3, accent);
            fillDiamond(g, x + 17, y + 4, 3, accent);
        },
        // Two keyframes trading places
        reverse: function (g, x, y, main, accent) {
            var pen = solidPen(g, main, 2);
            strokeLine(g, [[x + 7, y + 6], [x + 17, y + 6]], pen);
            strokeLine(g, [[x + 14, y + 3], [x + 17, y + 6], [x + 14, y + 9]], pen);
            strokeLine(g, [[x + 13, y + 14], [x + 3, y + 14]], pen);
            strokeLine(g, [[x + 6, y + 11], [x + 3, y + 14], [x + 6, y + 17]], pen);
            fillDiamond(g, x + 4, y + 6, 3, accent);
            fillDiamond(g, x + 16, y + 14, 3, accent);
        },
        // Repeat arrows
        loop: function (g, x, y, main, accent) {
            var upper = solidPen(g, main, 2);
            var lower = solidPen(g, accent, 2);
            strokeLine(g, [[x + 3, y + 11], [x + 3, y + 6], [x + 16, y + 6]], upper);
            strokeLine(g, [[x + 13, y + 3], [x + 16, y + 6], [x + 13, y + 9]], upper);
            strokeLine(g, [[x + 17, y + 9], [x + 17, y + 14], [x + 4, y + 14]], lower);
            strokeLine(g, [[x + 7, y + 11], [x + 4, y + 14], [x + 7, y + 17]], lower);
        },
        // A controller null linked down to its layer
        parent: function (g, x, y, main, accent) {
            strokeRect(g, x + 2, y + 2, 8, 8, accent, 2);
            strokeLine(g, [[x + 6, y + 10], [x + 6, y + 15], [x + 11, y + 15]], solidPen(g, main, 2));
            fillRect(g, x + 11, y + 11, 8, 8, main);
        },
        // A globe, for world space
        world: function (g, x, y, main, accent) {
            strokeEllipse(g, x + 2, y + 2, 16, 16, main, 2);
            strokeEllipse(g, x + 6, y + 2, 8, 16, accent, 1);
            strokeLine(g, [[x + 3, y + 10], [x + 17, y + 10]], solidPen(g, accent, 1));
        },
        // A Puppet Pin next to a text cursor
        pins: function (g, x, y, main, accent) {
            strokeEllipse(g, x + 1, y + 5, 10, 10, main, 2);
            fillDot(g, x + 6, y + 10, 2, accent);
            var pen = solidPen(g, accent, 2);
            strokeLine(g, [[x + 15, y + 3], [x + 15, y + 17]], pen);
            strokeLine(g, [[x + 12, y + 3], [x + 18, y + 3]], pen);
            strokeLine(g, [[x + 12, y + 17], [x + 18, y + 17]], pen);
        },
        // A null with a center mark
        centerNull: function (g, x, y, main, accent) {
            strokeRect(g, x + 2, y + 2, 16, 16, main, 2);
            var pen = solidPen(g, accent, 2);
            strokeLine(g, [[x + 10, y + 6], [x + 10, y + 14]], pen);
            strokeLine(g, [[x + 6, y + 10], [x + 14, y + 10]], pen);
        },
        // Crop marks
        crop: function (g, x, y, main, accent) {
            strokeLine(g, [[x + 6, y + 1], [x + 6, y + 14], [x + 19, y + 14]], solidPen(g, main, 2));
            strokeLine(g, [[x + 1, y + 6], [x + 14, y + 6], [x + 14, y + 19]], solidPen(g, accent, 2));
        },
        // Layer bars stepping down the timeline
        sequence: function (g, x, y, main, accent) {
            fillRect(g, x + 1, y + 3, 7, 4, main);
            fillRect(g, x + 7, y + 8, 7, 4, mix(main, accent, 0.5));
            fillRect(g, x + 13, y + 13, 6, 4, accent);
        }
    };

    // ------------------------------------------------------------------ widgets

    // Rounded badge in the accent blend with a sleepy "Zz".
    function addLogo(parent, size) {
        var logo = parent.add("group");
        logo.preferredSize = [size, size];
        var big = makeFont(true, Math.round(size * 0.52));
        var small = makeFont(true, Math.round(size * 0.32));
        logo.onDraw = function () {
            try {
                var g = this.graphics;
                fillRounded(g, 0, 0, size, size, Math.round(size * 0.26), C.accentA, C.accentB);
                var mBig = textSize(g, "Z", big);
                var mSmall = textSize(g, "z", small);
                var x = Math.round((size - mBig[0] - mSmall[0] * 0.8) / 2);
                var y = Math.round((size - mBig[1]) / 2 + size * 0.05);
                drawText(g, "Z", big, C.white, x, y);
                drawText(g, "z", small, C.white, Math.round(x + mBig[0] - mSmall[0] * 0.2), Math.round(y - mSmall[1] * 0.1));
            } catch (e) {
                drawFailed(this, e);
            }
        };
        return logo;
    }

    // A tool icon on a gradient badge, for dialog headers.
    function addIconBadge(parent, id, size) {
        var badge = parent.add("group");
        badge.preferredSize = [size, size];
        badge.onDraw = function () {
            try {
                var g = this.graphics;
                fillRounded(g, 0, 0, size, size, Math.round(size * 0.26), C.accentA, C.accentB);
                var o = Math.round((size - ICON_SIZE) / 2);
                ICONS[id](g, o, o, C.white, C.white);
            } catch (e) {
                drawFailed(this, e);
            }
        };
        return badge;
    }

    // A tool icon on a small tile, for the Help list.
    function addIconChip(parent, id, size) {
        var chip = parent.add("group");
        chip.preferredSize = [size, size];
        chip.onDraw = function () {
            try {
                var g = this.graphics;
                fillRounded(g, 0, 0, size, size, RADIUS, C.tile);
                strokeRounded(g, 0.5, 0.5, size - 1, size - 1, RADIUS, C.tileEdge, 1);
                var o = Math.round((size - ICON_SIZE) / 2);
                ICONS[id](g, o, o, C.icon, C.iconAccent);
            } catch (e) {
                drawFailed(this, e);
            }
        };
        return chip;
    }

    // Small rounded label, used for the version number.
    function addPill(parent, text) {
        var font = makeFont(true, 9);
        var pill = parent.add("group");
        pill.preferredSize = [36, 16];
        try {
            var m = pill.graphics.measureString(text, font !== null ? font : pill.graphics.font);
            pill.preferredSize = [Math.ceil(m[0]) + 14, 16];
        } catch (e) { }
        pill.onDraw = function () {
            try {
                var g = this.graphics;
                var w = this.size[0];
                var h = this.size[1];
                fillRounded(g, 0, 0, w, h, h / 2, C.pill);
                drawCentered(g, text, font, C.pillInk, w, h);
            } catch (e2) {
                drawFailed(this, e2);
            }
        };
        return pill;
    }

    function addGradientLine(parent, height) {
        var line = parent.add("group");
        line.alignment = ["fill", "top"];
        line.preferredSize = [10, height];
        setBackground(line, C.accentA);
        line.onDraw = function () {
            try {
                fillRounded(this.graphics, 0, 0, this.size[0], this.size[1], 0, C.accentA, C.accentB);
            } catch (e) {
                drawFailed(this, e);
            }
        };
        return line;
    }

    function addDivider(parent) {
        var line = parent.add("group");
        line.alignment = ["fill", "top"];
        line.preferredSize = [10, 1];
        setBackground(line, C.hairline);
        return line;
    }

    // Section title: a keyframe diamond, spaced-out capitals and a hairline to the edge.
    function addSectionHead(parent, title) {
        var head = parent.add("group");
        head.alignment = ["fill", "top"];
        head.preferredSize = [120, 14];
        var font = makeFont(true, 9);
        head.onDraw = function () {
            try {
                var g = this.graphics;
                var w = this.size[0];
                var h = this.size[1];
                var cy = Math.round(h / 2);
                fillDiamond(g, 4, cy, 3.5, C.accentA);
                var m = textSize(g, title, font);
                var end = drawTracked(g, title, font, C.section, 13, Math.round((h - m[1]) / 2), 1.2);
                var lineX = Math.ceil(end) + 8;
                if (lineX < w) fillRect(g, lineX, cy, w - lineX, 1, C.hairline);
            } catch (e) {
                drawFailed(this, e);
            }
        };
        return head;
    }

    // A tool tile: icon on top, label underneath. Lights up on hover and fills with
    // the accent blend while pressed. Long labels wrap onto two lines.
    function drawTile(g, w, h, ds, tool) {
        var pressed = !!(ds && ds.leftButtonPressed);
        var hover = !!(ds && ds.mouseOver);
        if (pressed) {
            fillRounded(g, 0, 0, w, h, RADIUS, C.accentA, C.accentB);
        } else {
            fillRounded(g, 0, 0, w, h, RADIUS, hover ? C.tileHover : C.tile);
            strokeRounded(g, 0.5, 0.5, w - 1, h - 1, RADIUS, hover ? C.accentA : C.tileEdge, 1);
        }
        var font = makeFont(false, 11);
        var maxWidth = w - 10;
        var lines = wrapLabel(g, tool.label, font, maxWidth);
        var lineHeight = Math.ceil(textSize(g, "Ag", font)[1]);
        var gap = 4;
        var block = ICON_SIZE + gap + mostLabelLines(g, font, maxWidth) * lineHeight;
        var top = Math.max(3, Math.round((h - block) / 2));
        var main = pressed ? C.white : (hover ? C.iconHover : C.icon);
        var accent = pressed ? C.white : (hover ? C.iconAccentHover : C.iconAccent);
        try {
            ICONS[tool.icon](g, Math.round((w - ICON_SIZE) / 2), top, main, accent);
        } catch (eIcon) { }
        var ink = (pressed || hover) ? C.white : C.label;
        for (var i = 0; i < lines.length; i++) {
            var tw = textSize(g, lines[i], font)[0];
            drawText(g, lines[i], font, ink, Math.round((w - tw) / 2), top + ICON_SIZE + gap + i * lineHeight);
        }
    }

    // Tiles are icon buttons, which can be any height on Windows and macOS.
    function addTile(parent, tool, onPick) {
        var tile = parent.add("iconbutton", undefined, undefined, { style: "toolbutton" });
        try { tile.text = tool.label; } catch (eText) { }
        tile.preferredSize = [TILE_SIZE[0], TILE_SIZE[1]];
        tile.helpTip = tool.label + ": " + tool.tip;
        tile.onDraw = function (ds) {
            try {
                drawTile(this.graphics, this.size[0], this.size[1], ds, tool);
            } catch (e) {
                drawFailed(this, e);
            }
        };
        tile.onClick = function () { onPick(tool); };
        return tile;
    }

    // Small square button with a minus, plus or question mark.
    function glyphButton(parent, glyph, tip) {
        var button = parent.add("iconbutton", undefined, undefined, { style: "toolbutton" });
        button.preferredSize = [22, 22];
        button.helpTip = tip;
        var font = makeFont(true, 12);
        button.onDraw = function (ds) {
            try {
                var g = this.graphics;
                var w = this.size[0];
                var h = this.size[1];
                var pressed = !!(ds && ds.leftButtonPressed);
                var hover = !!(ds && ds.mouseOver);
                if (pressed) {
                    fillRounded(g, 0, 0, w, h, RADIUS - 1, C.accentA, C.accentB);
                } else {
                    fillRounded(g, 0, 0, w, h, RADIUS - 1, hover ? C.tileHover : C.tile);
                    strokeRounded(g, 0.5, 0.5, w - 1, h - 1, RADIUS - 1, hover ? C.accentA : C.tileEdge, 1);
                }
                var ink = (pressed || hover) ? C.white : C.label;
                var cx = Math.round(w / 2);
                var cy = Math.round(h / 2);
                if (glyph === "help") {
                    drawCentered(g, "?", font, ink, w, h);
                } else {
                    var pen = solidPen(g, ink, 2);
                    strokeLine(g, [[cx - 4, cy], [cx + 4, cy]], pen);
                    if (glyph === "plus") strokeLine(g, [[cx, cy - 4], [cx, cy + 4]], pen);
                }
            } catch (e) {
                drawFailed(this, e);
            }
        };
        return button;
    }

    // Flat dialog button with rounded corners. PRIMARY is in the accent blend.
    function flatButton(parent, text, scheme, size, fontSize, props) {
        var button = parent.add("button", undefined, text, props || {});
        button.preferredSize = size;
        var font = makeFont(true, fontSize);
        button.onDraw = function (ds) {
            try {
                var g = this.graphics;
                var w = this.size[0];
                var h = this.size[1];
                if (scheme.primary) {
                    accentFill(g, w, h, ds, RADIUS);
                } else {
                    var fill = scheme.base;
                    if (ds && ds.leftButtonPressed) fill = scheme.pressed;
                    else if (ds && ds.mouseOver) fill = scheme.hover;
                    fillRounded(g, 0, 0, w, h, RADIUS, fill);
                }
                drawCentered(g, this.text, font, scheme.ink, w, h);
            } catch (e) {
                drawFailed(this, e);
            }
        };
        return button;
    }

    // Small round light for the status line.
    function statusLight(parent, getColor) {
        var light = parent.add("group");
        light.preferredSize = [10, 16];
        light.onDraw = function () {
            try {
                fillDot(this.graphics, 5, 8, 4, getColor());
            } catch (e) {
                drawFailed(this, e);
            }
        };
        return light;
    }

    // ------------------------------------------------------------------ dialogs

    function dialogHeader(d, title, subtitle, drawBadge) {
        var head = d.add("group");
        head.orientation = "row";
        head.alignChildren = ["left", "center"];
        head.spacing = 10;
        drawBadge(head);
        var headText = head.add("group");
        headText.orientation = "column";
        headText.alignChildren = ["left", "top"];
        headText.spacing = 0;
        addText(headText, title, true, 14);
        addText(headText, subtitle, false, 11);
        return head;
    }

    // Asks for the Puppet Pins' base name. Returns null if cancelled.
    function askBaseName(count) {
        var d = new Window("dialog", "Puppet Pin Renamer");
        d.orientation = "column";
        d.alignChildren = ["fill", "top"];
        d.margins = 16;
        d.spacing = 10;

        dialogHeader(d, "Rename Puppet Pins", plural(count, "Puppet Pin selected", "Puppet Pins selected"),
            function (head) { addIconBadge(head, "pins", 34); });

        d.add("statictext", undefined, "Base name");
        var input = d.add("edittext", undefined, "");
        input.characters = 28;
        var preview = d.add("statictext", undefined, "");
        preview.alignment = ["fill", "top"];
        preview.preferredSize = [220, 18];

        function update() {
            preview.text = (trimString(input.text) === "") ? "Type a name to see the new pin names." :
                "New names: " + pinNames(input.text, count);
        }
        input.onChanging = update;
        update();

        // Enter renames and Esc cancels.
        var buttons = d.add("group");
        buttons.orientation = "row";
        buttons.alignment = ["right", "top"];
        buttons.spacing = 8;
        var cancel = flatButton(buttons, "Cancel", SECONDARY, [86, 26], 11, { name: "cancel" });
        var rename = flatButton(buttons, "Rename", PRIMARY, [86, 26], 11, { name: "ok" });
        cancel.onClick = function () { d.close(2); };
        rename.onClick = function () {
            if (trimString(input.text) === "") {
                preview.text = "Type a base name first.";
                input.active = true;
                return;
            }
            d.close(1);
        };

        input.active = true;
        if (d.show() !== 1) return null;
        if (trimString(input.text) === "") throw userError("Type a base name to rename the Puppet Pins.");
        return input.text;
    }

    function showHelp() {
        var d = new Window("dialog", NAME + " Help");
        d.orientation = "column";
        d.alignChildren = ["fill", "top"];
        d.margins = 18;
        d.spacing = 12;

        dialogHeader(d, NAME + " " + VERSION, TAGLINE, function (head) { addLogo(head, 34); });

        for (var s = 0; s < SECTIONS.length; s++) {
            var block = d.add("group");
            block.orientation = "column";
            block.alignChildren = ["fill", "top"];
            block.spacing = 8;
            addSectionHead(block, SECTIONS[s].title);
            var tools = SECTIONS[s].tools;
            for (var t = 0; t < tools.length; t++) {
                var row = block.add("group");
                row.orientation = "row";
                row.alignChildren = ["left", "top"];
                row.spacing = 10;
                addIconChip(row, tools[t].icon, 30);
                var words = row.add("group");
                words.orientation = "column";
                words.alignChildren = ["left", "top"];
                words.spacing = 1;
                addText(words, tools[t].label, true, 12);
                addParagraph(words, tools[t].tip, 340);
            }
        }

        addParagraph(d, "Each tool is one undo: Ctrl+Z (Cmd+Z on a Mac) takes it back.", 380);

        var ok = flatButton(d, "OK", PRIMARY, [90, 26], 11, { name: "ok" });
        ok.alignment = ["right", "top"];
        ok.onClick = function () { d.close(1); };
        d.show();
    }

    // ------------------------------------------------------------------ panel

    function buildUI(thisObj) {
        var saved = readNumber(loadSetting("frames", "1"));
        state.frames = Math.round(clamp(saved === null ? 1 : saved, 1, MAX_FRAMES));

        var win = (thisObj instanceof Panel) ? thisObj :
            new Window("palette", NAME + " " + VERSION, undefined, { resizeable: true });
        win.orientation = "column";
        win.alignChildren = ["fill", "top"];
        win.spacing = 0;
        win.margins = 0;
        try { fontName = win.graphics.font.name || fontName; } catch (e) { }

        var light = null;
        var status = null;
        var statusKind = "idle";

        function setStatus(kind, text) {
            statusKind = C.hasOwnProperty(kind) ? kind : "idle";
            repaint(light);
            status.text = text;
            status.helpTip = text;
        }

        // Runs a tool as one undo step and reports how it went on the status line.
        function runTool(tool) {
            var grouped = !tool.ownUndo;
            if (grouped) app.beginUndoGroup(NAME + ": " + tool.label);
            try {
                var result = tool.run();
                setStatus(result.kind || "ok", result.text);
            } catch (err) {
                if (err && err.isUser) {
                    setStatus("error", err.message);
                } else {
                    setStatus("error", "Something went wrong in " + tool.label +
                        ". Press Ctrl+Z (Cmd+Z on a Mac) to undo anything it changed.");
                    alert("Something went wrong in " + tool.label + ": " + String(err) +
                        (err && err.line ? " (line " + err.line + ")" : ""), NAME);
                }
            } finally {
                if (grouped) app.endUndoGroup();
            }
        }

        // Header: logo, name, tagline, version and help on a dark bar, with the accent blend under it.
        var top = win.add("group");
        top.orientation = "column";
        top.alignChildren = ["fill", "top"];
        top.spacing = 0;
        top.margins = 0;
        var header = top.add("group");
        header.orientation = "row";
        header.alignChildren = ["left", "center"];
        header.margins = [12, 10, 10, 10];
        header.spacing = 10;
        setBackground(header, C.header);
        addLogo(header, 32);
        var titles = header.add("group");
        titles.orientation = "column";
        titles.alignChildren = ["left", "top"];
        titles.alignment = ["fill", "center"];
        titles.spacing = 1;
        addText(titles, "LAZY ANIMATOR", true, 15, C.title);
        addText(titles, TAGLINE, false, 10, C.subtitle);
        var badges = header.add("group");
        badges.orientation = "row";
        badges.alignChildren = ["right", "center"];
        badges.alignment = ["right", "center"];
        badges.spacing = 6;
        addPill(badges, "v" + VERSION);
        var helpButton = glyphButton(badges, "help", "What each tool does and what to select");
        helpButton.onClick = function () { showHelp(); };
        addGradientLine(top, 3);

        var body = win.add("group");
        body.orientation = "column";
        body.alignChildren = ["fill", "top"];
        body.margins = [12, 12, 12, 12];
        body.spacing = 12;

        // One block per section: a title and a row of three tiles.
        for (var s = 0; s < SECTIONS.length; s++) {
            var section = body.add("group");
            section.orientation = "column";
            section.alignChildren = ["fill", "top"];
            section.spacing = 6;
            addSectionHead(section, SECTIONS[s].title);
            var row = section.add("group");
            row.orientation = "row";
            row.alignChildren = ["fill", "fill"];
            row.spacing = 6;
            var tools = SECTIONS[s].tools;
            for (var t = 0; t < tools.length; t++) addTile(row, tools[t], runTool);
            if (SECTIONS[s].sequenceStep) addSequenceStep(section);
        }

        addDivider(body);

        var statusRow = body.add("group");
        statusRow.orientation = "row";
        statusRow.alignChildren = ["left", "top"];
        statusRow.spacing = 6;
        light = statusLight(statusRow, function () { return C[statusKind]; });
        status = statusRow.add("statictext", undefined, IDLE_TEXT, { multiline: true });
        status.alignment = ["fill", "top"];
        status.preferredSize = [240, 48];
        setStatus("idle", IDLE_TEXT);

        win.onResizing = win.onResize = function () { this.layout.resize(); };
        if (win instanceof Window) {
            win.center();
            win.show();
        } else {
            win.layout.layout(true);
            win.layout.resize();
        }
        return win;
    }

    // Sequence step: [-] frames [+], remembered between sessions.
    function addSequenceStep(parent) {
        var row = parent.add("group");
        row.orientation = "row";
        row.alignment = ["fill", "top"];
        row.alignChildren = ["left", "center"];
        row.spacing = 4;
        var tip = "How many frames each layer plays for when you click Sequence Layers.";
        var label = row.add("statictext", undefined, "Sequence step");
        label.helpTip = tip;
        var minus = glyphButton(row, "minus", "One frame less");
        var field = row.add("edittext", undefined, String(state.frames));
        field.characters = 4;
        field.helpTip = tip;
        var plus = glyphButton(row, "plus", "One frame more");
        var unit = row.add("statictext", undefined, "frames");

        function setFrames(v) {
            v = Math.round(clamp(v, 1, MAX_FRAMES));
            state.frames = v;
            field.text = String(v);
            unit.text = (v === 1) ? "frame" : "frames";
            saveSetting("frames", v);
        }
        minus.onClick = function () { setFrames(state.frames - 1); };
        plus.onClick = function () { setFrames(state.frames + 1); };
        field.onChange = function () {
            var v = readNumber(field.text);
            setFrames(v === null ? state.frames : v);
        };
        setFrames(state.frames);
        return row;
    }

    buildUI(thisObj);
})(this);
