1
0
Fork 0
mirror of https://github.com/juce-framework/JUCE.git synced 2026-01-10 23:44:24 +00:00

Android: Fix problems with accessible text navigation

Previously, when navigating in a text editor by words, the cursor would
get 'stuck' after moving a single word. This issue should now be
resolved.

Additionally, the cursor position was not updated properly when
adjusting a selection, and would instead be moved to the end of the
selected range. With this patch applied, the cursor should now be set to
the correct position when modifying selections. When extending a
selection backwards, the cursor will display at the beginning of the
selected range, rather than the end.

Finally, most Android apps announce the 'skipped' characters or words
whenever the cursor is moved, but this feature was broken in JUCE. This
patch enables this feature.
This commit is contained in:
reuk 2022-05-24 20:35:28 +01:00
parent 83dca0f1e5
commit d4d9740037
No known key found for this signature in database
GPG key ID: 9ADCD339CFC98A11
10 changed files with 599 additions and 216 deletions

View file

@ -66,6 +66,9 @@ public:
/** Returns a section of text. */
virtual String getText (Range<int> 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;

View file

@ -45,6 +45,8 @@
#include "juce_gui_basics.h"
#include <cctype>
//==============================================================================
#if JUCE_MAC
#import <WebKit/WebKit.h>
@ -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

View file

@ -26,7 +26,32 @@
namespace juce
{
namespace AccessibilityTextHelpers
template <typename Ptr>
struct CharPtrIteratorTraits
{
using difference_type = int;
using value_type = decltype (*std::declval<Ptr>());
using pointer = value_type*;
using reference = value_type;
using iterator_category = std::bidirectional_iterator_tag;
};
} // namespace juce
namespace std
{
template <> struct iterator_traits<juce::CharPointer_UTF8> : juce::CharPtrIteratorTraits<juce::CharPointer_UTF8> {};
template <> struct iterator_traits<juce::CharPointer_UTF16> : juce::CharPtrIteratorTraits<juce::CharPointer_UTF16> {};
template <> struct iterator_traits<juce::CharPointer_UTF32> : juce::CharPtrIteratorTraits<juce::CharPointer_UTF32> {};
template <> struct iterator_traits<juce::CharPointer_ASCII> : juce::CharPtrIteratorTraits<juce::CharPointer_ASCII> {};
} // 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 <typename Iter>
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 <typename CharPtr>
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 <typename CharPtr>
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<int> 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<int>::between (cursorPos, oldPos == start ? end : start);
}
};
} // namespace juce

View file

@ -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<int>& 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<int> initial,
auto boundary,
const std::vector<Range<int>>& 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<int> 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<int> getSelection() const override { return selection; }
int getTextInsertionOffset() const override { return insert == CursorPosition::begin ? selection.getStart() : selection.getEnd(); }
String getText (Range<int> range) const override { return string.substring (range.getStart(), range.getEnd()); }
RectangleList<int> getTextBounds (Range<int>) const override { return {}; }
int getOffsetAtPoint (Point<int>) const override { return 0; }
void setSelection (Range<int> newRange) override { selection = newRange; }
void setText (const String& newText) override { string = newText; }
private:
String string;
Range<int> selection;
CursorPosition insert;
};
};
static AccessibilityTextHelpersTest accessibilityTextHelpersTest;
} // namespace juce

View file

@ -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<int>::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<jobject> accessibilityManager (env->CallObjectMethod (appContext.get(), AndroidContext.getSystemService,
javaString ("accessibility").get()));
if (accessibilityManager != nullptr)
return env->CallBooleanMethod (accessibilityManager.get(), AndroidAccessibilityManager.isEnabled);
}
return false;
}
template <typename ModificationCallback>
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<jobject> 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<int, AccessibilityHandler*> 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<int>
{
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<jobject> 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<jobject> 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 = []

View file

@ -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<int> (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;
});

View file

@ -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;
}
}

View file

@ -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<int> 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<int> 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<AccessibilityHandler> TextEditor::createAccessibilityHandler()
{
return std::make_unique<TextEditorAccessibilityHandler> (*this);
return std::make_unique<EditorAccessibilityHandler> (*this);
}
} // namespace juce

View file

@ -760,6 +760,7 @@ private:
struct TextEditorViewport;
struct InsertAction;
struct RemoveAction;
class EditorAccessibilityHandler;
std::unique_ptr<Viewport> viewport;
TextHolderComponent* textHolder;

View file

@ -71,6 +71,9 @@ private:
void setSelection (Range<int> 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<int> 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());
}