From 671a846d02ab34958bff360d5ef5978cbcc0e30c Mon Sep 17 00:00:00 2001 From: J-P Nurmi Date: Wed, 7 Jun 2023 19:18:04 +0200 Subject: [PATCH] [Linux][a11y] implement AtkText::get_text/string_at_offset() (#38144) This PR implements `AtkText::get_string_at_offset()` (and the deprecated `AtkText::get_text_at_offset()` still used by e.g. Orca) for `FlAccessibleTextField` to allow Orca to read out loud the current character while moving the text cursor around. ### Before (unmute to hear the screen reader) [textfield-a11y-before.webm](https://user-images.githubusercontent.com/140617/206556644-fb4f4df8-acca-4d97-86d5-7120f0a4871d.webm) ### After (unmute to hear the screen reader) [textfield-a11y-after.webm](https://user-images.githubusercontent.com/140617/206556678-4fbf9112-291e-4518-a258-e9ca33469430.webm) Fixes: flutter/flutter#113049 [C++, Objective-C, Java style guides]: https://github.com/flutter/engine/blob/main/CONTRIBUTING.md#style --- shell/platform/linux/fl_accessible_node.cc | 15 ++ shell/platform/linux/fl_accessible_node.h | 12 + .../linux/fl_accessible_text_field.cc | 219 +++++++++++++++++- .../linux/fl_accessible_text_field_test.cc | 117 ++++++++++ shell/platform/linux/fl_view_accessible.cc | 1 + testing/lsan_suppressions.txt | 3 + 6 files changed, 366 insertions(+), 1 deletion(-) diff --git a/shell/platform/linux/fl_accessible_node.cc b/shell/platform/linux/fl_accessible_node.cc index d0681de80e761..f8316755ecb87 100644 --- a/shell/platform/linux/fl_accessible_node.cc +++ b/shell/platform/linux/fl_accessible_node.cc @@ -405,6 +405,11 @@ static void fl_accessible_node_set_text_selection_impl(FlAccessibleNode* self, gint base, gint extent) {} +// Implements FlAccessibleNode::set_text_direction. +static void fl_accessible_node_set_text_direction_impl( + FlAccessibleNode* self, + FlutterTextDirection direction) {} + // Implements FlAccessibleNode::perform_action. static void fl_accessible_node_perform_action_impl( FlAccessibleNode* self, @@ -436,6 +441,8 @@ static void fl_accessible_node_class_init(FlAccessibleNodeClass* klass) { fl_accessible_node_set_value_impl; FL_ACCESSIBLE_NODE_CLASS(klass)->set_text_selection = fl_accessible_node_set_text_selection_impl; + FL_ACCESSIBLE_NODE_CLASS(klass)->set_text_direction = + fl_accessible_node_set_text_direction_impl; FL_ACCESSIBLE_NODE_CLASS(klass)->perform_action = fl_accessible_node_perform_action_impl; @@ -561,6 +568,14 @@ void fl_accessible_node_set_text_selection(FlAccessibleNode* self, extent); } +void fl_accessible_node_set_text_direction(FlAccessibleNode* self, + FlutterTextDirection direction) { + g_return_if_fail(FL_IS_ACCESSIBLE_NODE(self)); + + return FL_ACCESSIBLE_NODE_GET_CLASS(self)->set_text_direction(self, + direction); +} + void fl_accessible_node_perform_action(FlAccessibleNode* self, FlutterSemanticsAction action, GBytes* data) { diff --git a/shell/platform/linux/fl_accessible_node.h b/shell/platform/linux/fl_accessible_node.h index d9c0f9ec1ebf5..7969b5419fa34 100644 --- a/shell/platform/linux/fl_accessible_node.h +++ b/shell/platform/linux/fl_accessible_node.h @@ -45,6 +45,8 @@ struct _FlAccessibleNodeClass { void (*set_actions)(FlAccessibleNode* node, FlutterSemanticsAction actions); void (*set_value)(FlAccessibleNode* node, const gchar* value); void (*set_text_selection)(FlAccessibleNode* node, gint base, gint extent); + void (*set_text_direction)(FlAccessibleNode* node, + FlutterTextDirection direction); void (*perform_action)(FlAccessibleNode* node, FlutterSemanticsAction action, @@ -151,6 +153,16 @@ void fl_accessible_node_set_text_selection(FlAccessibleNode* node, gint base, gint extent); +/** + * fl_accessible_node_set_text_direction: + * @node: an #FlAccessibleNode. + * @direction: the direction of the text. + * + * Sets the text direction of this node. + */ +void fl_accessible_node_set_text_direction(FlAccessibleNode* node, + FlutterTextDirection direction); + /** * fl_accessible_node_dispatch_action: * @node: an #FlAccessibleNode. diff --git a/shell/platform/linux/fl_accessible_text_field.cc b/shell/platform/linux/fl_accessible_text_field.cc index 9c483e61fe360..9a6052d4777ec 100644 --- a/shell/platform/linux/fl_accessible_text_field.cc +++ b/shell/platform/linux/fl_accessible_text_field.cc @@ -6,12 +6,18 @@ #include "flutter/shell/platform/linux/public/flutter_linux/fl_standard_message_codec.h" #include "flutter/shell/platform/linux/public/flutter_linux/fl_value.h" +G_DEFINE_AUTOPTR_CLEANUP_FUNC(PangoContext, g_object_unref) +G_DEFINE_AUTOPTR_CLEANUP_FUNC(PangoLayout, g_object_unref) + +typedef bool (*FlTextBoundaryCallback)(const PangoLogAttr* attr); + struct _FlAccessibleTextField { FlAccessibleNode parent_instance; gint selection_base; gint selection_extent; GtkEntryBuffer* buffer; + FlutterTextDirection text_direction; }; static void fl_accessible_text_iface_init(AtkTextIface* iface); @@ -36,6 +42,145 @@ static gchar* get_substring(FlAccessibleTextField* self, return g_utf8_substring(value, start, end); } +static PangoContext* get_pango_context(FlAccessibleTextField* self) { + PangoFontMap* font_map = pango_cairo_font_map_get_default(); + PangoContext* context = pango_font_map_create_context(font_map); + pango_context_set_base_dir(context, + self->text_direction == kFlutterTextDirectionRTL + ? PANGO_DIRECTION_RTL + : PANGO_DIRECTION_LTR); + return context; +} + +static PangoLayout* create_pango_layout(FlAccessibleTextField* self) { + g_autoptr(PangoContext) context = get_pango_context(self); + PangoLayout* layout = pango_layout_new(context); + pango_layout_set_text(layout, gtk_entry_buffer_get_text(self->buffer), -1); + return layout; +} + +static gchar* get_string_at_offset(FlAccessibleTextField* self, + gint start, + gint end, + FlTextBoundaryCallback is_start, + FlTextBoundaryCallback is_end, + gint* start_offset, + gint* end_offset) { + g_autoptr(PangoLayout) layout = create_pango_layout(self); + + gint n_attrs = 0; + const PangoLogAttr* attrs = + pango_layout_get_log_attrs_readonly(layout, &n_attrs); + + while (start > 0 && !is_start(&attrs[start])) { + --start; + } + if (start_offset != nullptr) { + *start_offset = start; + } + + while (end < n_attrs && !is_end(&attrs[end])) { + ++end; + } + if (end_offset != nullptr) { + *end_offset = end; + } + + return get_substring(self, start, end); +} + +static gchar* get_char_at_offset(FlAccessibleTextField* self, + gint offset, + gint* start_offset, + gint* end_offset) { + return get_string_at_offset( + self, offset, offset + 1, + [](const PangoLogAttr* attr) -> bool { return attr->is_char_break; }, + [](const PangoLogAttr* attr) -> bool { return attr->is_char_break; }, + start_offset, end_offset); +} + +static gchar* get_word_at_offset(FlAccessibleTextField* self, + gint offset, + gint* start_offset, + gint* end_offset) { + return get_string_at_offset( + self, offset, offset, + [](const PangoLogAttr* attr) -> bool { return attr->is_word_start; }, + [](const PangoLogAttr* attr) -> bool { return attr->is_word_end; }, + start_offset, end_offset); +} + +static gchar* get_sentence_at_offset(FlAccessibleTextField* self, + gint offset, + gint* start_offset, + gint* end_offset) { + return get_string_at_offset( + self, offset, offset, + [](const PangoLogAttr* attr) -> bool { return attr->is_sentence_start; }, + [](const PangoLogAttr* attr) -> bool { return attr->is_sentence_end; }, + start_offset, end_offset); +} + +static gchar* get_line_at_offset(FlAccessibleTextField* self, + gint offset, + gint* start_offset, + gint* end_offset) { + g_autoptr(PangoLayout) layout = create_pango_layout(self); + + GSList* lines = pango_layout_get_lines_readonly(layout); + while (lines != nullptr) { + PangoLayoutLine* line = static_cast(lines->data); + if (offset >= line->start_index && + offset <= line->start_index + line->length) { + if (start_offset != nullptr) { + *start_offset = line->start_index; + } + if (end_offset != nullptr) { + *end_offset = line->start_index + line->length; + } + return get_substring(self, line->start_index, + line->start_index + line->length); + } + lines = lines->next; + } + + return nullptr; +} + +static gchar* get_paragraph_at_offset(FlAccessibleTextField* self, + gint offset, + gint* start_offset, + gint* end_offset) { + g_autoptr(PangoLayout) layout = create_pango_layout(self); + + PangoLayoutLine* start = nullptr; + PangoLayoutLine* end = nullptr; + gint n_lines = pango_layout_get_line_count(layout); + for (gint i = 0; i < n_lines; ++i) { + PangoLayoutLine* line = pango_layout_get_line(layout, i); + if (line->is_paragraph_start) { + end = line; + } + if (start != nullptr && end != nullptr && offset >= start->start_index && + offset <= end->start_index + end->length) { + if (start_offset != nullptr) { + *start_offset = start->start_index; + } + if (end_offset != nullptr) { + *end_offset = end->start_index + end->length; + } + return get_substring(self, start->start_index, + end->start_index + end->length); + } + if (line->is_paragraph_start) { + start = line; + } + } + + return nullptr; +} + static void perform_set_text_action(FlAccessibleTextField* self, const char* text) { g_autoptr(FlValue) value = fl_value_new_string(text); @@ -109,6 +254,16 @@ static void fl_accessible_text_field_set_text_selection(FlAccessibleNode* node, } } +// Implements FlAccessibleNode::set_text_direction. +static void fl_accessible_text_field_set_text_direction( + FlAccessibleNode* node, + FlutterTextDirection direction) { + g_return_if_fail(FL_IS_ACCESSIBLE_TEXT_FIELD(node)); + FlAccessibleTextField* self = FL_ACCESSIBLE_TEXT_FIELD(node); + + self->text_direction = direction; +} + // Overrides FlAccessibleNode::perform_action. void fl_accessible_text_field_perform_action(FlAccessibleNode* self, FlutterSemanticsAction action, @@ -154,6 +309,65 @@ static gchar* fl_accessible_text_field_get_text(AtkText* text, return get_substring(self, start_offset, end_offset); } +// Implements AtkText::get_string_at_offset. +static gchar* fl_accessible_text_field_get_string_at_offset( + AtkText* text, + gint offset, + AtkTextGranularity granularity, + gint* start_offset, + gint* end_offset) { + g_return_val_if_fail(FL_IS_ACCESSIBLE_TEXT_FIELD(text), nullptr); + FlAccessibleTextField* self = FL_ACCESSIBLE_TEXT_FIELD(text); + + switch (granularity) { + case ATK_TEXT_GRANULARITY_CHAR: + return get_char_at_offset(self, offset, start_offset, end_offset); + case ATK_TEXT_GRANULARITY_WORD: + return get_word_at_offset(self, offset, start_offset, end_offset); + case ATK_TEXT_GRANULARITY_SENTENCE: + return get_sentence_at_offset(self, offset, start_offset, end_offset); + case ATK_TEXT_GRANULARITY_LINE: + return get_line_at_offset(self, offset, start_offset, end_offset); + case ATK_TEXT_GRANULARITY_PARAGRAPH: + return get_paragraph_at_offset(self, offset, start_offset, end_offset); + default: + return nullptr; + } +} + +// Implements AtkText::get_text_at_offset (deprecated but still commonly used). +static gchar* fl_accessible_text_field_get_text_at_offset( + AtkText* text, + gint offset, + AtkTextBoundary boundary_type, + gint* start_offset, + gint* end_offset) { + switch (boundary_type) { + case ATK_TEXT_BOUNDARY_CHAR: + return fl_accessible_text_field_get_string_at_offset( + text, offset, ATK_TEXT_GRANULARITY_CHAR, start_offset, end_offset); + break; + case ATK_TEXT_BOUNDARY_WORD_START: + case ATK_TEXT_BOUNDARY_WORD_END: + return fl_accessible_text_field_get_string_at_offset( + text, offset, ATK_TEXT_GRANULARITY_WORD, start_offset, end_offset); + break; + case ATK_TEXT_BOUNDARY_SENTENCE_START: + case ATK_TEXT_BOUNDARY_SENTENCE_END: + return fl_accessible_text_field_get_string_at_offset( + text, offset, ATK_TEXT_GRANULARITY_SENTENCE, start_offset, + end_offset); + break; + case ATK_TEXT_BOUNDARY_LINE_START: + case ATK_TEXT_BOUNDARY_LINE_END: + return fl_accessible_text_field_get_string_at_offset( + text, offset, ATK_TEXT_GRANULARITY_LINE, start_offset, end_offset); + break; + default: + return nullptr; + } +} + // Implements AtkText::get_caret_offset. static gint fl_accessible_text_field_get_caret_offset(AtkText* text) { g_return_val_if_fail(FL_IS_ACCESSIBLE_TEXT_FIELD(text), -1); @@ -338,6 +552,8 @@ static void fl_accessible_text_field_class_init( fl_accessible_text_field_set_value; FL_ACCESSIBLE_NODE_CLASS(klass)->set_text_selection = fl_accessible_text_field_set_text_selection; + FL_ACCESSIBLE_NODE_CLASS(klass)->set_text_direction = + fl_accessible_text_field_set_text_direction; FL_ACCESSIBLE_NODE_CLASS(klass)->perform_action = fl_accessible_text_field_perform_action; } @@ -345,7 +561,8 @@ static void fl_accessible_text_field_class_init( static void fl_accessible_text_iface_init(AtkTextIface* iface) { iface->get_character_count = fl_accessible_text_field_get_character_count; iface->get_text = fl_accessible_text_field_get_text; - // TODO(jpnurmi): get_text_at/before/after_offset + iface->get_text_at_offset = fl_accessible_text_field_get_text_at_offset; + iface->get_string_at_offset = fl_accessible_text_field_get_string_at_offset; iface->get_caret_offset = fl_accessible_text_field_get_caret_offset; iface->set_caret_offset = fl_accessible_text_field_set_caret_offset; diff --git a/shell/platform/linux/fl_accessible_text_field_test.cc b/shell/platform/linux/fl_accessible_text_field_test.cc index 475711345ab67..396c4b0b7bf00 100644 --- a/shell/platform/linux/fl_accessible_text_field_test.cc +++ b/shell/platform/linux/fl_accessible_text_field_test.cc @@ -521,4 +521,121 @@ TEST(FlAccessibleTextFieldTest, CopyCutPasteText) { EXPECT_EQ(act, kFlutterSemanticsActionPaste); } +TEST(FlAccessibleTextFieldTest, TextBoundary) { + g_autoptr(FlEngine) engine = make_mock_engine(); + g_autoptr(FlAccessibleNode) node = fl_accessible_text_field_new(engine, 1); + + fl_accessible_node_set_value(node, + "Lorem ipsum.\nDolor sit amet. Praesent commodo?" + "\n\nPraesent et felis dui."); + + // |Lorem + gint start_offset = -1, end_offset = -1; + g_autofree gchar* lorem_char = atk_text_get_string_at_offset( + ATK_TEXT(node), 0, ATK_TEXT_GRANULARITY_CHAR, &start_offset, &end_offset); + EXPECT_STREQ(lorem_char, "L"); + EXPECT_EQ(start_offset, 0); + EXPECT_EQ(end_offset, 1); + + g_autofree gchar* lorem_word = atk_text_get_string_at_offset( + ATK_TEXT(node), 0, ATK_TEXT_GRANULARITY_WORD, &start_offset, &end_offset); + EXPECT_STREQ(lorem_word, "Lorem"); + EXPECT_EQ(start_offset, 0); + EXPECT_EQ(end_offset, 5); + + g_autofree gchar* lorem_sentence = atk_text_get_string_at_offset( + ATK_TEXT(node), 0, ATK_TEXT_GRANULARITY_SENTENCE, &start_offset, + &end_offset); + EXPECT_STREQ(lorem_sentence, "Lorem ipsum."); + EXPECT_EQ(start_offset, 0); + EXPECT_EQ(end_offset, 12); + + g_autofree gchar* lorem_line = atk_text_get_string_at_offset( + ATK_TEXT(node), 0, ATK_TEXT_GRANULARITY_LINE, &start_offset, &end_offset); + EXPECT_STREQ(lorem_line, "Lorem ipsum."); + EXPECT_EQ(start_offset, 0); + EXPECT_EQ(end_offset, 12); + + g_autofree gchar* lorem_paragraph = atk_text_get_string_at_offset( + ATK_TEXT(node), 0, ATK_TEXT_GRANULARITY_PARAGRAPH, &start_offset, + &end_offset); + EXPECT_STREQ(lorem_paragraph, + "Lorem ipsum.\nDolor sit amet. Praesent commodo?"); + EXPECT_EQ(start_offset, 0); + EXPECT_EQ(end_offset, 46); + + // Pra|esent + g_autofree gchar* praesent_char = atk_text_get_string_at_offset( + ATK_TEXT(node), 32, ATK_TEXT_GRANULARITY_CHAR, &start_offset, + &end_offset); + EXPECT_STREQ(praesent_char, "e"); + EXPECT_EQ(start_offset, 32); + EXPECT_EQ(end_offset, 33); + + g_autofree gchar* praesent_word = atk_text_get_string_at_offset( + ATK_TEXT(node), 32, ATK_TEXT_GRANULARITY_WORD, &start_offset, + &end_offset); + EXPECT_STREQ(praesent_word, "Praesent"); + EXPECT_EQ(start_offset, 29); + EXPECT_EQ(end_offset, 37); + + g_autofree gchar* praesent_sentence = atk_text_get_string_at_offset( + ATK_TEXT(node), 32, ATK_TEXT_GRANULARITY_SENTENCE, &start_offset, + &end_offset); + EXPECT_STREQ(praesent_sentence, "Praesent commodo?"); + EXPECT_EQ(start_offset, 29); + EXPECT_EQ(end_offset, 46); + + g_autofree gchar* praesent_line = atk_text_get_string_at_offset( + ATK_TEXT(node), 32, ATK_TEXT_GRANULARITY_LINE, &start_offset, + &end_offset); + EXPECT_STREQ(praesent_line, "Dolor sit amet. Praesent commodo?"); + EXPECT_EQ(start_offset, 13); + EXPECT_EQ(end_offset, 46); + + g_autofree gchar* praesent_paragraph = atk_text_get_string_at_offset( + ATK_TEXT(node), 32, ATK_TEXT_GRANULARITY_PARAGRAPH, &start_offset, + &end_offset); + EXPECT_STREQ(praesent_paragraph, + "Lorem ipsum.\nDolor sit amet. Praesent commodo?"); + EXPECT_EQ(start_offset, 0); + EXPECT_EQ(end_offset, 46); + + // feli|s + g_autofree gchar* felis_char = atk_text_get_string_at_offset( + ATK_TEXT(node), 64, ATK_TEXT_GRANULARITY_CHAR, &start_offset, + &end_offset); + EXPECT_STREQ(felis_char, "s"); + EXPECT_EQ(start_offset, 64); + EXPECT_EQ(end_offset, 65); + + g_autofree gchar* felis_word = atk_text_get_string_at_offset( + ATK_TEXT(node), 64, ATK_TEXT_GRANULARITY_WORD, &start_offset, + &end_offset); + EXPECT_STREQ(felis_word, "felis"); + EXPECT_EQ(start_offset, 60); + EXPECT_EQ(end_offset, 65); + + g_autofree gchar* felis_sentence = atk_text_get_string_at_offset( + ATK_TEXT(node), 64, ATK_TEXT_GRANULARITY_SENTENCE, &start_offset, + &end_offset); + EXPECT_STREQ(felis_sentence, "Praesent et felis dui."); + EXPECT_EQ(start_offset, 48); + EXPECT_EQ(end_offset, 70); + + g_autofree gchar* felis_line = atk_text_get_string_at_offset( + ATK_TEXT(node), 64, ATK_TEXT_GRANULARITY_LINE, &start_offset, + &end_offset); + EXPECT_STREQ(felis_line, "Praesent et felis dui."); + EXPECT_EQ(start_offset, 48); + EXPECT_EQ(end_offset, 70); + + g_autofree gchar* felis_paragraph = atk_text_get_string_at_offset( + ATK_TEXT(node), 64, ATK_TEXT_GRANULARITY_PARAGRAPH, &start_offset, + &end_offset); + EXPECT_STREQ(felis_paragraph, "\nPraesent et felis dui."); + EXPECT_EQ(start_offset, 47); + EXPECT_EQ(end_offset, 70); +} + // NOLINTEND(clang-analyzer-core.StackAddressEscape) diff --git a/shell/platform/linux/fl_view_accessible.cc b/shell/platform/linux/fl_view_accessible.cc index 19d81c18050f6..5ed84091214db 100644 --- a/shell/platform/linux/fl_view_accessible.cc +++ b/shell/platform/linux/fl_view_accessible.cc @@ -209,6 +209,7 @@ void fl_view_accessible_handle_update_semantics_node( fl_accessible_node_set_value(atk_node, node->value); fl_accessible_node_set_text_selection(atk_node, node->text_selection_base, node->text_selection_extent); + fl_accessible_node_set_text_direction(atk_node, node->text_direction); FlValue* children = fl_value_new_int32_list(node->children_in_traversal_order, node->child_count); diff --git a/testing/lsan_suppressions.txt b/testing/lsan_suppressions.txt index d6891ae3cf12f..9a36fe57ecd68 100644 --- a/testing/lsan_suppressions.txt +++ b/testing/lsan_suppressions.txt @@ -68,3 +68,6 @@ leak:g_realloc # ANGLE's egl impelementation uses annotations that our LSAN setup does # not respect. Ignore leaks from egl::* leak:egl::* + +# False positives in libfontconfig. http://crbug.com/39050 +leak:libfontconfig