diff --git a/modules/yup_core/misc/yup_ResultValue.h b/modules/yup_core/misc/yup_ResultValue.h index da1968e79..4ec2b9311 100644 --- a/modules/yup_core/misc/yup_ResultValue.h +++ b/modules/yup_core/misc/yup_ResultValue.h @@ -181,6 +181,36 @@ class YUP_API ResultValue return valueOrErrorMessage.index() != 1; } + /** Returns a copy of the value that was set when this result was created, + or a value constructed from @p defaultValue if the result indicates a failure. + + This overload is available when the held value can be copied. Use the + rvalue-qualified overload when working with move-only value types. + */ + template + auto valueOr (U&& defaultValue) const& -> std::enable_if_t && std::is_constructible_v, T> + { + if (valueOrErrorMessage.index() == 1) + return std::get<1> (valueOrErrorMessage); + + return T (std::forward (defaultValue)); + } + + /** Returns the moved value that was set when this result was created, + or a value constructed from @p defaultValue if the result indicates a failure. + + This overload allows valueOr() to be used with move-only value types when + the ResultValue itself is an rvalue. + */ + template + auto valueOr (U&& defaultValue) && -> std::enable_if_t && std::is_constructible_v, T> + { + if (valueOrErrorMessage.index() == 1) + return std::get<1> (std::move (valueOrErrorMessage)); + + return T (std::forward (defaultValue)); + } + /** Returns a copy of the value that was set when this result was created. */ T getValue() const& { diff --git a/modules/yup_graphics/fonts/yup_StyledText.cpp b/modules/yup_graphics/fonts/yup_StyledText.cpp index 7824883ee..c39491737 100644 --- a/modules/yup_graphics/fonts/yup_StyledText.cpp +++ b/modules/yup_graphics/fonts/yup_StyledText.cpp @@ -178,9 +178,17 @@ StyledText::TextModifier StyledText::startUpdate() void StyledText::clear() { styledTexts.clear(); + shape = rive::SimpleArray(); + lines = rive::SimpleArray>(); + orderedLines.clear(); + ellipsisRun = {}; styles.clear(); - - update(); + renderStyles.clear(); + glyphLookup.clear(); + bounds = {}; + paragraphYOffsets.clear(); + defaultLineHeight = 0.0f; + isDirty = false; } //============================================================================== @@ -332,6 +340,8 @@ void StyledText::update() return; orderedLines.clear(); + paragraphYOffsets.clear(); + defaultLineHeight = 0.0f; ellipsisRun = {}; const auto& runs = styledTexts.runs(); @@ -368,6 +378,19 @@ void StyledText::update() bool isEllipsisLineLast = false; bool wantEllipsis = (overflow == TextOverflow::ellipsis); + // Initialize defaultLineHeight from the first non-empty paragraph so that empty paragraphs + // (consecutive \n) advance y by a sensible amount even before any non-empty para is seen. + for (const auto& pgLines : lines) + { + if (! pgLines.empty()) + { + defaultLineHeight = pgLines.back().bottom; + break; + } + } + + float initialDefaultLineHeight = defaultLineHeight; + int lastLineIndex = -1; for (const rive::SimpleArray& paragraphLines : lines) { @@ -387,7 +410,14 @@ void StyledText::update() } if (! paragraphLines.empty()) + { + defaultLineHeight = paragraphLines.back().bottom; y += paragraphLines.back().bottom; + } + else + { + y += defaultLineHeight; + } y += paragraphSpacing; } @@ -401,6 +431,10 @@ void StyledText::update() paragraphIndex = 0; bounds = { 0.0f, minY, measuredWidth, jmax (minY, y - paragraphSpacing) - minY }; + paragraphYOffsets.clear(); + paragraphYOffsets.reserve (lines.size()); + defaultLineHeight = initialDefaultLineHeight; + y = 0; if (origin == TextOrigin::baseline && ! lines.empty() && ! lines[0].empty()) y -= lines[0][0].baseline; @@ -409,6 +443,7 @@ void StyledText::update() for (const rive::SimpleArray& paragraphLines : lines) { + paragraphYOffsets.push_back (y); const rive::Paragraph& paragraph = shape[paragraphIndex++]; for (const rive::GlyphLine& line : paragraphLines) { @@ -484,7 +519,14 @@ void StyledText::update() } if (! paragraphLines.empty()) + { + defaultLineHeight = paragraphLines.back().bottom; y += paragraphLines.back().bottom; + } + else + { + y += defaultLineHeight; + } y += paragraphSpacing; } @@ -501,50 +543,38 @@ int StyledText::getGlyphIndexAtPosition (const Point& position) const float clickX = position.getX(); float clickY = position.getY(); - // Use the same approach as getSelectionRectangles to find the line - int targetLineIndex = -1; - - for (size_t lineIdx = 0; lineIdx < orderedLines.size(); ++lineIdx) + for (int paraIdx = 0; paraIdx < static_cast (lines.size()); ++paraIdx) { - const rive::OrderedLine& line = orderedLines[lineIdx]; - const rive::GlyphLine& glyphLine = line.glyphLine(); + if (! lines[paraIdx].empty() || paraIdx >= static_cast (paragraphYOffsets.size())) + continue; - float lineY = line.y(); - float lineTop = lineY + glyphLine.top; - float lineBottom = lineY + glyphLine.bottom; + const float paraTop = paragraphYOffsets[paraIdx]; + const float paraBottom = paraTop + defaultLineHeight; - // Check if click is within this line's vertical bounds (same as getSelectionRectangles) - if (clickY >= lineTop && clickY <= lineBottom) - { - targetLineIndex = static_cast (lineIdx); - break; - } - // If click is above the first line, use the first line - else if (lineIdx == 0 && clickY < lineTop) - { - targetLineIndex = 0; - break; - } - // If click is below all lines, use the last line - else if (lineIdx == orderedLines.size() - 1 && clickY > lineBottom) + if (clickY >= paraTop && clickY < paraBottom) + return findParagraphNewlinePositionByIndex (paraIdx); + } + + int targetLineIndex = 0; + float closestLineDistance = std::numeric_limits::max(); + + for (int lineIdx = 0; lineIdx < static_cast (orderedLines.size()); ++lineIdx) + { + const auto& line = orderedLines[static_cast (lineIdx)]; + const auto& glyphLine = line.glyphLine(); + const float lineCenter = line.y() + (glyphLine.top + glyphLine.bottom) * 0.5f; + const float lineDistance = std::abs (clickY - lineCenter); + + if (lineDistance < closestLineDistance) { - targetLineIndex = static_cast (lineIdx); - break; + closestLineDistance = lineDistance; + targetLineIndex = lineIdx; } } - if (targetLineIndex == -1) - return static_cast (styledTexts.unichars().size()); - - const rive::OrderedLine& targetLine = orderedLines[targetLineIndex]; + const rive::OrderedLine& targetLine = orderedLines[static_cast (targetLineIndex)]; const rive::GlyphLine& glyphLine = targetLine.glyphLine(); - // Find the closest character using the same xpos logic as getSelectionRectangles - int bestCharIndex = 0; - float minDistance = std::numeric_limits::max(); - bool foundAnyGlyph = false; - - // If click is before the line start, return the first character in the line if (clickX <= glyphLine.startX) { for (const auto& [glyphRun, glyphIndex] : targetLine) @@ -553,9 +583,14 @@ int StyledText::getGlyphIndexAtPosition (const Point& position) const return static_cast (glyphRun->textIndices[glyphIndex]); } - return 0; + return findParagraphNewlinePosition (targetLineIndex); } + // Find the closest character using the same xpos logic as getSelectionRectangles + int bestCharIndex = 0; + float minDistance = std::numeric_limits::max(); + bool foundAnyGlyph = false; + for (const auto& [glyphRun, glyphIndex] : targetLine) { // Check if this glyph run has valid data (same check as getSelectionRectangles) @@ -598,18 +633,9 @@ int StyledText::getGlyphIndexAtPosition (const Point& position) const } } - // If no glyph was found, return the start of this line + // If no glyph was found, this is an empty line (e.g. consecutive newlines) if (! foundAnyGlyph) - { - // Find the first character in this line - for (const auto& [glyphRun, glyphIndex] : targetLine) - { - if (glyphIndex < glyphRun->textIndices.size()) - return static_cast (glyphRun->textIndices[glyphIndex]); - } - - return 0; - } + return findParagraphNewlinePosition (targetLineIndex); // Ensure the result is within valid bounds return jlimit (0, static_cast (styledTexts.unichars().size()), bestCharIndex); @@ -627,6 +653,81 @@ Rectangle StyledText::getCaretBounds (int characterIndex) const if (characterIndex < 0) characterIndex = 0; + // Newline characters have no glyph. Position the caret at the end of the line whose + // paragraph the newline terminates. For empty lines (consecutive newlines) the caret + // lands at startX, which is the left edge of that empty line. This avoids calling + // lastCodePointIndex() on empty orderedLines (which would be UB). + const auto& unichars = styledTexts.unichars(); + + // Bug 2: caret is at end-of-text and the text ends with \n — the caret belongs on the + // virtual new line created by the trailing newline, not at the end of the previous line. + if (characterIndex == static_cast (unichars.size()) && ! unichars.empty() && unichars.back() == '\n' && ! paragraphYOffsets.empty()) + { + if (! lines.empty() && lines.back().empty()) + return { 0.0f, paragraphYOffsets.back(), 1.0f, defaultLineHeight }; + + if (! orderedLines.empty()) + { + const auto& lastLine = orderedLines.back(); + const auto& glyphLine = lastLine.glyphLine(); + return { 0.0f, lastLine.y() + glyphLine.bottom + paragraphSpacing, 1.0f, defaultLineHeight }; + } + + return { 0.0f, paragraphYOffsets.back(), 1.0f, defaultLineHeight }; + } + + if (characterIndex < static_cast (unichars.size()) && unichars[characterIndex] == '\n') + { + // Count how many \n appear before characterIndex to identify the paragraph + int targetParagraphIdx = 0; + for (int i = 0; i < characterIndex; ++i) + if (unichars[i] == '\n') + ++targetParagraphIdx; + + // Walk the paragraph/line structure to find the last orderedLine of that paragraph + int lineOffset = 0; + for (int paraIdx = 0; paraIdx < static_cast (lines.size()); ++paraIdx) + { + int numLinesInPara = static_cast (lines[paraIdx].size()); + + if (paraIdx == targetParagraphIdx) + { + // Bug 3: empty paragraph — numLinesInPara == 0 makes lastLineIdx wrong. + // Use the stored Y offset directly instead of trying to index orderedLines. + if (numLinesInPara == 0) + { + float paraY = (paraIdx < static_cast (paragraphYOffsets.size())) + ? paragraphYOffsets[paraIdx] + : 0.0f; + return { 0.0f, paraY, 1.0f, defaultLineHeight }; + } + + int lastLineIdx = lineOffset + numLinesInPara - 1; + if (lastLineIdx >= static_cast (orderedLines.size())) + break; + + const rive::OrderedLine& targetOLine = orderedLines[lastLineIdx]; + const rive::GlyphLine& gl = targetOLine.glyphLine(); + + // Find end-X of visible content; stays at startX for empty paragraphs + float endX = gl.startX; + for (auto [gr, gi] : targetOLine) + { + if (gi < gr->xpos.size()) + { + endX = (gi + 1 < gr->xpos.size()) + ? gr->xpos[gi + 1] + : gr->xpos[gi] + (gi < gr->advances.size() ? gr->advances[gi] : 0.0f); + } + } + + return { endX, targetOLine.y() + gl.top, 1.0f, gl.bottom - gl.top }; + } + + lineOffset += numLinesInPara; + } + } + // Use the same approach as getSelectionRectangles for (size_t lineIdx = 0; lineIdx < orderedLines.size(); ++lineIdx) { @@ -673,7 +774,7 @@ Rectangle StyledText::getCaretBounds (int characterIndex) const // If we've checked all glyphs in this line and character index is beyond them, // position at the end of this line - if (characterIndex <= static_cast (line.lastCodePointIndex (glyphLookup))) + if (characterIndex < static_cast (line.lastCodePointIndex (glyphLookup))) { // Find the rightmost position in this line float endX = glyphLine.startX; @@ -732,6 +833,132 @@ Rectangle StyledText::getCaretBounds (int characterIndex) const //============================================================================== +int StyledText::getGlyphIndexOnAdjacentLine (int characterIndex, bool moveDown) const +{ + jassert (! isDirty); + if (isDirty || orderedLines.empty()) + return 0; + + characterIndex = jlimit (0, static_cast (styledTexts.unichars().size()), characterIndex); + + auto caretBounds = getCaretBounds (characterIndex); + if (caretBounds.isEmpty()) + return moveDown ? static_cast (styledTexts.unichars().size()) : 0; + + struct VisualLine + { + float center = 0.0f; + int orderedLineIndex = -1; + int emptyParagraphIndex = -1; + }; + + std::vector visualLines; + visualLines.reserve (orderedLines.size() + lines.size()); + + int orderedLineOffset = 0; + for (int paraIdx = 0; paraIdx < static_cast (lines.size()); ++paraIdx) + { + const int numLinesInPara = static_cast (lines[paraIdx].size()); + + if (numLinesInPara == 0) + { + if (paraIdx < static_cast (paragraphYOffsets.size())) + visualLines.push_back ({ paragraphYOffsets[paraIdx] + defaultLineHeight * 0.5f, -1, paraIdx }); + } + else + { + for (int lineIdx = 0; lineIdx < numLinesInPara; ++lineIdx) + { + const int orderedLineIndex = orderedLineOffset + lineIdx; + if (orderedLineIndex >= static_cast (orderedLines.size())) + continue; + + const auto& line = orderedLines[static_cast (orderedLineIndex)]; + const auto& glyphLine = line.glyphLine(); + visualLines.push_back ({ line.y() + (glyphLine.top + glyphLine.bottom) * 0.5f, orderedLineIndex, -1 }); + } + } + + orderedLineOffset += numLinesInPara; + } + + if (visualLines.empty()) + return moveDown ? static_cast (styledTexts.unichars().size()) : 0; + + int currentLineIndex = 0; + float closestLineDistance = std::numeric_limits::max(); + const float caretCenterY = caretBounds.getCenterY(); + + for (int lineIndex = 0; lineIndex < static_cast (visualLines.size()); ++lineIndex) + { + const float lineDistance = std::abs (caretCenterY - visualLines[static_cast (lineIndex)].center); + + if (lineDistance < closestLineDistance) + { + closestLineDistance = lineDistance; + currentLineIndex = lineIndex; + } + } + + const int targetLineIndex = currentLineIndex + (moveDown ? 1 : -1); + if (targetLineIndex < 0) + return 0; + + if (targetLineIndex >= static_cast (visualLines.size())) + return static_cast (styledTexts.unichars().size()); + + const auto& targetVisualLine = visualLines[static_cast (targetLineIndex)]; + if (targetVisualLine.emptyParagraphIndex >= 0) + return findParagraphNewlinePositionByIndex (targetVisualLine.emptyParagraphIndex); + + const auto& currentVisualLine = visualLines[static_cast (currentLineIndex)]; + + struct LineInfo + { + int end = 0; + bool hasGlyph = false; + bool hasNonWhitespace = false; + }; + + auto getLineInfo = [this] (const auto& line) + { + LineInfo info; + const auto& unichars = styledTexts.unichars(); + + for (const auto& [glyphRun, glyphIndex] : line) + { + if (glyphIndex >= glyphRun->textIndices.size()) + continue; + + const int charIndex = static_cast (glyphRun->textIndices[glyphIndex]); + info.end = jmax (info.end, charIndex + 1); + info.hasGlyph = true; + + if (charIndex < static_cast (unichars.size()) && ! CharacterFunctions::isWhitespace (static_cast (unichars[charIndex]))) + { + info.hasNonWhitespace = true; + } + } + + return info; + }; + + const auto currentLineInfo = currentVisualLine.orderedLineIndex >= 0 + ? getLineInfo (orderedLines[static_cast (currentVisualLine.orderedLineIndex)]) + : LineInfo {}; + + const auto& targetLine = orderedLines[static_cast (targetVisualLine.orderedLineIndex)]; + const auto& targetGlyphLine = targetLine.glyphLine(); + const float targetY = targetLine.y() + (targetGlyphLine.top + targetGlyphLine.bottom) * 0.5f; + + if (moveDown && currentLineInfo.hasGlyph && ! currentLineInfo.hasNonWhitespace && characterIndex >= currentLineInfo.end) + return getGlyphIndexAtPosition ({ targetGlyphLine.startX, targetY }); + + return getGlyphIndexAtPosition ({ caretBounds.getX(), targetY }); +} + +//============================================================================== + std::vector> StyledText::getSelectionRectangles (int startIndex, int endIndex) const { std::vector> rectangles; @@ -858,4 +1085,55 @@ bool StyledText::isValidCharacterIndex (int characterIndex) const return characterIndex <= (int) styledTexts.unichars().size(); } +//============================================================================== + +int StyledText::findParagraphNewlinePositionByIndex (int paragraphIndex) const +{ + const auto& unichars = styledTexts.unichars(); + int nlCount = 0; + for (int i = 0; i < static_cast (unichars.size()); ++i) + { + if (unichars[i] == '\n') + { + if (nlCount == paragraphIndex) + return i; + ++nlCount; + } + } + return static_cast (unichars.size()); +} + +int StyledText::findParagraphNewlinePosition (int orderedLineIndex) const +{ + const auto& unichars = styledTexts.unichars(); + int lineOffset = 0; + + for (int paraIdx = 0; paraIdx < static_cast (lines.size()); ++paraIdx) + { + int numLinesInPara = static_cast (lines[paraIdx].size()); + + if (orderedLineIndex >= lineOffset && orderedLineIndex < lineOffset + numLinesInPara) + { + int nlCount = 0; + for (int i = 0; i < static_cast (unichars.size()); ++i) + { + if (unichars[i] == '\n') + { + if (nlCount == paraIdx) + return i; + ++nlCount; + } + } + + // The orderedLine belongs to the last paragraph which has no trailing '\n'. + // Return end-of-text so the caret lands at the very end. + return static_cast (unichars.size()); + } + + lineOffset += numLinesInPara; + } + + return 0; +} + } // namespace yup diff --git a/modules/yup_graphics/fonts/yup_StyledText.h b/modules/yup_graphics/fonts/yup_StyledText.h index ebdbd4de7..0ffa87b83 100644 --- a/modules/yup_graphics/fonts/yup_StyledText.h +++ b/modules/yup_graphics/fonts/yup_StyledText.h @@ -170,6 +170,18 @@ class YUP_API StyledText */ Rectangle getCaretBounds (int characterIndex) const; + /** Returns the character index at the same horizontal position on an adjacent visual line. + + This uses the shaped and wrapped line data, so soft-wrapped lines are treated the same + as explicit newline-separated lines. + + @param characterIndex The current caret character index + @param moveDown True to move to the next visual line, false to move to the previous visual line + + @returns The character index on the adjacent visual line, or the nearest valid boundary + */ + int getGlyphIndexOnAdjacentLine (int characterIndex, bool moveDown) const; + /** Returns all selection rectangles for multiline selections. @param startIndex The start character index @@ -209,6 +221,8 @@ class YUP_API StyledText void setWrap (TextWrap value); void update(); + int findParagraphNewlinePosition (int orderedLineIndex) const; + int findParagraphNewlinePositionByIndex (int paragraphIndex) const; rive::SimpleArray shape; rive::SimpleArray> lines; @@ -228,6 +242,8 @@ class YUP_API StyledText float paragraphSpacing = 0.0f; Rectangle bounds; bool isDirty = false; + std::vector paragraphYOffsets; + float defaultLineHeight = 0.0f; }; } // namespace yup diff --git a/modules/yup_gui/component/yup_Component.cpp b/modules/yup_gui/component/yup_Component.cpp index 297238206..490ed3c73 100644 --- a/modules/yup_gui/component/yup_Component.cpp +++ b/modules/yup_gui/component/yup_Component.cpp @@ -904,7 +904,7 @@ void Component::setWantsKeyboardFocus (bool wantsFocus) void Component::takeKeyboardFocus() { - if (! options.wantsKeyboardFocus) + if (! options.wantsKeyboardFocus || ! isEnabled()) return; if (auto nativeComponent = getNativeComponent()) @@ -922,7 +922,7 @@ void Component::leaveKeyboardFocus() bool Component::hasKeyboardFocus() const { - if (! options.wantsKeyboardFocus) + if (! options.wantsKeyboardFocus || ! isEnabled()) return false; if (auto nativeComponent = getNativeComponent()) @@ -1367,7 +1367,7 @@ void Component::internalMouseWheel (const MouseEvent& event, const MouseWheelDat void Component::internalKeyDown (const KeyPress& keys, const Point& position) { - if (! isVisible()) + if (! isVisible() || ! isEnabled()) return; keyDown (keys, position); @@ -1377,7 +1377,7 @@ void Component::internalKeyDown (const KeyPress& keys, const Point& posit void Component::internalKeyUp (const KeyPress& keys, const Point& position) { - if (! isVisible()) + if (! isVisible() || ! isEnabled()) return; keyUp (keys, position); @@ -1387,7 +1387,7 @@ void Component::internalKeyUp (const KeyPress& keys, const Point& positio void Component::internalTextInput (const String& text) { - if (! options.wantsKeyboardFocus || ! isVisible()) + if (! options.wantsKeyboardFocus || ! isVisible() || ! isEnabled()) return; textInput (text); diff --git a/modules/yup_gui/component/yup_ComponentNative.h b/modules/yup_gui/component/yup_ComponentNative.h index ab64fdc5f..3b18516d5 100644 --- a/modules/yup_gui/component/yup_ComponentNative.h +++ b/modules/yup_gui/component/yup_ComponentNative.h @@ -385,6 +385,12 @@ class YUP_API ComponentNative : public ReferenceCountedObject */ virtual void stopTextInput (Component& component) = 0; + /** Updates the native text input rectangle for the specified component. + + @param component The component whose text input rectangle has changed. + */ + virtual void updateTextInputRect (Component& component) = 0; + //============================================================================== /** Gets the DPI scale factor. diff --git a/modules/yup_gui/keyboard/yup_TextInputTarget.cpp b/modules/yup_gui/keyboard/yup_TextInputTarget.cpp index f0df147a0..efb435e80 100644 --- a/modules/yup_gui/keyboard/yup_TextInputTarget.cpp +++ b/modules/yup_gui/keyboard/yup_TextInputTarget.cpp @@ -52,6 +52,18 @@ void TextInputTarget::relinquishTextInput() } } +void TextInputTarget::updateTextInputRect() +{ + if (! textInputActive) + return; + + if (auto* component = dynamic_cast (this)) + { + if (auto* nativeComponent = component->getNativeComponent()) + nativeComponent->updateTextInputRect (*component); + } +} + bool TextInputTarget::isTextInputActive() const noexcept { return textInputActive; diff --git a/modules/yup_gui/keyboard/yup_TextInputTarget.h b/modules/yup_gui/keyboard/yup_TextInputTarget.h index 8c69d1a4e..32c5df8c0 100644 --- a/modules/yup_gui/keyboard/yup_TextInputTarget.h +++ b/modules/yup_gui/keyboard/yup_TextInputTarget.h @@ -103,6 +103,13 @@ class TextInputTarget */ void relinquishTextInput(); + /** + Updates the active text input rectangle for this target. + + Call this after the caret or edited text area moves while text input is active. + */ + void updateTextInputRect(); + /** Returns true if this target currently has active text input. diff --git a/modules/yup_gui/native/yup_Windowing_sdl2.cpp b/modules/yup_gui/native/yup_Windowing_sdl2.cpp index 4b0421ba8..75a7a1255 100644 --- a/modules/yup_gui/native/yup_Windowing_sdl2.cpp +++ b/modules/yup_gui/native/yup_Windowing_sdl2.cpp @@ -369,10 +369,29 @@ float SDL2ComponentNative::getOpacity() const void SDL2ComponentNative::setFocusedComponent (Component* comp) { + const auto focusNativeWindowIfNeeded = [&] + { + if (window != nullptr && isVisible() && lastComponentFocused != nullptr) + { + SDL_RaiseWindow (window); + + focusNativeWindow (getNativeHandle()); + + SDL_SetWindowInputFocus (window); + } + }; + + if (lastComponentFocused == comp) + { + focusNativeWindowIfNeeded(); + return; + } + auto compBailOut = Component::BailOutChecker (comp); // Check if we need to stop text input for the previously focused component Component* previousComponent = lastComponentFocused.get(); + WeakReference previousComponentWeak (previousComponent); bool previousWantsTextInput = previousComponent != nullptr && dynamic_cast (previousComponent) != nullptr; if (lastComponentFocused != nullptr) @@ -386,7 +405,14 @@ void SDL2ComponentNative::setFocusedComponent (Component* comp) } if (compBailOut.shouldBailOut()) + { + lastComponentFocused = nullptr; + + if (previousWantsTextInput && previousComponentWeak.get() != nullptr) + stopTextInput (*previousComponentWeak.get()); + return; + } lastComponentFocused = comp; @@ -395,8 +421,8 @@ void SDL2ComponentNative::setFocusedComponent (Component* comp) bool newWantsTextInput = newComponent != nullptr && dynamic_cast (newComponent) != nullptr; // Stop text input if the previous component had it but the new one doesn't - if (previousWantsTextInput && ! newWantsTextInput && previousComponent != nullptr) - stopTextInput (*previousComponent); + if (previousWantsTextInput && ! newWantsTextInput && previousComponentWeak.get() != nullptr) + stopTextInput (*previousComponentWeak.get()); if (lastComponentFocused != nullptr) { @@ -408,19 +434,12 @@ void SDL2ComponentNative::setFocusedComponent (Component* comp) lastComponentFocused->repaint(); } - if (window != nullptr && isVisible() && lastComponentFocused != nullptr) - { - SDL_RaiseWindow (window); - - focusNativeWindow (getNativeHandle()); - - SDL_SetWindowInputFocus (window); - } + focusNativeWindowIfNeeded(); } Component* SDL2ComponentNative::getFocusedComponent() const { - return lastComponentFocused; + return hasNativeKeyboardFocus() ? lastComponentFocused.get() : nullptr; } //============================================================================== @@ -541,6 +560,18 @@ void SDL2ComponentNative::startTextInput (Component& component) SDL_StartTextInput(); + updateTextInputRect (component); +} + +void SDL2ComponentNative::updateTextInputRect (Component& component) +{ + if (window == nullptr || currentTextInputComponent != std::addressof (component)) + return; + + auto* target = dynamic_cast (std::addressof (component)); + if (target == nullptr) + return; + SDL_Rect sdlRect; auto textRect = target->getTextInputRect(); sdlRect.x = static_cast (textRect.getX()); @@ -996,10 +1027,10 @@ void SDL2ComponentNative::handleKeyUp (const KeyPress& keys, const Point& void SDL2ComponentNative::handleTextInput (const String& textInput) { - if (lastComponentFocused != nullptr) - lastComponentFocused->internalTextInput (textInput); - else - component.internalTextInput (textInput); + if (! hasNativeKeyboardFocus() || currentTextInputComponent == nullptr) + return; + + currentTextInputComponent->internalTextInput (textInput); } //============================================================================== @@ -1057,16 +1088,36 @@ void SDL2ComponentNative::handleFocusChanged (bool gotFocus) component.internalFocusChanged (true); - if (auto* comp = currentTextInputComponent.get()) + // Re-notify the focused widget so it restarts its caret and text input. + // This pairs with the focusLost() call in the gotFocus=false branch below. + if (lastComponentFocused != nullptr && lastComponentFocused.get() != std::addressof (component)) { - if (auto* target = dynamic_cast (comp)) - startTextInput (*comp); + auto focusBailOut = Component::BailOutChecker (lastComponentFocused.get()); + + lastComponentFocused->focusGained(); + + if (! focusBailOut.shouldBailOut()) + lastComponentFocused->repaint(); } } else { - if (currentTextInputComponent != nullptr) + // Properly notify the focused widget so it stops its caret and text input + // via relinquishTextInput(), keeping textInputActive in sync with SDL's state. + if (lastComponentFocused != nullptr && lastComponentFocused.get() != std::addressof (component)) + { + auto focusBailOut = Component::BailOutChecker (lastComponentFocused.get()); + + lastComponentFocused->focusLost(); + + if (! focusBailOut.shouldBailOut()) + lastComponentFocused->repaint(); + } + else if (currentTextInputComponent != nullptr) + { + currentTextInputComponent = nullptr; SDL_StopTextInput(); + } component.internalFocusChanged (false); @@ -1082,6 +1133,11 @@ void SDL2ComponentNative::handleFocusChanged (bool gotFocus) } } +bool SDL2ComponentNative::hasNativeKeyboardFocus() const +{ + return window != nullptr && (SDL_GetWindowFlags (window) & SDL_WINDOW_INPUT_FOCUS) != 0; +} + void SDL2ComponentNative::handleMinimized() { PopupMenu::dismissAllPopups(); diff --git a/modules/yup_gui/native/yup_Windowing_sdl2.h b/modules/yup_gui/native/yup_Windowing_sdl2.h index 64a31cfb9..f0434ed25 100644 --- a/modules/yup_gui/native/yup_Windowing_sdl2.h +++ b/modules/yup_gui/native/yup_Windowing_sdl2.h @@ -110,6 +110,7 @@ class SDL2ComponentNative final //============================================================================== void startTextInput (Component& component) override; void stopTextInput (Component& component) override; + void updateTextInputRect (Component& component) override; //============================================================================== void run() override; @@ -158,6 +159,7 @@ class SDL2ComponentNative final void startRendering(); void stopRendering(); bool isRendering() const; + bool hasNativeKeyboardFocus() const; SDL_Window* window = nullptr; SDL_GLContext windowContext = nullptr; diff --git a/modules/yup_gui/themes/theme_v1/yup_ThemeVersion1.cpp b/modules/yup_gui/themes/theme_v1/yup_ThemeVersion1.cpp index d319d1503..3692799ed 100644 --- a/modules/yup_gui/themes/theme_v1/yup_ThemeVersion1.cpp +++ b/modules/yup_gui/themes/theme_v1/yup_ThemeVersion1.cpp @@ -309,7 +309,7 @@ void paintTextEditor (Graphics& g, const ApplicationTheme& theme, const TextEdit auto bounds = t.getLocalBounds(); auto textBounds = t.getTextBounds(); auto scrollOffset = t.getScrollOffset(); - constexpr auto cornerRadius = 6.0f; + constexpr auto cornerRadius = 4.0f; // Draw background auto backgroundColor = t.findColor (TextEditor::Style::backgroundColorId).value_or (Colors::white); @@ -319,7 +319,7 @@ void paintTextEditor (Graphics& g, const ApplicationTheme& theme, const TextEdit // Draw outline auto outlineColor = t.hasKeyboardFocus() ? t.findColor (TextEditor::Style::focusedOutlineColorId).value_or (Colors::cornflowerblue) - : t.findColor (TextEditor::Style::outlineColorId).value_or (Colors::gray); + : t.findColor (TextEditor::Style::outlineColorId).value_or (Color (0xff232323)); g.setStrokeColor (outlineColor); float strokeWidth = t.hasKeyboardFocus() ? 2.0f : 1.0f; @@ -344,7 +344,7 @@ void paintTextEditor (Graphics& g, const ApplicationTheme& theme, const TextEdit } // Draw text with scroll offset - auto textColor = t.findColor (TextEditor::Style::textColorId).value_or (Colors::gray); + auto textColor = t.findColor (TextEditor::Style::textColorId).value_or (Color (0xff232323)); g.setFillColor (textColor); auto scrolledTextBounds = textBounds.translated (-scrollOffset.getX(), -scrollOffset.getY()); diff --git a/modules/yup_gui/widgets/yup_TextEditor.cpp b/modules/yup_gui/widgets/yup_TextEditor.cpp index 0b8ee63aa..65c864703 100644 --- a/modules/yup_gui/widgets/yup_TextEditor.cpp +++ b/modules/yup_gui/widgets/yup_TextEditor.cpp @@ -53,11 +53,13 @@ String TextEditor::getText() const void TextEditor::setText (String newText, NotificationType notification) { + newText = newText.removeCharacters ("\r"); + if (text != newText) { text = newText; - caretPosition = jmin (caretPosition, text.length()); - selectionStart = selectionEnd = caretPosition; + caretPosition = 0; + selectionStart = selectionEnd = 0; needsUpdate = true; sendChangeNotification (notification, [this] @@ -66,6 +68,7 @@ void TextEditor::setText (String newText, NotificationType notification) onTextChange(); }); + updateCaretPosition(); repaint(); } } @@ -77,25 +80,22 @@ void TextEditor::insertText (const String& textToInsert, NotificationType notifi if (readOnly) return; - deleteSelectedText(); + deleteSelectedText (dontSendNotification); - String filteredText = textToInsert; + String filteredText = textToInsert.removeCharacters ("\r"); if (! multiLine) - { - // Remove line breaks for single-line editor - filteredText = filteredText.replaceCharacters ("\r\n", " "); - } + filteredText = filteredText.replaceCharacters ("\n", " "); text = text.substring (0, caretPosition) + filteredText + text.substring (caretPosition); caretPosition += filteredText.length(); selectionStart = selectionEnd = caretPosition; needsUpdate = true; - if (notification == sendNotification) + sendChangeNotification (notification, [this] { if (onTextChange) onTextChange(); - } + }); updateCaretPosition(); repaint(); @@ -109,6 +109,7 @@ void TextEditor::setMultiLine (bool shouldBeMultiLine) { multiLine = shouldBeMultiLine; needsUpdate = true; + updateTextInputRectIfActive(); repaint(); } } @@ -121,6 +122,11 @@ void TextEditor::setReadOnly (bool shouldBeReadOnly) { readOnly = shouldBeReadOnly; setMouseCursor (readOnly ? MouseCursor::Default : MouseCursor::Text); + + if (canUseTextInput() && hasKeyboardFocus()) + requestTextInput(); + else + relinquishTextInput(); } } @@ -144,7 +150,7 @@ Range TextEditor::getSelection() const { int start = jmin (selectionStart, selectionEnd); int end = jmax (selectionStart, selectionEnd); - return Range (start, end - start); + return Range (start, end); } void TextEditor::setSelection (const Range& newSelection) @@ -194,11 +200,11 @@ void TextEditor::deleteSelectedText (NotificationType notification) caretPosition = selectionStart = selectionEnd = start; needsUpdate = true; - if (notification == sendNotification) + sendChangeNotification (notification, [this] { if (onTextChange) onTextChange(); - } + }); updateCaretPosition(); repaint(); @@ -253,6 +259,7 @@ void TextEditor::setFont (Font newFont) font = newFont; needsUpdate = true; + updateTextInputRectIfActive(); repaint(); } } @@ -264,6 +271,7 @@ void TextEditor::resetFont() font.reset(); needsUpdate = true; + updateTextInputRectIfActive(); repaint(); } } @@ -280,6 +288,7 @@ void TextEditor::setFontSize (float newFontSize) fontSize = newFontSize; needsUpdate = true; + updateTextInputRectIfActive(); repaint(); } } @@ -291,6 +300,7 @@ void TextEditor::resetFontSize() fontSize.reset(); needsUpdate = true; + updateTextInputRectIfActive(); repaint(); } } @@ -308,19 +318,32 @@ void TextEditor::paint (Graphics& g) void TextEditor::resized() { needsUpdate = true; + clampScrollOffset(); } //============================================================================== void TextEditor::focusGained() { - startCaretBlinking(); - requestTextInput(); + if (isEnabled()) + { + startCaretBlinking(); + + if (canUseTextInput()) + requestTextInput(); + } + else + { + stopCaretBlinking(); + relinquishTextInput(); + } + repaint(); } void TextEditor::focusLost() { + isDragging = false; stopCaretBlinking(); relinquishTextInput(); repaint(); @@ -330,8 +353,7 @@ void TextEditor::focusLost() void TextEditor::mouseDown (const MouseEvent& event) { - if (! hasKeyboardFocus()) - takeKeyboardFocus(); + takeKeyboardFocus(); auto position = event.getPosition().to(); int newCaretPos = getGlyphIndexAtPosition (position); @@ -381,13 +403,53 @@ void TextEditor::mouseUp (const MouseEvent& event) void TextEditor::mouseDoubleClick (const MouseEvent& event) { - selectAll(); + auto position = event.getPosition().to(); + int clickPos = getGlyphIndexAtPosition (position); + + if (clickPos < 0 || clickPos >= text.length()) + { + selectAll(); + return; + } + + yup_wchar ch = text[clickPos]; + bool isWhitespace = CharacterFunctions::isWhitespace (ch); + + int wordStart = clickPos; + int wordEnd = clickPos + 1; + + if (isWhitespace) + { + while (wordStart > 0 && (CharacterFunctions::isWhitespace (text[wordStart - 1]))) + wordStart--; + + while (wordEnd < text.length() && (CharacterFunctions::isWhitespace (text[wordEnd]))) + wordEnd++; + } + else if (! isWordSeparator (ch)) + { + while (wordStart > 0 && ! isWordSeparator (text[wordStart - 1])) + wordStart--; + + while (wordEnd < text.length() && ! isWordSeparator (text[wordEnd])) + wordEnd++; + } + + selectionStart = wordStart; + selectionEnd = wordEnd; + caretPosition = wordEnd; + + updateCaretPosition(); + repaint(); } //============================================================================== void TextEditor::keyDown (const KeyPress& key, const Point& position) { + if (! isEnabled()) + return; + bool shiftDown = key.getModifiers().isShiftDown(); bool ctrlDown = key.getModifiers().isControlDown() || key.getModifiers().isCommandDown(); @@ -453,7 +515,7 @@ void TextEditor::keyDown (const KeyPress& key, const Point& position) { insertText ("\n"); } - else if (key.getKey() == KeyPress::tabKey) + else if (multiLine && key.getKey() == KeyPress::tabKey) { insertText ("\t"); } @@ -485,8 +547,12 @@ void TextEditor::keyDown (const KeyPress& key, const Point& position) void TextEditor::textInput (const String& inputText) { - if (! readOnly && inputText.isNotEmpty()) - insertText (inputText); + if (canUseTextInput()) + { + const auto filtered = inputText.removeCharacters ("\r"); + if (filtered.isNotEmpty()) + insertText (filtered); + } } //============================================================================== @@ -507,7 +573,7 @@ void TextEditor::updateStyledTextIfNeeded() modifier.setMaxSize (getTextBounds().getSize()); modifier.setHorizontalAlign (StyledText::left); modifier.setVerticalAlign (StyledText::top); - modifier.setWrap (/*multiLine ? StyledText::wrap :*/ StyledText::noWrap); + modifier.setWrap (multiLine ? StyledText::wrap : StyledText::noWrap); modifier.setOverflow (StyledText::visible); modifier.appendText (text, currentFont.withHeight (fontSize.value_or (14.0f))); @@ -521,10 +587,11 @@ void TextEditor::updateStyledTextIfNeeded() void TextEditor::updateCaretPosition() { caretVisible = true; - if (hasKeyboardFocus()) + if (hasKeyboardFocus() && ! caretBlinking) startCaretBlinking(); ensureCaretVisible(); + updateTextInputRectIfActive(); } //============================================================================== @@ -576,14 +643,7 @@ void TextEditor::ensureCaretVisible() needsRepaint = true; } - // Ensure scroll offset doesn't go negative - scrollOffset.setX (jmax (0.0f, scrollOffset.getX())); - scrollOffset.setY (jmax (0.0f, scrollOffset.getY())); - - // Limit scrolling to the actual text bounds - auto textSize = styledText.getComputedTextBounds(); - scrollOffset.setX (jmin (scrollOffset.getX(), jmax (0.0f, textSize.getWidth() - textBounds.getWidth()))); - scrollOffset.setY (jmin (scrollOffset.getY(), jmax (0.0f, textSize.getHeight() - textBounds.getHeight()))); + clampScrollOffset(); if (needsRepaint) repaint(); @@ -626,91 +686,27 @@ Rectangle TextEditor::getCaretBounds() const void TextEditor::moveCaretUp (bool extendSelection) { if (multiLine) - { - updateStyledTextIfNeeded(); - - // Get current caret bounds to maintain horizontal position - auto currentCaretBounds = getCaretBounds(); - if (currentCaretBounds.isEmpty()) - { - moveCaretToStart (extendSelection); - return; - } - - // Calculate target position above current line - auto targetX = currentCaretBounds.getCenterX(); - auto targetY = currentCaretBounds.getY() - 5.0f; // Move up by a small amount to get to previous line - - // Convert back to text coordinate space - auto textBounds = getTextBounds(); - auto relativeTargetPos = Point (targetX, targetY) - textBounds.getTopLeft() + scrollOffset; - - // Get character index at target position - int newPosition = styledText.getGlyphIndexAtPosition (relativeTargetPos); - - // Ensure we moved to a different position - if (newPosition == caretPosition) - { - // If we didn't move, try to find the previous line manually - newPosition = findPreviousLinePosition (caretPosition); - } - - caretPosition = jlimit (0, text.length(), newPosition); + caretPosition = findVisualLinePosition (caretPosition, false); + else + caretPosition = 0; - if (! extendSelection) - selectionStart = selectionEnd = caretPosition; - else - selectionEnd = caretPosition; - } + if (! extendSelection) + selectionStart = selectionEnd = caretPosition; else - { - moveCaretToStart (extendSelection); - } + selectionEnd = caretPosition; } void TextEditor::moveCaretDown (bool extendSelection) { if (multiLine) - { - updateStyledTextIfNeeded(); - - // Get current caret bounds to maintain horizontal position - auto currentCaretBounds = getCaretBounds(); - if (currentCaretBounds.isEmpty()) - { - moveCaretToEnd (extendSelection); - return; - } - - // Calculate target position below current line - auto targetX = currentCaretBounds.getCenterX(); - auto targetY = currentCaretBounds.getBottom() + 5.0f; // Move down by a small amount to get to next line - - // Convert back to text coordinate space - auto textBounds = getTextBounds(); - auto relativeTargetPos = Point (targetX, targetY) - textBounds.getTopLeft() + scrollOffset; - - // Get character index at target position - int newPosition = styledText.getGlyphIndexAtPosition (relativeTargetPos); - - // Ensure we moved to a different position - if (newPosition == caretPosition) - { - // If we didn't move, try to find the next line manually - newPosition = findNextLinePosition (caretPosition); - } - - caretPosition = jlimit (0, text.length(), newPosition); + caretPosition = findVisualLinePosition (caretPosition, true); + else + caretPosition = text.length(); - if (! extendSelection) - selectionStart = selectionEnd = caretPosition; - else - selectionEnd = caretPosition; - } + if (! extendSelection) + selectionStart = selectionEnd = caretPosition; else - { - moveCaretToEnd (extendSelection); - } + selectionEnd = caretPosition; } void TextEditor::moveCaretLeft (bool extendSelection) @@ -785,6 +781,8 @@ void TextEditor::moveCaretToEnd (bool extendSelection) Rectangle TextEditor::getTextInputRect() const { + const_cast (this)->updateStyledTextIfNeeded(); + auto caretBounds = getCaretBounds(); auto screenPos = localToScreen (caretBounds.getTopLeft()); @@ -809,8 +807,11 @@ void TextEditor::handleBackspace() selectionStart = selectionEnd = caretPosition; needsUpdate = true; - if (onTextChange) - onTextChange(); + sendChangeNotification (sendNotification, [this] + { + if (onTextChange) + onTextChange(); + }); updateCaretPosition(); repaint(); @@ -831,8 +832,11 @@ void TextEditor::handleDelete() text = text.substring (0, caretPosition) + text.substring (caretPosition + 1); needsUpdate = true; - if (onTextChange) - onTextChange(); + sendChangeNotification (sendNotification, [this] + { + if (onTextChange) + onTextChange(); + }); updateCaretPosition(); repaint(); @@ -844,13 +848,78 @@ void TextEditor::handleDelete() void TextEditor::startCaretBlinking() { caretVisible = true; - caretTimer.startTimer (500); // Blink every 500ms + caretBlinking = true; + caretTimer.startTimer (500); } void TextEditor::stopCaretBlinking() { caretTimer.stopTimer(); caretVisible = false; + caretBlinking = false; +} + +//============================================================================== + +void TextEditor::clampScrollOffset() +{ + updateStyledTextIfNeeded(); + + auto textBounds = getTextBounds(); + auto textSize = styledText.getComputedTextBounds(); + + scrollOffset.setX (jmax (0.0f, jmin (scrollOffset.getX(), jmax (0.0f, textSize.getWidth() - textBounds.getWidth())))); + scrollOffset.setY (jmax (0.0f, jmin (scrollOffset.getY(), jmax (0.0f, textSize.getHeight() - textBounds.getHeight())))); + + updateTextInputRectIfActive(); +} + +//============================================================================== + +void TextEditor::mouseWheel (const MouseEvent& /*event*/, const MouseWheelData& wheelData) +{ + const float wheelSpeed = 30.0f; + scrollOffset.setX (scrollOffset.getX() - wheelData.getDeltaX() * wheelSpeed); + scrollOffset.setY (scrollOffset.getY() - wheelData.getDeltaY() * wheelSpeed); + clampScrollOffset(); + repaint(); +} + +//============================================================================== + +void TextEditor::enablementChanged() +{ + if (isEnabled()) + { + setMouseCursor (readOnly ? MouseCursor::Default : MouseCursor::Text); + if (hasKeyboardFocus()) + { + startCaretBlinking(); + + if (canUseTextInput()) + requestTextInput(); + } + } + else + { + isDragging = false; + stopCaretBlinking(); + relinquishTextInput(); + setMouseCursor (MouseCursor::Default); + } + + repaint(); +} + +bool TextEditor::canUseTextInput() const noexcept +{ + return isEnabled() && ! readOnly; +} + +void TextEditor::updateTextInputRectIfActive() +{ + if (isTextInputActive()) + updateTextInputRect(); } //============================================================================== @@ -868,8 +937,10 @@ int TextEditor::findLineStart (int position) const return 0; int pos = jlimit (0, text.length(), position); + while (pos > 0 && text[pos - 1] != '\n') pos--; + return pos; } @@ -879,8 +950,10 @@ int TextEditor::findLineEnd (int position) const return text.length(); int pos = jlimit (0, text.length(), position); + while (pos < text.length() && text[pos] != '\n') pos++; + return pos; } @@ -928,12 +1001,21 @@ int TextEditor::findNextLinePosition (int position) const return nextLineStart + jmin (currentColumn, nextLineLength); } +int TextEditor::findVisualLinePosition (int position, bool moveDown) const +{ + if (! multiLine) + return moveDown ? text.length() : 0; + + const_cast (this)->updateStyledTextIfNeeded(); + return styledText.getGlyphIndexOnAdjacentLine (position, moveDown); +} + int TextEditor::findWordStart (int position) const { int pos = jlimit (0, text.length(), position); // Skip any whitespace backwards - while (pos > 0 && (text[pos - 1] == ' ' || text[pos - 1] == '\t' || text[pos - 1] == '\n')) + while (pos > 0 && CharacterFunctions::isWhitespace (text[pos - 1])) pos--; // Find the start of the current word @@ -948,7 +1030,7 @@ int TextEditor::findWordEnd (int position) const int pos = jlimit (0, text.length(), position); // Skip any whitespace forward - while (pos < text.length() && (text[pos] == ' ' || text[pos] == '\t' || text[pos] == '\n')) + while (pos < text.length() && CharacterFunctions::isWhitespace (text[pos])) pos++; // Find the end of the current word @@ -1003,8 +1085,11 @@ void TextEditor::deleteWordBackward() caretPosition = selectionStart = selectionEnd = wordStart; needsUpdate = true; - if (onTextChange) - onTextChange(); + sendChangeNotification (sendNotification, [this] + { + if (onTextChange) + onTextChange(); + }); updateCaretPosition(); repaint(); @@ -1024,13 +1109,19 @@ void TextEditor::deleteWordForward() else { int wordEnd = findWordEnd (caretPosition); + while (wordEnd < text.length() && CharacterFunctions::isWhitespace (text[wordEnd])) + wordEnd++; + if (wordEnd > caretPosition) { text = text.substring (0, caretPosition) + text.substring (wordEnd); needsUpdate = true; - if (onTextChange) - onTextChange(); + sendChangeNotification (sendNotification, [this] + { + if (onTextChange) + onTextChange(); + }); updateCaretPosition(); repaint(); diff --git a/modules/yup_gui/widgets/yup_TextEditor.h b/modules/yup_gui/widgets/yup_TextEditor.h index 3b84ce886..c63ccb2f3 100644 --- a/modules/yup_gui/widgets/yup_TextEditor.h +++ b/modules/yup_gui/widgets/yup_TextEditor.h @@ -276,6 +276,8 @@ class YUP_API TextEditor : public Component /** @internal */ void focusLost() override; /** @internal */ + void enablementChanged() override; + /** @internal */ void mouseDown (const MouseEvent& event) override; /** @internal */ void mouseDrag (const MouseEvent& event) override; @@ -284,6 +286,8 @@ class YUP_API TextEditor : public Component /** @internal */ void mouseDoubleClick (const MouseEvent& event) override; /** @internal */ + void mouseWheel (const MouseEvent& event, const MouseWheelData& wheelData) override; + /** @internal */ void keyDown (const KeyPress& key, const Point& position) override; /** @internal */ void textInput (const String& text) override; @@ -307,12 +311,16 @@ class YUP_API TextEditor : public Component int getGlyphIndexAtPosition (const Point& position) const; void handleBackspace(); void handleDelete(); + void clampScrollOffset(); void startCaretBlinking(); void stopCaretBlinking(); + bool canUseTextInput() const noexcept; + void updateTextInputRectIfActive(); int findLineStart (int position) const; int findLineEnd (int position) const; int findPreviousLinePosition (int position) const; int findNextLinePosition (int position) const; + int findVisualLinePosition (int position, bool moveDown) const; // Word navigation methods int findWordStart (int position) const; @@ -337,6 +345,7 @@ class YUP_API TextEditor : public Component bool readOnly = false; bool isDragging = false; bool caretVisible = true; + bool caretBlinking = false; bool needsUpdate = true; Point scrollOffset; diff --git a/tests/yup_core/yup_ResultValue.cpp b/tests/yup_core/yup_ResultValue.cpp index a75dd3611..d04cc7e12 100644 --- a/tests/yup_core/yup_ResultValue.cpp +++ b/tests/yup_core/yup_ResultValue.cpp @@ -21,6 +21,8 @@ #include +#include + #include using namespace yup; @@ -62,6 +64,55 @@ TEST (ResultValueTests, ConversionOperators) EXPECT_TRUE (! failure); } +TEST (ResultValueTests, ValueOrReturnsStoredValueForSuccess) +{ + auto result = ResultValue::ok (42); + + EXPECT_EQ (result.valueOr (7), 42); +} + +TEST (ResultValueTests, ValueOrReturnsDefaultValueForFailure) +{ + auto result = ResultValue::fail ("Error"); + + EXPECT_EQ (result.valueOr (7), 7); +} + +TEST (ResultValueTests, ValueOrAcceptsLvalueDefault) +{ + auto result = ResultValue::fail ("Error"); + int defaultValue = 7; + + EXPECT_EQ (result.valueOr (defaultValue), defaultValue); +} + +TEST (ResultValueTests, ValueOrAcceptsConvertibleDefault) +{ + auto result = ResultValue::fail ("Error"); + + EXPECT_DOUBLE_EQ (result.valueOr (7), 7.0); +} + +TEST (ResultValueTests, ValueOrMovesStoredValueFromRvalueResult) +{ + auto result = ResultValue>::ok (std::make_unique (42)); + + auto value = std::move (result).valueOr (std::make_unique (7)); + + ASSERT_NE (value, nullptr); + EXPECT_EQ (*value, 42); +} + +TEST (ResultValueTests, ValueOrMovesDefaultValueFromRvalueFailure) +{ + auto result = ResultValue>::fail ("Error"); + + auto value = std::move (result).valueOr (std::make_unique (7)); + + ASSERT_NE (value, nullptr); + EXPECT_EQ (*value, 7); +} + TEST (ResultValueTests, CopyConstructor) { auto original = ResultValue::fail ("Original error"); diff --git a/tests/yup_graphics/yup_StyledText.cpp b/tests/yup_graphics/yup_StyledText.cpp index 0092e826e..72619deee 100644 --- a/tests/yup_graphics/yup_StyledText.cpp +++ b/tests/yup_graphics/yup_StyledText.cpp @@ -25,6 +25,32 @@ using namespace yup; +namespace +{ +File getStyledTextTestFontFile() +{ + return +#if YUP_EMSCRIPTEN + File ("/") +#else + File (__FILE__) + .getParentDirectory() + .getParentDirectory() +#endif + .getChildFile ("data") + .getChildFile ("fonts") + .getChildFile ("Linefont-VariableFont_wdth,wght.ttf"); +} + +Font loadStyledTextTestFont (float height = 16.0f) +{ + Font font; + auto result = font.loadFromFile (getStyledTextTestFontFile()); + EXPECT_TRUE (result.wasOk()); + return font.withHeight (height); +} +} // namespace + // ============================================================================== // Default Constructor and State Tests // ============================================================================== @@ -963,3 +989,121 @@ TEST (StyledTextTests, AllAlignmentCombinations) } } } + +// ============================================================================== +// Shaped Text Tests +// ============================================================================== + +TEST (StyledTextTests, AppendingTextProducesLinesBoundsAndRenderStyles) +{ + StyledText text; + auto font = loadStyledTextTestFont(); + const String testText = "Hello shaped text"; + + { + auto modifier = text.startUpdate(); + modifier.setMaxSize ({ 400.0f, 200.0f }); + modifier.appendText (testText, font); + } + + EXPECT_FALSE (text.isEmpty()); + EXPECT_FALSE (text.needsUpdate()); + EXPECT_FALSE (text.getOrderedLines().empty()); + EXPECT_FALSE (text.getRenderStyles().empty()); + + const auto bounds = text.getComputedTextBounds(); + EXPECT_GT (bounds.getWidth(), 0.0f); + EXPECT_GT (bounds.getHeight(), 0.0f); + + EXPECT_TRUE (text.isValidCharacterIndex (0)); + EXPECT_TRUE (text.isValidCharacterIndex (testText.length())); + EXPECT_FALSE (text.isValidCharacterIndex (testText.length() + 1)); + + EXPECT_FALSE (text.getCaretBounds (0).isEmpty()); + EXPECT_FALSE (text.getCaretBounds (testText.length()).isEmpty()); +} + +TEST (StyledTextTests, CaretBoundsHandleNewlinesEmptyParagraphsAndTrailingNewline) +{ + StyledText text; + auto font = loadStyledTextTestFont(); + + { + auto modifier = text.startUpdate(); + modifier.setMaxSize ({ 400.0f, 400.0f }); + modifier.setWrap (StyledText::wrap); + modifier.appendText ("first\n\nlast\n", font); + } + + const auto firstLineNewline = text.getCaretBounds (5); + const auto emptyParagraphNewline = text.getCaretBounds (6); + const auto trailingNewline = text.getCaretBounds (12); + + ASSERT_FALSE (firstLineNewline.isEmpty()); + ASSERT_FALSE (emptyParagraphNewline.isEmpty()); + ASSERT_FALSE (trailingNewline.isEmpty()); + + EXPECT_GT (emptyParagraphNewline.getY(), firstLineNewline.getY()); + EXPECT_GT (trailingNewline.getY(), emptyParagraphNewline.getY()); +} + +TEST (StyledTextTests, SelectionRectanglesCoverMultipleVisualLines) +{ + StyledText text; + auto font = loadStyledTextTestFont(); + + { + auto modifier = text.startUpdate(); + modifier.setMaxSize ({ 400.0f, 400.0f }); + modifier.appendText ("one\ntwo\nthree", font); + } + + const auto rectangles = text.getSelectionRectangles (1, 11); + + ASSERT_GE (rectangles.size(), 3u); + EXPECT_LT (rectangles[0].getY(), rectangles[1].getY()); + EXPECT_LT (rectangles[1].getY(), rectangles[2].getY()); +} + +TEST (StyledTextTests, ClearRemovesPreviouslyShapedLineAndRenderData) +{ + StyledText text; + auto font = loadStyledTextTestFont(); + + { + auto modifier = text.startUpdate(); + modifier.setMaxSize ({ 400.0f, 400.0f }); + modifier.appendText ("temporary text", font); + } + + ASSERT_FALSE (text.getOrderedLines().empty()); + ASSERT_FALSE (text.getRenderStyles().empty()); + ASSERT_GT (text.getComputedTextBounds().getWidth(), 0.0f); + + { + auto modifier = text.startUpdate(); + modifier.clear(); + } + + EXPECT_TRUE (text.isEmpty()); + EXPECT_TRUE (text.getOrderedLines().empty()); + EXPECT_TRUE (text.getRenderStyles().empty()); + EXPECT_TRUE (text.getComputedTextBounds().isEmpty()); + EXPECT_TRUE (text.isValidCharacterIndex (0)); + EXPECT_FALSE (text.isValidCharacterIndex (1)); +} + +TEST (StyledTextTests, AdjacentLineMovementClampsAtDocumentEdges) +{ + StyledText text; + auto font = loadStyledTextTestFont(); + + { + auto modifier = text.startUpdate(); + modifier.setMaxSize ({ 400.0f, 400.0f }); + modifier.appendText ("one\ntwo", font); + } + + EXPECT_EQ (0, text.getGlyphIndexOnAdjacentLine (0, false)); + EXPECT_EQ (7, text.getGlyphIndexOnAdjacentLine (7, true)); +} diff --git a/tests/yup_gui/yup_TextEditor.cpp b/tests/yup_gui/yup_TextEditor.cpp index da2e9347e..d6e80176d 100644 --- a/tests/yup_gui/yup_TextEditor.cpp +++ b/tests/yup_gui/yup_TextEditor.cpp @@ -29,6 +29,16 @@ namespace { constexpr auto kSingleText = "Hello World"; constexpr auto kMultilineText = "Line 1\nLine 2\nLine 3"; + +File getTextEditorTestFontFile() +{ + return File (__FILE__) + .getParentDirectory() + .getParentDirectory() + .getChildFile ("data") + .getChildFile ("fonts") + .getChildFile ("Linefont-VariableFont_wdth,wght.ttf"); +} } // namespace class TextEditorTests : public ::testing::Test @@ -36,14 +46,30 @@ class TextEditorTests : public ::testing::Test protected: void SetUp() override { + oldTheme = ApplicationTheme::getGlobalTheme(); + + theme = new ApplicationTheme(); + + Font font; + auto result = font.loadFromFile (getTextEditorTestFontFile()); + ASSERT_TRUE (result.wasOk()); + + theme->setDefaultFont (std::move (font)); + ApplicationTheme::setGlobalTheme (theme); + editor = std::make_unique ("testEditor"); } void TearDown() override { editor.reset(); + ApplicationTheme::setGlobalTheme (oldTheme.get()); + theme = nullptr; + oldTheme = nullptr; } + ApplicationTheme::Ptr theme; + ApplicationTheme::Ptr oldTheme; std::unique_ptr editor; }; @@ -63,6 +89,13 @@ TEST_F (TextEditorTests, SetTextUpdatesContent) EXPECT_EQ (0, editor->getCaretPosition()); } +TEST_F (TextEditorTests, SetTextStripsCarriageReturns) +{ + editor->setText ("Hello\r\nWorld\r"); + + EXPECT_EQ (String ("Hello\nWorld"), editor->getText()); +} + TEST_F (TextEditorTests, CaretPositionHandling) { editor->setText (kSingleText); @@ -106,6 +139,55 @@ TEST_F (TextEditorTests, TextInsertion) EXPECT_EQ (11, editor->getCaretPosition()); } +TEST_F (TextEditorTests, InsertTextNormalizesNewlinesInSingleLineMode) +{ + editor->setText ("Hello"); + editor->setCaretPosition (5); + + editor->insertText ("\r\nWorld"); + + EXPECT_EQ (String ("Hello World"), editor->getText()); + EXPECT_EQ (11, editor->getCaretPosition()); +} + +TEST_F (TextEditorTests, InsertTextReplacesSelectionWithSingleNotification) +{ + editor->setText ("Hello brave world", dontSendNotification); + editor->setSelection (Range (6, 11)); + + int callCount = 0; + editor->onTextChange = [&callCount] + { + ++callCount; + }; + + editor->insertText ("small", sendNotification); + + EXPECT_EQ (String ("Hello small world"), editor->getText()); + EXPECT_EQ (11, editor->getCaretPosition()); + EXPECT_FALSE (editor->hasSelection()); + EXPECT_EQ (1, callCount); +} + +TEST_F (TextEditorTests, InsertTextReplacingSelectionHonorsDontSendNotification) +{ + editor->setText ("abcdef", dontSendNotification); + editor->setSelection (Range (1, 5)); + + int callCount = 0; + editor->onTextChange = [&callCount] + { + ++callCount; + }; + + editor->insertText ("X", dontSendNotification); + + EXPECT_EQ (String ("aXf"), editor->getText()); + EXPECT_EQ (2, editor->getCaretPosition()); + EXPECT_FALSE (editor->hasSelection()); + EXPECT_EQ (0, callCount); +} + TEST_F (TextEditorTests, TextDeletion) { editor->setText (kSingleText); @@ -126,6 +208,19 @@ TEST_F (TextEditorTests, MultiLineMode) EXPECT_EQ (String (kMultilineText), editor->getText()); } +TEST_F (TextEditorTests, StyledTextWrapModeFollowsMultiLineMode) +{ + editor->setBounds (0.0f, 0.0f, 200.0f, 100.0f); + editor->setText ("A long enough line to exercise styled text setup"); + + editor->getCaretBounds(); + EXPECT_EQ (StyledText::noWrap, editor->getStyledText().getWrap()); + + editor->setMultiLine (true); + editor->moveCaretDown(); + EXPECT_EQ (StyledText::wrap, editor->getStyledText().getWrap()); +} + TEST_F (TextEditorTests, ReadOnlyMode) { editor->setText (kSingleText); @@ -142,6 +237,40 @@ TEST_F (TextEditorTests, ReadOnlyMode) EXPECT_EQ (String (kSingleText), editor->getText()); } +TEST_F (TextEditorTests, TextInputIgnoredWhenReadOnly) +{ + editor->setText ("Hello"); + editor->setCaretPosition (5); + editor->setReadOnly (true); + + editor->textInput (" World"); + + EXPECT_EQ (String ("Hello"), editor->getText()); +} + +TEST_F (TextEditorTests, TextInputIgnoredWhenDisabled) +{ + editor->setText ("Hello"); + editor->setCaretPosition (5); + editor->setEnabled (false); + + editor->textInput (" World"); + + EXPECT_EQ (String ("Hello"), editor->getText()); +} + +TEST_F (TextEditorTests, KeyDownIgnoredWhenDisabled) +{ + editor->setText ("Hello"); + editor->setCaretPosition (5); + editor->setEnabled (false); + + editor->keyDown (KeyPress (KeyPress::backspaceKey), {}); + + EXPECT_EQ (String ("Hello"), editor->getText()); + EXPECT_EQ (5, editor->getCaretPosition()); +} + TEST_F (TextEditorTests, FontHandling) { // Test default font @@ -167,3 +296,232 @@ TEST (TextEditorStaticTests, ColorIdentifiersExist) EXPECT_FALSE (TextEditor::Style::outlineColorId.toString().isEmpty()); EXPECT_FALSE (TextEditor::Style::focusedOutlineColorId.toString().isEmpty()); } + +TEST_F (TextEditorTests, SetTextResetsCaret) +{ + editor->setText ("Hello"); + editor->setCaretPosition (5); + editor->setText ("New text"); + EXPECT_EQ (0, editor->getCaretPosition()); + EXPECT_FALSE (editor->hasSelection()); +} + +TEST_F (TextEditorTests, GetSelectionRoundTripsWithSetSelection) +{ + editor->setText (kSingleText); + editor->setSelection (Range (2, 7)); + EXPECT_EQ (String ("llo W"), editor->getSelectedText()); + + auto saved = editor->getSelection(); + editor->setCaretPosition (0); + editor->setSelection (saved); + + EXPECT_EQ (String ("llo W"), editor->getSelectedText()); + EXPECT_EQ (2, editor->getSelection().getStart()); + EXPECT_EQ (7, editor->getSelection().getEnd()); +} + +TEST_F (TextEditorTests, InsertTextNotificationCallback) +{ + editor->setText ("Hello"); + editor->setCaretPosition (5); + + int callCount = 0; + editor->onTextChange = [&callCount] + { + ++callCount; + }; + + editor->insertText (" World", sendNotification); + EXPECT_EQ (1, callCount); + + editor->insertText ("!", dontSendNotification); + EXPECT_EQ (1, callCount); +} + +TEST_F (TextEditorTests, DeleteSelectedTextNotificationCallback) +{ + editor->setText (kSingleText, dontSendNotification); + editor->selectAll(); + + int callCount = 0; + editor->onTextChange = [&callCount] + { + ++callCount; + }; + + editor->deleteSelectedText (sendNotification); + EXPECT_EQ (1, callCount); + + editor->setText (kSingleText, dontSendNotification); + editor->selectAll(); + + editor->deleteSelectedText (dontSendNotification); + EXPECT_EQ (1, callCount); +} + +TEST_F (TextEditorTests, MoveCaretLeftRight) +{ + editor->setText (kSingleText); + editor->setCaretPosition (5); + + editor->moveCaretLeft(); + EXPECT_EQ (4, editor->getCaretPosition()); + EXPECT_FALSE (editor->hasSelection()); + + editor->moveCaretRight(); + EXPECT_EQ (5, editor->getCaretPosition()); + + editor->moveCaretLeft (true); + EXPECT_EQ (4, editor->getCaretPosition()); + EXPECT_TRUE (editor->hasSelection()); + EXPECT_EQ (String ("o"), editor->getSelectedText()); +} + +TEST_F (TextEditorTests, MoveCaretToLineBoundariesInMultilineText) +{ + editor->setMultiLine (true); + editor->setText ("abc\ndefg\nhi"); + editor->setCaretPosition (6); + + editor->moveCaretToStartOfLine(); + EXPECT_EQ (4, editor->getCaretPosition()); + EXPECT_FALSE (editor->hasSelection()); + + editor->setCaretPosition (6); + editor->moveCaretToEndOfLine (true); + EXPECT_EQ (8, editor->getCaretPosition()); + EXPECT_TRUE (editor->hasSelection()); + EXPECT_EQ (String ("fg"), editor->getSelectedText()); +} + +TEST_F (TextEditorTests, ControlArrowKeysMoveAcrossWords) +{ + editor->setText ("Hello, brave world"); + editor->setCaretPosition (7); + + editor->keyDown (KeyPress (KeyPress::rightKey, KeyModifiers (KeyModifiers::controlMask)), {}); + EXPECT_EQ (12, editor->getCaretPosition()); + EXPECT_FALSE (editor->hasSelection()); + + editor->keyDown (KeyPress (KeyPress::leftKey, KeyModifiers (KeyModifiers::controlMask | KeyModifiers::shiftMask)), {}); + EXPECT_EQ (7, editor->getCaretPosition()); + EXPECT_TRUE (editor->hasSelection()); + EXPECT_EQ (String ("brave"), editor->getSelectedText()); +} + +/* TODO: moveCaretToWordEnd/moveCaretToWordStart is private +TEST_F (TextEditorTests, WordNavigation) +{ + editor->setText ("Hello World"); + editor->setCaretPosition (6); + + editor->moveCaretToWordEnd(); + EXPECT_EQ (11, editor->getCaretPosition()); + + editor->moveCaretToWordStart(); + EXPECT_EQ (6, editor->getCaretPosition()); + + editor->moveCaretToWordStart (true); + EXPECT_TRUE (editor->hasSelection()); +} +*/ + +TEST_F (TextEditorTests, BackspaceDeleteBehavior) +{ + editor->setText ("Hello"); + editor->setCaretPosition (5); + + editor->keyDown (KeyPress (KeyPress::backspaceKey), {}); + EXPECT_EQ (String ("Hell"), editor->getText()); + EXPECT_EQ (4, editor->getCaretPosition()); + + editor->setCaretPosition (0); + editor->keyDown (KeyPress (KeyPress::deleteKey), {}); + EXPECT_EQ (String ("ell"), editor->getText()); + EXPECT_EQ (0, editor->getCaretPosition()); +} + +TEST_F (TextEditorTests, WordDeletionBackwardForward) +{ + editor->setText ("Hello World"); + editor->setCaretPosition (11); + + editor->keyDown (KeyPress (KeyPress::backspaceKey, KeyModifiers (KeyModifiers::controlMask)), {}); + EXPECT_EQ (String ("Hello "), editor->getText()); + + editor->setCaretPosition (0); + editor->keyDown (KeyPress (KeyPress::deleteKey, KeyModifiers (KeyModifiers::controlMask)), {}); + EXPECT_EQ (String (""), editor->getText()); +} + +TEST_F (TextEditorTests, TabKeyIgnoredInSingleLineMode) +{ + editor->setText ("Hello"); + editor->setCaretPosition (5); + EXPECT_FALSE (editor->isMultiLine()); + + editor->keyDown (KeyPress (KeyPress::tabKey), {}); + EXPECT_EQ (String ("Hello"), editor->getText()); +} + +TEST_F (TextEditorTests, TabKeyInsertsInMultiLineMode) +{ + editor->setMultiLine (true); + editor->setText ("Hello"); + editor->setCaretPosition (5); + + editor->keyDown (KeyPress (KeyPress::tabKey), {}); + EXPECT_EQ (String ("Hello\t"), editor->getText()); +} + +TEST_F (TextEditorTests, MoveCaretUpDownMultiLine) +{ + editor->setMultiLine (true); + editor->setText (kMultilineText); + editor->setCaretPosition (0); + + editor->moveCaretToEnd(); + EXPECT_EQ (editor->getText().length(), editor->getCaretPosition()); + + editor->moveCaretToStart(); + EXPECT_EQ (0, editor->getCaretPosition()); +} + +TEST_F (TextEditorTests, MoveCaretDownUsesWrappedVisualLines) +{ + editor->setBounds (0.0f, 0.0f, 20.0f, 200.0f); + editor->setMultiLine (true); + editor->setText ("abcdefghijklmnopqrstuvwxyz"); + editor->setCaretPosition (0); + + const auto firstLineCaretBounds = editor->getCaretBounds(); + ASSERT_FALSE (firstLineCaretBounds.isEmpty()); + + editor->moveCaretDown(); + + const auto secondLineCaretBounds = editor->getCaretBounds(); + ASSERT_FALSE (secondLineCaretBounds.isEmpty()); + + EXPECT_GT (secondLineCaretBounds.getY(), firstLineCaretBounds.getY()); + EXPECT_GT (editor->getCaretPosition(), 0); + EXPECT_LT (editor->getCaretPosition(), editor->getText().length()); +} + +/* TODO: moveCaretToWordEnd/moveCaretToWordStart is private +TEST_F (TextEditorTests, DoubleClickSelectsWord) +{ + editor->setText ("Hello World"); + editor->setCaretPosition (6); + + editor->moveCaretToWordEnd(); + int wordEnd = editor->getCaretPosition(); + + editor->setCaretPosition (6); + editor->moveCaretToWordStart(); + int wordStart = editor->getCaretPosition(); + + editor->setSelection (Range (wordStart, wordEnd)); + EXPECT_EQ (String ("World"), editor->getSelectedText()); +} +*/