diff --git a/CHANGELOG.md b/CHANGELOG.md index bfbcfb15c..fbe57ae25 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,9 @@ current (development) - Feature: Add support for `Input`'s insert mode. Add `InputOption::insert` option. Added by @mingsheng13. - Feature: Add `DropdownOption` to configure the dropdown. See #826. +- Feature: Add support for Selection. Thanks @clement-roblot. See #926. + - See `ScreenInteractive::GetSelection()`. + - See `ScreenInteractive::SelectionChange(...)` listener. - Bugfix/Breaking change: `Mouse transition`: - Detect when the mouse move, as opposed to being pressed. The Mouse::Moved motion was added. @@ -49,6 +52,12 @@ current (development) - Feature: Add `extend_beyond_screen` option to `Dimension::Fit(..)`, allowing the element to be larger than the screen. Proposed by @LordWhiro. See #572 and #949. +- Feature: Add support for Selection. Thanks @clement-roblot. See #926. + - See `selectionColor` decorator. + - See `selectionBackgroundColor` decorator. + - See `selectionForegroundColor` decorator. + - See `selectionStyle(style)` decorator. + - See `selectionStyleReset` decorator. ### Screen - Feature: Add `Box::IsEmpty()`. diff --git a/CMakeLists.txt b/CMakeLists.txt index bf4a9dac3..2c121b1ab 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -56,11 +56,12 @@ add_library(dom include/ftxui/dom/flexbox_config.hpp include/ftxui/dom/node.hpp include/ftxui/dom/requirement.hpp + include/ftxui/dom/selection.hpp include/ftxui/dom/take_any_args.hpp src/ftxui/dom/automerge.cpp + src/ftxui/dom/selection_style.cpp src/ftxui/dom/blink.cpp src/ftxui/dom/bold.cpp - src/ftxui/dom/hyperlink.cpp src/ftxui/dom/border.cpp src/ftxui/dom/box_helper.cpp src/ftxui/dom/box_helper.hpp @@ -81,6 +82,7 @@ add_library(dom src/ftxui/dom/graph.cpp src/ftxui/dom/gridbox.cpp src/ftxui/dom/hbox.cpp + src/ftxui/dom/hyperlink.cpp src/ftxui/dom/inverted.cpp src/ftxui/dom/linear_gradient.cpp src/ftxui/dom/node.cpp @@ -88,6 +90,7 @@ add_library(dom src/ftxui/dom/paragraph.cpp src/ftxui/dom/reflect.cpp src/ftxui/dom/scroll_indicator.cpp + src/ftxui/dom/selection.cpp src/ftxui/dom/separator.cpp src/ftxui/dom/size.cpp src/ftxui/dom/spinner.cpp diff --git a/cmake/ftxui_test.cmake b/cmake/ftxui_test.cmake index c75ea4316..56b21bd9d 100644 --- a/cmake/ftxui_test.cmake +++ b/cmake/ftxui_test.cmake @@ -40,6 +40,7 @@ add_executable(ftxui-tests src/ftxui/dom/hyperlink_test.cpp src/ftxui/dom/linear_gradient_test.cpp src/ftxui/dom/scroll_indicator_test.cpp + src/ftxui/dom/selection_test.cpp src/ftxui/dom/separator_test.cpp src/ftxui/dom/spinner_test.cpp src/ftxui/dom/table_test.cpp diff --git a/examples/component/CMakeLists.txt b/examples/component/CMakeLists.txt index 43392152b..62318b03e 100644 --- a/examples/component/CMakeLists.txt +++ b/examples/component/CMakeLists.txt @@ -38,6 +38,7 @@ example(radiobox) example(radiobox_in_frame) example(renderer) example(resizable_split) +example(selection) example(scrollbar) example(slider) example(slider_direction) diff --git a/examples/component/gallery.cpp b/examples/component/gallery.cpp index 6add4f8be..9c51120ba 100644 --- a/examples/component/gallery.cpp +++ b/examples/component/gallery.cpp @@ -97,7 +97,25 @@ int main() { }); sliders = Wrap("Slider", sliders); - // -- Layout ----------------------------------------------------------------- + // A large text: + auto lorel_ipsum = Renderer([] { + return vbox({ + text("Lorem ipsum dolor sit amet, consectetur adipiscing elit. "), + text("Sed do eiusmod tempor incididunt ut labore et dolore magna " + "aliqua. "), + text("Ut enim ad minim veniam, quis nostrud exercitation ullamco " + "laboris nisi ut aliquip ex ea commodo consequat. "), + text("Duis aute irure dolor in reprehenderit in voluptate velit esse " + "cillum dolore eu fugiat nulla pariatur. "), + text("Excepteur sint occaecat cupidatat non proident, sunt in culpa " + "qui officia deserunt mollit anim id est laborum. "), + + }); + }); + lorel_ipsum = Wrap("Lorel Ipsum", lorel_ipsum); + + // -- Layout + // ----------------------------------------------------------------- auto layout = Container::Vertical({ menu, toggle, @@ -106,6 +124,7 @@ int main() { input, sliders, button, + lorel_ipsum, }); auto component = Renderer(layout, [&] { @@ -123,6 +142,8 @@ int main() { sliders->Render(), separator(), button->Render(), + separator(), + lorel_ipsum->Render(), }) | xflex | size(WIDTH, GREATER_THAN, 40) | border; }); diff --git a/examples/component/homescreen.cpp b/examples/component/homescreen.cpp index d650ba2e4..7a5e710c5 100644 --- a/examples/component/homescreen.cpp +++ b/examples/component/homescreen.cpp @@ -424,7 +424,7 @@ int main() { auto paragraph_renderer_left = Renderer([&] { std::string str = "Lorem Ipsum is simply dummy text of the printing and typesetting " - "industry. Lorem Ipsum has been the industry's standard dummy text " + "industry.\nLorem Ipsum has been the industry's standard dummy text " "ever since the 1500s, when an unknown printer took a galley of type " "and scrambled it to make a type specimen book."; return vbox({ diff --git a/examples/component/selection.cpp b/examples/component/selection.cpp new file mode 100644 index 000000000..93e96ea14 --- /dev/null +++ b/examples/component/selection.cpp @@ -0,0 +1,87 @@ +// 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. +#include // for char_traits, operator+, string, basic_string + +#include "ftxui/component/component.hpp" // for Input, Renderer, Vertical +#include "ftxui/component/component_base.hpp" // for ComponentBase +#include "ftxui/component/component_options.hpp" // for InputOption +#include "ftxui/component/screen_interactive.hpp" // for Component, ScreenInteractive +#include "ftxui/dom/elements.hpp" // for text, hbox, separator, Element, operator|, vbox, border +#include "ftxui/util/ref.hpp" // for Ref + +using namespace ftxui; + +Element LoremIpsum() { + return vbox({ + text("FTXUI: A powerful library for building user interfaces."), + text("Enjoy a rich set of components and a declarative style."), + text("Create beautiful and responsive UIs with minimal effort."), + text("Join the community and experience the power of FTXUI."), + }); +} + +int main() { + auto screen = ScreenInteractive::TerminalOutput(); + + auto quit = + Button("Quit", screen.ExitLoopClosure(), ButtonOption::Animated()); + + int selection_change_counter = 0; + std::string selection_content = ""; + screen.SelectionChange([&] { + selection_change_counter++; + selection_content = screen.GetSelection(); + }); + + // The components: + auto renderer = Renderer(quit, [&] { + return vbox({ + text("Select changed: " + std::to_string(selection_change_counter) + + " times"), + text("Currently selected: "), + paragraph(selection_content) | vscroll_indicator | frame | border | + size(HEIGHT, EQUAL, 10), + window(text("Horizontal split"), hbox({ + LoremIpsum(), + separator(), + LoremIpsum(), + separator(), + LoremIpsum(), + })), + window(text("Vertical split"), vbox({ + LoremIpsum(), + separator(), + LoremIpsum(), + separator(), + LoremIpsum(), + })), + window(text("Grid split with different style"), + vbox({ + hbox({ + LoremIpsum(), + separator(), + LoremIpsum() // + | selectionBackgroundColor(Color::Yellow) // + | selectionColor(Color::Black) // + | selectionStyleReset, + separator(), + LoremIpsum() | selectionColor(Color::Blue), + }), + separator(), + hbox({ + LoremIpsum() | selectionColor(Color::Red), + separator(), + LoremIpsum() | selectionStyle([](Pixel& pixel) { + pixel.underlined_double = true; + }), + separator(), + LoremIpsum(), + }), + })), + quit->Render(), + }); + }); + + screen.Loop(renderer); +} diff --git a/include/ftxui/component/screen_interactive.hpp b/include/ftxui/component/screen_interactive.hpp index 6c799136f..a2909fca8 100644 --- a/include/ftxui/component/screen_interactive.hpp +++ b/include/ftxui/component/screen_interactive.hpp @@ -16,6 +16,7 @@ #include "ftxui/component/captured_mouse.hpp" // for CapturedMouse #include "ftxui/component/event.hpp" // for Event #include "ftxui/component/task.hpp" // for Task, Closure +#include "ftxui/dom/selection.hpp" // for SelectionOption #include "ftxui/screen/screen.hpp" // for Screen namespace ftxui { @@ -68,6 +69,10 @@ class ScreenInteractive : public Screen { void ForceHandleCtrlC(bool force); void ForceHandleCtrlZ(bool force); + // Selection API. + std::string GetSelection(); + void SelectionChange(std::function callback); + private: void ExitNow(); @@ -82,6 +87,8 @@ class ScreenInteractive : public Screen { void RunOnceBlocking(Component component); void HandleTask(Component component, Task& task); + bool HandleSelection(bool handled, Event event); + void RefreshSelection(); void Draw(Component component); void ResetCursorPosition(); @@ -129,6 +136,22 @@ class ScreenInteractive : public Screen { // The style of the cursor to restore on exit. int cursor_reset_shape_ = 1; + // Selection API: + CapturedMouse selection_pending_; + struct SelectionData { + int start_x = -1; + int start_y = -1; + int end_x = -2; + int end_y = -2; + bool empty = true; + bool operator==(const SelectionData& other) const; + bool operator!=(const SelectionData& other) const; + }; + SelectionData selection_data_; + SelectionData selection_data_previous_; + std::unique_ptr selection_; + std::function selection_on_change_; + friend class Loop; public: diff --git a/include/ftxui/dom/elements.hpp b/include/ftxui/dom/elements.hpp index a2dd04427..fa16080e2 100644 --- a/include/ftxui/dom/elements.hpp +++ b/include/ftxui/dom/elements.hpp @@ -113,6 +113,11 @@ Decorator focusPositionRelative(float x, float y); Element automerge(Element child); Decorator hyperlink(std::string link); Element hyperlink(std::string link, Element child); +Element selectionStyleReset(Element); +Decorator selectionColor(Color foreground); +Decorator selectionBackgroundColor(Color foreground); +Decorator selectionForegroundColor(Color foreground); +Decorator selectionStyle(std::function style); // --- Layout is // Horizontal, Vertical or stacked set of elements. diff --git a/include/ftxui/dom/node.hpp b/include/ftxui/dom/node.hpp index f43157a9d..87edce778 100644 --- a/include/ftxui/dom/node.hpp +++ b/include/ftxui/dom/node.hpp @@ -8,6 +8,7 @@ #include // for vector #include "ftxui/dom/requirement.hpp" // for Requirement +#include "ftxui/dom/selection.hpp" // for Selection #include "ftxui/screen/box.hpp" // for Box #include "ftxui/screen/screen.hpp" @@ -40,9 +41,15 @@ class Node { // Propagated from Parents to Children. virtual void SetBox(Box box); - // Step 3: Draw this element. + // Step 3: (optional) Selection + // Propagated from Parents to Children. + virtual void Select(Selection& selection); + + // Step 4: Draw this element. virtual void Render(Screen& screen); + virtual std::string GetSelectedContent(Selection& selection); + // Layout may not resolve within a single iteration for some elements. This // allows them to request additionnal iterations. This signal must be // forwarded to children at least once. @@ -60,6 +67,10 @@ class Node { void Render(Screen& screen, const Element& element); void Render(Screen& screen, Node* node); +void Render(Screen& screen, Node* node, Selection& selection); +std::string GetNodeSelectedContent(Screen& screen, + Node* node, + Selection& selection); } // namespace ftxui diff --git a/include/ftxui/dom/selection.hpp b/include/ftxui/dom/selection.hpp new file mode 100644 index 000000000..3ec0e4821 --- /dev/null +++ b/include/ftxui/dom/selection.hpp @@ -0,0 +1,50 @@ +// 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. + +#ifndef FTXUI_DOM_SELECTION_HPP +#define FTXUI_DOM_SELECTION_HPP + +#include + +#include +#include "ftxui/screen/box.hpp" // for Box +#include "ftxui/screen/pixel.hpp" // for Pixel + +namespace ftxui { + +/// @brief Represent a selection in the terminal. +class Selection { + public: + Selection(); // Empty selection. + Selection(int start_x, int start_y, int end_x, int end_y); + + const Box& GetBox() const; + + Selection SaturateHorizontal(Box box); + Selection SaturateVertical(Box box); + bool IsEmpty() const { return empty_; } + + void AddPart(const std::string& part, int y, int left, int right); + std::string GetParts() { return parts_.str(); } + + private: + Selection(int start_x, int start_y, int end_x, int end_y, Selection* parent); + + Selection* const parent_ = this; + const bool empty_ = true; + const int start_x_ = 0; + const int start_y_ = 0; + const int end_x_ = 0; + const int end_y_ = 0; + const Box box_ = {}; + std::stringstream parts_; + + // The position of the last inserted part. + int x_ = 0; + int y_ = 0; +}; + +} // namespace ftxui + +#endif /* end of include guard: FTXUI_DOM_SELECTION_HPP */ diff --git a/include/ftxui/screen/screen.hpp b/include/ftxui/screen/screen.hpp index 51b83a03c..aa2f4f8cc 100644 --- a/include/ftxui/screen/screen.hpp +++ b/include/ftxui/screen/screen.hpp @@ -4,12 +4,14 @@ #ifndef FTXUI_SCREEN_SCREEN_HPP #define FTXUI_SCREEN_SCREEN_HPP -#include // for uint8_t -#include // for string, basic_string, allocator -#include // for vector +#include // for uint8_t +#include // for function +#include // for string, basic_string, allocator +#include // for vector #include "ftxui/screen/image.hpp" // for Pixel, Image #include "ftxui/screen/terminal.hpp" // for Dimensions +#include "ftxui/util/autoreset.hpp" // for AutoReset namespace ftxui { @@ -67,9 +69,18 @@ class Screen : public Image { uint8_t RegisterHyperlink(const std::string& link); const std::string& Hyperlink(uint8_t id) const; + using SelectionStyle = std::function; + const SelectionStyle& GetSelectionStyle() const; + void SetSelectionStyle(SelectionStyle decorator); + protected: Cursor cursor_; std::vector hyperlinks_ = {""}; + + // The current selection style. This is overridden by various dom elements. + SelectionStyle selection_style_ = [](Pixel& pixel) { + pixel.inverted ^= true; + }; }; } // namespace ftxui diff --git a/src/ftxui/component/container.cpp b/src/ftxui/component/container.cpp index 1948a47d9..410bb9f5c 100644 --- a/src/ftxui/component/container.cpp +++ b/src/ftxui/component/container.cpp @@ -163,6 +163,7 @@ class VerticalContainer : public ContainerBase { return false; } + int old_selected = *selector_; if (event.mouse().button == Mouse::WheelUp) { MoveSelector(-1); } @@ -171,7 +172,7 @@ class VerticalContainer : public ContainerBase { } *selector_ = std::max(0, std::min(int(children_.size()) - 1, *selector_)); - return true; + return old_selected != *selector_; } Box box_; diff --git a/src/ftxui/component/screen_interactive.cpp b/src/ftxui/component/screen_interactive.cpp index 45a4fff90..0c1794422 100644 --- a/src/ftxui/component/screen_interactive.cpp +++ b/src/ftxui/component/screen_interactive.cpp @@ -576,6 +576,18 @@ void ScreenInteractive::ForceHandleCtrlZ(bool force) { force_handle_ctrl_z_ = force; } +/// @brief Returns the content of the current selection +std::string ScreenInteractive::GetSelection() { + if (!selection_) { + return ""; + } + return selection_->GetParts(); +} + +void ScreenInteractive::SelectionChange(std::function callback) { + selection_on_change_ = std::move(callback); +} + /// @brief Return the currently active screen, or null if none. // static ScreenInteractive* ScreenInteractive::Active() { @@ -751,6 +763,14 @@ void ScreenInteractive::RunOnce(Component component) { ExecuteSignalHandlers(); } Draw(std::move(component)); + + if (selection_data_previous_ != selection_data_) { + selection_data_previous_ = selection_data_; + if (selection_on_change_) { + selection_on_change_(); + Post(Event::Custom); + } + } } // private @@ -781,7 +801,9 @@ void ScreenInteractive::HandleTask(Component component, Task& task) { arg.screen_ = this; - const bool handled = component->OnEvent(arg); + bool handled = component->OnEvent(arg); + + handled = HandleSelection(handled, arg); if (arg == Event::CtrlC && (!handled || force_handle_ctrl_c_)) { RecordSignal(SIGABRT); @@ -824,6 +846,59 @@ void ScreenInteractive::HandleTask(Component component, Task& task) { // clang-format on } +// private +bool ScreenInteractive::HandleSelection(bool handled, Event event) { + if (handled) { + selection_pending_ = nullptr; + selection_data_.empty = false; + selection_ = nullptr; + return true; + } + + if (!event.is_mouse()) { + return false; + } + + auto& mouse = event.mouse(); + if (mouse.button != Mouse::Left) { + return false; + } + + if (mouse.motion == Mouse::Pressed) { + selection_pending_ = CaptureMouse(); + selection_data_.start_x = mouse.x; + selection_data_.start_y = mouse.y; + selection_data_.end_x = mouse.x; + selection_data_.end_y = mouse.y; + return false; + } + + if (!selection_pending_) { + return false; + } + + if (mouse.motion == Mouse::Moved) { + if ((mouse.x != selection_data_.end_x) || + (mouse.y != selection_data_.end_y)) { + selection_data_.end_x = mouse.x; + selection_data_.end_y = mouse.y; + selection_data_.empty = false; + } + + return true; + } + + if (mouse.motion == Mouse::Released) { + selection_pending_ = nullptr; + selection_data_.end_x = mouse.x; + selection_data_.end_y = mouse.y; + selection_data_.empty = false; + return true; + } + + return false; +} + // private // NOLINTNEXTLINE void ScreenInteractive::Draw(Component component) { @@ -899,7 +974,12 @@ void ScreenInteractive::Draw(Component component) { #endif previous_frame_resized_ = resized; - Render(*this, document); + selection_ = selection_data_.empty + ? std::make_unique() + : std::make_unique( + selection_data_.start_x, selection_data_.start_y, // + selection_data_.end_x, selection_data_.end_y); + Render(*this, document.get(), *selection_); // Set cursor position for user using tools to insert CJK characters. { @@ -988,4 +1068,21 @@ void ScreenInteractive::Signal(int signal) { #endif } +bool ScreenInteractive::SelectionData::operator==( + const ScreenInteractive::SelectionData& other) const { + if (empty && other.empty) { + return true; + } + if (empty || other.empty) { + return false; + } + return start_x == other.start_x && start_y == other.start_y && + end_x == other.end_x && end_y == other.end_y; +} + +bool ScreenInteractive::SelectionData::operator!=( + const ScreenInteractive::SelectionData& other) const { + return !(*this == other); +} + } // namespace ftxui. diff --git a/src/ftxui/dom/flex.cpp b/src/ftxui/dom/flex.cpp index 2fd3adf67..b6f95c6e6 100644 --- a/src/ftxui/dom/flex.cpp +++ b/src/ftxui/dom/flex.cpp @@ -80,6 +80,7 @@ class Flex : public Node { } void SetBox(Box box) override { + Node::SetBox(box); if (children_.empty()) { return; } diff --git a/src/ftxui/dom/hbox.cpp b/src/ftxui/dom/hbox.cpp index 2053e84f1..bc08b0fc1 100644 --- a/src/ftxui/dom/hbox.cpp +++ b/src/ftxui/dom/hbox.cpp @@ -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 diff --git a/src/ftxui/dom/node.cpp b/src/ftxui/dom/node.cpp index 5220335d9..caaa2b38e 100644 --- a/src/ftxui/dom/node.cpp +++ b/src/ftxui/dom/node.cpp @@ -1,3 +1,4 @@ +#include // 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 diff --git a/src/ftxui/dom/paragraph.cpp b/src/ftxui/dom/paragraph.cpp index 93f75c2ff..482e7e3be 100644 --- a/src/ftxui/dom/paragraph.cpp +++ b/src/ftxui/dom/paragraph.cpp @@ -20,6 +20,18 @@ Elements Split(const std::string& the_text) { } return output; } + +Element Split(const std::string& paragraph, + std::function 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 diff --git a/src/ftxui/dom/selection.cpp b/src/ftxui/dom/selection.cpp new file mode 100644 index 000000000..6c7f508dd --- /dev/null +++ b/src/ftxui/dom/selection.cpp @@ -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 // 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 diff --git a/src/ftxui/dom/selection_style.cpp b/src/ftxui/dom/selection_style.cpp new file mode 100644 index 000000000..c864a79d1 --- /dev/null +++ b/src/ftxui/dom/selection_style.cpp @@ -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 // for make_shared +#include // 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 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 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(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 style) { + return [style](Element child) -> Element { + return std::make_shared(std::move(child), style); + }; +} + +} // namespace ftxui diff --git a/src/ftxui/dom/selection_test.cpp b/src/ftxui/dom/selection_test.cpp new file mode 100644 index 000000000..7c48e28ec --- /dev/null +++ b/src/ftxui/dom/selection_test.cpp @@ -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 +#include // 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 diff --git a/src/ftxui/dom/text.cpp b/src/ftxui/dom/text.cpp index 228e71421..c416c9766 100644 --- a/src/ftxui/dom/text.cpp +++ b/src/ftxui/dom/text.cpp @@ -3,8 +3,9 @@ // the LICENSE file. #include // for min #include // for make_shared -#include // for string, wstring -#include // for move +#include +#include // for string, wstring +#include // 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 selectionTransform; }; class VText : public Node { diff --git a/src/ftxui/dom/vbox.cpp b/src/ftxui/dom/vbox.cpp index 28d885d93..271345682 100644 --- a/src/ftxui/dom/vbox.cpp +++ b/src/ftxui/dom/vbox.cpp @@ -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 diff --git a/src/ftxui/screen/screen.cpp b/src/ftxui/screen/screen.cpp index 7bd64e296..beb3870ac 100644 --- a/src/ftxui/screen/screen.cpp +++ b/src/ftxui/screen/screen.cpp @@ -544,4 +544,16 @@ const std::string& Screen::Hyperlink(std::uint8_t id) const { return hyperlinks_[id]; } +/// @brief Return the current selection style. +/// @see SetSelectionStyle +const Screen::SelectionStyle& Screen::GetSelectionStyle() const { + return selection_style_; +} + +/// @brief Set the current selection style. +/// @see GetSelectionStyle +void Screen::SetSelectionStyle(SelectionStyle decorator) { + selection_style_ = decorator; +} + } // namespace ftxui diff --git a/tools/license_headers.cpp b/tools/license_headers.cpp index 851e894a5..aa00321f1 100644 --- a/tools/license_headers.cpp +++ b/tools/license_headers.cpp @@ -1,4 +1,4 @@ -// Copyright 2023 Arthur Sonzogni. All rights reserved. +// 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.