Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,20 @@ Next
`animation::RequestAnimationFrame()` (e.g. by `animation::Animator`), as in
FTXUI 6. Receiving an event no longer implicitly triggers an animation
frame.
- Bugfix: `App::PostEvent` is now thread safe again, as documented. Since the
7.0.0 event loop rework it pushed into an unsynchronized buffer, racing with
the main loop when called from another thread.
- Bugfix: Fix unbounded memory growth in the event buffer. A receiver used
during terminal setup was kept for the whole `App` lifetime, retaining every
subsequent event (including every mouse move).

### Dom
- Performance: `text` computes its requirement once and renders only the
visible lines. This makes scrolling a large text inside a `frame`
significantly faster. Thanks @patlefort. See #1309.
- Performance: `text` selection now only visits and stores the selected line
range, instead of scanning and allocating one entry per line of the whole
text on every frame.

7.0.1 (2026-07-14)
------------------
Expand Down
10 changes: 7 additions & 3 deletions src/ftxui/component/app.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -246,7 +246,6 @@ struct App::Internal {
ThrottledRequest cursor_position_request;

MultiReceiverBuffer<Event> event_buffer;
std::unique_ptr<MultiReceiverBuffer<Event>::Receiver> setup_receiver;
std::unique_ptr<MultiReceiverBuffer<Event>::Receiver> main_loop_receiver;

Internal(App* app, AppDimension dimension, bool use_alternative_screen);
Expand Down Expand Up @@ -561,7 +560,6 @@ App::Internal::Internal(App* app,
cursor_position_request(this, [this] {
TerminalSend(DeviceStatusReport(DSRMode::kCursor));
}) {
setup_receiver = event_buffer.CreateReceiver();
main_loop_receiver = event_buffer.CreateReceiver();
}

Expand Down Expand Up @@ -1182,6 +1180,10 @@ void App::Internal::InstallTerminalInfo() {

// Wait for the cursor shape reply using the setup head.
if (is_stdin_a_tty_ && is_stdout_a_tty_) {
// A receiver scoped to the setup: keeping one alive after setup would pin
// every subsequent event in the buffer, growing it for the whole app
// lifetime.
auto setup_receiver = event_buffer.CreateReceiver();
auto start = std::chrono::steady_clock::now();
bool terminal_capabilities_received = false;
// Wait for the cursor shape reply using the setup head.
Expand Down Expand Up @@ -1532,7 +1534,9 @@ void App::Post(Task task) {
}

void App::PostEvent(Event event) {
internal_->event_buffer.Push(std::move(event));
// PostEvent is documented as thread safe: go through the mutex-protected
// task queue. The event_buffer is only safe to use from the main thread.
Post(Task(std::move(event)));
}

// static
Expand Down
32 changes: 32 additions & 0 deletions src/ftxui/component/app_test.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,10 @@
// Use of this source code is governed by the MIT license that can be found in
// the LICENSE file.
#include <gtest/gtest.h> // for Test, TestInfo (ptr only), TEST, EXPECT_EQ, Message, TestPartResult
#include <chrono> // for steady_clock, seconds
#include <csignal> // for raise, SIGABRT, SIGFPE, SIGILL, SIGINT, SIGSEGV, SIGTERM
#include <ftxui/component/event.hpp> // for Event, Event::Custom
#include <thread> // for thread
#include <tuple> // for _Swallow_assign, ignore

#include "ftxui/component/app.hpp"
Expand Down Expand Up @@ -296,6 +298,36 @@ TEST(App, MoveConstructor) {
EXPECT_EQ(called, 1);
}

// PostEvent is documented as thread safe. Posting from another thread while
// the main loop runs must deliver every event, without data races.
TEST(App, PostEventFromAnotherThread) {
int received = 0;
auto component =
CatchEvent(Renderer([] { return text(""); }), [&](const Event& event) {
if (event == Event::Custom) {
received++;
}
return true;
});
auto screen = App::FixedSize(10, 1);
Loop loop(&screen, component);

constexpr int kCount = 10000;
std::thread poster([&] {
for (int i = 0; i < kCount; ++i) {
screen.PostEvent(Event::Custom);
}
});

const auto deadline =
std::chrono::steady_clock::now() + std::chrono::seconds(10);
while (received < kCount && std::chrono::steady_clock::now() < deadline) {
loop.RunOnce();
}
poster.join();
EXPECT_EQ(received, kCount);
}

TEST(App, MoveAssignment) {
auto screen = App::FixedSize(10, 10);
auto screen2 = App::FixedSize(5, 5);
Expand Down
18 changes: 18 additions & 0 deletions src/ftxui/dom/selection_test.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,24 @@ TEST(SelectionTest, StyleClearedWhenSelectionCleared) {
}
}

// A selection over a subset of the lines of a multi-line text must style and
// extract exactly the selected rows.
TEST(SelectionTest, MultiLineTextSelection) {
auto element = text("abc\ndef\nghi");
auto screen = App::FixedSize(3, 3);
Selection selection(1, 1, 2, 2);
Render(screen, element.get(), selection);

EXPECT_EQ(selection.GetParts(), "ef\nghi");
for (int y = 0; y < 3; y++) {
for (int x = 0; x < 3; x++) {
const bool selected = (y == 1 && x >= 1) || y == 2;
EXPECT_EQ(screen.CellAt(x, y).inverted, selected)
<< "at (" << x << "," << y << ")";
}
}
}

TEST(SelectionTest, VBoxSelection) {
auto element = vbox({
text("Lorem ipsum dolor"),
Expand Down
35 changes: 21 additions & 14 deletions src/ftxui/dom/text.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -56,27 +56,30 @@ class Text : public Node {
}

void Select(Selection& selection) override {
if (Box::Intersection(selection.GetBox(), box_).IsEmpty()) {
const Box selection_box = Box::Intersection(selection.GetBox(), box_);
if (selection_box.IsEmpty()) {
return;
}

selection_rows_.assign(lines_offsets_.size() - 1, {-1, -1});
// Only store the selected line range. Sizing per line would allocate one
// entry per line of the whole text on every frame.
const size_t lines_count = lines_offsets_.size() - 1;
const size_t first = selection_box.y_min - box_.y_min;
const size_t last =
std::min<size_t>(selection_box.y_max - box_.y_min + 1, lines_count);
if (first >= last) {
return;
}
selection_first_line_ = first;
selection_rows_.assign(last - first, {-1, -1});

for (size_t i = 0; i < lines_offsets_.size() - 1; ++i) {
for (size_t i = first; i < last; ++i) {
const int y = box_.y_min + (int)i;
if (y > box_.y_max) {
break;
}

const Box row_box{box_.x_min, box_.x_max, y, y};
if (Box::Intersection(selection.GetBox(), row_box).IsEmpty()) {
continue;
}

const Selection row_sel = selection.SaturateHorizontal(row_box);
const int sel_start = row_sel.GetBox().x_min;
const int sel_end = row_sel.GetBox().x_max;
selection_rows_[i] = {sel_start, sel_end};
selection_rows_[i - first] = {sel_start, sel_end};

std::string part;
int x = box_.x_min;
Expand Down Expand Up @@ -116,8 +119,9 @@ class Text : public Node {
auto& cell = screen.CellAt(x, y);
cell.character = *glyph;

if (line < selection_rows_.size()) {
const auto& [sel_start, sel_end] = selection_rows_[line];
const size_t sel_index = line - selection_first_line_;
if (sel_index < selection_rows_.size()) {
const auto& [sel_start, sel_end] = selection_rows_[sel_index];
if (sel_start != -1 && x >= sel_start && x <= sel_end) {
screen.GetSelectionStyle()(cell);
}
Expand All @@ -129,6 +133,9 @@ class Text : public Node {
private:
std::vector<std::string> glyphs_;
std::vector<int> lines_offsets_;
// Selection state for the line range [selection_first_line_,
// selection_first_line_ + selection_rows_.size()).
size_t selection_first_line_ = 0;
std::vector<std::pair<int, int>> selection_rows_;
};

Expand Down
Loading