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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,11 @@ Next
opposite direction. Thanks @Ardet696 in #1203.

### Screen
- Performance: Collapse the per-row cursor walk-up in the non-clear
`Screen::ResetPosition` into a single parameterized CSI cursor-up
(`\x1B[<n>A`) instead of emitting one `\x1B[1A` per row. This reduces the
per-frame escape bytes during steady-state redraw (e.g. ~197 -> 6 bytes for a
50-row screen, ~33x). On-screen output is unchanged.
- Performance: Optimize `Screen::ToString()`, `Color::Print()` and
`string_width()`.
This was achieved by:
Expand Down
1 change: 1 addition & 0 deletions cmake/ftxui_test.cmake
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ add_executable(ftxui-tests
src/ftxui/dom/vbox_test.cpp
src/ftxui/screen/color_test.cpp
src/ftxui/screen/compatibility_test.cpp
src/ftxui/screen/screen_test.cpp
src/ftxui/screen/string_test.cpp
src/ftxui/util/ref_test.cpp
)
Expand Down
10 changes: 8 additions & 2 deletions src/ftxui/screen/screen.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -514,16 +514,22 @@ std::string Screen::ResetPosition(bool clear) const {
/// @param clear Whether to clear the screen or not.
void Screen::ResetPosition(std::string& ss, bool clear) const {
if (clear) {
// The clear branch must move up one row at a time, because each row needs
// its own CLEAR_LINE (\x1B[2K) erase. It cannot be collapsed into a single
// parameterized cursor-up.
ss += '\r'; // MOVE_LEFT;
ss += "\x1b[2K"; // CLEAR_SCREEN;
for (int y = 1; y < dimy_; ++y) {
ss += "\x1B[1A"; // MOVE_UP;
ss += "\x1B[2K"; // CLEAR_LINE;
}
} else {
// The non-clear branch only needs to reposition the cursor at the top-left,
// so the per-row walk-up is collapsed into a single parameterized
// CSI cursor-up (\x1B[<n>A), emitting far fewer bytes per frame.
ss += '\r'; // MOVE_LEFT;
for (int y = 1; y < dimy_; ++y) {
ss += "\x1B[1A"; // MOVE_UP;
if (dimy_ > 1) {
ss += "\x1B[" + std::to_string(dimy_ - 1) + "A"; // MOVE_UP;
}
}
}
Expand Down
105 changes: 105 additions & 0 deletions src/ftxui/screen/screen_test.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
// 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 <string>

#include "ftxui/screen/screen.hpp"
#include "gtest/gtest.h"

namespace ftxui {

namespace {
// The old, pre-optimization non-clear ResetPosition output: a leading '\r'
// followed by one "\x1B[1A" cursor-up per extra row. Used as the reference
// baseline for the collapsed single-CSI form.
std::string OldNonClearResetPosition(int dimy) {
std::string ss;
ss += '\r'; // MOVE_LEFT;
for (int y = 1; y < dimy; ++y) {
ss += "\x1B[1A"; // MOVE_UP;
}
return ss;
}

// Decode the total upward cursor movement encoded by a ResetPosition string:
// the leading '\r' moves to column 0, and the cursor-up CSIs (single "\x1B[1A"
// moves or one parameterized "\x1B[<n>A") sum to the number of rows moved up.
// This lets us compare the collapsed single-CSI form to the per-row walk by
// terminal *effect* rather than by exact bytes.
int RowsMovedUp(const std::string& s) {
int total = 0;
size_t i = 0;
while ((i = s.find("\x1B[", i)) != std::string::npos) {
i += 2; // Skip the CSI introducer.
int n = 0;
bool has_digits = false;
while (i < s.size() && s[i] >= '0' && s[i] <= '9') {
n = n * 10 + (s[i] - '0');
has_digits = true;
++i;
}
if (i < s.size() && s[i] == 'A') { // Cursor-up final byte.
total += has_digits ? n : 1; // No parameter defaults to 1.
}
}
return total;
}
} // namespace

// The non-clear ResetPosition emits a single parameterized CSI cursor-up.
TEST(ScreenTest, ResetPositionNonClearSingleCSI) {
for (int dimy : {2, 3, 10, 24, 50, 100}) {
Screen screen(10, dimy);
const std::string expected =
"\r\x1B[" + std::to_string(dimy - 1) + "A";
EXPECT_EQ(screen.ResetPosition(false), expected) << "dimy=" << dimy;
}
}

// A single-row screen needs no cursor-up: only the leading '\r'.
TEST(ScreenTest, ResetPositionNonClearSingleRow) {
Screen screen(10, 1);
EXPECT_EQ(screen.ResetPosition(false), "\r");
}

// The collapsed single-CSI form moves the cursor to exactly the same place as
// the old per-row walk-up, for every height. The byte sequences differ (that is
// the whole point of the optimization), so equivalence is checked by terminal
// *effect*: both forms move the cursor up by the same number of rows.
TEST(ScreenTest, ResetPositionNonClearEquivalentToPerRowWalk) {
for (int dimy : {1, 2, 3, 10, 24, 50, 100}) {
Screen screen(10, dimy);
const std::string new_form = screen.ResetPosition(false);
EXPECT_EQ(RowsMovedUp(new_form), RowsMovedUp(OldNonClearResetPosition(dimy)))
<< "dimy=" << dimy;
EXPECT_EQ(RowsMovedUp(new_form), dimy - 1) << "dimy=" << dimy;
}
}

// The new non-clear output is at least 10x smaller in bytes than the old
// per-row form for a 50-row screen (<= 8 bytes vs 197).
TEST(ScreenTest, ResetPositionNonClearByteReduction) {
Screen screen(10, 50);
const std::string new_form = screen.ResetPosition(false);
const std::string old_form = OldNonClearResetPosition(50);

EXPECT_EQ(old_form.size(), 197u);
EXPECT_LE(new_form.size(), 8u);
EXPECT_GE(old_form.size(), new_form.size() * 10);
}

// The clear branch is intentionally left per-row (each row keeps its own
// CLEAR_LINE erase), so it is NOT collapsed into a single CSI.
TEST(ScreenTest, ResetPositionClearIsPerRow) {
Screen screen(10, 3);
std::string expected;
expected += '\r';
expected += "\x1b[2K";
for (int y = 1; y < 3; ++y) {
expected += "\x1B[1A";
expected += "\x1B[2K";
}
EXPECT_EQ(screen.ResetPosition(true), expected);
}

} // namespace ftxui
Loading