From 0e4fb9ae735b804002b323eb34af73e0525896e3 Mon Sep 17 00:00:00 2001 From: Timothy Flynn Date: Fri, 15 Aug 2025 15:13:23 -0400 Subject: [PATCH] LibWeb: Ensure up/down arrow navigation is grapheme-aware Previously, it was possible for an up/down arrow press to place the cursor in the middle of a multi-code point grapheme cluster. We want to prevent this in a way that matches the behavior of other browsers. Both Chrome and Firefox will map the starting position to a visually equivalent position in the target line with harfbuzz and ICU segmenters. The need for this is explained in a code comment. The result is a much more natural feeling of text navigation. --- .../LibWeb/HTML/FormAssociatedElement.cpp | 139 +++++++++++++----- Libraries/LibWeb/Layout/TextNode.cpp | 9 ++ Libraries/LibWeb/Layout/TextNode.h | 1 + .../expected/textarea-arrow-navigation.txt | 18 +++ .../Text/input/textarea-arrow-navigation.html | 51 +++++++ 5 files changed, 182 insertions(+), 36 deletions(-) create mode 100644 Tests/LibWeb/Text/expected/textarea-arrow-navigation.txt create mode 100644 Tests/LibWeb/Text/input/textarea-arrow-navigation.html diff --git a/Libraries/LibWeb/HTML/FormAssociatedElement.cpp b/Libraries/LibWeb/HTML/FormAssociatedElement.cpp index 66a61630be2..a45236e83a3 100644 --- a/Libraries/LibWeb/HTML/FormAssociatedElement.cpp +++ b/Libraries/LibWeb/HTML/FormAssociatedElement.cpp @@ -26,6 +26,7 @@ #include #include #include +#include #include #include @@ -1034,70 +1035,136 @@ static constexpr size_t find_line_end(Utf16View const& view, size_t offset) return offset; } +static float measure_text_width(Layout::TextNode const& text_node, Utf16View const& text) +{ + if (text.is_empty()) + return 0; + + auto segmenter = text_node.grapheme_segmenter().clone(); + segmenter->set_segmented_text(text); + + Layout::TextNode::ChunkIterator iterator { text_node, text, *segmenter, false, false }; + float width = 0; + + for (auto chunk = iterator.next(); chunk.has_value(); chunk = iterator.next()) + width += chunk->font->width(chunk->view); + + return width; +} + +static size_t translate_position_across_lines(Layout::TextNode const& text_node, Utf16View const& source_line, Utf16View const& target_line) +{ + // When we want to move the cursor from some position within a line to a visually-equivalent position in an adjacent + // line, there are several things to consider. Let's use the following HTML as an example: + // + // + // + // And let's define the following terms: + // * logical index = the raw code unit offset of the cursor + // * visual index = the grapheme-aware offset of the cursor (i.e. the offset the user actually perceives) + // * text affinity = the side (left or right) of a grapheme that the cursor is visually closest to + // + // If we want to move the cursor from the position just after "hello" (logical index=5, visual index=5) to the next + // line, the user will expect the cursor to be located just after the "👩🏼‍❤️‍👨🏻" (logical index=15, visual index=4). These + // locations do not share the same visual index, so it's not enough to simply map the visual index of 5 back to a + // logical index on the next line. The difference becomes even more apparent when multiple fonts are used within a + // single line. + // + // Instead, we must measure the text between the start of the line and the starting index. On the next line, we want + // to find the position whose corresponding width is as close to the starting width as possible. The target width + // might not be the same as the starting width at all, so we must further consider the text affinity. We want to + // chose a target index whose affinity brings us closest to the starting width. + + auto source_line_width = measure_text_width(text_node, source_line); + + auto left_edge = 0uz; + auto width_to_left_edge = 0.0f; + + auto right_edge = 0uz; + auto width_to_right_edge = 0.0f; + + text_node.grapheme_segmenter().clone()->for_each_boundary(target_line, [&](auto index) { + auto current_width = measure_text_width(text_node, target_line.substring_view(left_edge, index - left_edge)); + + right_edge = index; + width_to_right_edge = width_to_left_edge + current_width; + + if (width_to_right_edge >= source_line_width) + return IterationDecision::Break; + + left_edge = index; + width_to_left_edge += current_width; + + return IterationDecision::Continue; + }); + + if ((source_line_width - width_to_left_edge) < (width_to_right_edge - source_line_width)) + return left_edge; + return right_edge; +} + void FormAssociatedTextControlElement::increment_cursor_position_to_next_line(CollapseSelection collapse) { - auto const text_node = form_associated_element_to_text_node(); - if (!text_node) + auto dom_node = form_associated_element_to_text_node(); + if (!dom_node) return; - auto code_points = text_node->data().utf16_view(); - auto length = code_points.length_in_code_units(); - auto current_line_end = find_line_end(code_points, m_selection_end); + auto const* layout_node = as_if(dom_node->layout_node()); + if (!layout_node) + return; - // initialize to handle the case of last line - size_t new_offset = current_line_end; + auto text = dom_node->data().utf16_view(); + auto new_offset = text.length_in_code_units(); + + if (auto current_line_end = find_line_end(text, m_selection_end); current_line_end < text.length_in_code_units()) { + auto current_line_start = find_line_start(text, m_selection_end); + auto current_line_text = text.substring_view(current_line_start, m_selection_end - current_line_start); - if (current_line_end < length) { auto next_line_start = current_line_end + 1; - auto position_within_line = m_selection_end - find_line_start(code_points, m_selection_end); - auto next_line_end = find_line_end(code_points, next_line_start); - auto next_line_length = next_line_end - next_line_start; + auto next_line_length = find_line_end(text, next_line_start) - next_line_start; + auto next_line_text = text.substring_view(next_line_start, next_line_length); - new_offset = next_line_start + min(position_within_line, next_line_length); - - if (new_offset > 0 && new_offset < length && AK::UnicodeUtils::is_utf16_low_surrogate(code_points.code_unit_at(new_offset))) { - if (AK::UnicodeUtils::is_utf16_high_surrogate(code_points.code_unit_at(new_offset - 1))) - --new_offset; - } + new_offset = next_line_start + translate_position_across_lines(*layout_node, current_line_text, next_line_text); } - if (collapse == CollapseSelection::Yes) { + if (collapse == CollapseSelection::Yes) collapse_selection_to_offset(new_offset); - } else { + else m_selection_end = new_offset; - } selection_was_changed(); } void FormAssociatedTextControlElement::decrement_cursor_position_to_previous_line(CollapseSelection collapse) { - auto const text_node = form_associated_element_to_text_node(); - if (!text_node) + auto dom_node = form_associated_element_to_text_node(); + if (!dom_node) return; - auto code_points = text_node->data().utf16_view(); - size_t new_offset = 0; + auto const* layout_node = as_if(dom_node->layout_node()); + if (!layout_node) + return; - if (auto current_line_start = find_line_start(code_points, m_selection_end); current_line_start != 0) { - auto position_within_line = m_selection_end - current_line_start; + auto text = dom_node->data().utf16_view(); + auto new_offset = 0uz; - auto previous_line_start = find_line_start(code_points, current_line_start - 1); + if (auto current_line_start = find_line_start(text, m_selection_end); current_line_start != 0) { + auto current_line_text = text.substring_view(current_line_start, m_selection_end - current_line_start); + + auto previous_line_start = find_line_start(text, current_line_start - 1); auto previous_line_length = current_line_start - previous_line_start - 1; + auto previous_line_text = text.substring_view(previous_line_start, previous_line_length); - new_offset = previous_line_start + min(position_within_line, previous_line_length); - - if (new_offset > 0 && AK::UnicodeUtils::is_utf16_low_surrogate(code_points.code_unit_at(new_offset))) { - if (AK::UnicodeUtils::is_utf16_high_surrogate(code_points.code_unit_at(new_offset - 1))) - --new_offset; - } + new_offset = previous_line_start + translate_position_across_lines(*layout_node, current_line_text, previous_line_text); } - if (collapse == CollapseSelection::Yes) { + if (collapse == CollapseSelection::Yes) collapse_selection_to_offset(new_offset); - } else { + else m_selection_end = new_offset; - } selection_was_changed(); } diff --git a/Libraries/LibWeb/Layout/TextNode.cpp b/Libraries/LibWeb/Layout/TextNode.cpp index f709abaf128..5ec3c793c34 100644 --- a/Libraries/LibWeb/Layout/TextNode.cpp +++ b/Libraries/LibWeb/Layout/TextNode.cpp @@ -391,6 +391,15 @@ TextNode::ChunkIterator::ChunkIterator(TextNode const& text_node, bool wrap_line { } +TextNode::ChunkIterator::ChunkIterator(TextNode const& text_node, Utf16View const& text, Unicode::Segmenter& grapheme_segmenter, bool wrap_lines, bool respect_linebreaks) + : m_wrap_lines(wrap_lines) + , m_respect_linebreaks(respect_linebreaks) + , m_view(text) + , m_font_cascade_list(text_node.computed_values().font_list()) + , m_grapheme_segmenter(grapheme_segmenter) +{ +} + static Gfx::GlyphRun::TextType text_type_for_code_point(u32 code_point) { switch (Unicode::bidirectional_class(code_point)) { diff --git a/Libraries/LibWeb/Layout/TextNode.h b/Libraries/LibWeb/Layout/TextNode.h index 6e12d1be672..985811a627c 100644 --- a/Libraries/LibWeb/Layout/TextNode.h +++ b/Libraries/LibWeb/Layout/TextNode.h @@ -43,6 +43,7 @@ public: class ChunkIterator { public: ChunkIterator(TextNode const&, bool wrap_lines, bool respect_linebreaks); + ChunkIterator(TextNode const&, Utf16View const&, Unicode::Segmenter&, bool wrap_lines, bool respect_linebreaks); Optional next(); Optional peek(size_t); diff --git a/Tests/LibWeb/Text/expected/textarea-arrow-navigation.txt b/Tests/LibWeb/Text/expected/textarea-arrow-navigation.txt new file mode 100644 index 00000000000..1320f6bb89a --- /dev/null +++ b/Tests/LibWeb/Text/expected/textarea-arrow-navigation.txt @@ -0,0 +1,18 @@ +Right: position=1 character="e" +Right: position=2 character="l" +Right: position=3 character="l" +Right: position=4 character="o" +Right: position=5 character=" " +Down: position=28 character="👩🏼‍❤️‍👨🏻" +Left: position=27 character=" " +Up: position=2 character="l" +Right: position=3 character="l" +Right: position=4 character="o" +Right: position=5 character=" " +Right: position=6 character="👩🏼‍❤️‍👨🏻" +Down: position=40 character=" " +Up: position=6 character="👩🏼‍❤️‍👨🏻" +Down: position=40 character=" " +Left: position=28 character="👩🏼‍❤️‍👨🏻" +Left: position=27 character=" " +Up: position=2 character="l" diff --git a/Tests/LibWeb/Text/input/textarea-arrow-navigation.html b/Tests/LibWeb/Text/input/textarea-arrow-navigation.html new file mode 100644 index 00000000000..ea5ffeaaeb5 --- /dev/null +++ b/Tests/LibWeb/Text/input/textarea-arrow-navigation.html @@ -0,0 +1,51 @@ + + + +