From 143b24c6a5c994b5e17b135760cdc46853baddcb Mon Sep 17 00:00:00 2001 From: Harri Pehkonen Date: Sat, 9 Aug 2025 20:26:37 -0700 Subject: [PATCH] Add opt-in piped input support for POSIX systems Enables applications to read piped data while maintaining interactive keyboard input by redirecting stdin to /dev/tty when explicitly enabled. --- README.md | 16 ++ .../ftxui/component/screen_interactive.hpp | 8 + src/ftxui/component/screen_interactive.cpp | 51 ++++ .../screen_interactive_piped_input_test.cpp | 219 ++++++++++++++++++ 4 files changed, 294 insertions(+) create mode 100644 src/ftxui/component/screen_interactive_piped_input_test.cpp diff --git a/README.md b/README.md index e5eba35e..fdfdd28c 100644 --- a/README.md +++ b/README.md @@ -378,6 +378,22 @@ 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) +## Advanced Usage + +### Piped Input Support + +If your application reads from stdin (piped data) and also needs interactive keyboard input: + +```cpp +auto screen = ScreenInteractive::Fullscreen(); +screen.HandlePipedInput(true); // Enable before Loop() +screen.Loop(component); +``` + +This allows commands like `cat data.txt | your_app` to work with full keyboard interaction. + +**Note:** This feature is only available on POSIX systems (Linux/macOS). On Windows, the method call is a no-op. + ## 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/include/ftxui/component/screen_interactive.hpp b/include/ftxui/component/screen_interactive.hpp index f8b899f3..0291e76e 100644 --- a/include/ftxui/component/screen_interactive.hpp +++ b/include/ftxui/component/screen_interactive.hpp @@ -47,6 +47,7 @@ class ScreenInteractive : public Screen { // 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(); @@ -141,6 +142,13 @@ class ScreenInteractive : public Screen { bool force_handle_ctrl_c_ = true; bool force_handle_ctrl_z_ = true; +#if !defined(_WIN32) && !defined(__EMSCRIPTEN__) + // Piped input handling state (POSIX only) + bool handle_piped_input_ = false; + bool stdin_was_redirected_ = false; + int original_stdin_fd_ = -1; +#endif + // 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..fad54113 100644 --- a/src/ftxui/component/screen_interactive.cpp +++ b/src/ftxui/component/screen_interactive.cpp @@ -372,6 +372,24 @@ 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 to /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 +/// @note This must be called before Loop(). +/// @note This feature is disabled by default for backward compatibility. +/// @note This feature is only available on POSIX systems (Linux/macOS). +#if !defined(_WIN32) && !defined(__EMSCRIPTEN__) +void ScreenInteractive::HandlePipedInput(bool enable) { + handle_piped_input_ = enable; +} +#else +void ScreenInteractive::HandlePipedInput(bool /*enable*/) { + // This feature is not supported on this platform. +} +#endif + /// @brief Add a task to the main loop. /// It will be executed later, after every other scheduled tasks. void ScreenInteractive::Post(Task task) { @@ -658,6 +676,28 @@ void ScreenInteractive::Install() { // ensure it is fully applied: Flush(); +#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_ && !stdin_was_redirected_ && !isatty(STDIN_FILENO)) { + // Save the current stdin so we can restore it later + original_stdin_fd_ = dup(STDIN_FILENO); + if (original_stdin_fd_ >= 0) { + // Redirect stdin to the controlling terminal for keyboard input + if (freopen("/dev/tty", "r", stdin) != nullptr) { + stdin_was_redirected_ = true; + } else { + // Failed to open /dev/tty (containers, headless systems, etc.) + // Clean up and continue without redirection + close(original_stdin_fd_); + original_stdin_fd_ = -1; + } + } + // If dup() failed, we silently continue without redirection + } +#endif + quit_ = false; PostAnimationTask(); @@ -666,6 +706,17 @@ void ScreenInteractive::Install() { // private void ScreenInteractive::Uninstall() { ExitNow(); + +#if !defined(_WIN32) && !defined(__EMSCRIPTEN__) + // Restore stdin to its original state if we redirected it + if (stdin_was_redirected_ && original_stdin_fd_ >= 0) { + dup2(original_stdin_fd_, STDIN_FILENO); + close(original_stdin_fd_); + original_stdin_fd_ = -1; + stdin_was_redirected_ = false; + } +#endif + OnExit(); } 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..e953bbd3 --- /dev/null +++ b/src/ftxui/component/screen_interactive_piped_input_test.cpp @@ -0,0 +1,219 @@ +// 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, DefaultBehaviorNoChange) { + // Test that HandlePipedInput is disabled by default + auto screen = ScreenInteractive::TerminalOutput(); + auto component = Renderer([] { return text("test"); }); + + SetupPipedStdin(); + WriteToPipedStdin("test data\n"); + + // Install should not redirect stdin since HandlePipedInput not called + screen.Install(); + + // Stdin should still be the pipe (isatty should return false) + EXPECT_FALSE(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, PipedInputDetectionAndRedirection) { + 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(); + screen.HandlePipedInput(true); + 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(); + screen.HandlePipedInput(true); + 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(); + screen.HandlePipedInput(true); + 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__) \ No newline at end of file