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.
This commit is contained in:
Arthur Sonzogni 2021-12-12 21:31:54 +01:00 committed by GitHub
parent 602392c43d
commit 52276c8a2b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 425 additions and 62 deletions

View File

@ -5,6 +5,8 @@ unreleased (development)
------------------------ ------------------------
### Features: ### Features:
#### DOM:
- Support `flexbox` dom elements. This is build symmetrically to the HTML one. - Support `flexbox` dom elements. This is build symmetrically to the HTML one.
All the following attributes are supported: direction, wrap, justify-content, All the following attributes are supported: direction, wrap, justify-content,
align-items, align-content, gap align-items, align-content, gap
@ -16,13 +18,16 @@ unreleased (development)
- `paragraphAlignJustify` - `paragraphAlignJustify`
- Add the helper elements based on `flexbox`: `hflow()`, `vflow()`. - 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: ### Breaking changes:
- The behavior of `paragraph` has been modified. It now returns en Element, - The behavior of `paragraph` has been modified. It now returns en Element,
instead of a list of elements. instead of a list of elements.
### Bug
- Input shouldn't take focus when hovered by the mouse.
0.11.1 0.11.1
------ ------

View File

@ -14,7 +14,20 @@ std::wstring to_wstring(T s) {
} }
int string_width(const std::string&); int string_width(const std::string&);
// Split the string into a its glyphs. An empty one is inserted ater fullwidth
// ones.
std::vector<std::string> Utf8ToGlyphs(const std::string& input); std::vector<std::string> 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<int> CellToGlyphIndex(const std::string& input);
} // namespace ftxui } // namespace ftxui

View File

