From 52276c8a2b48a9c4120840fe209100930ca60ec5 Mon Sep 17 00:00:00 2001 From: Arthur Sonzogni Date: Sun, 12 Dec 2021 21:31:54 +0100 Subject: [PATCH] Bugfix Input use std::string (#279) Use std::string by default for the implementation of FTXUI's input component. Along the way: - Give a correct implementation for fullwidth characters. - Add tests - Modify the way the cursor is drawn. --- CHANGELOG.md | 13 ++- include/ftxui/screen/string.hpp | 13 +++ src/ftxui/component/input.cpp | 145 +++++++++++++++++------------ src/ftxui/component/input_test.cpp | 145 +++++++++++++++++++++++++++++ src/ftxui/screen/string.cpp | 92 ++++++++++++++++++ src/ftxui/screen/string_test.cpp | 79 ++++++++++++++++ 6 files changed, 425 insertions(+), 62 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7ba4517..fd5554c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,8 @@ unreleased (development) ------------------------ ### Features: + +#### DOM: - Support `flexbox` dom elements. This is build symmetrically to the HTML one. All the following attributes are supported: direction, wrap, justify-content, align-items, align-content, gap @@ -16,12 +18,15 @@ unreleased (development) - `paragraphAlignJustify` - Add the helper elements based on `flexbox`: `hflow()`, `vflow()`. +### Bug + +#### Component +- `Input` shouldn't take focus when hovered by the mouse. +- Modifying `Input`'s during on_enter/on_change event is now working correctly. + ### Breaking changes: - The behavior of `paragraph` has been modified. It now returns en Element, - instead of a list of elements. - -### Bug -- Input shouldn't take focus when hovered by the mouse. + instead of a list of elements. 0.11.1 ------ diff --git a/include/ftxui/screen/string.hpp b/include/ftxui/screen/string.hpp index 92f2753..21783ec 100644 --- a/include/ftxui/screen/string.hpp +++ b/include/ftxui/screen/string.hpp @@ -14,7 +14,20 @@ std::wstring to_wstring(T s) { } int string_width(const std::string&); +// Split the string into a its glyphs. An empty one is inserted ater fullwidth +// ones. std::vector Utf8ToGlyphs(const std::string& input); +// If |input| was an array of glyphs, this returns the number of char to eat +// before reaching the glyph at index |glyph_index|. +int GlyphPosition(const std::string& input, + size_t glyph_index, + size_t start = 0); +// Returns the number of glyphs in |input|. +int GlyphCount(const std::string& input); + +// Map every cells drawn by |input| to their corresponding Glyphs. Half-size +// Glyphs takes one cell, full-size Glyphs take two cells. +std::vector CellToGlyphIndex(const std::string& input); } // namespace ftxui diff --git a/src/ftxui/component/input.cpp b/src/ftxui/component/input.cpp index 846e383..13a2380 100644 --- a/src/ftxui/component/input.cpp +++ b/src/ftxui/component/input.cpp @@ -20,12 +20,22 @@ namespace ftxui { +namespace { + +std::string PasswordField(int size) { + std::string out; + out.reserve(2 * size); + while (size--) + out += "•"; + return out; +} + // An input box. The user can type text into it. -class WideInputBase : public ComponentBase { +class InputBase : public ComponentBase { public: - WideInputBase(WideStringRef content, - ConstStringRef placeholder, - Ref option) + InputBase(StringRef content, + ConstStringRef placeholder, + Ref option) : content_(content), placeholder_(placeholder), option_(option) {} int cursor_position_internal_ = 0; @@ -38,22 +48,23 @@ class WideInputBase : public ComponentBase { // Component implementation: Element Render() override { - std::wstring password_content; + std::string password_content; if (option_->password()) - password_content = std::wstring(content_->size(), U'•'); - std::wstring& content = option_->password() ? password_content : *content_; + password_content = PasswordField(content_->size()); + std::string& content = option_->password() ? password_content : *content_; - cursor_position() = - std::max(0, std::min(content.size(), cursor_position())); - auto main_decorator = flex | size(HEIGHT, EQUAL, 1); + int size = GlyphCount(content); + + cursor_position() = std::max(0, std::min(size, cursor_position())); + auto main_decorator = flex | ftxui::size(HEIGHT, EQUAL, 1); bool is_focused = Focused(); // placeholder. - if (content.size() == 0) { + if (size == 0) { bool hovered = hovered_; Decorator decorator = dim | main_decorator; if (is_focused) - decorator = decorator | focus | bold; + decorator = decorator | focus | inverted; if (hovered || is_focused) decorator = decorator | inverted; return text(*placeholder_) | decorator | reflect(box_); @@ -67,22 +78,22 @@ class WideInputBase : public ComponentBase { return text(content) | main_decorator | reflect(box_); } - std::wstring part_before_cursor = content.substr(0, cursor_position()); - std::wstring part_at_cursor = cursor_position() < (int)content.size() - ? content.substr(cursor_position(), 1) - : L" "; - std::wstring part_after_cursor = cursor_position() < (int)content.size() - 1 - ? content.substr(cursor_position() + 1) - : L""; + int index_before_cursor = GlyphPosition(content, cursor_position()); + int index_after_cursor = GlyphPosition(content, 1, index_before_cursor); + std::string part_before_cursor = content.substr(0, index_before_cursor); + std::string part_at_cursor = " "; + if (cursor_position() < size) { + part_at_cursor = content.substr(index_before_cursor, + index_after_cursor - index_before_cursor); + } + std::string part_after_cursor = content.substr(index_after_cursor); auto focused = (is_focused || hovered_) ? focus : select; - // clang-format off - return - hbox( - text(part_before_cursor), - text(part_at_cursor) | underlined | focused | reflect(cursor_box_), - text(part_after_cursor) - ) | flex | inverted | frame | bold |main_decorator | reflect(box_); - // clang-format on + return hbox({ + text(part_before_cursor), + text(part_at_cursor) | focused | inverted | reflect(cursor_box_), + text(part_after_cursor), + }) | + flex | frame | bold | main_decorator | reflect(box_); } bool OnEvent(Event event) override { @@ -92,13 +103,15 @@ class WideInputBase : public ComponentBase { if (event.is_mouse()) return OnMouseEvent(event); - std::wstring c; + std::string c; // Backspace. if (event == Event::Backspace) { if (cursor_position() == 0) return false; - content_->erase(cursor_position() - 1, 1); + size_t start = GlyphPosition(*content_, cursor_position() - 1); + size_t end = GlyphPosition(*content_, cursor_position()); + content_->erase(start, end - start); cursor_position()--; option_->on_change(); return true; @@ -108,7 +121,9 @@ class WideInputBase : public ComponentBase { if (event == Event::Delete) { if (cursor_position() == int(content_->size())) return false; - content_->erase(cursor_position(), 1); + size_t start = GlyphPosition(*content_, cursor_position()); + size_t end = GlyphPosition(*content_, cursor_position() + 1); + content_->erase(start, end - start); option_->on_change(); return true; } @@ -140,13 +155,14 @@ class WideInputBase : public ComponentBase { } if (event == Event::End) { - cursor_position() = (int)content_->size(); + cursor_position() = GlyphCount(*content_); return true; } // Content if (event.is_character()) { - content_->insert(cursor_position(), 1, to_wstring(event.character())[0]); + size_t start = GlyphPosition(*content_, cursor_position()); + content_->insert(start, event.character()); cursor_position()++; option_->on_change(); return true; @@ -167,12 +183,27 @@ class WideInputBase : public ComponentBase { } TakeFocus(); - int new_cursor_position = - cursor_position() + event.mouse().x - cursor_box_.x_min; - new_cursor_position = - std::max(0, std::min(content_->size(), new_cursor_position)); - if (cursor_position() != new_cursor_position) { - cursor_position() = new_cursor_position; + if (content_->size() == 0) + return true; + + auto mapping = CellToGlyphIndex(*content_); + int original_glyph = cursor_position(); + original_glyph = std::clamp(original_glyph, 0, int(mapping.size())); + int original_cell = 0; + for (size_t i = 0; i < mapping.size(); i++) { + if (mapping[i] == original_glyph) { + original_cell = i; + break; + } + } + if (mapping[original_cell] != original_glyph) + original_cell = mapping.size(); + int target_cell = original_cell + event.mouse().x - cursor_box_.x_min; + int target_glyph = target_cell < (int)mapping.size() ? mapping[target_cell] + : (int)mapping.size(); + target_glyph = std::clamp(target_glyph, 0, GlyphCount(*content_)); + if (cursor_position() != target_glyph) { + cursor_position() = target_glyph; option_->on_change(); } return true; @@ -181,7 +212,7 @@ class WideInputBase : public ComponentBase { bool Focusable() const final { return true; } bool hovered_ = false; - WideStringRef content_; + StringRef content_; ConstStringRef placeholder_; Box box_; @@ -190,39 +221,37 @@ class WideInputBase : public ComponentBase { }; // An input box. The user can type text into it. -// For convenience, the std::string version of Input simply wrap a -// WideInputBase. -// TODO(arthursonzogni): Provide an implementation handling std::string natively -// and adds better support for combining characters. -class InputBase : public WideInputBase { +// For convenience, the std::wstring version of Input simply wrap a +// InputBase. +class WideInputBase : public InputBase { public: - InputBase(StringRef content, - ConstStringRef placeholder, - Ref option) - : WideInputBase(&wrapped_content_, - std::move(placeholder), - std::move(option)), + WideInputBase(WideStringRef content, + ConstStringRef placeholder, + Ref option) + : InputBase(&wrapped_content_, std::move(placeholder), std::move(option)), content_(std::move(content)), - wrapped_content_(to_wstring(*content_)) {} + wrapped_content_(to_string(*content_)) {} Element Render() override { - wrapped_content_ = to_wstring(*content_); - return WideInputBase::Render(); + wrapped_content_ = to_string(*content_); + return InputBase::Render(); } bool OnEvent(Event event) override { - wrapped_content_ = to_wstring(*content_); - if (WideInputBase::OnEvent(event)) { - *content_ = to_string(wrapped_content_); + wrapped_content_ = to_string(*content_); + if (InputBase::OnEvent(event)) { + *content_ = to_wstring(wrapped_content_); return true; } return false; } - StringRef content_; - std::wstring wrapped_content_; + WideStringRef content_; + std::string wrapped_content_; }; +} // namespace + /// @brief An input box for editing text. /// @param content The editable content. /// @param placeholder The text displayed when content is still empty. diff --git a/src/ftxui/component/input_test.cpp b/src/ftxui/component/input_test.cpp index b1ff08f..c12cf18 100644 --- a/src/ftxui/component/input_test.cpp +++ b/src/ftxui/component/input_test.cpp @@ -226,6 +226,151 @@ TEST(InputTest, Backspace) { EXPECT_EQ(option.cursor_position(), 0u); } +TEST(InputTest, MouseClick) { + std::string content; + std::string placeholder; + auto option = InputOption(); + option.cursor_position = 0; + auto input = Input(&content, &placeholder, &option); + + input->OnEvent(Event::Character("a")); + input->OnEvent(Event::Character("b")); + input->OnEvent(Event::Character("c")); + input->OnEvent(Event::Character("d")); + + EXPECT_EQ(option.cursor_position(), 4u); + + auto render = [&] { + auto document = input->Render(); + auto screen = Screen::Create(Dimension::Fixed(10), Dimension::Fixed(1)); + Render(screen, document); + }; + render(); + + Mouse mouse; + mouse.button = Mouse::Button::Left; + mouse.motion = Mouse::Motion::Pressed; + mouse.y = 0; + mouse.shift = false; + mouse.meta = false; + mouse.control = false; + + mouse.x = 0; + input->OnEvent(Event::Mouse("", mouse)); + render(); + EXPECT_EQ(option.cursor_position(), 0u); + + mouse.x = 2; + input->OnEvent(Event::Mouse("", mouse)); + render(); + EXPECT_EQ(option.cursor_position(), 2u); + + mouse.x = 2; + input->OnEvent(Event::Mouse("", mouse)); + render(); + EXPECT_EQ(option.cursor_position(), 2u); + + mouse.x = 1; + input->OnEvent(Event::Mouse("", mouse)); + render(); + EXPECT_EQ(option.cursor_position(), 1u); + + mouse.x = 3; + input->OnEvent(Event::Mouse("", mouse)); + render(); + EXPECT_EQ(option.cursor_position(), 3u); + + mouse.x = 4; + input->OnEvent(Event::Mouse("", mouse)); + render(); + EXPECT_EQ(option.cursor_position(), 4u); + + mouse.x = 5; + input->OnEvent(Event::Mouse("", mouse)); + render(); + EXPECT_EQ(option.cursor_position(), 4u); +} + +TEST(InputTest, MouseClickComplex) { + std::string content; + std::string placeholder; + auto option = InputOption(); + option.cursor_position = 0; + auto input = Input(&content, &placeholder, &option); + + input->OnEvent(Event::Character("测")); + input->OnEvent(Event::Character("试")); + input->OnEvent(Event::Character("a⃒")); + input->OnEvent(Event::Character("ā")); + + EXPECT_EQ(option.cursor_position(), 4u); + + auto render = [&] { + auto document = input->Render(); + auto screen = Screen::Create(Dimension::Fixed(10), Dimension::Fixed(1)); + Render(screen, document); + }; + render(); + + Mouse mouse; + mouse.button = Mouse::Button::Left; + mouse.motion = Mouse::Motion::Pressed; + mouse.y = 0; + mouse.shift = false; + mouse.meta = false; + mouse.control = false; + + mouse.x = 0; + input->OnEvent(Event::Mouse("", mouse)); + render(); + EXPECT_EQ(option.cursor_position(), 0u); + + mouse.x = 0; + input->OnEvent(Event::Mouse("", mouse)); + render(); + EXPECT_EQ(option.cursor_position(), 0u); + + mouse.x = 1; + input->OnEvent(Event::Mouse("", mouse)); + render(); + EXPECT_EQ(option.cursor_position(), 0u); + + mouse.x = 1; + input->OnEvent(Event::Mouse("", mouse)); + render(); + EXPECT_EQ(option.cursor_position(), 0u); + + mouse.x = 2; + input->OnEvent(Event::Mouse("", mouse)); + render(); + EXPECT_EQ(option.cursor_position(), 1u); + + mouse.x = 2; + input->OnEvent(Event::Mouse("", mouse)); + render(); + EXPECT_EQ(option.cursor_position(), 1u); + + mouse.x = 1; + input->OnEvent(Event::Mouse("", mouse)); + render(); + EXPECT_EQ(option.cursor_position(), 0u); + + mouse.x = 4; + input->OnEvent(Event::Mouse("", mouse)); + render(); + EXPECT_EQ(option.cursor_position(), 2u); + + mouse.x = 5; + input->OnEvent(Event::Mouse("", mouse)); + render(); + EXPECT_EQ(option.cursor_position(), 3u); + + mouse.x = 6; + input->OnEvent(Event::Mouse("", mouse)); + render(); + EXPECT_EQ(option.cursor_position(), 4u); +} + // Copyright 2021 Arthur Sonzogni. All rights reserved. // Use of this source code is governed by the MIT license that can be found in // the LICENSE file. diff --git a/src/ftxui/screen/string.cpp b/src/ftxui/screen/string.cpp index 17bb097..49ad615 100644 --- a/src/ftxui/screen/string.cpp +++ b/src/ftxui/screen/string.cpp @@ -288,6 +288,98 @@ std::vector Utf8ToGlyphs(const std::string& input) { return out; } +int GlyphPosition(const std::string& input, size_t glyph_to_skip, size_t start) { + if (glyph_to_skip <= 0) + return 0; + size_t end = 0; + while (start < input.size()) { + uint32_t codepoint; + bool eaten = EatCodePoint(input, start, &end, &codepoint); + + // Ignore invalid, control characters and combining characters. + if (!eaten || IsControl(codepoint) || IsCombining(codepoint)) { + start = end; + continue; + } + + // We eat the beginning of the next glyph. If we are eating the one + // requested, return its start position immediately. + if (glyph_to_skip == 0) + return start; + + // Otherwise, skip this glyph and iterate: + glyph_to_skip--; + start = end; + } + return input.size(); +} + +std::vector CellToGlyphIndex(const std::string& input) { + int x = -1; + std::vector out; + out.reserve(input.size()); + size_t start = 0; + size_t end = 0; + while (start < input.size()) { + uint32_t codepoint; + bool eaten = EatCodePoint(input, start, &end, &codepoint); + start = end; + + // Ignore invalid / control characters. + if (!eaten || IsControl(codepoint)) + continue; + + // Combining characters are put with the previous glyph they are modifying. + if (IsCombining(codepoint)) { + if (x == -1) { + ++x; + out.push_back(x); + } + continue; + } + + // Fullwidth characters take two cells. The second is made of the empty + // string to reserve the space the first is taking. + if (IsFullWidth(codepoint)) { + ++x; + out.push_back(x); + out.push_back(x); + continue; + } + + // Normal characters: + ++x; + out.push_back(x); + } + return out; +} + +int GlyphCount(const std::string& input) { + int size = 0; + size_t start = 0; + size_t end = 0; + while (start < input.size()) { + uint32_t codepoint; + bool eaten = EatCodePoint(input, start, &end, &codepoint); + start = end; + + // Ignore invalid characters: + if (!eaten || IsControl(codepoint)) + continue; + + // Ignore combining characters, except when they don't have a preceding to + // combine with. + if (IsCombining(codepoint)) { + if (size == 0) + size++; + continue; + } + + size++; + } + return size; +} + #ifdef _MSC_VER #pragma warning(push) #pragma warning(disable : 4996) // codecvt_utf8_utf16 is deprecated diff --git a/src/ftxui/screen/string_test.cpp b/src/ftxui/screen/string_test.cpp index 502a2ab..5d16122 100644 --- a/src/ftxui/screen/string_test.cpp +++ b/src/ftxui/screen/string_test.cpp @@ -42,6 +42,85 @@ TEST(StringTest, Utf8ToGlyphs) { EXPECT_EQ(Utf8ToGlyphs("a\1a"), T({"a", "a"})); } +TEST(StringTest, GlyphCount) { + // Basic: + EXPECT_EQ(GlyphCount(""), 0); + EXPECT_EQ(GlyphCount("a"), 1); + EXPECT_EQ(GlyphCount("ab"), 2); + // Fullwidth glyphs: + EXPECT_EQ(GlyphCount("测"), 1); + EXPECT_EQ(GlyphCount("测试"), 2); + // Combining characters: + EXPECT_EQ(GlyphCount("ā"), 1); + EXPECT_EQ(GlyphCount("a⃒"), 1); + EXPECT_EQ(GlyphCount("a̗"), 1); + // Control characters: + EXPECT_EQ(GlyphCount("\1"), 0); + EXPECT_EQ(GlyphCount("a\1a"), 2); +} + + +TEST(StringTest, GlyphPosition) { + // Basic: + EXPECT_EQ(GlyphPosition("", -1), 0); + EXPECT_EQ(GlyphPosition("", 0), 0); + EXPECT_EQ(GlyphPosition("", 1), 0); + EXPECT_EQ(GlyphPosition("a", 0), 0); + EXPECT_EQ(GlyphPosition("a", 1), 1); + EXPECT_EQ(GlyphPosition("ab", 0), 0); + EXPECT_EQ(GlyphPosition("ab", 1), 1); + EXPECT_EQ(GlyphPosition("ab", 2), 2); + EXPECT_EQ(GlyphPosition("abc", 0), 0); + EXPECT_EQ(GlyphPosition("abc", 1), 1); + EXPECT_EQ(GlyphPosition("abc", 2), 2); + EXPECT_EQ(GlyphPosition("abc", 3), 3); + // Fullwidth glyphs: + EXPECT_EQ(GlyphPosition("测", 0), 0); + EXPECT_EQ(GlyphPosition("测", 1), 3); + EXPECT_EQ(GlyphPosition("测试", 0), 0); + EXPECT_EQ(GlyphPosition("测试", 1), 3); + EXPECT_EQ(GlyphPosition("测试", 2), 6); + EXPECT_EQ(GlyphPosition("测试", 1, 3), 6); + EXPECT_EQ(GlyphPosition("测试", 1, 0), 3); + // Combining characters: + EXPECT_EQ(GlyphPosition("ā", 0), 0); + EXPECT_EQ(GlyphPosition("ā", 1), 3); + EXPECT_EQ(GlyphPosition("a⃒a̗ā", 0), 0); + EXPECT_EQ(GlyphPosition("a⃒a̗ā", 1), 4); + EXPECT_EQ(GlyphPosition("a⃒a̗ā", 2), 7); + EXPECT_EQ(GlyphPosition("a⃒a̗ā", 3), 10); + // Control characters: + EXPECT_EQ(GlyphPosition("\1", 0), 0); + EXPECT_EQ(GlyphPosition("\1", 1), 1); + EXPECT_EQ(GlyphPosition("a\1a", 0), 0); + EXPECT_EQ(GlyphPosition("a\1a", 1), 2); + EXPECT_EQ(GlyphPosition("a\1a", 2), 3); +} + +TEST(StringTest, CellToGlyphIndex) { + // Basic: + auto basic = CellToGlyphIndex("abc"); + ASSERT_EQ(basic.size(), 3); + EXPECT_EQ(basic[0], 0); + EXPECT_EQ(basic[1], 1); + EXPECT_EQ(basic[2], 2); + + // Fullwidth glyphs: + auto fullwidth = CellToGlyphIndex("测试"); + ASSERT_EQ(fullwidth.size(), 4); + EXPECT_EQ(fullwidth[0], 0); + EXPECT_EQ(fullwidth[1], 0); + EXPECT_EQ(fullwidth[2], 1); + EXPECT_EQ(fullwidth[3], 1); + + // Combining characters: + auto combining = CellToGlyphIndex("a⃒a̗ā"); + ASSERT_EQ(combining.size(), 3); + EXPECT_EQ(combining[0], 0); + EXPECT_EQ(combining[1], 1); + EXPECT_EQ(combining[2], 2); +} + // Copyright 2020 Arthur Sonzogni. All rights reserved. // Use of this source code is governed by the MIT license that can be found in // the LICENSE file.