| #include "rive/text/text_input.hpp" |
| #include "rive/text/text_style.hpp" |
| #include "rive/text/text_input_drawable.hpp" |
| #include "rive/math/mat2d.hpp" |
| #include "rive/artboard.hpp" |
| #include "rive/factory.hpp" |
| #include "rive/constraints/scrolling/scroll_constraint.hpp" |
| #include <algorithm> |
| |
| #if defined(__EMSCRIPTEN__) |
| #include <emscripten/emscripten.h> |
| #include <emscripten/html5.h> |
| #endif |
| |
| using namespace rive; |
| |
| void TextInput::draw(Renderer* renderer) {} |
| |
| Core* TextInput::hitTest(HitInfo*, const Mat2D&) { return nullptr; } |
| |
| bool TextInput::hitTestPoint(const Vec2D& position, |
| bool skipOnUnclipped, |
| bool isPrimaryHit) |
| { |
| // TextInput must check its own bounds before delegating to parent. |
| // Unlike layouts, we always check bounds regardless of clip state. |
| Mat2D inverseWorld; |
| if (!worldTransform().invert(&inverseWorld)) |
| { |
| return false; |
| } |
| |
| Vec2D localPosition = inverseWorld * position; |
| if (!localBounds().contains(localPosition)) |
| { |
| return false; |
| } |
| |
| // Bounds check passed, now check parent hierarchy |
| return Drawable::hitTestPoint(position, skipOnUnclipped, isPrimaryHit); |
| } |
| |
| void TextInput::textChanged() |
| { |
| m_sourceText = m_Text; |
| #ifdef WITH_RIVE_TEXT |
| syncDisplayedTextFromSource(false); |
| #endif |
| #ifdef WITH_RIVE_LAYOUT |
| markLayoutNodeDirty(); |
| #endif |
| markShapeDirty(); |
| } |
| void TextInput::selectionRadiusChanged() |
| { |
| #ifdef WITH_RIVE_TEXT |
| m_rawTextInput.selectionCornerRadius(selectionRadius()); |
| markShapeDirty(); |
| #endif |
| } |
| |
| void TextInput::multilineChanged() { updateMultiline(true); } |
| |
| void TextInput::markPaintDirty() { addDirt(ComponentDirt::Paint); } |
| |
| void TextInput::markShapeDirty() { addDirt(ComponentDirt::TextShape); } |
| |
| AABB TextInput::localBounds() const |
| { |
| #ifdef WITH_RIVE_TEXT |
| return m_rawTextInput.bounds(); |
| #else |
| return AABB(); |
| #endif |
| } |
| |
| StatusCode TextInput::onAddedClean(CoreContext* context) |
| { |
| Super::onAddedClean(context); |
| |
| m_textStyle = children<TextStyle>().first(); |
| m_sourceText = m_Text; |
| |
| #ifdef WITH_RIVE_TEXT |
| if (m_textStyle != nullptr && m_textStyle->font() != nullptr) |
| { |
| m_rawTextInput.font(m_textStyle->font()); |
| m_rawTextInput.fontSize(m_textStyle->fontSize()); |
| } |
| syncDisplayedTextFromSource(false); |
| #endif |
| |
| if (parent() != nullptr) |
| { |
| auto parentParent = parent()->parent(); |
| if (parentParent != nullptr && parentParent->is<TransformComponent>()) |
| { |
| for (Constraint* constraint : |
| parentParent->as<TransformComponent>()->constraints()) |
| { |
| if (constraint->is<ScrollConstraint>()) |
| { |
| m_scrollConstraint = constraint->as<ScrollConstraint>(); |
| break; |
| } |
| } |
| } |
| } |
| updateMultiline(); |
| return m_textStyle == nullptr ? StatusCode::MissingObject : StatusCode::Ok; |
| } |
| |
| void TextInput::update(ComponentDirt value) |
| { |
| Super::update(value); |
| #ifdef WITH_RIVE_TEXT |
| if (hasDirt(value, ComponentDirt::Paint | ComponentDirt::TextShape)) |
| { |
| Factory* factory = artboard()->factory(); |
| m_rawTextInput.fontSize(m_textStyle->fontSize()); |
| RawTextInput::Flags changed = m_rawTextInput.update(factory); |
| if (enums::is_flag_set(changed, RawTextInput::Flags::shapeDirty)) |
| { |
| m_worldBounds = |
| worldTransform().mapBoundingBox(m_rawTextInput.bounds()); |
| |
| #ifdef WITH_RIVE_LAYOUT |
| if (m_rawTextInput.sizing() == TextSizing::autoHeight) |
| { |
| markLayoutNodeDirty(); |
| } |
| #endif |
| } |
| if (enums::is_flag_set(changed, RawTextInput::Flags::selectionDirty)) |
| { |
| for (auto child : children<TextInputDrawable>()) |
| { |
| child->invalidateStrokeEffects(); |
| } |
| } |
| // Skip scroll adjustment during drag - edge scrolling handles it. |
| if (m_scrollX == 0.0f && m_scrollY == 0.0f && |
| m_scrollConstraint != nullptr && !m_isDragging) |
| { |
| CursorVisualPosition cursorPosition = |
| m_rawTextInput.cursorVisualPosition(); |
| float viewportWidth = m_scrollConstraint->viewportWidth(); |
| float viewportHeight = m_scrollConstraint->viewportHeight(); |
| const float cursorWidth = 1.0f; |
| bool useHorizontalScroll = |
| !multiline() && m_scrollConstraint->constrainsHorizontal(); |
| bool useVerticalScroll = |
| multiline() && m_scrollConstraint->constrainsVertical(); |
| |
| float viewportX = |
| cursorPosition.x() + m_scrollConstraint->scrollOffsetX(); |
| float viewportTop = |
| cursorPosition.top() + m_scrollConstraint->scrollOffsetY(); |
| float viewportBottom = |
| cursorPosition.bottom() + m_scrollConstraint->scrollOffsetY(); |
| |
| if (useHorizontalScroll && viewportX < 0.0f) |
| { |
| m_scrollConstraint->stopPhysics(); |
| |
| float scrollOffset = |
| m_scrollConstraint->scrollOffsetX() - viewportX; |
| m_scrollConstraint->scrollOffsetX(scrollOffset); |
| } |
| else if (useHorizontalScroll && |
| viewportX > viewportWidth - cursorWidth) |
| { |
| m_scrollConstraint->stopPhysics(); |
| |
| float scrollOffset = m_scrollConstraint->scrollOffsetX() - |
| (viewportX - viewportWidth + cursorWidth); |
| m_scrollConstraint->scrollOffsetX(scrollOffset); |
| } |
| |
| if (useVerticalScroll && viewportTop < 0.0f) |
| { |
| m_scrollConstraint->stopPhysics(); |
| float scrollOffset = |
| m_scrollConstraint->scrollOffsetY() - viewportTop; |
| m_scrollConstraint->scrollOffsetY(scrollOffset); |
| } |
| else if (useVerticalScroll && viewportBottom > viewportHeight) |
| { |
| m_scrollConstraint->stopPhysics(); |
| float scrollOffset = m_scrollConstraint->scrollOffsetY() - |
| (viewportBottom - viewportHeight); |
| m_scrollConstraint->scrollOffsetY(scrollOffset); |
| } |
| } |
| } |
| #endif |
| } |
| |
| Vec2D TextInput::measureLayout(float width, |
| LayoutMeasureMode widthMode, |
| float height, |
| LayoutMeasureMode heightMode) |
| { |
| #ifdef WITH_RIVE_TEXT |
| AABB bounds = |
| m_rawTextInput.measure(widthMode == LayoutMeasureMode::undefined |
| ? std::numeric_limits<float>::max() |
| : width, |
| heightMode == LayoutMeasureMode::undefined |
| ? std::numeric_limits<float>::max() |
| : height); |
| return bounds.size(); |
| #else |
| return Vec2D(); |
| #endif |
| } |
| |
| void TextInput::controlSize(Vec2D size, |
| LayoutScaleType widthScaleType, |
| LayoutScaleType heightScaleType, |
| LayoutDirection direction) |
| { |
| m_layoutWidth = size.x; |
| updateMultiline(); |
| } |
| |
| std::string TextInput::strippedLineBreaks(const std::string& text) |
| { |
| std::string stripped; |
| stripped.reserve(text.size()); |
| bool inLineBreak = false; |
| for (char c : text) |
| { |
| if (c == '\n' || c == '\r') |
| { |
| if (!inLineBreak) |
| { |
| stripped.push_back(' '); |
| inLineBreak = true; |
| } |
| } |
| else |
| { |
| stripped.push_back(c); |
| inLineBreak = false; |
| } |
| } |
| return stripped; |
| } |
| |
| std::string TextInput::displayedText() const |
| { |
| return multiline() ? m_sourceText : strippedLineBreaks(m_sourceText); |
| } |
| |
| void TextInput::syncDisplayedTextFromSource(bool preserveCursor) |
| { |
| #ifdef WITH_RIVE_TEXT |
| std::string nextDisplayText = displayedText(); |
| if (m_rawTextInput.text() == nextDisplayText) |
| { |
| return; |
| } |
| if (preserveCursor) |
| { |
| m_rawTextInput.textPreserveCursor(nextDisplayText); |
| } |
| else |
| { |
| m_rawTextInput.text(nextDisplayText); |
| } |
| #endif |
| } |
| |
| void TextInput::syncSourceTextFromRaw() |
| { |
| #ifdef WITH_RIVE_TEXT |
| std::string rawText = m_rawTextInput.text(); |
| if (!multiline()) |
| { |
| std::string singleLineText = strippedLineBreaks(rawText); |
| if (singleLineText != rawText) |
| { |
| m_rawTextInput.textPreserveCursor(singleLineText); |
| rawText = std::move(singleLineText); |
| } |
| } |
| m_sourceText = std::move(rawText); |
| text(m_sourceText); |
| #endif |
| } |
| |
| #ifdef WITH_RIVE_TEXT |
| static CursorBoundary cursorBoundary(KeyModifiers modifiers) |
| { |
| if ((modifiers & KeyModifiers::meta) != KeyModifiers::none) |
| { |
| return CursorBoundary::line; |
| } |
| if ((modifiers & KeyModifiers::alt) != KeyModifiers::none) |
| { |
| if ((modifiers & KeyModifiers::ctrl) != KeyModifiers::none) |
| { |
| return CursorBoundary::subWord; |
| } |
| return CursorBoundary::word; |
| } |
| return CursorBoundary::character; |
| } |
| |
| #if defined(__EMSCRIPTEN__) |
| EM_JS(bool, isWindowsBrowser, (), { |
| return Boolean(navigator.platform.indexOf('Win') > -1); |
| }); |
| #endif |
| |
| static KeyModifiers systemModifier() |
| { |
| #if defined(__EMSCRIPTEN__) |
| return isWindowsBrowser() ? KeyModifiers::ctrl : KeyModifiers::meta; |
| #elif defined(RIVE_WINDOWS) |
| return KeyModifiers::ctrl; |
| #else |
| return KeyModifiers::meta; |
| #endif |
| } |
| |
| #endif |
| |
| void TextInput::updateMultiline(bool syncDisplayedText) |
| { |
| #ifdef WITH_RIVE_TEXT |
| if (multiline()) |
| { |
| m_rawTextInput.maxWidth(m_layoutWidth); |
| m_rawTextInput.sizing(TextSizing::autoHeight); |
| } |
| else |
| { |
| m_rawTextInput.maxWidth(0); |
| m_rawTextInput.sizing(TextSizing::autoWidth); |
| } |
| |
| if (m_scrollConstraint != nullptr) |
| { |
| if (multiline() && m_scrollConstraint->scrollOffsetX() != 0.0f) |
| { |
| m_scrollConstraint->stopPhysics(); |
| m_scrollConstraint->scrollOffsetX(0.0f); |
| } |
| else if (!multiline() && m_scrollConstraint->scrollOffsetY() != 0.0f) |
| { |
| m_scrollConstraint->stopPhysics(); |
| m_scrollConstraint->scrollOffsetY(0.0f); |
| } |
| } |
| |
| if (syncDisplayedText) |
| { |
| syncDisplayedTextFromSource(true); |
| } |
| |
| #ifdef WITH_RIVE_LAYOUT |
| markLayoutNodeDirty(); |
| #endif |
| addDirt(ComponentDirt::TextShape); |
| #endif |
| } |
| |
| bool TextInput::keyInput(Key value, |
| KeyModifiers modifiers, |
| bool isPressed, |
| bool isRepeat) |
| { |
| #ifdef WITH_RIVE_TEXT |
| if (isPressed) |
| { |
| switch (value) |
| { |
| case Key::z: |
| |
| if ((modifiers & (systemModifier() | KeyModifiers::shift)) == |
| (systemModifier() | KeyModifiers::shift)) |
| { |
| m_rawTextInput.redo(); |
| syncSourceTextFromRaw(); |
| markShapeDirty(); |
| return true; |
| } |
| else if ((modifiers & systemModifier()) != KeyModifiers::none) |
| { |
| m_rawTextInput.undo(); |
| syncSourceTextFromRaw(); |
| markShapeDirty(); |
| return true; |
| } |
| break; |
| case Key::backspace: |
| m_rawTextInput.backspace(-1); |
| syncSourceTextFromRaw(); |
| markShapeDirty(); |
| return true; |
| case Key::deleteKey: |
| m_rawTextInput.backspace(1); |
| syncSourceTextFromRaw(); |
| markShapeDirty(); |
| return true; |
| case Key::left: |
| m_rawTextInput.cursorLeft(cursorBoundary(modifiers), |
| (modifiers & KeyModifiers::shift) != |
| KeyModifiers::none); |
| markPaintDirty(); |
| return true; |
| case Key::right: |
| m_rawTextInput.cursorRight(cursorBoundary(modifiers), |
| (modifiers & KeyModifiers::shift) != |
| KeyModifiers::none); |
| markPaintDirty(); |
| return true; |
| case Key::up: |
| m_rawTextInput.cursorUp((modifiers & KeyModifiers::shift) != |
| KeyModifiers::none); |
| markPaintDirty(); |
| return true; |
| case Key::down: |
| m_rawTextInput.cursorDown((modifiers & KeyModifiers::shift) != |
| KeyModifiers::none); |
| markPaintDirty(); |
| return true; |
| case Key::enter: |
| if (!multiline()) |
| { |
| return false; |
| } |
| m_rawTextInput.insert("\n"); |
| syncSourceTextFromRaw(); |
| markPaintDirty(); |
| return true; |
| default: |
| return false; |
| } |
| } |
| #endif |
| return false; |
| } |
| |
| bool TextInput::textInput(const std::string& value) |
| { |
| #ifdef WITH_RIVE_TEXT |
| std::string textToInsert = multiline() ? value : strippedLineBreaks(value); |
| if (textToInsert.empty()) |
| { |
| return true; |
| } |
| m_rawTextInput.insert(textToInsert); |
| syncSourceTextFromRaw(); |
| markShapeDirty(); |
| #endif |
| return true; |
| } |
| |
| void TextInput::focused() |
| { |
| // TODO: Implement focus visual feedback |
| } |
| |
| void TextInput::blurred() |
| { |
| // TODO: Implement blur visual feedback |
| } |
| |
| bool TextInput::worldPosition(Vec2D& outPosition) |
| { |
| // TextInput is a TransformComponent, so we can access worldTransform |
| // directly |
| Vec2D localPos = worldTranslation(); |
| |
| // Transform to root artboard space (handles nested artboards) |
| auto* ab = artboard(); |
| if (ab) |
| { |
| outPosition = ab->rootTransform(localPos); |
| } |
| else |
| { |
| outPosition = localPos; |
| } |
| return true; |
| } |
| |
| bool TextInput::worldBounds(AABB& outBounds) |
| { |
| // m_worldBounds is computed in update() via |
| // worldTransform().mapBoundingBox() |
| if (m_worldBounds.isEmptyOrNaN()) |
| { |
| return false; |
| } |
| |
| // Transform to root artboard space (handles nested artboards) |
| auto* ab = artboard(); |
| if (ab) |
| { |
| Vec2D minPt = ab->rootTransform(m_worldBounds.min()); |
| Vec2D maxPt = ab->rootTransform(m_worldBounds.max()); |
| outBounds = AABB(minPt, maxPt); |
| } |
| else |
| { |
| outBounds = m_worldBounds; |
| } |
| return true; |
| } |
| |
| float TextInput::edgeScrollSpeedForDistance(float distanceFromEdge) const |
| { |
| static constexpr float kEdgeScrollBaseSpeed = 45.0f; |
| static constexpr float kEdgeScrollMaxSpeed = 400.0f; |
| static constexpr float kEdgeScrollSpeedRamp = 4.0f; |
| float speed = |
| kEdgeScrollBaseSpeed + distanceFromEdge * kEdgeScrollSpeedRamp; |
| return std::max(kEdgeScrollBaseSpeed, std::min(kEdgeScrollMaxSpeed, speed)); |
| } |
| |
| float TextInput::edgeActivationDistance(float position, float edgeStart) const |
| { |
| return position >= edgeStart ? 0.0f : edgeStart - position; |
| } |
| |
| bool TextInput::worldToLocalWithViewport(Vec2D worldPosition, |
| Vec2D& outLocal, |
| bool enableAutoScroll) |
| { |
| m_scrollX = 0.0f; |
| m_scrollY = 0.0f; |
| |
| // Get the inverse of the world transform to convert to local coordinates |
| Mat2D inverseWorld; |
| if (!worldTransform().invert(&inverseWorld)) |
| { |
| return false; |
| } |
| |
| Vec2D localPos = inverseWorld * worldPosition; |
| |
| // If we have a scroll constraint, handle viewport clamping and edge |
| // detection. |
| if (m_scrollConstraint != nullptr) |
| { |
| float viewportWidth = m_scrollConstraint->viewportWidth(); |
| float viewportHeight = m_scrollConstraint->viewportHeight(); |
| float scrollOffsetX = m_scrollConstraint->scrollOffsetX(); |
| float scrollOffsetY = m_scrollConstraint->scrollOffsetY(); |
| const float edgeThreshold = 20.0f; |
| bool useHorizontalScroll = |
| !multiline() && m_scrollConstraint->constrainsHorizontal(); |
| bool useVerticalScroll = |
| multiline() && m_scrollConstraint->constrainsVertical(); |
| |
| if (useHorizontalScroll) |
| { |
| // Convert to viewport-relative coordinates. |
| float viewportX = localPos.x + scrollOffsetX; |
| float leftDistance = |
| edgeActivationDistance(viewportX, edgeThreshold); |
| float rightDistance = |
| edgeActivationDistance(viewportWidth - viewportX, |
| edgeThreshold); |
| |
| // Edge detection for auto-scrolling. |
| if (enableAutoScroll && leftDistance > 0.0f) |
| { |
| m_scrollX = edgeScrollSpeedForDistance(leftDistance); |
| if (viewportX < 0.0f) |
| { |
| localPos.x = -scrollOffsetX; |
| } |
| } |
| else if (enableAutoScroll && rightDistance > 0.0f) |
| { |
| m_scrollX = -edgeScrollSpeedForDistance(rightDistance); |
| if (viewportX > viewportWidth) |
| { |
| localPos.x = viewportWidth - scrollOffsetX; |
| } |
| } |
| } |
| |
| if (useVerticalScroll) |
| { |
| // Convert to viewport-relative coordinates. |
| float viewportY = localPos.y + scrollOffsetY; |
| float topDistance = |
| edgeActivationDistance(viewportY, edgeThreshold); |
| float bottomDistance = |
| edgeActivationDistance(viewportHeight - viewportY, |
| edgeThreshold); |
| |
| // Edge detection for auto-scrolling. |
| if (enableAutoScroll && topDistance > 0.0f) |
| { |
| m_scrollY = edgeScrollSpeedForDistance(topDistance); |
| if (viewportY < 0.0f) |
| { |
| localPos.y = -scrollOffsetY; |
| } |
| } |
| else if (enableAutoScroll && bottomDistance > 0.0f) |
| { |
| m_scrollY = -edgeScrollSpeedForDistance(bottomDistance); |
| if (viewportY > viewportHeight) |
| { |
| localPos.y = viewportHeight - scrollOffsetY; |
| } |
| } |
| } |
| } |
| |
| outLocal = localPos; |
| return true; |
| } |
| |
| void TextInput::startDrag(Vec2D worldPosition) |
| { |
| #ifdef WITH_RIVE_TEXT |
| m_isDragging = true; |
| m_lastDragWorldPosition = worldPosition; |
| |
| Vec2D localPos; |
| if (!worldToLocalWithViewport(worldPosition, localPos, false)) |
| { |
| return; |
| } |
| |
| m_rawTextInput.moveCursorTo(localPos, false); |
| markPaintDirty(); |
| // Note: Focus is handled by TextInputListenerGroup which can cross |
| // artboard boundaries to find the appropriate FocusData. |
| #endif |
| } |
| |
| void TextInput::drag(Vec2D worldPosition) |
| { |
| #ifdef WITH_RIVE_TEXT |
| m_lastDragWorldPosition = worldPosition; |
| |
| Vec2D localPos; |
| if (!worldToLocalWithViewport(worldPosition, localPos, true)) |
| { |
| return; |
| } |
| |
| m_rawTextInput.moveCursorTo(localPos, true); |
| markPaintDirty(); |
| #endif |
| } |
| |
| void TextInput::endDrag(Vec2D worldPosition) |
| { |
| m_isDragging = false; |
| m_lastDragWorldPosition = Vec2D(NAN, NAN); |
| m_scrollX = 0.0f; |
| m_scrollY = 0.0f; |
| } |
| |
| bool TextInput::advanceDrag(float elapsedSeconds) |
| { |
| #ifdef WITH_RIVE_TEXT |
| if (!m_isDragging) |
| { |
| m_scrollX = 0.0f; |
| m_scrollY = 0.0f; |
| return false; |
| } |
| |
| if ((m_scrollX == 0.0f && m_scrollY == 0.0f) || |
| m_scrollConstraint == nullptr) |
| { |
| return false; |
| } |
| |
| m_scrollConstraint->stopPhysics(); |
| if (m_scrollX != 0.0f) |
| { |
| float scrollDeltaX = m_scrollX * elapsedSeconds; |
| float newScrollOffsetX = |
| m_scrollConstraint->scrollOffsetX() + scrollDeltaX; |
| if (!m_scrollConstraint->infinite()) |
| { |
| newScrollOffsetX = std::max(m_scrollConstraint->maxOffsetX(), |
| std::min(0.0f, newScrollOffsetX)); |
| } |
| m_scrollConstraint->scrollOffsetX(newScrollOffsetX); |
| } |
| if (m_scrollY != 0.0f) |
| { |
| float scrollDeltaY = m_scrollY * elapsedSeconds; |
| float newScrollOffsetY = |
| m_scrollConstraint->scrollOffsetY() + scrollDeltaY; |
| if (!m_scrollConstraint->infinite()) |
| { |
| newScrollOffsetY = std::max(m_scrollConstraint->maxOffsetY(), |
| std::min(0.0f, newScrollOffsetY)); |
| } |
| m_scrollConstraint->scrollOffsetY(newScrollOffsetY); |
| } |
| |
| // Keep selection/caret in sync while edge-scrolling even if pointer isn't |
| // moving. |
| if (std::isfinite(m_lastDragWorldPosition.x) && |
| std::isfinite(m_lastDragWorldPosition.y)) |
| { |
| Vec2D localPos; |
| if (worldToLocalWithViewport(m_lastDragWorldPosition, localPos, true)) |
| { |
| m_rawTextInput.moveCursorTo(localPos, true); |
| markPaintDirty(); |
| } |
| } |
| |
| // Continue scrolling while still dragging |
| return m_isDragging; |
| #else |
| return false; |
| #endif |
| } |
| |
| bool TextInput::advanceComponent(float elapsedSeconds, AdvanceFlags flags) |
| { |
| return advanceDrag(elapsedSeconds); |
| } |