Skip to content

Commit be9a497

Browse files
committed
Refactor and improve text widget navigation and selection
- Make the selection box be extended by newlines again. - Avoid selection box overflowing the element, by force clipping in such situations. - For word-wrapped text widgets, allow the edit region to "overflow" with blank spaces. Instead, force the element not to scroll, and clamp the cursor to the content space. - For `text-align: right`, don't remove any soft-wrapped space at the end of the line from the edit region. Instead, adjust the alignment offset to position the line appropriately. - This ensures that newline selection extension also works for righ alignment. - Share selection and composition IME geometry. - Adjust composition underline position so that it sits at a fixed position compared to the text cursor. - Avoid doing unnecessary string width calculations during alignment.
1 parent 4cb518e commit be9a497

File tree

2 files changed

+121
-143
lines changed

2 files changed

+121
-143
lines changed

Source/Core/Elements/WidgetTextInput.cpp

Lines changed: 106 additions & 127 deletions
Original file line numberDiff line numberDiff line change
@@ -50,8 +50,9 @@
5050

5151
namespace Rml {
5252

53-
static constexpr float CURSOR_BLINK_TIME = 0.7f; // [s]
54-
static constexpr float OVERFLOW_TOLERANCE = 0.5f; // [px]
53+
static constexpr float CURSOR_BLINK_TIME = 0.7f; // [s]
54+
static constexpr float OVERFLOW_TOLERANCE = 0.5f; // [px]
55+
static constexpr float COMPOSITION_UNDERLINE_WIDTH = 2.f; // [px]
5556

5657
enum class CharacterClass { Word, Punctuation, Newline, Whitespace, Undefined };
5758
static CharacterClass GetCharacterClass(char c)
@@ -193,6 +194,7 @@ WidgetTextInput::WidgetTextInput(ElementFormControl* _parent) :
193194
parent->SetProperty(PropertyId::Drag, Property(Style::Drag::Drag));
194195
parent->SetProperty(PropertyId::WordBreak, Property(Style::WordBreak::BreakWord));
195196
parent->SetProperty(PropertyId::TextTransform, Property(Style::TextTransform::None));
197+
parent->SetProperty(PropertyId::Clip, Property(Style::Clip::Type::Auto));
196198

197199
parent->AddEventListener(EventId::Keydown, this, true);
198200
parent->AddEventListener(EventId::Textinput, this, true);
@@ -242,6 +244,7 @@ WidgetTextInput::WidgetTextInput(ElementFormControl* _parent) :
242244
ime_composition_end_index = 0;
243245

244246
last_update_time = 0;
247+
ink_overflow = false;
245248

246249
ShowCursor(false);
247250
}
@@ -467,8 +470,7 @@ void WidgetTextInput::OnRender()
467470
ElementUtilities::SetClippingRegion(text_element);
468471

469472
Vector2f text_translation = parent->GetAbsoluteOffset() - Vector2f(parent->GetScrollLeft(), parent->GetScrollTop());
470-
selection_geometry.Render(text_translation);
471-
ime_composition_geometry.Render(text_translation);
473+
selection_composition_geometry.Render(text_translation);
472474

473475
if (cursor_visible && !parent->IsDisabled())
474476
{
@@ -1059,20 +1061,36 @@ int WidgetTextInput::CalculateLineIndex(float position) const
10591061
return Math::Clamp(line_index, 0, (int)(lines.size() - 1));
10601062
}
10611063