@ -20,10 +20,20 @@
namespace ftxui { 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. // An input box. The user can type text into it.
class WideInputBase : public ComponentBase { class InputBase : public ComponentBase {
public: public:
WideInputBase(WideStringRef content, InputBase(StringRef content,
ConstStringRef placeholder, ConstStringRef placeholder,
Ref<InputOption> option) Ref<InputOption> option)
: content_(content), placeholder_(placeholder), option_(option) {} : content_(content), placeholder_(placeholder), option_(option) {}
@ -38,22 +48,23 @@ class WideInputBase : public ComponentBase {
// Component implementation: // Component implementation:
Element Render() override { Element Render() override {
std::wstring password_content; std::string password_content;
if (option_->password()) if (option_->password())
password_content = std::wstring(content_->size(), U''); password_content = PasswordField(content_->size());
std::wstring& content = option_->password() ? password_content : *content_; std::string& content = option_->password() ? password_content : *content_;
cursor_position() = int size = GlyphCount(content);
std::max(0, std::min<int>(content.size(), cursor_position()));
auto main_decorator = flex | size(HEIGHT, EQUAL, 1); cursor_position() = std::max(0, std::min<int>(size, cursor_position()));
auto main_decorator = flex | ftxui::size(HEIGHT, EQUAL, 1);
bool is_focused = Focused(); bool is_focused = Focused();
// placeholder. // placeholder.
if (content.size() == 0) { if (size == 0) {
bool hovered = hovered_; bool hovered = hovered_;
Decorator decorator = dim | main_decorator; Decorator decorator = dim | main_decorator;
if (is_focused) if (is_focused)
decorator = decorator | focus | bold; decorator = decorator | focus | inverted;
if (hovered || is_focused) if (hovered || is_focused)
decorator = decorator | inverted; decorator = decorator | inverted;
return text(*placeholder_) | decorator | reflect(box_); return text(*placeholder_) | decorator | reflect(box_);
@ -67,22 +78,22 @@ class WideInputBase : public ComponentBase {
return text(content) | main_decorator | reflect(box_); return text(content) | main_decorator | reflect(box_);
} }
std::wstring part_before_cursor = content.substr(0, cursor_position()); int index_before_cursor = GlyphPosition(content, cursor_position());
std::wstring part_at_cursor = cursor_position() < (int)content.size() int index_after_cursor = GlyphPosition(content, 1, index_before_cursor);
? content.substr(cursor_position(), 1) std::string part_before_cursor = content.substr(0, index_before_cursor);
: L" "; std::string part_at_cursor = " ";
std::wstring part_after_cursor = cursor_position() < (int)content.size() - 1 if (cursor_position() < size) {
? content.substr(cursor_position() + 1) part_at_cursor = content.substr(index_before_cursor,
: L""; index_after_cursor - index_before_cursor);
}
std::string part_after_cursor = content.substr(index_after_cursor);
auto focused = (is_focused || hovered_) ? focus : select; auto focused = (is_focused || hovered_) ? focus : select;
// clang-format off return hbox({
return
hbox(
text(part_before_cursor), text(part_before_cursor),
text(part_at_cursor) | underlined | focused | reflect(cursor_box_), text(part_at_cursor) | focused | inverted | reflect(cursor_box_),
text(part_after_cursor) text(part_after_cursor),
) | flex | inverted | frame | bold |main_decorator | reflect(box_); }) |
// clang-format on flex | frame | bold | main_decorator | reflect(box_);
} }
bool OnEvent(Event event) override { bool OnEvent(Event event) override {
@ -92,13 +103,15 @@ class WideInputBase : public ComponentBase {
if (event.is_mouse()) if (event.is_mouse())
return OnMouseEvent(event); return OnMouseEvent(event);
std::wstring c; std::string c;
// Backspace. // Backspace.
if (event == Event::Backspace) { if (event == Event::Backspace) {
if (cursor_position() == 0) if (cursor_position() == 0)
return false; 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()--; cursor_position()--;
option_->on_change(); option_->on_change();
return true; return true;
@ -108,7 +121,9 @@ class WideInputBase : public ComponentBase {
if (event == Event::Delete) { if (event == Event::Delete) {
if (cursor_position() == int(content_->size())) if (cursor_position() == int(content_->size()))
return false; 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(); option_->on_change();
return true; return true;
} }
@ -140,13 +155,14 @@ class WideInputBase : public ComponentBase {
} }
if (event == Event::End) { if (event == Event::End) {
cursor_position() = (int)content_->size(); cursor_position() = GlyphCount(*content_);
return true; return true;
} }
// Content // Content
if (event.is_character()) { 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()++; cursor_position()++;
option_->on_change(); option_->on_change();
return true; return true;
@ -167,12 +183,27 @@ class WideInputBase : public ComponentBase {
} }
TakeFocus(); TakeFocus();
int new_cursor_position = if (content_->size() == 0)
cursor_position() + event.mouse().x - cursor_box_.x_min; return true;
new_cursor_position =
std::max(0, std::min<int>(content_->size(), new_cursor_position)); auto mapping = CellToGlyphIndex(*content_);
if (cursor_position() != new_cursor_position) { int original_glyph = cursor_position();
cursor_position() = new_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(); option_->on_change();
} }
return true; return true;
@ -181,7 +212,7 @@ class WideInputBase : public ComponentBase {
bool Focusable() const final { return true; } bool Focusable() const final { return true; }
bool hovered_ = false; bool hovered_ = false;
WideStringRef content_; StringRef content_;
ConstStringRef placeholder_; ConstStringRef placeholder_;
Box box_; Box box_;
@ -190,39 +221,37 @@ class WideInputBase : public ComponentBase {
}; };
// An input box. The user can type text into it. // An input box. The user can type text into it.
// For convenience, the std::string version of Input simply wrap a // For convenience, the std::wstring version of Input simply wrap a
// WideInputBase. // InputBase.
// TODO(arthursonzogni): Provide an implementation handling std::string natively class WideInputBase : public InputBase {
// and adds better support for combining characters.
class InputBase : public WideInputBase {
public: public:
InputBase(StringRef content, WideInputBase(WideStringRef content,
ConstStringRef placeholder, ConstStringRef placeholder,
Ref<InputOption> option) Ref<InputOption> option)
: WideInputBase(&wrapped_content_, : InputBase(&wrapped_content_, std::move(placeholder), std::move(option)),
std::move(placeholder),
std::move(option)),
content_(std::move(content)), content_(std::move(content)),
wrapped_content_(to_wstring(*content_)) {} wrapped_content_(to_string(*content_)) {}
Element Render() override { Element Render() override {
wrapped_content_ = to_wstring(*content_); wrapped_content_ = to_string(*content_);
return WideInputBase::Render(); return InputBase::Render();
} }
bool OnEvent(Event event) override { bool OnEvent(Event event) override {
wrapped_content_ = to_wstring(*content_); wrapped_content_ = to_string(*content_);
if (WideInputBase::OnEvent(event)) { if (InputBase::OnEvent(event)) {
*content_ = to_string(wrapped_content_); *content_ = to_wstring(wrapped_content_);
return true; return true;
} }
return false; return false;
} }
StringRef content_; WideStringRef content_;
std::wstring wrapped_content_; std::string wrapped_content_;
}; };
} // namespace
/// @brief An input box for editing text. /// @brief An input box for editing text.
/// @param content The editable content. /// @param content The editable content.
/// @param placeholder The text displayed when content is still empty. /// @param placeholder The text displayed when content is still empty.

View File

@ -226,6 +226,151 @@ TEST(InputTest, Backspace) {
EXPECT_EQ(option.cursor_position(), 0u); 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. // Copyright 2021 Arthur Sonzogni. All rights reserved.
// Use of this source code is governed by the MIT license that can be found in // Use of this source code is governed by the MIT license that can be found in
// the LICENSE file. // the LICENSE file.

View File

@ -288,6 +288,98 @@ std::vector<std::string> Utf8ToGlyphs(const std::string& input) {
return out; 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<int> CellToGlyphIndex(const std::string& input) {
int x = -1;
std::vector<int> 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 #ifdef _MSC_VER
#pragma warning(push) #pragma warning(push)
#pragma warning(disable : 4996) // codecvt_utf8_utf16 is deprecated #pragma warning(disable : 4996) // codecvt_utf8_utf16 is deprecated

View File

@ -42,6 +42,85 @@ TEST(StringTest, Utf8ToGlyphs) {
EXPECT_EQ(Utf8ToGlyphs("a\1a"), T({"a", "a"})); 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(""), 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. // Copyright 2020 Arthur Sonzogni. All rights reserved.
// Use of this source code is governed by the MIT license that can be found in // Use of this source code is governed by the MIT license that can be found in
// the LICENSE file. // the LICENSE file.