[skottie] Initial text editing experiment

Introduce an optional GlyphDecorator callback, executed when rendering
each individual glyph.

Start experimenting with a sample editor implementation for Viewer
(selection-only so far).

Bug: b/259298707
Change-Id: I5737c91f4fc56d8877b7919009d889865aac1135
Reviewed-on: https://skia-review.googlesource.com/c/skia/+/606516
Reviewed-by: Jorge Betancourt <jmbetancourt@google.com>
Commit-Queue: Florin Malita <fmalita@google.com>
Commit-Queue: Florin Malita <fmalita@chromium.org>
diff --git a/BUILD.gn b/BUILD.gn
index 1fea0b8..1c3bc74 100644
--- a/BUILD.gn
+++ b/BUILD.gn
@@ -3044,6 +3044,8 @@
         "tools/viewer/SkSLSlide.h",
         "tools/viewer/SkottieSlide.cpp",
         "tools/viewer/SkottieSlide.h",
+        "tools/viewer/SkottieTextEditor.cpp",
+        "tools/viewer/SkottieTextEditor.h",
         "tools/viewer/Slide.h",
         "tools/viewer/SlideDir.cpp",
         "tools/viewer/SlideDir.h",
diff --git a/modules/skottie/include/SkottieProperty.h b/modules/skottie/include/SkottieProperty.h
index b9ccbb4..625c7d6 100644
--- a/modules/skottie/include/SkottieProperty.h
+++ b/modules/skottie/include/SkottieProperty.h
@@ -9,16 +9,19 @@
 #define SkottieProperty_DEFINED
 
 #include "include/core/SkColor.h"
+#include "include/core/SkMatrix.h"
 #include "include/core/SkPaint.h"
 #include "include/core/SkPoint.h"
+#include "include/core/SkRect.h"
 #include "include/core/SkRefCnt.h"
 #include "include/core/SkTypeface.h"
 #include "include/utils/SkTextUtils.h"
 #include "modules/skottie/src/text/SkottieShaper.h"
 
 #include <functional>
+#include <vector>
 
-class SkMatrix;
+class SkCanvas;
 
 namespace sksg {
 
@@ -37,6 +40,19 @@
     kStrokeFill,
 };
 
+// EXPERIMENTAL
+// Optional callback invoked when drawing text layers.
+// Allows clients to render custom text decorations.
+class GlyphDecorator : public SkRefCnt {
+public:
+    struct GlyphInfo {
+        SkRect   fBounds;  // visual glyph bounds
+        SkMatrix fMatrix;  // glyph matrix
+    };
+
+    virtual void onDecorate(SkCanvas*, const GlyphInfo[], size_t size) = 0;
+};
+
 struct TextPropertyValue {
     sk_sp<SkTypeface>       fTypeface;
     SkString                fText;
@@ -61,6 +77,7 @@
     SkPaint::Join           fStrokeJoin     = SkPaint::Join::kMiter_Join;
     bool                    fHasFill        = false,
                             fHasStroke      = false;
+    sk_sp<GlyphDecorator>   fDecorator;
 
     bool operator==(const TextPropertyValue& other) const;
     bool operator!=(const TextPropertyValue& other) const;
diff --git a/modules/skottie/src/SkottieProperty.cpp b/modules/skottie/src/SkottieProperty.cpp
index 2dddc9a..db1d19e 100644
--- a/modules/skottie/src/SkottieProperty.cpp
+++ b/modules/skottie/src/SkottieProperty.cpp
@@ -35,7 +35,8 @@
         && fPaintOrder == other.fPaintOrder
         && fStrokeJoin == other.fStrokeJoin
         && fHasFill == other.fHasFill
-        && fHasStroke == other.fHasStroke;
+        && fHasStroke == other.fHasStroke
+        && fDecorator == other.fDecorator;
 }
 
 bool TextPropertyValue::operator!=(const TextPropertyValue& other) const {
diff --git a/modules/skottie/src/SkottieTest.cpp b/modules/skottie/src/SkottieTest.cpp
index ed30dc3..4018b00 100644
--- a/modules/skottie/src/SkottieTest.cpp
+++ b/modules/skottie/src/SkottieTest.cpp
@@ -331,7 +331,8 @@
       TextPaintOrder::kFillStroke,
       SkPaint::Join::kDefault_Join,
       false,
-      false
+      false,
+      nullptr
     }));
 }
 