1062-
float WidgetTextInput::GetAlignmentSpecificTextOffset(const char* p_begin, int line_index) const
1064+
float WidgetTextInput::GetAlignmentSpecificTextOffset(const Line& line) const
10631065
{
1064-
const float available_width = GetAvailableWidth();
1065-
const float total_width = (float)ElementUtilities::GetStringWidth(text_element, String(p_begin, lines[line_index].editable_length));
1066-
auto text_align = GetElement()->GetComputedValues().text_align();
1066+
const String& value = GetValue();
1067+
StringView editable_line_string(value, line.value_offset, line.editable_length);
1068+
1069+
const Style::TextAlign text_align = parent->GetComputedValues().text_align();
1070+
1071+
// Callback to avoid expensive calculation in the cases where it is not needed.
1072+
auto RemainingWidth = [&] {
1073+
const float total_width = (float)ElementUtilities::GetStringWidth(text_element, String(editable_line_string));
1074+
return GetAvailableWidth() - total_width;
1075+
};
10671076

1068-
// offset position depending on text align
10691077
switch (text_align)
10701078
{
1071-
case Style::TextAlign::Right: return Math::Max(0.0f, (available_width - total_width));
1072-
case Style::TextAlign::Center: return Math::Max(0.0f, ((available_width - total_width) / 2));
1073-
default: break;
1079+
case Style::TextAlign::Left: return 0;
1080+
case Style::TextAlign::Right:
1081+
{
1082+
// For right alignment with soft-wrapped newlines, remove up to a single space to align the last word to the right edge.
1083+
const bool is_last_line = (line.value_offset + line.size == (int)value.size());
1084+
const bool is_soft_wrapped = (!is_last_line && line.editable_length == line.size);
1085+
if (is_soft_wrapped && editable_line_string.size() > 0 && *(editable_line_string.end() - 1) == ' ')
1086+
{
1087+
editable_line_string = StringView(editable_line_string.begin(), editable_line_string.end() - 1);
1088+
}
1089+
return Math::Max(0.0f, RemainingWidth());
1090+
}
1091+
case Style::TextAlign::Center: return Math::Max(0.0f, 0.5f * RemainingWidth());
1092+
case Style::TextAlign::Justify: return 0;
10741093
}
1075-
10761094
return 0;
10771095
}
10781096

@@ -1083,11 +1101,12 @@ int WidgetTextInput::CalculateCharacterIndex(int line_index, float position)
10831101

10841102
ideal_cursor_position_to_the_right_of_cursor = true;
10851103

1086-
const char* p_begin = GetValue().data() + lines[line_index].value_offset;
1104+
const Line& line = lines[line_index];
1105+
const char* p_begin = GetValue().data() + line.value_offset;
10871106

1088-
position -= GetAlignmentSpecificTextOffset(p_begin, line_index);
1107+
position -= GetAlignmentSpecificTextOffset(line);
10891108

