diff --git a/Source/Core/Elements/WidgetTextInput.cpp b/Source/Core/Elements/WidgetTextInput.cpp index 28b832746..5733c8c96 100644 --- a/Source/Core/Elements/WidgetTextInput.cpp +++ b/Source/Core/Elements/WidgetTextInput.cpp @@ -50,8 +50,9 @@ namespace Rml { -static constexpr float CURSOR_BLINK_TIME = 0.7f; // [s] -static constexpr float OVERFLOW_TOLERANCE = 0.5f; // [px] +static constexpr float CURSOR_BLINK_TIME = 0.7f; // [s] +static constexpr float OVERFLOW_TOLERANCE = 0.5f; // [px] +static constexpr float COMPOSITION_UNDERLINE_WIDTH = 2.f; // [px] enum class CharacterClass { Word, Punctuation, Newline, Whitespace, Undefined }; static CharacterClass GetCharacterClass(char c) @@ -193,6 +194,7 @@ WidgetTextInput::WidgetTextInput(ElementFormControl* _parent) : parent->SetProperty(PropertyId::Drag, Property(Style::Drag::Drag)); parent->SetProperty(PropertyId::WordBreak, Property(Style::WordBreak::BreakWord)); parent->SetProperty(PropertyId::TextTransform, Property(Style::TextTransform::None)); + parent->SetProperty(PropertyId::Clip, Property(Style::Clip::Type::Auto)); parent->AddEventListener(EventId::Keydown, this, true); parent->AddEventListener(EventId::Textinput, this, true); @@ -242,6 +244,7 @@ WidgetTextInput::WidgetTextInput(ElementFormControl* _parent) : ime_composition_end_index = 0; last_update_time = 0; + ink_overflow = false; ShowCursor(false); } @@ -467,8 +470,7 @@ void WidgetTextInput::OnRender() ElementUtilities::SetClippingRegion(text_element); Vector2f text_translation = parent->GetAbsoluteOffset() - Vector2f(parent->GetScrollLeft(), parent->GetScrollTop()); - selection_geometry.Render(text_translation); - ime_composition_geometry.Render(text_translation); + selection_composition_geometry.Render(text_translation); if (cursor_visible && !parent->IsDisabled()) { @@ -1059,20 +1061,36 @@ int WidgetTextInput::CalculateLineIndex(float position) const return Math::Clamp(line_index, 0, (int)(lines.size() - 1)); } -float WidgetTextInput::GetAlignmentSpecificTextOffset(const char* p_begin, int line_index) const +float WidgetTextInput::GetAlignmentSpecificTextOffset(const Line& line) const { - const float available_width = GetAvailableWidth(); - const float total_width = (float)ElementUtilities::GetStringWidth(text_element, String(p_begin, lines[line_index].editable_length)); - auto text_align = GetElement()->GetComputedValues().text_align(); + const String& value = GetValue(); + StringView editable_line_string(value, line.value_offset, line.editable_length); + + const Style::TextAlign text_align = parent->GetComputedValues().text_align(); + + // Callback to avoid expensive calculation in the cases where it is not needed. + auto RemainingWidth = [&] { + const float total_width = (float)ElementUtilities::GetStringWidth(text_element, String(editable_line_string)); + return GetAvailableWidth() - total_width; + }; - // offset position depending on text align switch (text_align) { - case Style::TextAlign::Right: return Math::Max(0.0f, (available_width - total_width)); - case Style::TextAlign::Center: return Math::Max(0.0f, ((available_width - total_width) / 2)); - default: break; + case Style::TextAlign::Left: return 0; + case Style::TextAlign::Right: + { + // For right alignment with soft-wrapped newlines, remove up to a single space to align the last word to the right edge. + const bool is_last_line = (line.value_offset + line.size == (int)value.size()); + const bool is_soft_wrapped = (!is_last_line && line.editable_length == line.size); + if (is_soft_wrapped && editable_line_string.size() > 0 && *(editable_line_string.end() - 1) == ' ') + { + editable_line_string = StringView(editable_line_string.begin(), editable_line_string.end() - 1); + } + return Math::Max(0.0f, RemainingWidth()); + } + case Style::TextAlign::Center: return Math::Max(0.0f, 0.5f * RemainingWidth()); + case Style::TextAlign::Justify: return 0; } - return 0; } @@ -1083,11 +1101,12 @@ int WidgetTextInput::CalculateCharacterIndex(int line_index, float position) ideal_cursor_position_to_the_right_of_cursor = true; - const char* p_begin = GetValue().data() + lines[line_index].value_offset; + const Line& line = lines[line_index]; + const char* p_begin = GetValue().data() + line.value_offset; - position -= GetAlignmentSpecificTextOffset(p_begin, line_index); + position -= GetAlignmentSpecificTextOffset(line); - for (auto it = StringIteratorU8(p_begin, p_begin, p_begin + lines[line_index].editable_length); it;) + for (auto it = StringIteratorU8(p_begin, p_begin, p_begin + line.editable_length); it;) { ++it; const int offset = (int)it.offset(); @@ -1130,8 +1149,11 @@ void WidgetTextInput::ShowCursor(bool show, bool move_to_cursor) else if (parent->GetScrollTop() > cursor_position.y) parent->SetScrollTop(cursor_position.y); + const bool word_wrap = parent->GetComputedValues().white_space() == Style::WhiteSpace::Prewrap; float minimum_scroll_left = (cursor_position.x + cursor_size.x) - GetAvailableWidth(); - if (parent->GetScrollLeft() < minimum_scroll_left) + if (word_wrap) + parent->SetScrollLeft(0.f); + else if (parent->GetScrollLeft() < minimum_scroll_left) parent->SetScrollLeft(minimum_scroll_left); else if (parent->GetScrollLeft() > cursor_position.x) parent->SetScrollLeft(cursor_position.x); @@ -1231,25 +1253,10 @@ Vector2f WidgetTextInput::FormatText(float height_constraint) Vector2f line_position = {0, top_to_baseline}; bool last_line = false; - auto text_align = GetElement()->GetComputedValues().text_align(); - - struct Segment { - Vector2f position; - int width; - String content; - bool selected; - int line_index; - }; + float max_selection_right_edge = 0; - Vector segments; - - struct IMESegment { - Vector2f position; - int width; - int line_index; - }; - - Vector ime_segments; + // Clear the selection background and IME composition geometry, and get the vertices and indices so the new geometry can be generated. + Mesh selection_composition_mesh = selection_composition_geometry.Release(Geometry::ReleaseMode::ClearMesh); // Keep generating lines until all the text content is placed. do @@ -1269,44 +1276,33 @@ Vector2f WidgetTextInput::FormatText(float height_constraint) last_line = text_element->GenerateLine(line_content, line.size, line_width, line_begin, available_width - cursor_size.x, 0, false, false, false); - // If this line terminates in a soft-return (word wrap), then the line may be leaving a space or two behind as an orphan. If so, we must - // append the orphan onto the line even though it will push the line outside of the input field's bounds. - String orphan; - if (!last_line && (line_content.empty() || line_content.back() != '\n')) + // Check if the editable length needs to be truncated to dodge a trailing endline. + line.editable_length = (int)line_content.size(); + if (!line_content.empty() && line_content.back() == '\n') + line.editable_length -= 1; + + // Include all spaces at the end of this line, if they were not included due to soft-wrapping in `GenerateLine`. + // This helps prevent sudden shifts when whitespace wraps down to the next line. { const String& text = GetValue(); - for (int i = 1; i >= 0; --i) + size_t i_space_begin = size_t(line_begin + line.editable_length); + size_t i_space_end = Math::Min(text.find_first_not_of(' ', i_space_begin), text.size()); + size_t count = i_space_end - i_space_begin; + if (count > 0) { - int index = line_begin + line.size + i; - if (index >= (int)text.size()) - continue; - - if (text[index] != ' ') - { - orphan.clear(); - continue; - } - - int next_index = index + 1; - if (!orphan.empty() || next_index >= (int)text.size() || text[next_index] != ' ') - orphan += ' '; + line_content.append(count, ' '); + line_width += ElementUtilities::GetStringWidth(text_element, " ") * (int)count; + line.editable_length += (int)count; + line.size += (int)count; + // Consume the hard wrap if we have one on this line, so that it doesn't make its own, empty line. + if (text[i_space_end] == '\n') + line.size += 1; + // If the spaces extend all the way to the end, we have consumed all the lines. + if (i_space_end == text.size()) + last_line = true; } } - if (!orphan.empty()) - { - line_content += orphan; - line.size += (int)orphan.size(); - line_width += ElementUtilities::GetStringWidth(text_element, orphan); - } - - // visually remove trailing space if right aligned - if (!last_line && text_align == Style::TextAlign::Right && !line_content.empty() && line_content.back() == ' ') - { - line_content.pop_back(); - line_width -= ElementUtilities::GetStringWidth(text_element, " "); - } - // Now that we have the string of characters appearing on the new line, we split it into // three parts; the unselected text appearing before any selected text on the line, the // selected text on the line, and any unselected text after the selection. @@ -1318,7 +1314,7 @@ Vector2f WidgetTextInput::FormatText(float height_constraint) if (!pre_selection.empty()) { const int width = ElementUtilities::GetStringWidth(text_element, pre_selection); - segments.push_back({line_position, width, pre_selection, false, (int)lines.size()}); + text_element->AddLine(line_position + Vector2f{GetAlignmentSpecificTextOffset(line), 0}, pre_selection); line_position.x += width; } @@ -1336,19 +1332,22 @@ Vector2f WidgetTextInput::FormatText(float height_constraint) return float(width_kerning - width_no_kerning); }; - // Check if the editable length needs to be truncated to dodge a trailing endline. - line.editable_length = (int)line_content.size(); - if (!line_content.empty() && line_content.back() == '\n') - line.editable_length -= 1; - // If there is any selected text on this line, place it in the selected text element and // generate the geometry for its background. if (!selection.empty()) { line_position.x += GetKerningBetween(pre_selection, selection); + const int selection_width = ElementUtilities::GetStringWidth(selected_text_element, selection); - segments.push_back({line_position, selection_width, selection, true, (int)lines.size()}); + const bool selection_contains_endline = (selection_begin_index + selection_length > line_begin + line.editable_length); + const Vector2f selection_size = {float(selection_width + (selection_contains_endline ? endline_font_width : 0)), line_height}; + const Vector2f aligned_position = line_position + Vector2f{GetAlignmentSpecificTextOffset(line), 0}; + MeshUtilities::GenerateQuad(selection_composition_mesh, aligned_position - Vector2f(0, top_to_baseline), selection_size, + selection_colour); + selected_text_element->AddLine(aligned_position, selection); + + max_selection_right_edge = Math::Max(max_selection_right_edge, aligned_position.x + selection_size.x); line_position.x += selection_width; } @@ -1357,8 +1356,7 @@ Vector2f WidgetTextInput::FormatText(float height_constraint) if (!post_selection.empty()) { line_position.x += GetKerningBetween(selection, post_selection); - const int width = ElementUtilities::GetStringWidth(text_element, post_selection); - segments.push_back({line_position, width, post_selection, false, (int)lines.size()}); + text_element->AddLine(line_position + Vector2f{GetAlignmentSpecificTextOffset(line), 0}, post_selection); } // We fetch the IME composition on the new line to highlight it. @@ -1368,9 +1366,16 @@ Vector2f WidgetTextInput::FormatText(float height_constraint) // If there is any IME composition string on the line, create a segment for its underline. if (!ime_composition.empty()) { + const bool composition_contains_endline = (ime_composition_end_index > line_begin + line.editable_length); const int composition_width = ElementUtilities::GetStringWidth(text_element, ime_composition); - const Vector2f composition_position(float(ElementUtilities::GetStringWidth(text_element, ime_pre_composition)), line_position.y); - ime_segments.push_back({composition_position, composition_width, (int)lines.size()}); + const Vector2f composition_position = { + float(ElementUtilities::GetStringWidth(text_element, ime_pre_composition)) + GetAlignmentSpecificTextOffset(line), + line_position.y - top_to_baseline + line_height - COMPOSITION_UNDERLINE_WIDTH, + }; + Vector2f line_size = {float(composition_width + (composition_contains_endline ? endline_font_width : 0)), COMPOSITION_UNDERLINE_WIDTH}; + + MeshUtilities::GenerateLine(selection_composition_mesh, composition_position, line_size, + parent->GetComputedValues().color().ToPremultiplied()); } // Update variables for the next line. @@ -1390,54 +1395,22 @@ Vector2f WidgetTextInput::FormatText(float height_constraint) // Clamp the cursor to a valid range. absolute_cursor_index = Math::Min(absolute_cursor_index, (int)GetValue().size()); - // Clear the selection background geometry, and get the vertices and indices so the new geometry can be generated. - Mesh selection_mesh = selection_geometry.Release(Geometry::ReleaseMode::ClearMesh); - - // Transform segments according to text alignment - for (Segment& it : segments) + selection_composition_geometry = parent->GetRenderManager()->MakeGeometry(std::move(selection_composition_mesh)); + + // Overflow is automatically caught by any text overflowing the content area. However, sometimes it is possible that + // the selection box extends beyond the text and outside the content area. This can even overflow the element + // itself. In particular, when the selection includes newlines near the right edge. We don't want the selection box + // to take part in the scrollable region of the element, which would be one way to ensure that it is always clipped. + // Instead, we here detect such possible overflow manually and force the element to clip. This will clip any parts + // of the selection box that is overflowing. Maybe in the future we'll have a better way to specify ink overflow and + // have that automatically clipped. + const bool new_ink_overflow = (max_selection_right_edge > available_width + parent->GetBox().GetEdge(BoxArea::Padding, BoxEdge::Right)); + if (new_ink_overflow != ink_overflow) { - const auto& line = lines[it.line_index]; - const char* p_begin = GetValue().data() + line.value_offset; - float offset = GetAlignmentSpecificTextOffset(p_begin, it.line_index); - - it.position.x += offset; - - if (it.selected) - { - const bool selection_contains_endline = (selection_begin_index + selection_length > line_begin + lines[it.line_index].editable_length); - const Vector2f selection_size(float(it.width + (selection_contains_endline ? endline_font_width : 0)), line_height); - - MeshUtilities::GenerateQuad(selection_mesh, it.position - Vector2f(0, top_to_baseline), selection_size, selection_colour); - - selected_text_element->AddLine(it.position, it.content); - } - else - text_element->AddLine(it.position, it.content); + ink_overflow = new_ink_overflow; + parent->SetProperty(PropertyId::Clip, Property(ink_overflow ? Style::Clip::Type::Always : Style::Clip::Type::Auto)); } - selection_geometry = parent->GetRenderManager()->MakeGeometry(std::move(selection_mesh)); - - // Clear the IME composition geometry, and get the vertices and indices so the new geometry can be generated. - Mesh ime_composition_mesh = ime_composition_geometry.Release(Geometry::ReleaseMode::ClearMesh); - - // Transform IME segments according to text alignment. - for (auto& it : ime_segments) - { - const auto& line = lines[it.line_index]; - const char* p_begin = GetValue().data() + line.value_offset; - float offset = GetAlignmentSpecificTextOffset(p_begin, it.line_index); - - it.position.x += offset; - it.position.y += font_metrics.underline_position; - - const bool composition_contains_endline = (ime_composition_end_index > line_begin + lines[it.line_index].editable_length); - const Vector2f line_size(float(it.width + (composition_contains_endline ? endline_font_width : 0)), font_metrics.underline_thickness); - - MeshUtilities::GenerateLine(ime_composition_mesh, it.position, line_size, parent->GetComputedValues().color().ToPremultiplied()); - } - - ime_composition_geometry = parent->GetRenderManager()->MakeGeometry(std::move(ime_composition_mesh)); - return content_area; } @@ -1473,12 +1446,18 @@ void WidgetTextInput::UpdateCursorPosition(bool update_ideal_cursor_position) GetRelativeCursorIndices(cursor_line_index, cursor_character_index); const auto& line = lines[cursor_line_index]; - const char* p_begin = GetValue().data() + line.value_offset; + const int string_width_pre_cursor = + ElementUtilities::GetStringWidth(text_element, String(StringView(GetValue(), line.value_offset, cursor_character_index))); + const float alignment_offset = GetAlignmentSpecificTextOffset(line); - cursor_position.x = (float)ElementUtilities::GetStringWidth(text_element, String(p_begin, cursor_character_index)); - cursor_position.y = -1.f + (float)cursor_line_index * text_element->GetLineHeight(); + cursor_position = { + (float)string_width_pre_cursor + alignment_offset, + -1.f + (float)cursor_line_index * text_element->GetLineHeight(), + }; - cursor_position.x += GetAlignmentSpecificTextOffset(p_begin, cursor_line_index); + const bool word_wrap = parent->GetComputedValues().white_space() == Style::WhiteSpace::Prewrap; + if (word_wrap) + cursor_position.x = Math::Min(cursor_position.x, GetAvailableWidth() - cursor_size.x); if (update_ideal_cursor_position) ideal_cursor_position = cursor_position.x; diff --git a/Source/Core/Elements/WidgetTextInput.h b/Source/Core/Elements/WidgetTextInput.h index 3c27f356e..13deaa544 100644 --- a/Source/Core/Elements/WidgetTextInput.h +++ b/Source/Core/Elements/WidgetTextInput.h @@ -106,8 +106,6 @@ class WidgetTextInput : public EventListener { protected: enum class CursorMovement { Begin = -4, BeginLine = -3, PreviousWord = -2, Left = -1, Right = 1, NextWord = 2, EndLine = 3, End = 4 }; - float GetAlignmentSpecificTextOffset(const char* p_begin, int line_index) const; - /// Processes the "keydown" and "textinput" event to write to the input field, and the "focus" and /// "blur" to set the state of the cursor. void ProcessEvent(Event& event) override; @@ -142,6 +140,15 @@ class WidgetTextInput : public EventListener { void DispatchChangeEvent(bool linebreak = false); private: + struct Line { + // Offset into the text field's value. + int value_offset; + // The size of the contents of the line (including the trailing endline, if that terminated the line). + int size; + // The length of the editable characters on the line (excluding any trailing endline). + int editable_length; + }; + /// Returns the displayed value of the text field. /// @note For password fields this would only return the displayed asterisks '****', while the attribute value below contains the underlying text. const String& GetValue() const; @@ -176,7 +183,6 @@ class WidgetTextInput : public EventListener { /// Calculates the character index along a line under a specific horizontal position. /// @param[in] line_index The line to query. /// @param[in] position The position to query. - /// @param[out] on_right_side True if position is on the right side of the returned character, else left side. /// @return The index of the character under the mouse cursor. int CalculateCharacterIndex(int line_index, float position); @@ -224,20 +230,14 @@ class WidgetTextInput : public EventListener { /// @param[in] line_begin The absolute index at the beginning of the line. void GetLineIMEComposition(String& pre_composition, String& ime_composition, const String& line, int line_begin) const; + /// Returns the offset that aligns the contents of the line according to the 'text-align' property. + float GetAlignmentSpecificTextOffset(const Line& line) const; + /// Returns the width available for the text contents without overflowing, that is, the content area subtracted by any scrollbar. float GetAvailableWidth() const; /// Returns the height available for the text contents without overflowing, that is, the content area subtracted by any scrollbar. float GetAvailableHeight() const; - struct Line { - // Offset into the text field's value. - int value_offset; - // The size of the contents of the line (including the trailing endline, if that terminated the line). - int size; - // The length of the editable characters on the line (excluding any trailing endline). - int editable_length; - }; - ElementFormControl* parent; ElementText* text_element; @@ -272,15 +272,12 @@ class WidgetTextInput : public EventListener { // The colour of the background of selected text. ColourbPremultiplied selection_colour; // The selection background. - Geometry selection_geometry; + Geometry selection_composition_geometry; // IME composition range. The start and end indices are in absolute coordinates. int ime_composition_begin_index; int ime_composition_end_index; - // The IME composition text highlighting. - Geometry ime_composition_geometry; - // The IME context for this widget. UniquePtr text_input_context; @@ -292,6 +289,8 @@ class WidgetTextInput : public EventListener { /// @param[in] active True if need activate keyboard, false if need deactivate. void SetKeyboardActive(bool active); + bool ink_overflow; + double last_update_time; // The cursor geometry.