diff --git a/modules/juce_gui_basics/accessibility/interfaces/juce_AccessibilityTextInterface.h b/modules/juce_gui_basics/accessibility/interfaces/juce_AccessibilityTextInterface.h index ea5a8944c1..43a76447ff 100644 --- a/modules/juce_gui_basics/accessibility/interfaces/juce_AccessibilityTextInterface.h +++ b/modules/juce_gui_basics/accessibility/interfaces/juce_AccessibilityTextInterface.h @@ -66,6 +66,9 @@ public: /** Returns a section of text. */ virtual String getText (Range range) const = 0; + /** Returns the full text. */ + String getAllText() const { return getText ({ 0, getTotalNumCharacters() }); } + /** Replaces the text with a new string. */ virtual void setText (const String& newText) = 0; diff --git a/modules/juce_gui_basics/juce_gui_basics.cpp b/modules/juce_gui_basics/juce_gui_basics.cpp index 8f2ef0b6b7..afdc8f428e 100644 --- a/modules/juce_gui_basics/juce_gui_basics.cpp +++ b/modules/juce_gui_basics/juce_gui_basics.cpp @@ -45,6 +45,8 @@ #include "juce_gui_basics.h" +#include + //============================================================================== #if JUCE_MAC #import @@ -257,7 +259,7 @@ namespace juce #include "native/juce_MultiTouchMapper.h" #endif -#if JUCE_ANDROID || JUCE_WINDOWS +#if JUCE_ANDROID || JUCE_WINDOWS || JUCE_UNIT_TESTS #include "native/accessibility/juce_AccessibilityTextHelpers.h" #endif @@ -430,3 +432,7 @@ bool juce::isWindowOnCurrentVirtualDesktop (void* x) // Depends on types defined in platform-specific windowing files #include "mouse/juce_MouseCursor.cpp" + +#if JUCE_UNIT_TESTS +#include "native/accessibility/juce_AccessibilityTextHelpers_test.cpp" +#endif diff --git a/modules/juce_gui_basics/native/accessibility/juce_AccessibilityTextHelpers.h b/modules/juce_gui_basics/native/accessibility/juce_AccessibilityTextHelpers.h index 96e4a6df2b..a33e063ebf 100644 --- a/modules/juce_gui_basics/native/accessibility/juce_AccessibilityTextHelpers.h +++ b/modules/juce_gui_basics/native/accessibility/juce_AccessibilityTextHelpers.h @@ -26,7 +26,32 @@ namespace juce { -namespace AccessibilityTextHelpers +template +struct CharPtrIteratorTraits +{ + using difference_type = int; + using value_type = decltype (*std::declval()); + using pointer = value_type*; + using reference = value_type; + using iterator_category = std::bidirectional_iterator_tag; +}; + +} // namespace juce + +namespace std +{ + +template <> struct iterator_traits : juce::CharPtrIteratorTraits {}; +template <> struct iterator_traits : juce::CharPtrIteratorTraits {}; +template <> struct iterator_traits : juce::CharPtrIteratorTraits {}; +template <> struct iterator_traits : juce::CharPtrIteratorTraits {}; + +} // namespace std + +namespace juce +{ + +struct AccessibilityTextHelpers { enum class BoundaryType { @@ -42,60 +67,169 @@ namespace AccessibilityTextHelpers backwards }; + enum class ExtendSelection + { + no, + yes + }; + + /* Indicates whether a function may return the current text position, in the case that the + position already falls on a text unit boundary. + */ + enum class IncludeThisBoundary + { + no, //< Always search for the following boundary, even if the current position falls on a boundary + yes //< Return the current position if it falls on a boundary + }; + + /* Indicates whether a word boundary should include any whitespaces that follow the + non-whitespace characters. + */ + enum class IncludeWhitespaceAfterWords + { + no, //< The word ends on the first whitespace character + yes //< The word ends after the last whitespace character + }; + + /* Like std::distance, but always does an O(N) count rather than an O(1) count, and doesn't + require the iterators to have any member type aliases. + */ + template + static int countDifference (Iter from, Iter to) + { + int distance = 0; + + while (from != to) + { + ++from; + ++distance; + } + + return distance; + } + + /* Returns the number of characters between ptr and the next word end in a specific + direction. + + If ptr is inside a word, the result will be the distance to the end of the same + word. + */ + template + static int findNextWordEndOffset (CharPtr begin, + CharPtr end, + CharPtr ptr, + Direction direction, + IncludeThisBoundary includeBoundary, + IncludeWhitespaceAfterWords includeWhitespace) + { + const auto move = [&] (auto b, auto e, auto iter) + { + const auto isSpace = [] (juce_wchar c) { return CharacterFunctions::isWhitespace (c); }; + + const auto start = [&] + { + if (iter == b && includeBoundary == IncludeThisBoundary::yes) + return b; + + const auto nudged = iter - (iter != b && includeBoundary == IncludeThisBoundary::yes ? 1 : 0); + + return includeWhitespace == IncludeWhitespaceAfterWords::yes + ? std::find_if (nudged, e, isSpace) + : std::find_if_not (nudged, e, isSpace); + }(); + + const auto found = includeWhitespace == IncludeWhitespaceAfterWords::yes + ? std::find_if_not (start, e, isSpace) + : std::find_if (start, e, isSpace); + + return countDifference (iter, found); + }; + + return direction == Direction::forwards ? move (begin, end, ptr) + : -move (std::make_reverse_iterator (end), + std::make_reverse_iterator (begin), + std::make_reverse_iterator (ptr)); + } + + /* Returns the number of characters between ptr and the beginning of the next line in a + specific direction. + */ + template + static int findNextLineOffset (CharPtr begin, + CharPtr end, + CharPtr ptr, + Direction direction, + IncludeThisBoundary includeBoundary) + { + const auto findNewline = [] (auto from, auto to) { return std::find (from, to, juce_wchar { '\n' }); }; + + if (direction == Direction::forwards) + { + if (ptr != begin && includeBoundary == IncludeThisBoundary::yes && *(ptr - 1) == '\n') + return 0; + + const auto newline = findNewline (ptr, end); + return countDifference (ptr, newline) + (newline == end ? 0 : 1); + } + + const auto rbegin = std::make_reverse_iterator (ptr); + const auto rend = std::make_reverse_iterator (begin); + + return -countDifference (rbegin, findNewline (rbegin + (rbegin == rend || includeBoundary == IncludeThisBoundary::yes ? 0 : 1), rend)); + } + + /* Unfortunately, the method of computing end-points of text units depends on context, and on + the current platform. + + Some examples of different behaviour: + - On Android, updating the cursor/selection always searches for the next text unit boundary; + but on Windows, ExpandToEnclosingUnit() should not move the starting point of the + selection if it already at a unit boundary. This means that we need both inclusive and + exclusive methods for finding the next text boundary. + - On Android, moving the cursor by 'words' should move to the first space following a + non-space character in the requested direction. On Windows, a 'word' includes trailing + whitespace, but not preceding whitespace. This means that we need a way of specifying + whether whitespace should be included when navigating by words. + */ static int findTextBoundary (const AccessibilityTextInterface& textInterface, int currentPosition, BoundaryType boundary, - Direction direction) + Direction direction, + IncludeThisBoundary includeBoundary, + IncludeWhitespaceAfterWords includeWhitespace) { const auto numCharacters = textInterface.getTotalNumCharacters(); const auto isForwards = (direction == Direction::forwards); - const auto offsetWithDirection = [isForwards] (auto num) { return isForwards ? num : -num; }; + const auto currentClamped = jlimit (0, numCharacters, currentPosition); switch (boundary) { case BoundaryType::character: - return jlimit (0, numCharacters, currentPosition + offsetWithDirection (1)); + { + const auto offset = includeBoundary == IncludeThisBoundary::yes ? 0 + : (isForwards ? 1 : -1); + return jlimit (0, numCharacters, currentPosition + offset); + } case BoundaryType::word: + { + const auto str = textInterface.getText ({ 0, numCharacters }); + return currentClamped + findNextWordEndOffset (str.begin(), + str.end(), + str.begin() + currentClamped, + direction, + includeBoundary, + includeWhitespace); + } + case BoundaryType::line: { - const auto text = [&]() -> String - { - if (isForwards) - return textInterface.getText ({ currentPosition, textInterface.getTotalNumCharacters() }); - - const auto str = textInterface.getText ({ 0, currentPosition }); - - auto start = str.getCharPointer(); - auto end = start.findTerminatingNull(); - const auto size = getAddressDifference (end.getAddress(), start.getAddress()); - - String reversed; - - if (size > 0) - { - reversed.preallocateBytes ((size_t) size); - - auto destPtr = reversed.getCharPointer(); - - for (;;) - { - destPtr.write (*--end); - - if (end == start) - break; - } - - destPtr.writeNull(); - } - - return reversed; - }(); - - auto tokens = (boundary == BoundaryType::line ? StringArray::fromLines (text) - : StringArray::fromTokens (text, false)); - - return currentPosition + offsetWithDirection (tokens[0].length()); + const auto str = textInterface.getText ({ 0, numCharacters }); + return currentClamped + findNextLineOffset (str.begin(), + str.end(), + str.begin() + currentClamped, + direction, + includeBoundary); } case BoundaryType::document: @@ -105,6 +239,31 @@ namespace AccessibilityTextHelpers jassertfalse; return -1; } -} + + /* Adjusts the current text selection range, using an algorithm appropriate for cursor movement + on Android. + */ + static Range findNewSelectionRangeAndroid (const AccessibilityTextInterface& textInterface, + BoundaryType boundaryType, + ExtendSelection extend, + Direction direction) + { + const auto oldPos = textInterface.getTextInsertionOffset(); + const auto cursorPos = findTextBoundary (textInterface, + oldPos, + boundaryType, + direction, + IncludeThisBoundary::no, + IncludeWhitespaceAfterWords::no); + + if (extend == ExtendSelection::no) + return { cursorPos, cursorPos }; + + const auto currentSelection = textInterface.getSelection(); + const auto start = currentSelection.getStart(); + const auto end = currentSelection.getEnd(); + return Range::between (cursorPos, oldPos == start ? end : start); + } +}; } // namespace juce diff --git a/modules/juce_gui_basics/native/accessibility/juce_AccessibilityTextHelpers_test.cpp b/modules/juce_gui_basics/native/accessibility/juce_AccessibilityTextHelpers_test.cpp new file mode 100644 index 0000000000..1b527f1091 --- /dev/null +++ b/modules/juce_gui_basics/native/accessibility/juce_AccessibilityTextHelpers_test.cpp @@ -0,0 +1,155 @@ +/* + ============================================================================== + + This file is part of the JUCE library. + Copyright (c) 2020 - Raw Material Software Limited + + JUCE is an open source library subject to commercial or open-source + licensing. + + By using JUCE, you agree to the terms of both the JUCE 6 End-User License + Agreement and JUCE Privacy Policy (both effective as of the 16th June 2020). + + End User License Agreement: www.juce.com/juce-6-licence + Privacy Policy: www.juce.com/juce-privacy-policy + + Or: You may also use this code under the terms of the GPL v3 (see + www.gnu.org/licenses). + + JUCE IS PROVIDED "AS IS" WITHOUT ANY WARRANTY, AND ALL WARRANTIES, WHETHER + EXPRESSED OR IMPLIED, INCLUDING MERCHANTABILITY AND FITNESS FOR PURPOSE, ARE + DISCLAIMED. + + ============================================================================== +*/ + +namespace juce +{ + +struct AccessibilityTextHelpersTest : public UnitTest +{ + AccessibilityTextHelpersTest() + : UnitTest ("AccessibilityTextHelpers", UnitTestCategories::gui) {} + + void runTest() override + { + using ATH = AccessibilityTextHelpers; + + beginTest ("Android find word end"); + { + const auto testMultiple = [this] (String str, + int start, + const std::vector& collection) + { + auto it = collection.begin(); + + for (const auto direction : { ATH::Direction::forwards, ATH::Direction::backwards }) + { + for (const auto includeBoundary : { ATH::IncludeThisBoundary::no, ATH::IncludeThisBoundary::yes }) + { + for (const auto includeWhitespace : { ATH::IncludeWhitespaceAfterWords::no, ATH::IncludeWhitespaceAfterWords::yes }) + { + const auto actual = ATH::findNextWordEndOffset (str.begin(), str.end(), str.begin() + start, direction, includeBoundary, includeWhitespace); + const auto expected = *it++; + expect (expected == actual); + } + } + } + }; + + // Character Indices 0 3 56 13 50 51 + // | | || | | | + const auto string = String ("hello world \r\n with some spaces in this sentence ") + String (CharPointer_UTF8 ("\xe2\x88\xae E\xe2\x8b\x85""da = Q")); + // Direction forwards forwards forwards forwards backwards backwards backwards backwards + // IncludeBoundary no no yes yes no no yes yes + // IncludeWhitespace no yes no yes no yes no yes + testMultiple (string, 0, { 5, 6, 5, 0, 0, 0, 0, 0 }); + testMultiple (string, 3, { 2, 3, 2, 3, -3, -3, -3, -3 }); + testMultiple (string, 5, { 6, 1, 0, 1, -5, -5, -5, 0 }); + testMultiple (string, 6, { 5, 9, 5, 0, -6, -1, 0, -1 }); + testMultiple (string, 13, { 6, 2, 6, 2, -7, -2, -7, -2 }); + testMultiple (string, 50, { 1, 2, 1, 0, -9, -1, 0, -1 }); + testMultiple (string, 51, { 5, 1, 0, 1, -1, -2, -1, 0 }); + + testMultiple (" a b ", 0, { 3, 2, 0, 2, 0, 0, 0, 0 }); + testMultiple (" a b ", 1, { 2, 1, 2, 1, -1, -1, -1, -1 }); + } + + beginTest ("Android text range adjustment"); + { + const auto testMultiple = [this] (String str, + Range initial, + auto boundary, + const std::vector>& collection) + { + auto it = collection.begin(); + + for (auto extend : { ATH::ExtendSelection::no, ATH::ExtendSelection::yes }) + { + for (auto direction : { ATH::Direction::forwards, ATH::Direction::backwards }) + { + for (auto insert : { CursorPosition::begin, CursorPosition::end }) + { + const MockAccessibilityTextInterface mock { str, initial, insert }; + const auto actual = ATH::findNewSelectionRangeAndroid (mock, boundary, extend, direction); + const auto expected = *it++; + expect (expected == actual); + } + } + } + }; + + // Extend no no no no yes yes yes yes + // Direction forwards forwards backwards backwards forwards forwards backwards backwards + // Insert begin end begin end begin end begin end + testMultiple ("hello world", { 5, 5 }, ATH::BoundaryType::character, { { 6, 6 }, { 6, 6 }, { 4, 4 }, { 4, 4 }, { 5, 6 }, { 5, 6 }, { 4, 5 }, { 4, 5 } }); + testMultiple ("hello world", { 0, 0 }, ATH::BoundaryType::character, { { 1, 1 }, { 1, 1 }, { 0, 0 }, { 0, 0 }, { 0, 1 }, { 0, 1 }, { 0, 0 }, { 0, 0 } }); + testMultiple ("hello world", { 11, 11 }, ATH::BoundaryType::character, { { 11, 11 }, { 11, 11 }, { 10, 10 }, { 10, 10 }, { 11, 11 }, { 11, 11 }, { 10, 11 }, { 10, 11 } }); + testMultiple ("hello world", { 4, 5 }, ATH::BoundaryType::character, { { 5, 5 }, { 6, 6 }, { 3, 3 }, { 4, 4 }, { 5, 5 }, { 4, 6 }, { 3, 5 }, { 4, 4 } }); + testMultiple ("hello world", { 0, 1 }, ATH::BoundaryType::character, { { 1, 1 }, { 2, 2 }, { 0, 0 }, { 0, 0 }, { 1, 1 }, { 0, 2 }, { 0, 1 }, { 0, 0 } }); + testMultiple ("hello world", { 10, 11 }, ATH::BoundaryType::character, { { 11, 11 }, { 11, 11 }, { 9, 9 }, { 10, 10 }, { 11, 11 }, { 10, 11 }, { 9, 11 }, { 10, 10 } }); + + testMultiple ("foo bar baz", { 0, 0 }, ATH::BoundaryType::word, { { 3, 3 }, { 3, 3 }, { 0, 0 }, { 0, 0 }, { 0, 3 }, { 0, 3 }, { 0, 0 }, { 0, 0 } }); + testMultiple ("foo bar baz", { 1, 6 }, ATH::BoundaryType::word, { { 3, 3 }, { 8, 8 }, { 0, 0 }, { 5, 5 }, { 3, 6 }, { 1, 8 }, { 0, 6 }, { 1, 5 } }); + testMultiple ("foo bar baz", { 3, 3 }, ATH::BoundaryType::word, { { 8, 8 }, { 8, 8 }, { 0, 0 }, { 0, 0 }, { 3, 8 }, { 3, 8 }, { 0, 3 }, { 0, 3 } }); + testMultiple ("foo bar baz", { 3, 5 }, ATH::BoundaryType::word, { { 8, 8 }, { 8, 8 }, { 0, 0 }, { 0, 0 }, { 5, 8 }, { 3, 8 }, { 0, 5 }, { 0, 3 } }); + + testMultiple ("foo bar\n\n\na b\nc d e", { 0, 0 }, ATH::BoundaryType::line, { { 8, 8 }, { 8, 8 }, { 0, 0 }, { 0, 0 }, { 0, 8 }, { 0, 8 }, { 0, 0 }, { 0, 0 } }); + testMultiple ("foo bar\n\n\na b\nc d e", { 7, 7 }, ATH::BoundaryType::line, { { 8, 8 }, { 8, 8 }, { 0, 0 }, { 0, 0 }, { 7, 8 }, { 7, 8 }, { 0, 7 }, { 0, 7 } }); + testMultiple ("foo bar\n\n\na b\nc d e", { 8, 8 }, ATH::BoundaryType::line, { { 9, 9 }, { 9, 9 }, { 0, 0 }, { 0, 0 }, { 8, 9 }, { 8, 9 }, { 0, 8 }, { 0, 8 } }); + + testMultiple ("foo bar\r\na b\r\nxyz", { 0, 0 }, ATH::BoundaryType::line, { { 9, 9 }, { 9, 9 }, { 0, 0 }, { 0, 0 }, { 0, 9 }, { 0, 9 }, { 0, 0 }, { 0, 0 } }); + testMultiple ("foo bar\r\na b\r\nxyz", { 10, 10 }, ATH::BoundaryType::line, { { 14, 14 }, { 14, 14 }, { 9, 9 }, { 9, 9 }, { 10, 14 }, { 10, 14 }, { 9, 10 }, { 9, 10 } }); + } + } + + enum class CursorPosition { begin, end }; + + class MockAccessibilityTextInterface : public AccessibilityTextInterface + { + public: + MockAccessibilityTextInterface (String stringIn, Range selectionIn, CursorPosition insertIn) + : string (stringIn), selection (selectionIn), insert (insertIn) {} + + bool isDisplayingProtectedText() const override { return false; } + bool isReadOnly() const override { return false; } + int getTotalNumCharacters() const override { return string.length(); } + Range getSelection() const override { return selection; } + int getTextInsertionOffset() const override { return insert == CursorPosition::begin ? selection.getStart() : selection.getEnd(); } + String getText (Range range) const override { return string.substring (range.getStart(), range.getEnd()); } + RectangleList getTextBounds (Range) const override { return {}; } + int getOffsetAtPoint (Point) const override { return 0; } + + void setSelection (Range newRange) override { selection = newRange; } + void setText (const String& newText) override { string = newText; } + + private: + String string; + Range selection; + CursorPosition insert; + }; +}; + +static AccessibilityTextHelpersTest accessibilityTextHelpersTest; + +} // namespace juce diff --git a/modules/juce_gui_basics/native/accessibility/juce_android_Accessibility.cpp b/modules/juce_gui_basics/native/accessibility/juce_android_Accessibility.cpp index 637cfbd8a8..578f65d09f 100644 --- a/modules/juce_gui_basics/native/accessibility/juce_android_Accessibility.cpp +++ b/modules/juce_gui_basics/native/accessibility/juce_android_Accessibility.cpp @@ -59,7 +59,10 @@ namespace juce #define JNI_CLASS_MEMBERS(METHOD, STATICMETHOD, FIELD, STATICFIELD, CALLBACK) \ STATICMETHOD (obtain, "obtain", "(I)Landroid/view/accessibility/AccessibilityEvent;") \ METHOD (setPackageName, "setPackageName", "(Ljava/lang/CharSequence;)V") \ - METHOD (setSource, "setSource", "(Landroid/view/View;I)V") \ + METHOD (setSource, "setSource","(Landroid/view/View;I)V") \ + METHOD (setAction, "setAction", "(I)V") \ + METHOD (setFromIndex, "setFromIndex", "(I)V") \ + METHOD (setToIndex, "setToIndex", "(I)V") \ DECLARE_JNI_CLASS (AndroidAccessibilityEvent, "android/view/accessibility/AccessibilityEvent") #undef JNI_CLASS_MEMBERS @@ -74,13 +77,14 @@ namespace { constexpr int HOST_VIEW_ID = -1; - constexpr int TYPE_VIEW_CLICKED = 0x00000001, - TYPE_VIEW_SELECTED = 0x00000004, - TYPE_VIEW_ACCESSIBILITY_FOCUSED = 0x00008000, - TYPE_VIEW_ACCESSIBILITY_FOCUS_CLEARED = 0x00010000, - TYPE_WINDOW_CONTENT_CHANGED = 0x00000800, - TYPE_VIEW_TEXT_SELECTION_CHANGED = 0x00002000, - TYPE_VIEW_TEXT_CHANGED = 0x00000010; + constexpr int TYPE_VIEW_CLICKED = 0x00000001, + TYPE_VIEW_SELECTED = 0x00000004, + TYPE_VIEW_ACCESSIBILITY_FOCUSED = 0x00008000, + TYPE_VIEW_ACCESSIBILITY_FOCUS_CLEARED = 0x00010000, + TYPE_WINDOW_CONTENT_CHANGED = 0x00000800, + TYPE_VIEW_TEXT_SELECTION_CHANGED = 0x00002000, + TYPE_VIEW_TEXT_CHANGED = 0x00000010, + TYPE_VIEW_TEXT_TRAVERSED_AT_MOVEMENT_GRANULARITY = 0x00020000; constexpr int CONTENT_CHANGE_TYPE_SUBTREE = 0x00000001, CONTENT_CHANGE_TYPE_CONTENT_DESCRIPTION = 0x00000004; @@ -198,8 +202,6 @@ static jobject getSourceView (const AccessibilityHandler& handler) return nullptr; } -void sendAccessibilityEventImpl (const AccessibilityHandler& handler, int eventType, int contentChangeTypes); - //============================================================================== class AccessibilityNativeHandle { @@ -377,7 +379,7 @@ public: { env->CallVoidMethod (info, AndroidAccessibilityNodeInfo.setText, - javaString (textInterface->getText ({ 0, textInterface->getTotalNumCharacters() })).get()); + javaString (textInterface->getAllText()).get()); const auto isReadOnly = textInterface->isReadOnly(); @@ -561,7 +563,10 @@ public: return env->CallIntMethod (arguments, AndroidBundle.getInt, key.get()); }; - return { getKey (selectionStartKey), getKey (selectionEndKey) }; + const auto start = getKey (selectionStartKey); + const auto end = getKey (selectionEndKey); + + return Range::between (start, end); } return {}; @@ -641,6 +646,78 @@ public: bool isInPopulateNodeInfo() const noexcept { return inPopulateNodeInfo; } + static bool areAnyAccessibilityClientsActive() + { + auto* env = getEnv(); + auto appContext = getAppContext(); + + if (appContext.get() != nullptr) + { + LocalRef accessibilityManager (env->CallObjectMethod (appContext.get(), AndroidContext.getSystemService, + javaString ("accessibility").get())); + + if (accessibilityManager != nullptr) + return env->CallBooleanMethod (accessibilityManager.get(), AndroidAccessibilityManager.isEnabled); + } + + return false; + } + + template + static void sendAccessibilityEventExtendedImpl (const AccessibilityHandler& handler, + int eventType, + ModificationCallback&& modificationCallback) + { + if (! areAnyAccessibilityClientsActive()) + return; + + if (const auto sourceView = getSourceView (handler)) + { + const auto* nativeImpl = handler.getNativeImplementation(); + + if (nativeImpl == nullptr || nativeImpl->isInPopulateNodeInfo()) + return; + + auto* env = getEnv(); + auto appContext = getAppContext(); + + if (appContext.get() == nullptr) + return; + + LocalRef event (env->CallStaticObjectMethod (AndroidAccessibilityEvent, + AndroidAccessibilityEvent.obtain, + eventType)); + + env->CallVoidMethod (event, + AndroidAccessibilityEvent.setPackageName, + env->CallObjectMethod (appContext.get(), + AndroidContext.getPackageName)); + + env->CallVoidMethod (event, + AndroidAccessibilityEvent.setSource, + sourceView, + nativeImpl->getVirtualViewId()); + + modificationCallback (event); + + env->CallBooleanMethod (sourceView, + AndroidViewGroup.requestSendAccessibilityEvent, + sourceView, + event.get()); + } + } + + static void sendAccessibilityEventImpl (const AccessibilityHandler& handler, int eventType, int contentChangeTypes) + { + sendAccessibilityEventExtendedImpl (handler, eventType, [contentChangeTypes] (auto event) + { + if (contentChangeTypes != 0 && accessibilityEventSetContentChangeTypes != nullptr) + getEnv()->CallVoidMethod (event, + accessibilityEventSetContentChangeTypes, + contentChangeTypes); + }); + } + private: static std::unordered_map virtualViewIdMap; @@ -659,7 +736,7 @@ private: const auto valueString = [this]() -> String { if (auto* textInterface = accessibilityHandler.getTextInterface()) - return textInterface->getText ({ 0, textInterface->getTotalNumCharacters() }); + return textInterface->getAllText(); if (auto* valueInterface = accessibilityHandler.getValueInterface()) return valueInterface->getCurrentValueAsString(); @@ -679,66 +756,68 @@ private: bool moveCursor (jobject arguments, bool forwards) { - if (auto* textInterface = accessibilityHandler.getTextInterface()) + using ATH = AccessibilityTextHelpers; + + auto* textInterface = accessibilityHandler.getTextInterface(); + + if (textInterface == nullptr) + return false; + + const auto granularityKey = javaString ("ACTION_ARGUMENT_MOVEMENT_GRANULARITY_INT"); + const auto extendSelectionKey = javaString ("ACTION_ARGUMENT_EXTEND_SELECTION_BOOLEAN"); + + auto* env = getEnv(); + + const auto boundaryType = [&] { - const auto granularityKey = javaString ("ACTION_ARGUMENT_MOVEMENT_GRANULARITY_INT"); - const auto extendSelectionKey = javaString ("ACTION_ARGUMENT_EXTEND_SELECTION_BOOLEAN"); + const auto granularity = env->CallIntMethod (arguments, AndroidBundle.getInt, granularityKey.get()); - auto* env = getEnv(); + using BoundaryType = ATH::BoundaryType; - const auto boundaryType = [&] + switch (granularity) { - const auto granularity = env->CallIntMethod (arguments, - AndroidBundle.getInt, - granularityKey.get()); + case MOVEMENT_GRANULARITY_CHARACTER: return BoundaryType::character; + case MOVEMENT_GRANULARITY_WORD: return BoundaryType::word; + case MOVEMENT_GRANULARITY_LINE: return BoundaryType::line; + case MOVEMENT_GRANULARITY_PARAGRAPH: + case MOVEMENT_GRANULARITY_PAGE: return BoundaryType::document; + } - using BoundaryType = AccessibilityTextHelpers::BoundaryType; + jassertfalse; + return BoundaryType::character; + }(); - switch (granularity) - { - case MOVEMENT_GRANULARITY_CHARACTER: return BoundaryType::character; - case MOVEMENT_GRANULARITY_WORD: return BoundaryType::word; - case MOVEMENT_GRANULARITY_LINE: return BoundaryType::line; - case MOVEMENT_GRANULARITY_PARAGRAPH: - case MOVEMENT_GRANULARITY_PAGE: return BoundaryType::document; - } + const auto direction = forwards + ? ATH::Direction::forwards + : ATH::Direction::backwards; - jassertfalse; - return BoundaryType::character; - }(); + const auto extend = env->CallBooleanMethod (arguments, AndroidBundle.getBoolean, extendSelectionKey.get()) + ? ATH::ExtendSelection::yes + : ATH::ExtendSelection::no; - using Direction = AccessibilityTextHelpers::Direction; + const auto oldSelection = textInterface->getSelection(); + const auto newSelection = ATH::findNewSelectionRangeAndroid (*textInterface, boundaryType, extend, direction); + textInterface->setSelection (newSelection); - const auto cursorPos = AccessibilityTextHelpers::findTextBoundary (*textInterface, - textInterface->getTextInsertionOffset(), - boundaryType, - forwards ? Direction::forwards - : Direction::backwards); + // Required for Android to read back the text that the cursor moved over + sendAccessibilityEventExtendedImpl (accessibilityHandler, TYPE_VIEW_TEXT_TRAVERSED_AT_MOVEMENT_GRANULARITY, [&] (auto event) + { + env->CallVoidMethod (event, + AndroidAccessibilityEvent.setAction, + forwards ? ACTION_NEXT_AT_MOVEMENT_GRANULARITY : ACTION_PREVIOUS_AT_MOVEMENT_GRANULARITY); - const auto newSelection = [&]() -> Range - { - const auto currentSelection = textInterface->getSelection(); - const auto extendSelection = env->CallBooleanMethod (arguments, - AndroidBundle.getBoolean, - extendSelectionKey.get()); + env->CallVoidMethod (event, + AndroidAccessibilityEvent.setFromIndex, + oldSelection.getStart() != newSelection.getStart() ? oldSelection.getStart() + : oldSelection.getEnd()); - if (! extendSelection) - return { cursorPos, cursorPos }; + env->CallVoidMethod (event, + AndroidAccessibilityEvent.setToIndex, + oldSelection.getStart() != newSelection.getStart() ? newSelection.getStart() + : newSelection.getEnd()); + }); - const auto start = currentSelection.getStart(); - const auto end = currentSelection.getEnd(); - - if (forwards) - return { start, jmax (start, cursorPos) }; - - return { jmin (start, cursorPos), end }; - }(); - - textInterface->setSelection (newSelection); - return true; - } - - return false; + return true; } AccessibilityHandler& accessibilityHandler; @@ -765,67 +844,6 @@ AccessibilityNativeHandle* AccessibilityHandler::getNativeImplementation() const return nativeImpl.get(); } -static bool areAnyAccessibilityClientsActive() -{ - auto* env = getEnv(); - auto appContext = getAppContext(); - - if (appContext.get() != nullptr) - { - LocalRef accessibilityManager (env->CallObjectMethod (appContext.get(), AndroidContext.getSystemService, - javaString ("accessibility").get())); - - if (accessibilityManager != nullptr) - return env->CallBooleanMethod (accessibilityManager.get(), AndroidAccessibilityManager.isEnabled); - } - - return false; -} - -void sendAccessibilityEventImpl (const AccessibilityHandler& handler, int eventType, int contentChangeTypes) -{ - if (! areAnyAccessibilityClientsActive()) - return; - - if (const auto sourceView = getSourceView (handler)) - { - const auto* nativeImpl = handler.getNativeImplementation(); - - if (nativeImpl == nullptr || nativeImpl->isInPopulateNodeInfo()) - return; - - auto* env = getEnv(); - auto appContext = getAppContext(); - - if (appContext.get() == nullptr) - return; - - LocalRef event (env->CallStaticObjectMethod (AndroidAccessibilityEvent, - AndroidAccessibilityEvent.obtain, - eventType)); - - env->CallVoidMethod (event, - AndroidAccessibilityEvent.setPackageName, - env->CallObjectMethod (appContext.get(), - AndroidContext.getPackageName)); - - env->CallVoidMethod (event, - AndroidAccessibilityEvent.setSource, - sourceView, - nativeImpl->getVirtualViewId()); - - if (contentChangeTypes != 0 && accessibilityEventSetContentChangeTypes != nullptr) - env->CallVoidMethod (event, - accessibilityEventSetContentChangeTypes, - contentChangeTypes); - - env->CallBooleanMethod (sourceView, - AndroidViewGroup.requestSendAccessibilityEvent, - sourceView, - event.get()); - } -} - void notifyAccessibilityEventInternal (const AccessibilityHandler& handler, InternalAccessibilityEvent eventType) { @@ -834,7 +852,7 @@ void notifyAccessibilityEventInternal (const AccessibilityHandler& handler, || eventType == InternalAccessibilityEvent::elementMovedOrResized) { if (auto* parent = handler.getParent()) - sendAccessibilityEventImpl (*parent, TYPE_WINDOW_CONTENT_CHANGED, CONTENT_CHANGE_TYPE_SUBTREE); + AccessibilityNativeHandle::sendAccessibilityEventImpl (*parent, TYPE_WINDOW_CONTENT_CHANGED, CONTENT_CHANGE_TYPE_SUBTREE); return; } @@ -859,7 +877,7 @@ void notifyAccessibilityEventInternal (const AccessibilityHandler& handler, }(); if (notification != 0) - sendAccessibilityEventImpl (handler, notification, 0); + AccessibilityNativeHandle::sendAccessibilityEventImpl (handler, notification, 0); } void AccessibilityHandler::notifyAccessibilityEvent (AccessibilityEvent eventType) const @@ -892,13 +910,13 @@ void AccessibilityHandler::notifyAccessibilityEvent (AccessibilityEvent eventTyp return 0; }(); - sendAccessibilityEventImpl (*this, notification, contentChangeTypes); + AccessibilityNativeHandle::sendAccessibilityEventImpl (*this, notification, contentChangeTypes); } void AccessibilityHandler::postAnnouncement (const String& announcementString, AnnouncementPriority) { - if (! areAnyAccessibilityClientsActive()) + if (! AccessibilityNativeHandle::areAnyAccessibilityClientsActive()) return; const auto rootView = [] diff --git a/modules/juce_gui_basics/native/accessibility/juce_win32_UIATextProvider.h b/modules/juce_gui_basics/native/accessibility/juce_win32_UIATextProvider.h index 13acd81747..2f7c66deea 100644 --- a/modules/juce_gui_basics/native/accessibility/juce_win32_UIATextProvider.h +++ b/modules/juce_gui_basics/native/accessibility/juce_win32_UIATextProvider.h @@ -246,19 +246,22 @@ private: if (auto* textInterface = owner->getHandler().getTextInterface()) { + using ATH = AccessibilityTextHelpers; + const auto boundaryType = getBoundaryType (unit); + const auto start = ATH::findTextBoundary (*textInterface, + selectionRange.getStart(), + boundaryType, + ATH::Direction::backwards, + ATH::IncludeThisBoundary::yes, + ATH::IncludeWhitespaceAfterWords::no); - const auto start = unit == ComTypes::TextUnit::TextUnit_Character - ? selectionRange.getStart() - : AccessibilityTextHelpers::findTextBoundary (*textInterface, - selectionRange.getStart(), - boundaryType, - AccessibilityTextHelpers::Direction::backwards); - - const auto end = AccessibilityTextHelpers::findTextBoundary (*textInterface, - start, - boundaryType, - AccessibilityTextHelpers::Direction::forwards); + const auto end = ATH::findTextBoundary (*textInterface, + start, + boundaryType, + ATH::Direction::forwards, + ATH::IncludeThisBoundary::no, + ATH::IncludeWhitespaceAfterWords::yes); selectionRange = Range (start, end); @@ -413,19 +416,39 @@ private: JUCE_COMRESULT Move (ComTypes::TextUnit unit, int count, int* pRetVal) override { - return owner->withTextInterface (pRetVal, [&] (const AccessibilityTextInterface&) + return owner->withTextInterface (pRetVal, [&] (const AccessibilityTextInterface& textInterface) { - if (count > 0) + using ATH = AccessibilityTextHelpers; + + const auto boundaryType = getBoundaryType (unit); + const auto previousUnitBoundary = ATH::findTextBoundary (textInterface, + selectionRange.getStart(), + boundaryType, + ATH::Direction::backwards, + ATH::IncludeThisBoundary::yes, + ATH::IncludeWhitespaceAfterWords::no); + + auto numMoved = 0; + auto movedEndpoint = previousUnitBoundary; + + for (; numMoved < std::abs (count); ++numMoved) { - MoveEndpointByUnit (ComTypes::TextPatternRangeEndpoint_End, unit, count, pRetVal); - MoveEndpointByUnit (ComTypes::TextPatternRangeEndpoint_Start, unit, count, pRetVal); - } - else if (count < 0) - { - MoveEndpointByUnit (ComTypes::TextPatternRangeEndpoint_Start, unit, count, pRetVal); - MoveEndpointByUnit (ComTypes::TextPatternRangeEndpoint_End, unit, count, pRetVal); + const auto nextEndpoint = ATH::findTextBoundary (textInterface, + movedEndpoint, + boundaryType, + count > 0 ? ATH::Direction::forwards : ATH::Direction::backwards, + ATH::IncludeThisBoundary::no, + count > 0 ? ATH::IncludeWhitespaceAfterWords::yes : ATH::IncludeWhitespaceAfterWords::no); + + if (nextEndpoint == movedEndpoint) + break; + + movedEndpoint = nextEndpoint; } + *pRetVal = numMoved; + + ExpandToEnclosingUnit (unit); return S_OK; }); } @@ -463,34 +486,37 @@ private: if (count == 0 || textInterface.getTotalNumCharacters() == 0) return S_OK; - auto endpointToMove = (endpoint == ComTypes::TextPatternRangeEndpoint_Start ? selectionRange.getStart() - : selectionRange.getEnd()); + const auto endpointToMove = (endpoint == ComTypes::TextPatternRangeEndpoint_Start ? selectionRange.getStart() + : selectionRange.getEnd()); - const auto direction = (count > 0 ? AccessibilityTextHelpers::Direction::forwards - : AccessibilityTextHelpers::Direction::backwards); + using ATH = AccessibilityTextHelpers; + + const auto direction = (count > 0 ? ATH::Direction::forwards + : ATH::Direction::backwards); const auto boundaryType = getBoundaryType (unit); + auto movedEndpoint = endpointToMove; - // handle case where endpoint is on a boundary - if (AccessibilityTextHelpers::findTextBoundary (textInterface, endpointToMove, boundaryType, direction) == endpointToMove) - endpointToMove += (direction == AccessibilityTextHelpers::Direction::forwards ? 1 : -1); - - int numMoved; - for (numMoved = 0; numMoved < std::abs (count); ++numMoved) + int numMoved = 0; + for (; numMoved < std::abs (count); ++numMoved) { - auto nextEndpoint = AccessibilityTextHelpers::findTextBoundary (textInterface, - endpointToMove, - boundaryType, - direction); + auto nextEndpoint = ATH::findTextBoundary (textInterface, + movedEndpoint, + boundaryType, + direction, + ATH::IncludeThisBoundary::no, + direction == ATH::Direction::forwards ? ATH::IncludeWhitespaceAfterWords::yes + : ATH::IncludeWhitespaceAfterWords::no); - if (nextEndpoint == endpointToMove) + if (nextEndpoint == movedEndpoint) break; - endpointToMove = nextEndpoint; + movedEndpoint = nextEndpoint; } *pRetVal = numMoved; - setEndpointChecked (endpoint, endpointToMove); + + setEndpointChecked (endpoint, movedEndpoint); return S_OK; }); diff --git a/modules/juce_gui_basics/native/juce_android_Windowing.cpp b/modules/juce_gui_basics/native/juce_android_Windowing.cpp index 73c82fdd99..59e17dc445 100644 --- a/modules/juce_gui_basics/native/juce_android_Windowing.cpp +++ b/modules/juce_gui_basics/native/juce_android_Windowing.cpp @@ -756,11 +756,11 @@ public: { case ACTION_HOVER_ENTER: case ACTION_HOVER_MOVE: - sendAccessibilityEventImpl (*virtualHandler, TYPE_VIEW_HOVER_ENTER, 0); + AccessibilityNativeHandle::sendAccessibilityEventImpl (*virtualHandler, TYPE_VIEW_HOVER_ENTER, 0); break; case ACTION_HOVER_EXIT: - sendAccessibilityEventImpl (*virtualHandler, TYPE_VIEW_HOVER_EXIT, 0); + AccessibilityNativeHandle::sendAccessibilityEventImpl (*virtualHandler, TYPE_VIEW_HOVER_EXIT, 0); break; } } diff --git a/modules/juce_gui_basics/widgets/juce_TextEditor.cpp b/modules/juce_gui_basics/widgets/juce_TextEditor.cpp index 41bcb17e05..f6e87e2907 100644 --- a/modules/juce_gui_basics/widgets/juce_TextEditor.cpp +++ b/modules/juce_gui_basics/widgets/juce_TextEditor.cpp @@ -2684,10 +2684,10 @@ void TextEditor::coalesceSimilarSections() } //============================================================================== -class TextEditorAccessibilityHandler : public AccessibilityHandler +class TextEditor::EditorAccessibilityHandler : public AccessibilityHandler { public: - explicit TextEditorAccessibilityHandler (TextEditor& textEditorToWrap) + explicit EditorAccessibilityHandler (TextEditor& textEditorToWrap) : AccessibilityHandler (textEditorToWrap, textEditorToWrap.isReadOnly() ? AccessibilityRole::staticText : AccessibilityRole::editableText, {}, @@ -2715,10 +2715,20 @@ private: void setSelection (Range r) override { + if (r == textEditor.getHighlightedRegion()) + return; + if (r.isEmpty()) + { textEditor.setCaretPosition (r.getStart()); + } else - textEditor.setHighlightedRegion (r); + { + const auto cursorAtStart = r.getEnd() == textEditor.getHighlightedRegion().getStart() + || r.getEnd() == textEditor.getHighlightedRegion().getEnd(); + textEditor.moveCaretTo (cursorAtStart ? r.getEnd() : r.getStart(), false); + textEditor.moveCaretTo (cursorAtStart ? r.getStart() : r.getEnd(), true); + } } String getText (Range r) const override @@ -2764,12 +2774,12 @@ private: TextEditor& textEditor; //============================================================================== - JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR (TextEditorAccessibilityHandler) + JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR (EditorAccessibilityHandler) }; std::unique_ptr TextEditor::createAccessibilityHandler() { - return std::make_unique (*this); + return std::make_unique (*this); } } // namespace juce diff --git a/modules/juce_gui_basics/widgets/juce_TextEditor.h b/modules/juce_gui_basics/widgets/juce_TextEditor.h index 5c5a4fadbf..1861354c3c 100644 --- a/modules/juce_gui_basics/widgets/juce_TextEditor.h +++ b/modules/juce_gui_basics/widgets/juce_TextEditor.h @@ -760,6 +760,7 @@ private: struct TextEditorViewport; struct InsertAction; struct RemoveAction; + class EditorAccessibilityHandler; std::unique_ptr viewport; TextHolderComponent* textHolder; diff --git a/modules/juce_gui_extra/code_editor/juce_CodeEditorComponent.cpp b/modules/juce_gui_extra/code_editor/juce_CodeEditorComponent.cpp index de0159e1c3..12189d23a5 100644 --- a/modules/juce_gui_extra/code_editor/juce_CodeEditorComponent.cpp +++ b/modules/juce_gui_extra/code_editor/juce_CodeEditorComponent.cpp @@ -71,6 +71,9 @@ private: void setSelection (Range r) override { + if (r == codeEditorComponent.getHighlightedRegion()) + return; + if (r.isEmpty()) { codeEditorComponent.caretPos.setPosition (r.getStart()); @@ -79,8 +82,10 @@ private: auto& doc = codeEditorComponent.document; - codeEditorComponent.selectRegion (CodeDocument::Position (doc, r.getStart()), - CodeDocument::Position (doc, r.getEnd())); + const auto cursorAtStart = r.getEnd() == codeEditorComponent.getHighlightedRegion().getStart() + || r.getEnd() == codeEditorComponent.getHighlightedRegion().getEnd(); + codeEditorComponent.selectRegion (CodeDocument::Position (doc, cursorAtStart ? r.getEnd() : r.getStart()), + CodeDocument::Position (doc, cursorAtStart ? r.getStart() : r.getEnd())); } String getText (Range r) const override @@ -126,7 +131,7 @@ private: localRects.add (startPos.x, startPos.y, - endPos.x - startPos.x, + jmax (1, endPos.x - startPos.x), codeEditorComponent.getLineHeight()); }