1090-
for (auto it = StringIteratorU8(p_begin, p_begin, p_begin + lines[line_index].editable_length); it;)
1109+
for (auto it = StringIteratorU8(p_begin, p_begin, p_begin + line.editable_length); it;)
10911110
{
10921111
++it;
10931112
const int offset = (int)it.offset();
@@ -1130,8 +1149,11 @@ void WidgetTextInput::ShowCursor(bool show, bool move_to_cursor)
11301149
else if (parent->GetScrollTop() > cursor_position.y)
11311150
parent->SetScrollTop(cursor_position.y);
11321151

1152+
const bool word_wrap = parent->GetComputedValues().white_space() == Style::WhiteSpace::Prewrap;
11331153
float minimum_scroll_left = (cursor_position.x + cursor_size.x) - GetAvailableWidth();
1134-
if (parent->GetScrollLeft() < minimum_scroll_left)
1154+
if (word_wrap)
1155+
parent->SetScrollLeft(0.f);
1156+
else if (parent->GetScrollLeft() < minimum_scroll_left)
11351157
parent->SetScrollLeft(minimum_scroll_left);
11361158
else if (parent->GetScrollLeft() > cursor_position.x)
11371159
parent->SetScrollLeft(cursor_position.x);
@@ -1231,25 +1253,10 @@ Vector2f WidgetTextInput::FormatText(float height_constraint)
12311253
Vector2f line_position = {0, top_to_baseline};
12321254
bool last_line = false;
12331255

1234-
auto text_align = GetElement()->GetComputedValues().text_align();
1235-
1236-
struct Segment {
1237-
Vector2f position;
1238-
int width;
1239-
String content;
1240-
bool selected;
1241-
int line_index;
1242-
};
1256+
float max_selection_right_edge = 0;
12431257

1244-
Vector<Segment> segments;
1245-
1246-
struct IMESegment {
1247-
Vector2f position;
1248-
int width;
1249-
int line_index;
1250-
};
1251-
1252-
Vector<IMESegment> ime_segments;
1258+
// Clear the selection background and IME composition geometry, and get the vertices and indices so the new geometry can be generated.
1259+
Mesh selection_composition_mesh = selection_composition_geometry.Release(Geometry::ReleaseMode::ClearMesh);
12531260

12541261
// Keep generating lines until all the text content is placed.
12551262
do
@@ -1269,44 +1276,33 @@ Vector2f WidgetTextInput::FormatText(float height_constraint)
12691276
last_line =
12701277
text_element->GenerateLine(line_content, line.size, line_width, line_begin, available_width - cursor_size.x, 0, false, false, false);
12711278

1272-
// 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
1273-
// append the orphan onto the line even though it will push the line outside of the input field's bounds.
1274-
String orphan;
1275-
if (!last_line && (line_content.empty() || line_content.back() != '\n'))
1279+
// Check if the editable length needs to be truncated to dodge a trailing endline.
1280+
line.editable_length = (int)line_content.size();
1281+
if (!line_content.empty() && line_content.back() == '\n')
1282+
line.editable_length -= 1;
1283+
1284+
// Include all spaces at the end of this line, if they were not included due to soft-wrapping in `GenerateLine`.
1285+
// This helps prevent sudden shifts when whitespace wraps down to the next line.
12761286
{
12771287
const String& text = GetValue();
1278-
for (int i = 1; i >= 0; --i)
1288+
size_t i_space_begin = size_t(line_begin + line.editable_length);
1289+
size_t i_space_end = Math::Min(text.find_first_not_of(' ', i_space_begin), text.size());
1290+
size_t count = i_space_end - i_space_begin;
1291+
if (count > 0)
12791292
{
1280-
int index = line_begin + line.size + i;
1281-
if (index >= (int)text.size())
1282-
continue;
1283-
1284-
if (text[index] != ' ')
1285-
{
1286-
orphan.clear();
1287-
continue;
1288-
}
1289-
1290-
int next_index = index + 1;
1291-
if (!orphan.empty() || next_index >= (int)text.size() || text[next_index] != ' ')
1292-
orphan += ' ';
1293+
line_content.append(count, ' ');
1294+
line_width += ElementUtilities::GetStringWidth(text_element, " ") * (int)count;
1295+
line.editable_length += (int)count;
1296+
line.size += (int)count;
1297+
// Consume the hard wrap if we have one on this line, so that it doesn't make its own, empty line.
1298+
if (text[i_space_end] == '\n')
1299+
line.size += 1;
1300+
// If the spaces extend all the way to the end, we have consumed all the lines.
1301+
if (i_space_end == text.size())
1302+
last_line = true;
12931303
}
12941304
}
12951305

1296-
if (!orphan.empty())
1297-
{
1298-
line_content += orphan;
1299-
line.size += (int)orphan.size();
1300-
line_width += ElementUtilities::GetStringWidth(text_element, orphan);
1301-
}
1302-
1303-
// visually remove trailing space if right aligned
1304-
if (!last_line && text_align == Style::TextAlign::Right && !line_content.empty() && line_content.back() == ' ')
1305-
{
1306-
line_content.pop_back();
1307-
line_width -= ElementUtilities::GetStringWidth(text_element, " ");
1308-
}
1309-
13101306
// Now that we have the string of characters appearing on the new line, we split it into
13111307
// three parts; the unselected text appearing before any selected text on the line, the
13121308
// selected text on the line, and any unselected text after the selection.
@@ -1318,7 +1314,7 @@ Vector2f WidgetTextInput::FormatText(float height_constraint)
13181314
if (!pre_selection.empty())
13191315
{
13201316
const int width = ElementUtilities::GetStringWidth(text_element, pre_selection);
1321-
segments.push_back({line_position, width, pre_selection, false, (int)lines.size()});
1317+
text_element->AddLine(line_position + Vector2f{GetAlignmentSpecificTextOffset(line), 0}, pre_selection);
13221318
line_position.x += width;
13231319
}
13241320

@@ -1336,19 +1332,22 @@ Vector2f WidgetTextInput::FormatText(float height_constraint)
13361332
return float(width_kerning - width_no_kerning);
13371333
};
13381334