diff --git a/modules/skottie/src/text/TextAdapter.cpp b/modules/skottie/src/text/TextAdapter.cpp
index b422888..29b15a2 100644
--- a/modules/skottie/src/text/TextAdapter.cpp
+++ b/modules/skottie/src/text/TextAdapter.cpp
@@ -22,19 +22,25 @@
 #include "modules/sksg/include/SkSGPath.h"
 #include "modules/sksg/include/SkSGRect.h"
 #include "modules/sksg/include/SkSGRenderEffect.h"
+#include "modules/sksg/include/SkSGRenderNode.h"
 #include "modules/sksg/include/SkSGTransform.h"
+#include "modules/sksg/src/SkSGTransformPriv.h"
 
 // Enable for text layout debugging.
 #define SHOW_LAYOUT_BOXES 0
 
 namespace skottie::internal {
 
+namespace {
+
 class GlyphTextNode final : public sksg::GeometryNode {
 public:
-    explicit GlyphTextNode(skottie::Shaper::ShapedGlyphs&& glyphs) : fGlyphs(std::move(glyphs)) {}
+    explicit GlyphTextNode(Shaper::ShapedGlyphs&& glyphs) : fGlyphs(std::move(glyphs)) {}
 
     ~GlyphTextNode() override = default;
 
+    const Shaper::ShapedGlyphs* glyphs() const { return &fGlyphs; }
+
 protected:
     SkRect onRevalidate(sksg::InvalidationController*, const SkMatrix&) override {
         return fGlyphs.computeBounds(Shaper::ShapedGlyphs::BoundsType::kConservative);
@@ -58,7 +64,7 @@
     }
 
 private:
-    const skottie::Shaper::ShapedGlyphs fGlyphs;
+    const Shaper::ShapedGlyphs fGlyphs;
 };
 
 static float align_factor(SkTextUtils::Align a) {
@@ -71,6 +77,67 @@
     SkUNREACHABLE;
 }
 
+} // namespace
+
+class TextAdapter::GlyphDecoratorNode final : public sksg::Group {
+public:
+    GlyphDecoratorNode(sk_sp<GlyphDecorator> decorator)
+        : fDecorator(std::move(decorator))
+    {}
+
+    ~GlyphDecoratorNode() override = default;
+
+    void updateFragmentData(const std::vector<TextAdapter::FragmentRec>& recs) {
+        fFragCount = recs.size();
+
+        SkASSERT(!fFragInfo);
+        fFragInfo = std::make_unique<FragmentInfo[]>(recs.size());
+
+        for (size_t i = 0; i < recs.size(); ++i) {
+            const auto& rec = recs[i];
+            fFragInfo[i] = {rec.fOrigin, rec.fGlyphs, rec.fMatrixNode};
+        }
+
+        SkASSERT(!fDecoratorInfo);
+        fDecoratorInfo = std::make_unique<GlyphDecorator::GlyphInfo[]>(recs.size());
+    }
+
+    SkRect onRevalidate(sksg::InvalidationController* ic, const SkMatrix& ctm) override {
+        const auto child_bounds = INHERITED::onRevalidate(ic, ctm);
+
+        for (size_t i = 0; i < fFragCount; ++i) {
+            fDecoratorInfo[i].fBounds =
+                    fFragInfo[i].fGlyphs->computeBounds(Shaper::ShapedGlyphs::BoundsType::kTight);
+            fDecoratorInfo[i].fMatrix = sksg::TransformPriv::As<SkMatrix>(fFragInfo[i].fMatrixNode);
+        }
+
+        return child_bounds;
+    }
+
+    void onRender(SkCanvas* canvas, const RenderContext* ctx) const override {
+        auto local_ctx = ScopedRenderContext(canvas, ctx).setIsolation(this->bounds(),
+                                                                       canvas->getTotalMatrix(),
+                                                                       true);
+        this->INHERITED::onRender(canvas, local_ctx);
+
+        fDecorator->onDecorate(canvas, fDecoratorInfo.get(), fFragCount);
+    }
+
+private:
+    struct FragmentInfo {
+        SkPoint                     fOrigin;
+        const Shaper::ShapedGlyphs* fGlyphs;
+        sk_sp<sksg::Matrix<SkM44>>  fMatrixNode;
+    };
+
+    const sk_sp<GlyphDecorator>                  fDecorator;
+    std::unique_ptr<FragmentInfo[]>              fFragInfo;
+    std::unique_ptr<GlyphDecorator::GlyphInfo[]> fDecoratorInfo;
+    size_t                                       fFragCount;
+
+    using INHERITED = Group;
+};
+
 // Text path semantics
 //
 //   * glyphs are positioned on the path based on their horizontal/x anchor point, interpreted as
@@ -365,7 +432,7 @@
     return draws;
 }
 
