blob: 6051a8f73dca15c32dd09d80ecf6d332f643ae30 [file] [edit]
#include "rive/text/text_selection_controller.hpp"
#ifdef WITH_RIVE_TEXT
#include "rive/artboard.hpp"
#include "rive/nested_artboard.hpp"
#include "rive/artboard_component_list.hpp"
#include "rive/factory.hpp"
#include "rive/file.hpp"
#include "rive/text/text.hpp"
#include "rive/text/text_selection_path.hpp"
#include "rive/text/utf.hpp"
#include <algorithm>
#include <cmath>
#include <limits>
using namespace rive;
struct TextSelectionController::Participant
{
Text* text;
uint32_t id = 0;
std::string separatorBefore;
std::vector<Unichar> source;
GlyphLookup lookup;
Cursor cursor = Cursor::zero();
std::vector<AABB> rects;
TextSelectionPath path;
rcp<RenderPaint> paint;
bool lookupDirty = true;
bool geometryDirty = true;
float radius = -1;
#ifdef TESTING
size_t lookupBuilds = 0;
#endif
Participant(Text* text, std::string separator) :
text(text), separatorBefore(std::move(separator))
{}
};
// rootTransform includes every nested host and its artboard's self transform.
// Sampling the affine basis also covers Dart-mounted artboard callbacks.
static Mat2D shapeToRoot(Text* text)
{
auto* artboard = text->artboard();
auto origin = artboard->rootTransform(Vec2D(0, 0));
auto x = artboard->rootTransform(Vec2D(1, 0)) - origin;
auto y = artboard->rootTransform(Vec2D(0, 1)) - origin;
return Mat2D(x.x, x.y, y.x, y.y, origin.x, origin.y) *
text->shapeWorldTransform();
}
static bool finite(Vec2D position)
{
return std::isfinite(position.x) && std::isfinite(position.y);
}
static AABB lineBounds(const OrderedLine& line)
{
float x = line.glyphLine().startX;
auto bounds = AABB::forExpansion();
for (auto glyph : line)
{
auto* run = std::get<0>(glyph);
auto index = std::get<1>(glyph);
AABB::expandTo(bounds,
Vec2D(x, line.y() + run->font->ascent(run->size)));
x += run->advances[index];
AABB::expandTo(bounds,
Vec2D(x, line.y() + run->font->descent(run->size)));
}
return bounds;
}
TextSelectionController::TextSelectionController() = default;
TextSelectionController::~TextSelectionController()
{
for (auto& participant : m_participants)
{
participant->text->m_selectionController = nullptr;
}
}
TextSelectionController::Participant* TextSelectionController::find(
Text* text) const
{
for (auto& participant : m_participants)
{
if (participant->text == text)
{
return participant.get();
}
}
return nullptr;
}
bool TextSelectionController::add(Text* text, std::string separatorBefore)
{
if (text == nullptr || text->artboard() == nullptr ||
text->m_selectionController != nullptr || text->haveModifiers() ||
text->overflow() == TextOverflow::ellipsis)
{
return false;
}
clear();
auto participant =
std::make_unique<Participant>(text, std::move(separatorBefore));
participant->source = text->m_styledText.unichars();
participant->id = m_nextTargetId++;
text->m_selectionController = this;
m_participants.push_back(std::move(participant));
return true;
}
void TextSelectionController::remove(Text* text)
{
auto it = std::find_if(m_participants.begin(),
m_participants.end(),
[text](const std::unique_ptr<Participant>& p) {
return p->text == text;
});
if (it == m_participants.end())
{
return;
}
clear();
text->m_selectionController = nullptr;
m_participants.erase(it);
}
bool TextSelectionController::eligible(const Participant& participant) const
{
auto* text = participant.text;
return !text->isHidden() && text->renderOpacity() > 0 &&
!text->haveModifiers() &&
text->overflow() != TextOverflow::ellipsis &&
!text->m_orderedLines.empty() && !participant.source.empty();
}
static void collectSelectable(Artboard* artboard, std::vector<Text*>& result)
{
if (artboard == nullptr)
return;
for (auto* object : artboard->objects())
{
if (object != nullptr && object->is<Text>() &&
object->as<Text>()->isSelectable())
result.push_back(object->as<Text>());
}
for (auto* nested : artboard->nestedArtboards())
collectSelectable(nested->artboardInstance(), result);
for (auto* list : artboard->artboardComponentLists())
for (size_t i = 0; i < list->artboardCount(); ++i)
collectSelectable(list->artboardInstance((int)i), result);
}
void TextSelectionController::synchronize(Artboard* root)
{
m_styleFile = root != nullptr && root->isInstance()
? static_cast<ArtboardInstance*>(root)->file()
: nullptr;
std::vector<Text*> texts;
collectSelectable(root, texts);
std::vector<Text*> removed;
for (const auto& p : m_participants)
if (std::find(texts.begin(), texts.end(), p->text) == texts.end())
removed.push_back(p->text);
for (auto* text : removed)
remove(text);
for (auto* text : texts)
if (find(text) == nullptr)
add(text);
}
std::vector<uint32_t> TextSelectionController::targetIds() const
{
std::vector<uint32_t> ids;
for (const auto& p : m_participants)
ids.push_back(p->id);
return ids;
}
Text* TextSelectionController::target(uint32_t id) const
{
for (const auto& p : m_participants)
if (p->id == id)
return p->text;
return nullptr;
}
bool TextSelectionController::isEligible(Text* text) const
{
auto* p = find(text);
return p != nullptr && eligible(*p);
}
std::string TextSelectionController::sourceText(Text* text) const
{
auto* p = find(text);
std::string result;
if (p == nullptr)
return result;
for (auto ch : p->source)
{
uint8_t bytes[4];
auto count = UTF::Encode(bytes, ch);
result.append(reinterpret_cast<char*>(bytes), count);
}
return result;
}
AABB TextSelectionController::rootBounds(Text* text) const
{
auto* p = find(text);
AABB result = AABB::forExpansion();
if (p == nullptr || !eligible(*p))
return result;
auto transform = shapeToRoot(text);
for (const auto& line : text->m_orderedLines)
{
auto b = lineBounds(line);
if (b.isEmptyOrNaN())
continue;
AABB::expandTo(result, transform * Vec2D(b.minX, b.minY));
AABB::expandTo(result, transform * Vec2D(b.maxX, b.minY));
AABB::expandTo(result, transform * Vec2D(b.minX, b.maxY));
AABB::expandTo(result, transform * Vec2D(b.maxX, b.maxY));
}
return result;
}
bool TextSelectionController::hitTest(Text* text,
Vec2D point,
bool nearest,
uint32_t& offset,
float& distanceSquared)
{
auto* p = find(text);
if (p == nullptr || !eligible(*p) || !finite(point))
return false;
auto transform = shapeToRoot(text);
Mat2D inverse;
if (!transform.invert(&inverse))
return false;
auto local = inverse * point;
if (!finite(local))
return false;
if (!nearest)
{
auto artboardPoint = text->shapeWorldTransform() * local;
if (!text->hitTestPoint(artboardPoint, false, true))
return false;
if (text->overflow() == TextOverflow::clipped)
{
Mat2D inverseWorld;
if (!text->worldTransform().invert(&inverseWorld) ||
!text->localBounds().contains(inverseWorld * artboardPoint))
return false;
}
}
bool found = false;
distanceSquared = std::numeric_limits<float>::infinity();
uint32_t index = 0;
for (const auto& line : text->m_orderedLines)
{
auto b = lineBounds(line);
if (!b.isEmptyOrNaN() && (nearest || b.contains(local)))
{
Vec2D closest(std::max(b.minX, std::min(local.x, b.maxX)),
std::max(b.minY, std::min(local.y, b.maxY)));
auto delta = transform * closest - point;
auto distance = delta.x * delta.x + delta.y * delta.y;
if (distance < distanceSquared)
{
distanceSquared = distance;
offset = CursorPosition::fromLineX(index, local.x, layout(*p))
.codePointIndex();
found = true;
}
}
++index;
}
return found;
}
void TextSelectionController::setRange(Text* text, uint32_t start, uint32_t end)
{
auto* p = find(text);
if (p == nullptr)
return;
if (!m_externalRanges)
clear();
m_externalRanges = true;
auto length = (uint32_t)p->source.size();
start = std::min(start, length);
end = std::min(end, length);
if (p->cursor.first().codePointIndex() == std::min(start, end) &&
p->cursor.last().codePointIndex() == std::max(start, end))
return;
p->cursor = Cursor(CursorPosition(start), CursorPosition(end));
p->geometryDirty = true;
}
TextLayoutView TextSelectionController::layout(Participant& participant)
{
auto* text = participant.text;
if (participant.lookupDirty)
{
participant.lookup.compute(text->m_styledText.unichars(),
text->m_shape);
participant.lookupDirty = false;
#ifdef TESTING
participant.lookupBuilds++;
#endif
}
return TextLayoutView(text->m_shape,
text->m_lines,
text->m_orderedLines,
participant.lookup,
(uint32_t)participant.source.size());
}
void TextSelectionController::textUpdated(Text* text, bool shapeChanged)
{
auto* participant = find(text);
if (participant == nullptr)
{
return;
}
if (shapeChanged)
{
const auto& source = text->m_styledText.unichars();
if (participant->source != source)
{
clear();
participant->source = source;
}
participant->lookupDirty = true;
}
participant->geometryDirty = true;
if (m_anchor.participant != nullptr && !validSelection())
{
clear();
}
}
#ifdef TESTING
size_t TextSelectionController::lookupBuildCount(Text* text) const
{
auto* participant = find(text);
return participant == nullptr ? 0 : participant->lookupBuilds;
}
#endif
bool TextSelectionController::validSelection() const
{
if (m_externalRanges)
{
for (auto& participant : m_participants)
{
if (participant->cursor.hasSelection() && !eligible(*participant))
return false;
}
return true;
}
if (m_anchor.participant == nullptr || m_extent.participant == nullptr ||
!eligible(*m_anchor.participant) || !eligible(*m_extent.participant))
{
return false;
}
for (auto& participant : m_participants)
{
if (participant->cursor.first().codePointIndex() !=
participant->cursor.last().codePointIndex() &&
!eligible(*participant))
{
return false;
}
}
return true;
}
void TextSelectionController::clear()
{
m_anchor = {};
m_extent = {};
m_dragging = false;
m_externalRanges = false;
for (auto& participant : m_participants)
{
participant->cursor = Cursor::zero();
participant->geometryDirty = true;
}
}
bool TextSelectionController::endpointAt(Participant& participant,
Vec2D rootPosition,
Endpoint& out)
{
Mat2D inverse;
if (!eligible(participant) || !finite(rootPosition) ||
!shapeToRoot(participant.text).invert(&inverse))
{
return false;
}
auto local = inverse * rootPosition;
if (!finite(local))
{
return false;
}
out = {&participant,
CursorPosition::fromTranslation(local, layout(participant))};
return true;
}
bool TextSelectionController::pointerDown(Text* hitText,
Vec2D rootPosition,
int pointerId,
bool extend)
{
// A second pointer must not replace the anchor or take over capture.
if (m_dragging)
{
return false;
}
auto* participant = find(hitText);
Endpoint endpoint;
if (participant == nullptr ||
!endpointAt(*participant, rootPosition, endpoint))
{
clear();
return false;
}
if (!extend || m_externalRanges || !validSelection())
{
m_anchor = endpoint;
}
m_externalRanges = false;
m_extent = endpoint;
m_pointerId = pointerId;
m_dragging = true;
updateRanges();
return true;
}
bool TextSelectionController::nearestEndpoint(Vec2D rootPosition, Endpoint& out)
{
if (!finite(rootPosition))
{
return false;
}
float bestDistance = std::numeric_limits<float>::infinity();
bool found = false;
for (auto& participant : m_participants)
{
Mat2D inverse;
auto transform = shapeToRoot(participant->text);
if (!eligible(*participant) || !transform.invert(&inverse))
{
continue;
}
auto local = inverse * rootPosition;
if (!finite(local))
{
continue;
}
uint32_t lineIndex = 0;
for (const auto& line : participant->text->m_orderedLines)
{
auto bounds = lineBounds(line);
if (!bounds.isEmptyOrNaN())
{
Vec2D closest(
std::max(bounds.minX, std::min(local.x, bounds.maxX)),
std::max(bounds.minY, std::min(local.y, bounds.maxY)));
auto delta = transform * closest - rootPosition;
float distance = delta.x * delta.x + delta.y * delta.y;
if (distance < bestDistance)
{
bestDistance = distance;
out = {participant.get(),
CursorPosition::fromLineX(lineIndex,
local.x,
layout(*participant))};
found = true;
}
}
lineIndex++;
}
}
return found;
}
bool TextSelectionController::pointerMove(Vec2D rootPosition, int pointerId)
{
if (!m_dragging || pointerId != m_pointerId)
{
return false;
}
if (!validSelection())
{
clear();
return false;
}
Endpoint endpoint;
if (nearestEndpoint(rootPosition, endpoint))
{
m_extent = endpoint;
updateRanges();
}
return true;
}
bool TextSelectionController::pointerUp(Vec2D rootPosition, int pointerId)
{
if (!pointerMove(rootPosition, pointerId))
{
return false;
}
m_dragging = false;
return true;
}
bool TextSelectionController::pointerCancel(int pointerId)
{
if (!m_dragging || pointerId != m_pointerId)
{
return false;
}
clear();
return true;
}
bool TextSelectionController::select(Text* anchorText,
uint32_t anchorOffset,
Text* extentText,
uint32_t extentOffset)
{
auto* anchor = find(anchorText);
auto* extent = find(extentText);
if (anchor == nullptr || extent == nullptr || !eligible(*anchor) ||
!eligible(*extent))
{
return false;
}
m_dragging = false;
m_anchor = {anchor, CursorPosition::atIndex(anchorOffset, layout(*anchor))};
m_externalRanges = false;
m_extent = {extent, CursorPosition::atIndex(extentOffset, layout(*extent))};
updateRanges();
return true;
}
void TextSelectionController::selectAll()
{
Participant* first = nullptr;
Participant* last = nullptr;
for (auto& participant : m_participants)
{
if (eligible(*participant))
{
if (first == nullptr)
{
first = participant.get();
}
last = participant.get();
}
}
if (first != nullptr)
{
select(first->text, 0, last->text, (uint32_t)last->source.size());
}
else
{
clear();
}
}
void TextSelectionController::updateRanges()
{
auto first = m_anchor;
auto last = m_extent;
size_t anchorIndex = 0, extentIndex = 0;
for (size_t i = 0; i < m_participants.size(); i++)
{
if (m_participants[i].get() == first.participant)
anchorIndex = i;
if (m_participants[i].get() == last.participant)
extentIndex = i;
}
if (anchorIndex > extentIndex ||
(anchorIndex == extentIndex && first.position > last.position))
{
std::swap(first, last);
std::swap(anchorIndex, extentIndex);
}
for (size_t i = 0; i < m_participants.size(); i++)
{
auto& participant = *m_participants[i];
uint32_t start = 0, end = 0;
if (i >= anchorIndex && i <= extentIndex && eligible(participant))
{
start = i == anchorIndex ? first.position.codePointIndex() : 0;
end = i == extentIndex ? last.position.codePointIndex()
: (uint32_t)participant.source.size();
}
participant.cursor = Cursor(CursorPosition(start), CursorPosition(end));
participant.geometryDirty = true;
}
}
bool TextSelectionController::hasSelection() const
{
if (!validSelection())
{
return false;
}
if (m_externalRanges)
{
for (const auto& p : m_participants)
if (p->cursor.hasSelection())
return true;
return false;
}
return m_anchor.participant != m_extent.participant ||
m_anchor.position.codePointIndex() !=
m_extent.position.codePointIndex();
}
Cursor TextSelectionController::selection(Text* text) const
{
auto* participant = find(text);
return validSelection() && participant != nullptr ? participant->cursor
: Cursor::zero();
}
std::string TextSelectionController::selectedText() const
{
std::string result;
if (!hasSelection())
{
return result;
}
if (m_externalRanges)
{
for (const auto& p : m_participants)
{
if (!p->cursor.hasSelection())
continue;
if (!result.empty())
result += p->separatorBefore;
for (auto i = p->cursor.first().codePointIndex();
i < p->cursor.last().codePointIndex();
++i)
{
uint8_t bytes[4];
auto count = UTF::Encode(bytes, p->source[i]);
result.append(reinterpret_cast<char*>(bytes), count);
}
}
return result;
}
bool started = false;
bool inRange = false;
uint32_t endpointsSeen = 0;
// Include object boundaries even when an endpoint contributes zero
// characters (end of A -> start of B still selects the separator).
for (auto& participant : m_participants)
{
bool endpoint = participant.get() == m_anchor.participant ||
participant.get() == m_extent.participant;
if (endpoint && !inRange)
{
inRange = true;
}
if (inRange && eligible(*participant))
{
if (started)
{
result += participant->separatorBefore;
}
started = true;
auto first = participant->cursor.first().codePointIndex();
auto last = participant->cursor.last().codePointIndex();
for (auto i = first; i < last; i++)
{
uint8_t bytes[4];
auto count = UTF::Encode(bytes, participant->source[i]);
result.append(reinterpret_cast<char*>(bytes), count);
}
}
if (endpoint)
{
endpointsSeen++;
if (m_anchor.participant == m_extent.participant ||
endpointsSeen == 2)
{
break;
}
}
}
return result;
}
Span<const AABB> TextSelectionController::selectionRects(Text* text)
{
auto* participant = find(text);
if (participant == nullptr || !participant->cursor.hasSelection() ||
!validSelection())
{
return {};
}
float radius = std::max(0.0f, style().cornerRadius());
if (participant->geometryDirty || participant->radius != radius)
{
participant->rects.clear();
auto view = layout(*participant);
if (!view.orderedLines().empty())
{
// Range endpoints are source offsets. Walk every drawn line, so
// hidden/trimmed lines do not confuse logical and visible indices.
auto cursor = Cursor(
CursorPosition(0, participant->cursor.first().codePointIndex()),
CursorPosition((uint32_t)view.orderedLines().size() - 1,
participant->cursor.last().codePointIndex()));
cursor.selectionRects(participant->rects, view);
}
participant->path.update(participant->rects, radius);
participant->radius = radius;
participant->geometryDirty = false;
}
return participant->rects;
}
void TextSelectionController::draw(Text* text, Renderer* renderer)
{
if (m_anchor.participant != nullptr && !validSelection())
{
clear();
}
if (selectionRects(text).empty())
{
return;
}
auto* participant = find(text);
auto* factory = text->artboard()->factory();
if (participant->paint == nullptr)
{
participant->paint = factory->makeRenderPaint();
participant->paint->style(RenderPaintStyle::fill);
}
participant->paint->color(
colorModulateOpacity(style().highlightColor(), text->renderOpacity()));
renderer->save();
renderer->transform(text->shapeWorldTransform());
renderer->drawPath(participant->path.renderPath(factory),
participant->paint.get());
renderer->restore();
}
const SelectionStyle& TextSelectionController::style() const
{
static const SelectionStyle defaults;
const auto* fileStyle =
m_styleFile == nullptr ? nullptr : m_styleFile->selectionStyle();
return m_style != nullptr ? *m_style
: fileStyle != nullptr ? *fileStyle
: defaults;
}
bool TextSelectionController::keyInput(Key key,
KeyModifiers modifiers,
bool isPressed)
{
if (!isPressed || !validSelection())
{
return false;
}
if (key == Key::escape)
{
clear();
return true;
}
if (key == Key::a &&
(modifiers & (KeyModifiers::ctrl | KeyModifiers::meta)) !=
KeyModifiers::none)
{
selectAll();
return true;
}
return false;
}
#endif