1339-
// Check if the editable length needs to be truncated to dodge a trailing endline.
1340-
line.editable_length = (int)line_content.size();
1341-
if (!line_content.empty() && line_content.back() == '\n')
1342-
line.editable_length -= 1;
1343-
13441335
// If there is any selected text on this line, place it in the selected text element and
13451336
// generate the geometry for its background.
13461337
if (!selection.empty())
13471338
{
13481339
line_position.x += GetKerningBetween(pre_selection, selection);
1340+
13491341
const int selection_width = ElementUtilities::GetStringWidth(selected_text_element, selection);
1350-
segments.push_back({line_position, selection_width, selection, true, (int)lines.size()});
1342+
const bool selection_contains_endline = (selection_begin_index + selection_length > line_begin + line.editable_length);
1343+
const Vector2f selection_size = {float(selection_width + (selection_contains_endline ? endline_font_width : 0)), line_height};
1344+
const Vector2f aligned_position = line_position + Vector2f{GetAlignmentSpecificTextOffset(line), 0};
13511345

1346+
MeshUtilities::GenerateQuad(selection_composition_mesh, aligned_position - Vector2f(0, top_to_baseline), selection_size,
1347+
selection_colour);
1348+
selected_text_element->AddLine(aligned_position, selection);
1349+
1350+
max_selection_right_edge = Math::Max(max_selection_right_edge, aligned_position.x + selection_size.x);
13521351
line_position.x += selection_width;
13531352
}
13541353

@@ -1357,8 +1356,7 @@ Vector2f WidgetTextInput::FormatText(float height_constraint)
13571356
if (!post_selection.empty())
13581357
{
13591358
line_position.x += GetKerningBetween(selection, post_selection);
1360-
const int width = ElementUtilities::GetStringWidth(text_element, post_selection);
1361-
segments.push_back({line_position, width, post_selection, false, (int)lines.size()});
1359+
text_element->AddLine(line_position + Vector2f{GetAlignmentSpecificTextOffset(line), 0}, post_selection);
13621360
}
13631361

13641362
// We fetch the IME composition on the new line to highlight it.
@@ -1368,9 +1366,16 @@ Vector2f WidgetTextInput::FormatText(float height_constraint)
13681366
// If there is any IME composition string on the line, create a segment for its underline.
13691367
if (!ime_composition.empty())
13701368
{
1369+
const bool composition_contains_endline = (ime_composition_end_index > line_begin + line.editable_length);
13711370
const int composition_width = ElementUtilities::GetStringWidth(text_element, ime_composition);
1372-
const Vector2f composition_position(float(ElementUtilities::GetStringWidth(text_element, ime_pre_composition)), line_position.y);
1373-
ime_segments.push_back({composition_position, composition_width, (int)lines.size()});
1371+
const Vector2f composition_position = {
1372+
float(ElementUtilities::GetStringWidth(text_element, ime_pre_composition)) + GetAlignmentSpecificTextOffset(line),
1373+
line_position.y - top_to_baseline + line_height - COMPOSITION_UNDERLINE_WIDTH,
1374+
};
1375+
Vector2f line_size = {float(composition_width + (composition_contains_endline ? endline_font_width : 0)), COMPOSITION_UNDERLINE_WIDTH};
1376+
1377+
MeshUtilities::GenerateLine(selection_composition_mesh, composition_position, line_size,
1378+
parent->GetComputedValues().color().ToPremultiplied());
13741379
}
13751380

13761381
// Update variables for the next line.
@@ -1390,54 +1395,22 @@ Vector2f WidgetTextInput::FormatText(float height_constraint)
13901395
// Clamp the cursor to a valid range.
13911396
absolute_cursor_index = Math::Min(absolute_cursor_index, (int)GetValue().size());
13921397