-void TextAdapter::addFragment(Shaper::Fragment& frag) {
+void TextAdapter::addFragment(Shaper::Fragment& frag, sksg::Group* container) {
     // For a given shaped fragment, build a corresponding SG fragment:
     //
     //   [TransformEffect] -> [Transform]
@@ -390,9 +457,11 @@
     // Note: comp glyph IDs are still present in the list, but they don't draw anything
     //       (using empty path in SkCustomTypeface).
     auto text_node = sk_make_sp<GlyphTextNode>(std::move(frag.fGlyphs));
+    rec.fGlyphs = text_node->glyphs();
 
     draws.reserve(draws.size() +
-                  static_cast<size_t>(fText->fHasFill) + static_cast<size_t>(fText->fHasStroke));
+                  static_cast<size_t>(fText->fHasFill) +
+                  static_cast<size_t>(fText->fHasStroke));
 
     SkASSERT(fText->fHasFill || fText->fHasStroke);
 
@@ -446,7 +515,7 @@
         draws_node = sksg::ImageFilterEffect::Make(std::move(draws_node), rec.fBlur);
     }
 
-    fRoot->addChild(sksg::TransformEffect::Make(std::move(draws_node), rec.fMatrixNode));
+    container->addChild(sksg::TransformEffect::Make(std::move(draws_node), rec.fMatrixNode));
     fFragments.push_back(std::move(rec));
 }
 
@@ -522,7 +591,8 @@
     //   - when animating
     //   - when positioning on a path
     //   - when clamping the number or lines (for accurate line count)
-    if (!fAnimators.empty() || fPathInfo || fText->fMaxLines) {
+    //   - when a text decorator is present
+    if (!fAnimators.empty() || fPathInfo || fText->fMaxLines || fText->fDecorator) {
         flags |= Shaper::Flags::kFragmentGlyphs;
     }
 
@@ -614,10 +684,24 @@
         }
     }
 
+    // Depending on whether a GlyphDecorator is present, we either add the glyph render nodes
+    // directly to the root group, or to an intermediate GlyphDecoratorNode container.
+    sksg::Group* container = fRoot.get();
+    sk_sp<GlyphDecoratorNode> decorator_node;
+    if (fText->fDecorator) {
+        decorator_node = sk_make_sp<GlyphDecoratorNode>(fText->fDecorator);
+        container = decorator_node.get();
+    }
+
     // N.B. addFragment moves shaped glyph data out of the fragment, so only the fragment
     // metrics are valid after this block.
