/* * 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/SkColor.h" #include "include/core/SkM44.h" #include "include/core/SkPath.h" #include "include/core/SkString.h" #include "include/private/base/SkAssert.h" #include "src/base/SkUTF.h" namespace { SkPath make_cursor_path() { // Normalized values, relative to text/font size. constexpr float kWidth = 0.2f, kHeight = 0.75f; SkPath p; p.lineTo(kWidth , 0); p.moveTo(kWidth/2, 0); p.lineTo(kWidth/2, kHeight); p.moveTo(0 , kHeight); p.lineTo(kWidth , kHeight); return p; } size_t next_utf8(const SkString& str, size_t index) { SkASSERT(index < str.size()); const char* utf8_ptr = str.c_str() + index; if (SkUTF::NextUTF8(&utf8_ptr, str.c_str() + str.size()) < 0){ // Invalid UTF sequence. return index; } return utf8_ptr - str.c_str(); } size_t prev_utf8(const SkString& str, size_t index) { SkASSERT(index > 0); // Find the previous utf8 index by probing the preceding 4 offsets. Utf8 leading bytes are // always distinct from continuation bytes, so only one of these probes will succeed. for (unsigned i = 1; i <= SkUTF::kMaxBytesInUTF8Sequence && i <= index; ++i) { const char* utf8_ptr = str.c_str() + index - i; if (SkUTF::NextUTF8(&utf8_ptr, str.c_str() + str.size()) >= 0) { return index - i; } } // Invalid UTF sequence. return index; } } // namespace SkottieTextEditor::SkottieTextEditor( std::unique_ptr&& prop, std::vector>&& deps) : fTextProp(std::move(prop)) , fDependentProps(std::move(deps)) , fCursorPath(make_cursor_path()) , fCursorBounds(fCursorPath.computeTightBounds()) {} SkottieTextEditor::~SkottieTextEditor() = default; void SkottieTextEditor::toggleEnabled() { fEnabled = !fEnabled; auto txt = fTextProp->get(); txt.fDecorator = fEnabled ? sk_ref_sp(this) : nullptr; fTextProp->set(txt); if (fEnabled) { // Always reset the cursor position to the end. fCursorIndex = txt.fText.size(); } fTimeBase = std::chrono::steady_clock::now(); } std::tuple SkottieTextEditor::currentSelection() const { // Selection can be inverted. return std::make_tuple(std::min(std::get<0>(fSelection), std::get<1>(fSelection)), std::max(std::get<0>(fSelection), std::get<1>(fSelection))); } size_t SkottieTextEditor::closestGlyph(const SkPoint& pt) const { float min_distance = std::numeric_limits::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::drawCursor(SkCanvas* canvas, const GlyphInfo glyphs[], size_t size) const { constexpr double kCursorHz = 2; const auto now_ms = std::chrono::duration_cast( std::chrono::steady_clock::now() - fTimeBase).count(); const long cycle = static_cast(static_cast(now_ms) * 0.001 * kCursorHz); if (cycle & 1) { // blink return; } auto txt_prop = fTextProp->get(); const auto glyph_index = [&]() -> size_t { if (!fCursorIndex) { return 0; } const auto prev_index = prev_utf8(txt_prop.fText, fCursorIndex); for (size_t i = 0; i < size; ++i) { if (glyphs[i].fCluster >= prev_index) { return i; } } return size - 1; }(); const auto& glyph_bounds = glyphs[glyph_index].fBounds; // Cursor index mapping: // 0 -> before the first char // 1 -> after the first char // 2 -> after the second char // ... // The cursor is bottom-aligned, and centered to the right/left edge of the glyph bounding box. const auto cscale = txt_prop.fTextSize, cxpos = (fCursorIndex ? glyph_bounds.fRight : glyph_bounds.fLeft) - fCursorBounds.width() * cscale * 0.5f, cypos = glyph_bounds.fBottom - fCursorBounds.height() * cscale; const auto cpath = fCursorPath.makeTransform(SkMatrix::Translate(cxpos, cypos) * SkMatrix::Scale(cscale, cscale)); SkPaint p; p.setAntiAlias(true); p.setStyle(SkPaint::kStroke_Style); p.setStrokeCap(SkPaint::kRound_Cap); SkAutoCanvasRestore acr(canvas, true); canvas->concat(glyphs[glyph_index].fMatrix); p.setColor(SK_ColorWHITE); p.setStrokeWidth(3); canvas->drawPath(cpath, p); p.setColor(SK_ColorBLACK); p.setStrokeWidth(2); canvas->drawPath(cpath, p); } void SkottieTextEditor::updateDeps(const SkString& txt) { for (const auto& dep : fDependentProps) { auto txt_prop = dep->get(); txt_prop.fText = txt; dep->set(txt_prop); } } void SkottieTextEditor::insertChar(SkUnichar c) { auto txt = fTextProp->get(); const auto initial_size = txt.fText.size(); txt.fText.insertUnichar(fCursorIndex, c); fCursorIndex += txt.fText.size() - initial_size; fTextProp->set(txt); this->updateDeps(txt.fText); } void SkottieTextEditor::deleteChars(size_t offset, size_t count) { auto txt = fTextProp->get(); txt.fText.remove(offset, count); fTextProp->set(txt); this->updateDeps(txt.fText); fCursorIndex = offset; } bool SkottieTextEditor::deleteSelection() { const auto [glyph_sel_start, glyph_sel_end] = this->currentSelection(); if (glyph_sel_start == glyph_sel_end) { return false; } const auto utf8_sel_start = fGlyphData[glyph_sel_start].fCluster, utf8_sel_end = fGlyphData[glyph_sel_end ].fCluster; SkASSERT(utf8_sel_start < utf8_sel_end); this->deleteChars(utf8_sel_start, utf8_sel_end - utf8_sel_start); fSelection = {0,0}; return true; } void SkottieTextEditor::onDecorate(SkCanvas* canvas, const GlyphInfo glyphs[], size_t size) { const auto [sel_start, sel_end] = this->currentSelection(); 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), ginfo.fCluster }); if (i < sel_start || i >= sel_end) { continue; } static constexpr SkColor4f kSelectionColor{0, 0, 1, 0.4f}; canvas->drawRect(ginfo.fBounds, SkPaint(kSelectionColor)); } // Only draw the cursor when there's no active selection. if (sel_start == sel_end) { this->drawCursor(canvas, glyphs, size); } } 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; } bool SkottieTextEditor::onCharInput(SkUnichar c) { if (!fEnabled || fGlyphData.empty()) { return false; } const auto& txt_str = fTextProp->get().fText; // Natural editor bindings are currently intercepted by Viewer, so we use these instead. switch (c) { case '|': // commit changes and exit editing mode this->toggleEnabled(); break; case ']': { // move right if (fCursorIndex < txt_str.size()) { fCursorIndex = next_utf8(txt_str, fCursorIndex); } } break; case '[': // move left if (fCursorIndex > 0) { fCursorIndex = prev_utf8(txt_str, fCursorIndex); } break; case '\\': { // delete if (!this->deleteSelection() && fCursorIndex > 0) { // Delete preceding char. const auto del_index = prev_utf8(txt_str, fCursorIndex), del_count = fCursorIndex - del_index; this->deleteChars(del_index, del_count); } } break; default: // Delete any selection on insert. this->deleteSelection(); this->insertChar(c); break; } // Reset the cursor blink timer on input. fTimeBase = std::chrono::steady_clock::now(); return true; }