1393-
// Clear the selection background geometry, and get the vertices and indices so the new geometry can be generated.
1394-
Mesh selection_mesh = selection_geometry.Release(Geometry::ReleaseMode::ClearMesh);
1395-
1396-
// Transform segments according to text alignment
1397-
for (Segment& it : segments)
1398+
selection_composition_geometry = parent->GetRenderManager()->MakeGeometry(std::move(selection_composition_mesh));
1399+
1400+
// Overflow is automatically caught by any text overflowing the content area. However, sometimes it is possible that
1401+
// the selection box extends beyond the text and outside the content area. This can even overflow the element
1402+
// itself. In particular, when the selection includes newlines near the right edge. We don't want the selection box
1403+
// to take part in the scrollable region of the element, which would be one way to ensure that it is always clipped.
1404+
// Instead, we here detect such possible overflow manually and force the element to clip. This will clip any parts
1405+
// of the selection box that is overflowing. Maybe in the future we'll have a better way to specify ink overflow and
1406+
// have that automatically clipped.
1407+
const bool new_ink_overflow = (max_selection_right_edge > available_width + parent->GetBox().GetEdge(BoxArea::Padding, BoxEdge::Right));
1408+
if (new_ink_overflow != ink_overflow)
13981409
{
1399-
const auto& line = lines[it.line_index];
1400-
const char* p_begin = GetValue().data() + line.value_offset;
1401-
float offset = GetAlignmentSpecificTextOffset(p_begin, it.line_index);
1402-
1403-
it.position.x += offset;
1404-
1405-
if (it.selected)
1406-
{
1407-
const bool selection_contains_endline = (selection_begin_index + selection_length > line_begin + lines[it.line_index].editable_length);
1408-
const Vector2f selection_size(float(it.width + (selection_contains_endline ? endline_font_width : 0)), line_height);
1409-
1410-
MeshUtilities::GenerateQuad(selection_mesh, it.position - Vector2f(0, top_to_baseline), selection_size, selection_colour);
1411-
1412-
selected_text_element->AddLine(it.position, it.content);
1413-
}
1414-
else
1415-
text_element->AddLine(it.position, it.content);
1410+
ink_overflow = new_ink_overflow;
1411+
parent->SetProperty(PropertyId::Clip, Property(ink_overflow ? Style::Clip::Type::Always : Style::Clip::Type::Auto));
14161412
}
14171413

1418-
selection_geometry = parent->GetRenderManager()->MakeGeometry(std::move(selection_mesh));
1419-
1420-
// Clear the IME composition geometry, and get the vertices and indices so the new geometry can be generated.
1421-
Mesh ime_composition_mesh = ime_composition_geometry.Release(Geometry::ReleaseMode::ClearMesh);
1422-
1423-
// Transform IME segments according to text alignment.
1424-
for (auto& it : ime_segments)
1425-
{
1426-
const auto& line = lines[it.line_index];
1427-
const char* p_begin = GetValue().data() + line.value_offset;
1428-
float offset = GetAlignmentSpecificTextOffset(p_begin, it.line_index);
1429-
1430-
it.position.x += offset;
1431-
it.position.y += font_metrics.underline_position;
1432-
1433-
const bool composition_contains_endline = (ime_composition_end_index > line_begin + lines[it.line_index].editable_length);
1434-
const Vector2f line_size(float(it.width + (composition_contains_endline ? endline_font_width : 0)), font_metrics.underline_thickness);
1435-
1436-
MeshUtilities::GenerateLine(ime_composition_mesh, it.position, line_size, parent->GetComputedValues().color().ToPremultiplied());
1437-
}
1438-
1439-
ime_composition_geometry = parent->GetRenderManager()->MakeGeometry(std::move(ime_composition_mesh));
1440-
14411414
return content_area;
14421415
}
14431416

@@ -1473,12 +1446,18 @@ void WidgetTextInput::UpdateCursorPosition(bool update_ideal_cursor_position)
14731446
GetRelativeCursorIndices(cursor_line_index, cursor_character_index);
14741447

14751448
const auto& line = lines[cursor_line_index];
1476-
const char* p_begin = GetValue().data() + line.value_offset;
1449+
const int string_width_pre_cursor =
1450+
ElementUtilities::GetStringWidth(text_element, String(StringView(GetValue(), line.value_offset, cursor_character_index)));
1451+
const float alignment_offset = GetAlignmentSpecificTextOffset(line);
14771452

1478-
cursor_position.x = (float)ElementUtilities::GetStringWidth(text_element, String(p_begin, cursor_character_index));
1479-
cursor_position.y = -1.f + (float)cursor_line_index * text_element->GetLineHeight();
1453+
cursor_position = {
1454+
(float)string_width_pre_cursor + alignment_offset,
1455+
-1.f + (float)cursor_line_index * text_element->GetLineHeight(),
1456+
};
14801457

1481-
cursor_position.x += GetAlignmentSpecificTextOffset(p_begin, cursor_line_index);
1458+
const bool word_wrap = parent->GetComputedValues().white_space() == Style::WhiteSpace::Prewrap;
1459+
if (word_wrap)
1460+
cursor_position.x = Math::Min(cursor_position.x, GetAvailableWidth() - cursor_size.x);
14821461

14831462
if (update_ideal_cursor_position)
14841463
ideal_cursor_position = cursor_position.x;

0 commit comments

Comments
 (0)