-    for (auto& frag : shape_result.fFragments) {
-        this->addFragment(frag);
+    for (size_t i = 0; i < shape_result.fFragments.size(); ++i) {
+        this->addFragment(shape_result.fFragments[i], container);
+    }
+
+    if (decorator_node) {
+        decorator_node->updateFragmentData(fFragments);
+        fRoot->addChild(std::move(decorator_node));
     }
 
     if (!fAnimators.empty() || fPathInfo) {
diff --git a/modules/skottie/src/text/TextAdapter.h b/modules/skottie/src/text/TextAdapter.h
index 8ab28e9..5a53ffd 100644
--- a/modules/skottie/src/text/TextAdapter.h
+++ b/modules/skottie/src/text/TextAdapter.h
@@ -47,6 +47,8 @@
     void onSync() override;
 
 private:
+    class GlyphDecoratorNode;
+
     enum class AnchorPointGrouping : uint8_t {
         kCharacter,
         kWord,
@@ -62,6 +64,7 @@
     struct FragmentRec {
         SkPoint                      fOrigin; // fragment position
 
+        const Shaper::ShapedGlyphs*  fGlyphs = nullptr;
         sk_sp<sksg::Matrix<SkM44>>   fMatrixNode;
         sk_sp<sksg::Color>           fFillColorNode,
                                      fStrokeColorNode;
@@ -72,7 +75,7 @@
     };
 
     void reshape();
-    void addFragment(Shaper::Fragment&);
+    void addFragment(Shaper::Fragment&, sksg::Group* container);
     void buildDomainMaps(const Shaper::Result&);
     std::vector<sk_sp<sksg::RenderNode>> buildGlyphCompNodes(Shaper::Fragment&) const;
 
diff --git a/tools/viewer/SkottieSlide.cpp b/tools/viewer/SkottieSlide.cpp
index e15bc65..d37ebcd 100644
--- a/tools/viewer/SkottieSlide.cpp
+++ b/tools/viewer/SkottieSlide.cpp
@@ -25,6 +25,7 @@
 #include "src/utils/SkOSPath.h"
 #include "tools/Resources.h"
 #include "tools/timer/TimeUtils.h"
+#include "tools/viewer/SkottieTextEditor.h"
 
 #include <cmath>
 #include <vector>
@@ -192,6 +193,28 @@
     { "Nested Skotties",     ParticleMarker::MakeSkottie },
 };
 
+class TextTracker final : public skottie::PropertyObserver {
+public:
+    explicit TextTracker(sk_sp<PropertyObserver> delegate) : fDelegate(std::move(delegate)) {}
+
+    std::vector<std::unique_ptr<skottie::TextPropertyHandle>>& props() {
+        return fTextProps;
+    }
+
+private:
+    void onTextProperty(const char node_name[],
+                        const LazyHandle<skottie::TextPropertyHandle>& lh) override {
+        fTextProps.push_back(lh());
+
+        if (fDelegate) {
+            fDelegate->onTextProperty(node_name, lh);
+        }
+    }
+
+    const sk_sp<PropertyObserver>                             fDelegate;
+    std::vector<std::unique_ptr<skottie::TextPropertyHandle>> fTextProps;
+};
+
 } // namespace
 
 class SkottieSlide::TransformTracker : public skottie::PropertyObserver {
@@ -481,6 +504,8 @@
                                                                            kInterceptPrefix);
 
     fTransformTracker = sk_make_sp<TransformTracker>();
+    auto text_tracker = sk_make_sp<TextTracker>(fTransformTracker);
+
     if (!fSlotManagerWrapper) {
         fSlotManagerWrapper = std::make_unique<SlotManagerWrapper>(resource_provider, this);
     }
@@ -494,7 +519,7 @@
                .setPropertyObserver(fSlotManagerWrapper->getPropertyObserver());
     } else {
         builder.setResourceProvider(std::move(resource_provider))
-               .setPropertyObserver(fTransformTracker);
+               .setPropertyObserver(text_tracker);
     }
     fAnimation = builder.makeFromFile(fPath.c_str());
     fAnimationStats = builder.getStats();
