Feature: Selection

Add support for selection content in the dom.
This commit is contained in:
Clément Roblot
2024-12-27 15:45:13 +07:00
committed by GitHub
parent 751c8fab26
commit 6fafa2dfed
25 changed files with 1001 additions and 31 deletions

View File

@@ -80,6 +80,7 @@ class Flex : public Node {
}
void SetBox(Box box) override {
Node::SetBox(box);
if (children_.empty()) {
return;
}

View File

@@ -64,6 +64,19 @@ class HBox : public Node {
x = box.x_max + 1;
}
}
void Select(Selection& selection) override {
// If this Node box_ doesn't intersect with the selection, then no
// selection.
if (Box::Intersection(selection.GetBox(), box_).IsEmpty()) {
return;
}
Selection selection_saturated = selection.SaturateHorizontal(box_);
for (auto& child : children_) {
child->Select(selection_saturated);
}
}
};
} // namespace

View File

@@ -1,3 +1,4 @@
#include <iostream>
// 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.
@@ -27,6 +28,20 @@ void Node::SetBox(Box box) {
box_ = box;
}
/// @brief Compute the selection of an element.
/// @ingroup dom
void Node::Select(Selection& selection) {
// If this Node box_ doesn't intersect with the selection, then no selection.
if (Box::Intersection(selection.GetBox(), box_).IsEmpty()) {
return;
}
// By default we defer the selection to the children.
for (auto& child : children_) {
child->Select(selection);
}
}
/// @brief Display an element on a ftxui::Screen.
/// @ingroup dom
void Node::Render(Screen& screen) {
@@ -42,15 +57,31 @@ void Node::Check(Status* status) {
status->need_iteration |= (status->iteration == 0);
}
std::string Node::GetSelectedContent(Selection& selection) {
std::string content;
for (auto& child : children_) {
content += child->GetSelectedContent(selection);
}
return content;
}
/// @brief Display an element on a ftxui::Screen.
/// @ingroup dom
void Render(Screen& screen, const Element& element) {
Render(screen, element.get());
Selection selection;
Render(screen, element.get(), selection);
}
/// @brief Display an element on a ftxui::Screen.
/// @ingroup dom
void Render(Screen& screen, Node* node) {
Selection selection;
Render(screen, node, selection);
}
void Render(Screen& screen, Node* node, Selection& selection) {
Box box;
box.x_min = 0;
box.y_min = 0;
@@ -73,12 +104,49 @@ void Render(Screen& screen, Node* node) {
node->Check(&status);
}
// Step 3: Draw the element.
// Step 3: Selection
if (!selection.IsEmpty()) {
node->Select(selection);
}
// Step 4: Draw the element.
screen.stencil = box;
node->Render(screen);
// Step 4: Apply shaders
// Step 5: Apply shaders
screen.ApplyShader();
}
std::string GetNodeSelectedContent(Screen& screen,
Node* node,
Selection& selection) {
Box box;
box.x_min = 0;
box.y_min = 0;
box.x_max = screen.dimx() - 1;
box.y_max = screen.dimy() - 1;
Node::Status status;
node->Check(&status);
const int max_iterations = 20;
while (status.need_iteration && status.iteration < max_iterations) {
// Step 1: Find what dimension this elements wants to be.
node->ComputeRequirement();
// Step 2: Assign a dimension to the element.
node->SetBox(box);
// Check if the element needs another iteration of the layout algorithm.
status.need_iteration = false;
status.iteration++;
node->Check(&status);
}
// Step 3: Selection
node->Select(selection);
// Step 4: get the selected content.
return node->GetSelectedContent(selection);
}
} // namespace ftxui

View File

