diff --git a/.gitignore b/.gitignore index ec18308f..746cefc5 100644 --- a/.gitignore +++ b/.gitignore @@ -45,6 +45,7 @@ out/ !doc/**/*.html !doc/**/*.xml !doc/**/*.md +!doc/*.md # examples directory: !examples/**/*.cpp diff --git a/CHANGELOG.md b/CHANGELOG.md index 0e8427d9..1e2c4533 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,12 @@ Next - Remove dependency on 'pthread'. ### Component +- Feature: POSIX Piped Input Handling. + - Allows FTXUI applications to read data from stdin (when piped) while still receiving keyboard input from the terminal. + - Enabled by default. + - Can be disabled using `ScreenInteractive::HandlePipedInput(false)`. + - Only available on Linux and macOS. + Thanks @HarryPehkonen for PR #1094. - Fix ScreenInteractive::FixedSize screen stomps on the preceding terminal output. Thanks @zozowell in #1064. - Fix vertical `ftxui::Slider`. The "up" key was previously decreasing the diff --git a/README.md b/README.md index 38759e70..36c349df 100644 --- a/README.md +++ b/README.md @@ -382,6 +382,8 @@ Several games using the FTXUI have been made during the Game Jam: - [smoothlife](https://github.com/cpp-best-practices/game_jam/blob/main/Jam1_April_2022/smoothlife.md) - [Consu](https://github.com/cpp-best-practices/game_jam/blob/main/Jam1_April_2022/consu.md) + + ## Build using CMake It is **highly** recommended to use CMake FetchContent to depend on FTXUI so you may specify which commit you would like to depend on. diff --git a/doc/posix_pipe.md b/doc/posix_pipe.md new file mode 100644 index 00000000..ebbe7ce1 --- /dev/null +++ b/doc/posix_pipe.md @@ -0,0 +1,58 @@ +# POSIX Piped Input in FTXUI + +> [!WARNING] +> This feature works only on Linux and macOS. It is not supported on +> Windows and WebAssembly. + +## What is a POSIX Pipe? + +A POSIX pipe is a way for two separate programs to communicate. One program sends its output directly as input to another program. Think of it like a one-way tube for data. + +**Example:** + +Imagine you want to list files and then filter them interactively. + +- `ls`: Lists files. +- `interactive_grep`: An FTXUI application that filters text and lets you type. + +You can connect them with a pipe (`|`): + +```bash +ls -l | interactive_grep +``` + +Here's what happens: +1. `ls -l` lists files with details. +2. The `|` sends this list directly to `interactive_grep`. +3. `interactive_grep` receives the list and displays it. Because it's an FTXUI app, you can then type to filter the list, even though it received initial data from `ls`. + +## How FTXUI Handles Piped Input + +Now that you understand what a POSIX pipe is, let's look at how FTXUI uses them. + +FTXUI lets your application read data from other programs (like from a pipe) while still allowing you to use your keyboard for interaction. This is useful for interactive command-line tools that process data. + +Normally, FTXUI applications receive all input from `stdin`. However, when FTXUI detects that `stdin` is connected to the output of a pipe (meaning data is being piped into your application), it automatically switches to reading interactive keyboard input from `/dev/tty`. This ensures that your application can still receive user input even while processing piped data. + +This feature is **turned on by default**. + +If your FTXUI application needs to read piped data and also respond to keyboard input, you typically don't need to do anything special: + +```cpp +auto screen = ScreenInteractive::Fullscreen(); +// screen.HandlePipedInput(true); // This is enabled by default +screen.Loop(component); +``` + + +## Turning Off Piped Input + +If you don't need this feature, or if it conflicts with your custom input handling, you can turn it off. + +To disable it, call `HandlePipedInput(false)` before starting your application's main loop: + +```cpp +auto screen = ScreenInteractive::Fullscreen(); +screen.HandlePipedInput(false); // Turn off piped input handling +screen.Loop(component); +``` diff --git a/include/ftxui/component/screen_interactive.hpp b/include/ftxui/component/screen_interactive.hpp index f8b899f3..aff53bc3 100644 --- a/include/ftxui/component/screen_interactive.hpp +++ b/include/ftxui/component/screen_interactive.hpp @@ -43,10 +43,11 @@ class ScreenInteractive : public Screen { static ScreenInteractive TerminalOutput(); // Destructor. - ~ScreenInteractive(); + ~ScreenInteractive() override; // Options. Must be called before Loop(). void TrackMouse(bool enable = true); + void HandlePipedInput(bool enable = true); // Return the currently active screen, nullptr if none. static ScreenInteractive* Active(); @@ -100,6 +101,8 @@ class ScreenInteractive : public Screen { void Draw(Component component); void ResetCursorPosition(); + void InstallPipedInputHandling(); + void Signal(int signal); void FetchTerminalEvents(); @@ -117,6 +120,7 @@ class ScreenInteractive : public Screen { int dimx, int dimy, bool use_alternative_screen); + const Dimension dimension_; const bool use_alternative_screen_; @@ -141,6 +145,11 @@ class ScreenInteractive : public Screen { bool force_handle_ctrl_c_ = true; bool force_handle_ctrl_z_ = true; + // Piped input handling state (POSIX only) + bool handle_piped_input_ = true; + // File descriptor for /dev/tty, used for piped input handling. + int tty_fd_ = -1; + // The style of the cursor to restore on exit. int cursor_reset_shape_ = 1; diff --git a/src/ftxui/component/screen_interactive.cpp b/src/ftxui/component/screen_interactive.cpp index c2341cdc..5bc33d3c 100644 --- a/src/ftxui/component/screen_interactive.cpp +++ b/src/ftxui/component/screen_interactive.cpp @@ -112,13 +112,13 @@ void ftxui_on_resize(int columns, int rows) { #else // POSIX (Linux & Mac) -int CheckStdinReady() { +int CheckStdinReady(int fd) { timeval tv = {0, 0}; // NOLINT fd_set fds; - FD_ZERO(&fds); // NOLINT - FD_SET(STDIN_FILENO, &fds); // NOLINT - select(STDIN_FILENO + 1, &fds, nullptr, nullptr, &tv); // NOLINT - return FD_ISSET(STDIN_FILENO, &fds); // NOLINT + FD_ZERO(&fds); // NOLINT + FD_SET(fd, &fds); // NOLINT + select(fd + 1, &fds, nullptr, nullptr, &tv); // NOLINT + return FD_ISSET(fd, &fds); // NOLINT } #endif @@ -372,6 +372,18 @@ void ScreenInteractive::TrackMouse(bool enable) { track_mouse_ = enable; } +/// @brief Enable or disable automatic piped input handling. +/// When enabled, FTXUI will detect piped input and redirect stdin from /dev/tty +/// for keyboard input, allowing applications to read piped data while still +/// receiving interactive keyboard events. +/// @param enable Whether to enable piped input handling. Default is true. +/// @note This must be called before Loop(). +/// @note This feature is enabled by default. +/// @note This feature is only available on POSIX systems (Linux/macOS). +void ScreenInteractive::HandlePipedInput(bool enable) { + handle_piped_input_ = enable; +} + /// @brief Add a task to the main loop. /// It will be executed later, after every other scheduled tasks. void ScreenInteractive::Post(Task task) { @@ -527,6 +539,8 @@ void ScreenInteractive::Install() { // https://github.com/ArthurSonzogni/FTXUI/issues/846 Flush(); + InstallPipedInputHandling(); + // After uninstalling the new configuration, flush it to the terminal to // ensure it is fully applied: on_exit_functions.emplace([] { Flush(); }); @@ -592,9 +606,10 @@ void ScreenInteractive::Install() { } struct termios terminal; // NOLINT - tcgetattr(STDIN_FILENO, &terminal); - on_exit_functions.emplace( - [=] { tcsetattr(STDIN_FILENO, TCSANOW, &terminal); }); + tcgetattr(tty_fd_, &terminal); + on_exit_functions.emplace([terminal = terminal, tty_fd_ = tty_fd_] { + tcsetattr(tty_fd_, TCSANOW, &terminal); + }); // Enabling raw terminal input mode terminal.c_iflag &= ~IGNBRK; // Disable ignoring break condition @@ -622,7 +637,7 @@ void ScreenInteractive::Install() { // read. terminal.c_cc[VTIME] = 0; // Timeout in deciseconds for non-canonical read. - tcsetattr(STDIN_FILENO, TCSANOW, &terminal); + tcsetattr(tty_fd_, TCSANOW, &terminal); #endif @@ -663,6 +678,37 @@ void ScreenInteractive::Install() { PostAnimationTask(); } +void ScreenInteractive::InstallPipedInputHandling() { + tty_fd_ = STDIN_FILENO; +#if !defined(_WIN32) && !defined(__EMSCRIPTEN__) + // Handle piped input redirection if explicitly enabled by the application. + // This allows applications to read data from stdin while still receiving + // keyboard input from the terminal for interactive use. + if (!handle_piped_input_) { + return; + } + + // If stdin is a terminal, we don't need to open /dev/tty. + if (isatty(STDIN_FILENO)) { + return; + } + + // Open /dev/tty for keyboard input. + tty_fd_ = open("/dev/tty", O_RDONLY); + if (tty_fd_ < 0) { + // Failed to open /dev/tty (containers, headless systems, etc.) + tty_fd_ = STDIN_FILENO; // Fallback to stdin. + return; + } + + // Close the /dev/tty file descriptor on exit. + on_exit_functions.emplace([this] { + close(tty_fd_); + tty_fd_ = -1; + }); +#endif +} + // private void ScreenInteractive::Uninstall() { ExitNow(); @@ -1096,7 +1142,7 @@ void ScreenInteractive::FetchTerminalEvents() { internal_->terminal_input_parser.Add(out[i]); } #else // POSIX (Linux & Mac) - if (!CheckStdinReady()) { + if (!CheckStdinReady(tty_fd_)) { const auto timeout = std::chrono::steady_clock::now() - internal_->last_char_time; const size_t timeout_ms = @@ -1108,7 +1154,7 @@ void ScreenInteractive::FetchTerminalEvents() { // Read chars from the terminal. std::array out{}; - size_t l = read(fileno(stdin), out.data(), out.size()); + size_t l = read(tty_fd_, out.data(), out.size()); // Convert the chars to events. for (size_t i = 0; i < l; ++i) { diff --git a/src/ftxui/component/screen_interactive_piped_input_test.cpp b/src/ftxui/component/screen_interactive_piped_input_test.cpp new file mode 100644 index 00000000..e0061bca --- /dev/null +++ b/src/ftxui/component/screen_interactive_piped_input_test.cpp @@ -0,0 +1,222 @@ +// Copyright 2025 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 +#include +#include +#include + +#include "ftxui/component/component.hpp" +#include "ftxui/component/screen_interactive.hpp" +#include "ftxui/dom/elements.hpp" + +#if !defined(_WIN32) && !defined(__EMSCRIPTEN__) + +namespace ftxui { + +namespace { + +// Test fixture for piped input functionality +class PipedInputTest : public ::testing::Test { + protected: + void SetUp() override { + // Save original stdin for restoration + original_stdin_ = dup(STDIN_FILENO); + } + + void TearDown() override { + // Restore original stdin + if (original_stdin_ >= 0) { + dup2(original_stdin_, STDIN_FILENO); + close(original_stdin_); + } + } + + // Create a pipe and redirect stdin to read from it + void SetupPipedStdin() { + if (pipe(pipe_fds_) == 0) { + dup2(pipe_fds_[0], STDIN_FILENO); + close(pipe_fds_[0]); + // Keep write end open for writing test data + piped_stdin_setup_ = true; + } + } + + // Write test data to the piped stdin + void WriteToPipedStdin(const std::string& data) { + if (piped_stdin_setup_) { + write(pipe_fds_[1], data.c_str(), data.length()); + close(pipe_fds_[1]); // Close write end to signal EOF + } + } + + // Check if /dev/tty is available (not available in some CI environments) + bool IsTtyAvailable() { + struct stat st; + return stat("/dev/tty", &st) == 0; + } + + private: + int original_stdin_ = -1; + int pipe_fds_[2] = {-1, -1}; + bool piped_stdin_setup_ = false; +}; + +TEST_F(PipedInputTest, DefaultBehaviorEnabled) { + // Test that HandlePipedInput is enabled by default + if (!IsTtyAvailable()) { + GTEST_SKIP() << "/dev/tty not available in this environment"; + } + + auto screen = ScreenInteractive::TerminalOutput(); + auto component = Renderer([] { return text("test"); }); + + SetupPipedStdin(); + WriteToPipedStdin("test data\n"); + + // Install should redirect stdin since HandlePipedInput is on by default + screen.Install(); + + // Stdin should be the tty + EXPECT_TRUE(isatty(STDIN_FILENO)); + + screen.Uninstall(); +} + +TEST_F(PipedInputTest, ExplicitlyDisabled) { + // Test that explicitly disabling works + auto screen = ScreenInteractive::TerminalOutput(); + screen.HandlePipedInput(false); + auto component = Renderer([] { return text("test"); }); + + SetupPipedStdin(); + WriteToPipedStdin("test data\n"); + + screen.Install(); + + // Stdin should still be the pipe since feature is disabled + EXPECT_FALSE(isatty(STDIN_FILENO)); + + screen.Uninstall(); +} + +TEST_F(PipedInputTest, ExplicitlyEnabled) { + if (!IsTtyAvailable()) { + GTEST_SKIP() << "/dev/tty not available in this environment"; + } + + auto screen = ScreenInteractive::TerminalOutput(); + screen.HandlePipedInput(true); // Explicitly enable + auto component = Renderer([] { return text("test"); }); + + SetupPipedStdin(); + WriteToPipedStdin("test data\n"); + + // Before install: stdin should be piped + EXPECT_FALSE(isatty(STDIN_FILENO)); + + screen.Install(); + + // After install with piped input handling: stdin should be redirected to tty + EXPECT_TRUE(isatty(STDIN_FILENO)); + + screen.Uninstall(); + + // After uninstall: stdin should be restored to original state + // Note: This will be the pipe we set up, so it should be non-tty + EXPECT_FALSE(isatty(STDIN_FILENO)); +} + +TEST_F(PipedInputTest, NormalStdinUnchanged) { + // Test that normal stdin (not piped) is not affected + auto screen = ScreenInteractive::TerminalOutput(); + auto component = Renderer([] { return text("test"); }); + + // Don't setup piped stdin - use normal stdin + bool original_isatty = isatty(STDIN_FILENO); + + screen.Install(); + + // Stdin should remain unchanged + EXPECT_EQ(original_isatty, isatty(STDIN_FILENO)); + + screen.Uninstall(); + + // Stdin should still be unchanged + EXPECT_EQ(original_isatty, isatty(STDIN_FILENO)); +} + +TEST_F(PipedInputTest, MultipleInstallUninstallCycles) { + if (!IsTtyAvailable()) { + GTEST_SKIP() << "/dev/tty not available in this environment"; + } + + auto screen = ScreenInteractive::TerminalOutput(); + auto component = Renderer([] { return text("test"); }); + + SetupPipedStdin(); + WriteToPipedStdin("test data\n"); + + // First cycle + screen.Install(); + EXPECT_TRUE(isatty(STDIN_FILENO)); + screen.Uninstall(); + EXPECT_FALSE(isatty(STDIN_FILENO)); + + // Second cycle should work the same + screen.Install(); + EXPECT_TRUE(isatty(STDIN_FILENO)); + screen.Uninstall(); + EXPECT_FALSE(isatty(STDIN_FILENO)); +} + +TEST_F(PipedInputTest, HandlePipedInputMethodBehavior) { + auto screen = ScreenInteractive::TerminalOutput(); + + // Test method can be called multiple times + screen.HandlePipedInput(true); + screen.HandlePipedInput(false); + screen.HandlePipedInput(true); + + // Should be enabled after last call + SetupPipedStdin(); + WriteToPipedStdin("test data\n"); + + if (IsTtyAvailable()) { + screen.Install(); + EXPECT_TRUE(isatty(STDIN_FILENO)); + screen.Uninstall(); + } +} + +// Test the graceful fallback when /dev/tty is not available +// This test simulates environments like containers where /dev/tty might not +// exist +TEST_F(PipedInputTest, GracefulFallbackWhenTtyUnavailable) { + auto screen = ScreenInteractive::TerminalOutput(); + auto component = Renderer([] { return text("test"); }); + + SetupPipedStdin(); + WriteToPipedStdin("test data\n"); + + // This test doesn't directly mock /dev/tty unavailability since that's hard + // to do in a unit test environment, but the code path handles freopen() + // failure gracefully + screen.Install(); + + // The behavior depends on whether /dev/tty is available + // If available, stdin gets redirected; if not, it remains piped + // Both behaviors are correct + + screen.Uninstall(); + + // After uninstall, stdin should be restored + EXPECT_FALSE(isatty(STDIN_FILENO)); // Should still be our test pipe +} + +} // namespace + +} // namespace ftxui + +#endif // !defined(_WIN32) && !defined(__EMSCRIPTEN__)