@@ -508,6 +533,12 @@
                  fAnimation->size().width(),
                  fAnimation->size().height());
         logger->report();
+
+        // Create an editor for the first text layer only.
+        // TODO: editors for all layers?
+        if (!text_tracker->props().empty()) {
+            fTextEditor = sk_make_sp<SkottieTextEditor>(std::move(text_tracker->props()[0]));
+        }
     } else {
         SkDebugf("failed to load Bodymovin animation: %s\n", fPath.c_str());
     }
@@ -628,12 +659,19 @@
     case 'M':
         fShowSlotManager = !fShowSlotManager;
         return true;
+    case 'E':
+        fTextEditor->toggleEnabled();
+        return true;
     }
 
     return INHERITED::onChar(c);
 }
 
-bool SkottieSlide::onMouse(SkScalar x, SkScalar y, skui::InputState state, skui::ModifierKey) {
+bool SkottieSlide::onMouse(SkScalar x, SkScalar y, skui::InputState state, skui::ModifierKey mod) {
+    if (fTextEditor->onMouseInput(x, y, state, mod)) {
+        return true;
+    }
+
     switch (state) {
     case skui::InputState::kUp:
         fShowAnimationInval = !fShowAnimationInval;
diff --git a/tools/viewer/SkottieSlide.h b/tools/viewer/SkottieSlide.h
index 8115da5..7464dc1 100644
--- a/tools/viewer/SkottieSlide.h
+++ b/tools/viewer/SkottieSlide.h
@@ -16,6 +16,8 @@
 
 #include <vector>
 
+class SkottieTextEditor;
+
 namespace sksg    { class Scene;     }
 
 class SkottieSlide : public Slide {
@@ -50,6 +52,7 @@
     sksg::InvalidationController       fInvalController;
     sk_sp<TransformTracker>            fTransformTracker;
     std::unique_ptr<SlotManagerWrapper>fSlotManagerWrapper;
+    sk_sp<SkottieTextEditor>           fTextEditor;
     std::vector<float>                 fFrameTimes;
     SkSize                             fWinSize              = SkSize::MakeEmpty();
     double                             fTimeBase             = 0,
diff --git a/tools/viewer/SkottieTextEditor.cpp b/tools/viewer/SkottieTextEditor.cpp
new file mode 100644
index 0000000..67eb1ce
--- /dev/null
+++ b/tools/viewer/SkottieTextEditor.cpp
@@ -0,0 +1,97 @@
+/*
+ * Copyright 2022 Google Inc.
+ *
+ * Use of this source code is governed by a BSD-style license that can be
+ * found in the LICENSE file.
+ */
+
+#include "tools/viewer/SkottieTextEditor.h"
+
+#include "include/core/SkCanvas.h"
+#include "include/core/SkM44.h"
+
+SkottieTextEditor::SkottieTextEditor(std::unique_ptr<skottie::TextPropertyHandle>&& prop)
+    : fTextProp(std::move(prop))
+{}
+
+SkottieTextEditor::~SkottieTextEditor() = default;
+
+void SkottieTextEditor::toggleEnabled() {
+    fEnabled = !fEnabled;
+
+    auto txt = fTextProp->get();
+    txt.fDecorator = fEnabled ? sk_ref_sp(this) : nullptr;
+    fTextProp->set(txt);
+}
+
+
+size_t SkottieTextEditor::closestGlyph(const SkPoint& pt) const {
+    float  min_distance = std::numeric_limits<float>::max();
+    size_t min_index    = 0;
+
+    for (size_t i = 0; i < fGlyphData.size(); ++i) {
+        const auto dist = (fGlyphData[i].fDevBounds.center() - pt).length();
+        if (dist < min_distance) {
+            min_distance = dist;
+            min_index = i;
+        }
+    }
+
+    return min_index;
+}
+
+void SkottieTextEditor::onDecorate(SkCanvas* canvas, const GlyphInfo glyphs[], size_t size) {
+    // Selection can be inverted.
+    const auto sel_start = std::min(std::get<0>(fSelection), std::get<1>(fSelection)),
+               sel_end   = std::max(std::get<0>(fSelection), std::get<1>(fSelection));
+
+    fGlyphData.clear();
+
+    for (size_t i = 0; i < size; ++i) {
+        const auto& ginfo = glyphs[i];
+
+        SkAutoCanvasRestore acr(canvas, true);
+        canvas->concat(ginfo.fMatrix);
+
+        // Stash some glyph info, for later use.
+        fGlyphData.push_back({canvas->getLocalToDevice().asM33().mapRect(ginfo.fBounds)});
+
+        if (i < sel_start || i >= sel_end) {
+            continue;
+        }
+
+        static constexpr SkColor4f kSelectionColor{0, 0, 1, 0.4f};
+        canvas->drawRect(ginfo.fBounds, SkPaint(kSelectionColor));
+    }
+}
+
+bool SkottieTextEditor::onMouseInput(SkScalar x, SkScalar y, skui::InputState state,
+                                     skui::ModifierKey) {
+    if (!fEnabled || fGlyphData.empty()) {
+        return false;
+    }
+
+    switch (state) {
+    case skui::InputState::kDown: {
+        fMouseDown = true;
+
+        const auto closest = this->closestGlyph({x, y});
+        fSelection = {closest, closest};
+    } break;
+    case skui::InputState::kUp:
+        fMouseDown = false;
+        break;
+    case skui::InputState::kMove:
+        if (fMouseDown) {
+            const auto closest = this->closestGlyph({x, y});
+            std::get<1>(fSelection) = closest < std::get<0>(fSelection)
+                                            ? closest
+                                            : closest + 1;
+        }
+        break;
+    default:
+        break;
+    }
+
+    return true;
+}
diff --git a/tools/viewer/SkottieTextEditor.h b/tools/viewer/SkottieTextEditor.h
new file mode 100644
index 0000000..dd7cdef
--- /dev/null
+++ b/tools/viewer/SkottieTextEditor.h
@@ -0,0 +1,42 @@
+/*
+ * Copyright 2022 Google Inc.
+ *
+ * Use of this source code is governed by a BSD-style license that can be
+ * found in the LICENSE file.
+ */
+
+#ifndef SkottieTextEditor_DEFINED
+#define SkottieTextEditor_DEFINED
+
+#include "modules/skottie/include/SkottieProperty.h"
+#include "tools/skui/InputState.h"
+#include "tools/skui/ModifierKey.h"
+
+// A sample WYSIWYG text editor built using the GlyphDecorator API.
+class SkottieTextEditor final : public skottie::GlyphDecorator {
+public:
+    explicit SkottieTextEditor(std::unique_ptr<skottie::TextPropertyHandle>&&);
+    ~SkottieTextEditor() override;
+
+    void toggleEnabled();
+
+    void onDecorate(SkCanvas*, const GlyphInfo[], size_t) override;
+
+    bool onMouseInput(SkScalar x, SkScalar y, skui::InputState state, skui::ModifierKey);
+
+private:
+    struct GlyphData {
+        SkRect fDevBounds; // Glyph bounds mapped to device space.
+    };
+
+    size_t closestGlyph(const SkPoint& pt) const;
+
+    const std::unique_ptr<skottie::TextPropertyHandle> fTextProp;
+
+    std::vector<GlyphData>     fGlyphData;
+    std::tuple<size_t, size_t> fSelection = {0, std::numeric_limits<size_t>::max()};
+    bool                       fEnabled   = false;
+    bool                       fMouseDown = false;
+};
+
+#endif // SkottieTextEditor_DEFINED