@@ -20,6 +20,18 @@ Elements Split(const std::string& the_text) {
}
return output;
}
Element Split(const std::string& paragraph,
std::function<Element(std::string)> f) {
Elements output;
std::stringstream ss(paragraph);
std::string line;
while (std::getline(ss, line, '\n')) {
output.push_back(f(line));
}
return vbox(std::move(output));
}
} // namespace
/// @brief Return an element drawing the paragraph on multiple lines.
@@ -34,18 +46,22 @@ Element paragraph(const std::string& the_text) {
/// @ingroup dom
/// @see flexbox.
Element paragraphAlignLeft(const std::string& the_text) {
static const auto config = FlexboxConfig().SetGap(1, 0);
return flexbox(Split(the_text), config);
}
return Split(the_text, [](const std::string& line) {
static const auto config = FlexboxConfig().SetGap(1, 0);
return flexbox(Split(line), config);
});
};
/// @brief Return an element drawing the paragraph on multiple lines, aligned on
/// the right.
/// @ingroup dom
/// @see flexbox.
Element paragraphAlignRight(const std::string& the_text) {
static const auto config =
FlexboxConfig().SetGap(1, 0).Set(FlexboxConfig::JustifyContent::FlexEnd);
return flexbox(Split(the_text), config);
return Split(the_text, [](const std::string& line) {
static const auto config = FlexboxConfig().SetGap(1, 0).Set(
FlexboxConfig::JustifyContent::FlexEnd);
return flexbox(Split(line), config);
});
}
/// @brief Return an element drawing the paragraph on multiple lines, aligned on
@@ -53,9 +69,11 @@ Element paragraphAlignRight(const std::string& the_text) {
/// @ingroup dom
/// @see flexbox.
Element paragraphAlignCenter(const std::string& the_text) {
static const auto config =
FlexboxConfig().SetGap(1, 0).Set(FlexboxConfig::JustifyContent::Center);
return flexbox(Split(the_text), config);
return Split(the_text, [](const std::string& line) {
static const auto config =
FlexboxConfig().SetGap(1, 0).Set(FlexboxConfig::JustifyContent::Center);
return flexbox(Split(line), config);
});
}
/// @brief Return an element drawing the paragraph on multiple lines, aligned
@@ -64,11 +82,13 @@ Element paragraphAlignCenter(const std::string& the_text) {
/// @ingroup dom
/// @see flexbox.
Element paragraphAlignJustify(const std::string& the_text) {
static const auto config = FlexboxConfig().SetGap(1, 0).Set(
FlexboxConfig::JustifyContent::SpaceBetween);
Elements words = Split(the_text);
words.push_back(text("") | xflex);
return flexbox(std::move(words), config);
return Split(the_text, [](const std::string& line) {
static const auto config = FlexboxConfig().SetGap(1, 0).Set(
FlexboxConfig::JustifyContent::SpaceBetween);
Elements words = Split(line);
words.push_back(text("") | xflex);
return flexbox(std::move(words), config);
});
}
} // namespace ftxui

168
src/ftxui/dom/selection.cpp Normal file
View File

@@ -0,0 +1,168 @@
// Copyright 2024 Arthur Sonzogni. All rights reserved.
// Use of this source code is governed by the MIT license that can be found in
// the LICENSE file.
#include "ftxui/dom/selection.hpp" // for Selection
#include <algorithm> // for max, min
#include "ftxui/dom/elements.hpp" // for Element, inverted
#include "ftxui/dom/node_decorator.hpp" // for NodeDecorator
namespace ftxui {
namespace {
class Unselectable : public NodeDecorator {
public:
using NodeDecorator::NodeDecorator;
void Select(Selection& selection) override {
// Overwrite the select method to do nothing.
}
};
} // namespace
/// @brief Create an empty selection.
Selection::Selection() : empty_(true) {}
/// @brief Create a selection.
/// @param start_x The x coordinate of the start of the selection.
/// @param start_y The y coordinate of the start of the selection.
/// @param end_x The x coordinate of the end of the selection.
/// @param end_y The y coordinate of the end of the selection.
Selection::Selection(int start_x, int start_y, int end_x, int end_y)
: start_x_(start_x),
start_y_(start_y),
end_x_(end_x),
end_y_(end_y),
box_{
std::min(start_x, end_x),
std::max(start_x, end_x),
std::min(start_y, end_y),
std::max(start_y, end_y),
},
empty_(false) {}
Selection::Selection(int start_x,
int start_y,
int end_x,
int end_y,
Selection* parent)
: start_x_(start_x),
start_y_(start_y),
end_x_(end_x),
end_y_(end_y),
box_{
std::min(start_x, end_x),
std::max(start_x, end_x),
std::min(start_y, end_y),
std::max(start_y, end_y),
},
parent_(parent),
empty_(false) {}
/// @brief Get the box of the selection.
/// @return The box of the selection.
const Box& Selection::GetBox() const {
return box_;
}
/// @brief Saturate the selection to be inside the box.
/// This is called by `hbox` to propagate the selection to its children.
/// @param box The box to saturate the selection in.
/// @return The saturated selection.
Selection Selection::SaturateHorizontal(Box box) {
int start_x = start_x_;
int start_y = start_y_;
int end_x = end_x_;
int end_y = end_y_;
const bool start_outside = !box.Contain(start_x, start_y);
const bool end_outside = !box.Contain(end_x, end_y);
const bool properly_ordered =
start_y < end_y || (start_y == end_y && start_x <= end_x);
if (properly_ordered) {
if (start_outside) {
start_x = box.x_min;
start_y = box.y_min;
}
if (end_outside) {
end_x = box.x_max;
end_y = box.y_max;
}
} else {
if (start_outside) {
start_x = box.x_max;
start_y = box.y_max;
}
if (end_outside) {
end_x = box.x_min;
end_y = box.y_min;
}
}
return Selection(start_x, start_y, end_x, end_y, parent_);
}
/// @brief Saturate the selection to be inside the box.
/// This is called by `vbox` to propagate the selection to its children.
/// @param box The box to saturate the selection in.
/// @return The saturated selection.
Selection Selection::SaturateVertical(Box box) {
int start_x = start_x_;
int start_y = start_y_;
int end_x = end_x_;
int end_y = end_y_;
const bool start_outside = !box.Contain(start_x, start_y);
const bool end_outside = !box.Contain(end_x, end_y);
const bool properly_ordered =
start_y < end_y || (start_y == end_y && start_x <= end_x);
if (properly_ordered) {
if (start_outside) {
start_x = box.x_min;
start_y = box.y_min;
}
if (end_outside) {
end_x = box.x_max;
end_y = box.y_max;
}
} else {
if (start_outside) {
start_x = box.x_max;
start_y = box.y_max;
}
if (end_outside) {
end_x = box.x_min;
end_y = box.y_min;
}
}
return Selection(start_x, start_y, end_x, end_y, parent_);
}
void Selection::AddPart(const std::string& part, int y, int left, int right) {
if (parent_ != this) {
return parent_->AddPart(part, y, left, right);
}
[&] {
if (parts_.str().empty()) {
parts_ << part;
return;
}
if (y_ != y) {
parts_ << '\n' << part;
return;
}
if (x_ == left + 1) {
parts_ << part;
return;
}
parts_ << part;
}();
y_ = y;
x_ = right;
}
} // namespace ftxui

View File

@@ -0,0 +1,89 @@
// Copyright 2024 Arthur Sonzogni. All rights reserved.
// Use of this source code is governed by the MIT license that can be found in
// the LICENSE file.
#include <memory> // for make_shared
#include <utility> // for move
#include "ftxui/dom/elements.hpp" // for Element, Decorator, bgcolor, color
#include "ftxui/dom/node_decorator.hpp" // for NodeDecorator
#include "ftxui/screen/box.hpp" // for Box
#include "ftxui/screen/color.hpp" // for Color
#include "ftxui/screen/screen.hpp" // for Pixel, Screen
namespace ftxui {
namespace {
class SelectionStyleReset : public NodeDecorator {
public:
SelectionStyleReset(Element child) : NodeDecorator(std::move(child)) {}
void Render(Screen& screen) final {
auto old_style = screen.GetSelectionStyle();
screen.SetSelectionStyle([](Pixel& pixel) {});
NodeDecorator::Render(screen);
screen.SetSelectionStyle(old_style);
}
};
class SelectionStyle : public NodeDecorator {
public:
SelectionStyle(Element child, std::function<void(Pixel&)> style)
: NodeDecorator(std::move(child)), style_(style) {}
void Render(Screen& screen) final {
auto old_style = screen.GetSelectionStyle();
auto new_style = [&, old_style](Pixel& pixel) {
old_style(pixel);
style_(pixel);
};
screen.SetSelectionStyle(new_style);
NodeDecorator::Render(screen);
screen.SetSelectionStyle(old_style);
}
std::function<void(Pixel&)> style_;
};
} // namespace
/// @brief Reset the selection style of an element.
/// @param child The input element.
/// @return The output element with the selection style reset.
Element selectionStyleReset(Element child) {
return std::make_shared<SelectionStyleReset>(std::move(child));
}
/// @brief Set the background color of an element when selected.
/// Note that the style is applied on top of the existing style.
Decorator selectionBackgroundColor(Color foreground) {
return selectionStyle([foreground](Pixel& pixel) { //
pixel.background_color = foreground;
});
}
/// @brief Set the foreground color of an element when selected.
/// Note that the style is applied on top of the existing style.
Decorator selectionForegroundColor(Color foreground) {
return selectionStyle([foreground](Pixel& pixel) { //
pixel.foreground_color = foreground;
});
}
/// @brief Set the color of an element when selected.
/// @param foreground The color to be applied.
/// Note that the style is applied on top of the existing style.
Decorator selectionColor(Color foreground) {
return selectionForegroundColor(foreground);
}
/// @brief Set the style of an element when selected.
/// @param style The style to be applied.
/// Note that the style is applied on top of the existing style.
Decorator selectionStyle(std::function<void(Pixel&)> style) {
return [style](Element child) -> Element {
return std::make_shared<SelectionStyle>(std::move(child), style);
};
}
} // namespace ftxui

View File

@@ -0,0 +1,224 @@
// Copyright 2022 Arthur Sonzogni. All rights reserved.
// Use of this source code is governed by the MIT license that can be found in
// the LICENSE file.
#include <gtest/gtest.h>
#include <csignal> // for raise, SIGABRT, SIGFPE, SIGILL, SIGINT, SIGSEGV, SIGTERM
#include "ftxui/component/component.hpp" // for Input, Renderer, Vertical
#include "ftxui/component/event.hpp" // for Event
#include "ftxui/component/loop.hpp" // for Loop
#include "ftxui/component/mouse.hpp" // for Mouse, Mouse::Left, Mouse::Pressed, Mouse::Released
#include "ftxui/component/screen_interactive.hpp"
#include "ftxui/dom/elements.hpp" // for text
#include "ftxui/dom/node.hpp" // for Render
#include "ftxui/screen/screen.hpp" // for Screen
// NOLINTBEGIN
namespace ftxui {
namespace {
Event MousePressed(int x, int y) {
Mouse mouse;
mouse.button = Mouse::Left;
mouse.motion = Mouse::Pressed;
mouse.shift = false;
mouse.meta = false;
mouse.control = false;
mouse.x = x;
mouse.y = y;
return Event::Mouse("", mouse);
}
Event MouseReleased(int x, int y) {
Mouse mouse;
mouse.button = Mouse::Left;
mouse.motion = Mouse::Released;
mouse.shift = false;
mouse.meta = false;
mouse.control = false;
mouse.x = x;
mouse.y = y;
return Event::Mouse("", mouse);
}
Event MouseMove(int x, int y) {
Mouse mouse;
mouse.button = Mouse::Left;
mouse.motion = Mouse::Moved;
mouse.shift = false;
mouse.meta = false;
mouse.control = false;
mouse.x = x;
mouse.y = y;
return Event::Mouse("", mouse);
}
} // namespace
TEST(SelectionTest, DefaultSelection) {
auto component = Renderer([&] { return text("Lorem ipsum dolor"); });
auto screen = ScreenInteractive::FixedSize(20, 1);
EXPECT_EQ(screen.GetSelection(), "");
Loop loop(&screen, component);
screen.PostEvent(MousePressed(3, 1));
screen.PostEvent(MouseReleased(10, 1));
loop.RunOnce();
EXPECT_EQ(screen.GetSelection(), "rem ipsu");
}
TEST(SelectionTest, SelectionChange) {
int selectionChangeCounter = 0;
auto component = Renderer([&] { return text("Lorem ipsum dolor"); });
auto screen = ScreenInteractive::FixedSize(20, 1);
screen.SelectionChange([&] { selectionChangeCounter++; });
Loop loop(&screen, component);
loop.RunOnce();
EXPECT_EQ(selectionChangeCounter, 0);
screen.PostEvent(MousePressed(3, 1));
loop.RunOnce();
EXPECT_EQ(selectionChangeCounter, 0);
screen.PostEvent(MouseMove(5, 1));
loop.RunOnce();
EXPECT_EQ(selectionChangeCounter, 1);
screen.PostEvent(MouseMove(7, 1));
loop.RunOnce();
EXPECT_EQ(selectionChangeCounter, 2);
screen.PostEvent(MouseReleased(10, 1));
loop.RunOnce();
EXPECT_EQ(selectionChangeCounter, 3);
screen.PostEvent(MouseMove(10, 1));
loop.RunOnce();
EXPECT_EQ(selectionChangeCounter, 3);
EXPECT_EQ(screen.GetSelection(), "rem ipsu");
}
// Check that submitting multiple mouse events quickly doesn't trigger multiple
// selection change events.
TEST(SelectionTest, SelectionOnChangeSquashedEvents) {
int selectionChangeCounter = 0;
auto component = Renderer([&] { return text("Lorem ipsum dolor"); });
auto screen = ScreenInteractive::FixedSize(20, 1);
screen.SelectionChange([&] { selectionChangeCounter++; });
Loop loop(&screen, component);
loop.RunOnce();
EXPECT_EQ(selectionChangeCounter, 0);
screen.PostEvent(MousePressed(3, 1));
screen.PostEvent(MouseMove(5, 1));
screen.PostEvent(MouseMove(7, 1));
loop.RunOnce();
EXPECT_EQ(selectionChangeCounter, 1);
screen.PostEvent(MouseReleased(10, 1));
screen.PostEvent(MouseMove(10, 1));
loop.RunOnce();
EXPECT_EQ(selectionChangeCounter, 2);
EXPECT_EQ(screen.GetSelection(), "rem ipsu");
}
TEST(SelectionTest, StyleSelection) {
int selectionChangeCounter = 0;
auto element = hbox({
text("Lorem "),
text("ipsum") | selectionColor(Color::Red),
text(" dolor"),
});
auto screen = ScreenInteractive::FixedSize(20, 1);
Selection selection(2, 0, 9, 0);
Render(screen, element.get(), selection);
for (int i = 0; i < 20; i++) {
if (i >= 2 && i <= 9) {
EXPECT_EQ(screen.PixelAt(i, 0).inverted, true);
} else {
EXPECT_EQ(screen.PixelAt(i, 0).inverted, false);
}
if (i >= 6 && i <= 9) {
EXPECT_EQ(screen.PixelAt(i, 0).foreground_color, Color::Red);
} else {
EXPECT_EQ(screen.PixelAt(i, 0).foreground_color, Color::Default);
}
}
}
TEST(SelectionTest, VBoxSelection) {
auto element = vbox({
text("Lorem ipsum dolor"),
text("Ut enim ad minim"),
});
auto screen = ScreenInteractive::FixedSize(20, 2);
Selection selection(2, 0, 2, 1);
Render(screen, element.get(), selection);
EXPECT_EQ(selection.GetParts(), "rem ipsum dolor\nUt ");
EXPECT_EQ(screen.ToString(),
"Lo\x1B[7mrem ipsum dolor\x1B[27m \r\n"
"\x1B[7mUt \x1B[27menim ad minim ");
}
TEST(SelectionTest, VBoxSaturatedSelection) {
auto element = vbox({
text("Lorem ipsum dolor"),
text("Ut enim ad minim"),
text("Duis aute irure"),
});
auto screen = ScreenInteractive::FixedSize(20, 3);
Selection selection(2, 0, 2, 2);
Render(screen, element.get(), selection);
EXPECT_EQ(selection.GetParts(), "rem ipsum dolor\nUt enim ad minim\nDui");
EXPECT_EQ(screen.ToString(),
"Lo\x1B[7mrem ipsum dolor\x1B[27m \r\n"
"\x1B[7mUt enim ad minim\x1B[27m \r\n"
"\x1B[7mDui\x1B[27ms aute irure ");
}
TEST(SelectionTest, HBoxSelection) {
auto element = hbox({
text("Lorem ipsum dolor"),
text("Ut enim ad minim"),
});
auto screen = ScreenInteractive::FixedSize(40, 1);
Selection selection(2, 0, 20, 0);
Render(screen, element.get(), selection);
EXPECT_EQ(selection.GetParts(), "rem ipsum dolorUt e");
EXPECT_EQ(screen.ToString(),
"Lo\x1B[7mrem ipsum dolorUt e\x1B[27mnim ad minim ");
}
TEST(SelectionTest, HBoxSaturatedSelection) {
auto element = hbox({
text("Lorem ipsum dolor"),
text("Ut enim ad minim"),
text("Duis aute irure"),
});
auto screen = ScreenInteractive::FixedSize(60, 1);
Selection selection(2, 0, 35, 0);
Render(screen, element.get(), selection);
EXPECT_EQ(selection.GetParts(), "rem ipsum dolorUt enim ad minimDui");
EXPECT_EQ(screen.ToString(),
"Lo\x1B[7mrem ipsum dolorUt enim ad minimDui\x1B[27ms aute irure "
" ");
}
} // namespace ftxui
// NOLINTEND

View File

@@ -3,8 +3,9 @@
// the LICENSE file.
#include <algorithm> // for min
#include <memory> // for make_shared
#include <string> // for string, wstring
#include <utility> // for move
#include <sstream>
#include <string> // for string, wstring
#include <utility> // for move
#include "ftxui/dom/deprecated.hpp" // for text, vtext
#include "ftxui/dom/elements.hpp" // for Element, text, vtext
@@ -26,28 +27,68 @@ class Text : public Node {
void ComputeRequirement() override {
requirement_.min_x = string_width(text_);
requirement_.min_y = 1;
has_selection = false;
}
void Select(Selection& selection) override {
if (Box::Intersection(selection.GetBox(), box_).IsEmpty()) {
return;
}
Selection selection_saturated = selection.SaturateHorizontal(box_);
has_selection = true;
selection_start_ = selection_saturated.GetBox().x_min;
selection_end_ = selection_saturated.GetBox().x_max;
std::stringstream ss;
int x = box_.x_min;
for (const auto& cell : Utf8ToGlyphs(text_)) {
if (cell == "\n") {
continue;
}
if (selection_start_ <= x && x <= selection_end_) {
ss << cell;
}
x++;
}
selection.AddPart(ss.str(), box_.y_min, selection_start_, selection_end_);
}
void Render(Screen& screen) override {
int x = box_.x_min;
const int y = box_.y_min;
if (y > box_.y_max) {
return;
}
for (const auto& cell : Utf8ToGlyphs(text_)) {
if (x > box_.x_max) {
return;
break;
}
if (cell == "\n") {
continue;
}
screen.PixelAt(x, y).character = cell;
if (has_selection) {
auto selectionTransform = screen.GetSelectionStyle();
if ((x >= selection_start_) && (x <= selection_end_)) {
selectionTransform(screen.PixelAt(x, y));
}
}
++x;
}
}
private:
std::string text_;
bool has_selection = false;
int selection_start_ = 0;
int selection_end_ = -1;
std::function<void(Pixel& pixel)> selectionTransform;
};
class VText : public Node {

View File

@@ -64,6 +64,20 @@ class VBox : public Node {
y = box.y_max + 1;
}
}
void Select(Selection& selection) override {
// If this Node box_ doesn't intersect with the selection, then no
// selection.
if (Box::Intersection(selection.GetBox(), box_).IsEmpty()) {
return;
}
Selection selection_saturated = selection.SaturateVertical(box_);
for (auto& child : children_) {
child->Select(selection_saturated);
}
}
};
} // namespace