From d05b9343f69a0841043963088e0003368e205bd9 Mon Sep 17 00:00:00 2001 From: Felix Morgner Date: Tue, 22 Sep 2026 22:47:33 +0200 Subject: lib: restructure into vertical slices --- CMakeLists.txt | 29 ++-- apps/echo.cpp | 11 +- ttwhy/core/concepts.cppm | 30 ++++ ttwhy/core/mod.cppm | 3 + ttwhy/io/mod.cppm | 5 - ttwhy/io/readers.cppm | 93 ----------- ttwhy/io/scoped_attributes.cppm | 135 ---------------- ttwhy/io/signal_handling.cppm | 58 ------- ttwhy/lib.cppm | 5 +- ttwhy/routers/concepts.cppm | 17 -- ttwhy/routers/echo.cppm | 206 ------------------------ ttwhy/routers/echo.tests.cpp | 257 ------------------------------ ttwhy/routers/mod.cppm | 4 - ttwhy/scanners/concepts.cppm | 30 ---- ttwhy/scanners/mod.cppm | 6 - ttwhy/scanners/terminal_events.cppm | 100 ------------ ttwhy/scanners/terminal_policies.cppm | 161 ------------------- ttwhy/scanners/terminal_scanner.cppm | 236 --------------------------- ttwhy/scanners/terminal_scanner.tests.cpp | 100 ------------ ttwhy/terminal/echo.cppm | 207 ++++++++++++++++++++++++ ttwhy/terminal/echo.tests.cpp | 257 ++++++++++++++++++++++++++++++ ttwhy/terminal/events.cppm | 107 +++++++++++++ ttwhy/terminal/mod.cppm | 9 ++ ttwhy/terminal/policies.cppm | 158 ++++++++++++++++++ ttwhy/terminal/readers.cppm | 94 +++++++++++ ttwhy/terminal/scanner.cppm | 236 +++++++++++++++++++++++++++ ttwhy/terminal/scanner.tests.cpp | 101 ++++++++++++ ttwhy/terminal/scoped_attributes.cppm | 135 ++++++++++++++++ ttwhy/terminal/signal_handling.cppm | 58 +++++++ 29 files changed, 1415 insertions(+), 1433 deletions(-) create mode 100644 ttwhy/core/concepts.cppm create mode 100644 ttwhy/core/mod.cppm delete mode 100644 ttwhy/io/mod.cppm delete mode 100644 ttwhy/io/readers.cppm delete mode 100644 ttwhy/io/scoped_attributes.cppm delete mode 100644 ttwhy/io/signal_handling.cppm delete mode 100644 ttwhy/routers/concepts.cppm delete mode 100644 ttwhy/routers/echo.cppm delete mode 100644 ttwhy/routers/echo.tests.cpp delete mode 100644 ttwhy/routers/mod.cppm delete mode 100644 ttwhy/scanners/concepts.cppm delete mode 100644 ttwhy/scanners/mod.cppm delete mode 100644 ttwhy/scanners/terminal_events.cppm delete mode 100644 ttwhy/scanners/terminal_policies.cppm delete mode 100644 ttwhy/scanners/terminal_scanner.cppm delete mode 100644 ttwhy/scanners/terminal_scanner.tests.cpp create mode 100644 ttwhy/terminal/echo.cppm create mode 100644 ttwhy/terminal/echo.tests.cpp create mode 100644 ttwhy/terminal/events.cppm create mode 100644 ttwhy/terminal/mod.cppm create mode 100644 ttwhy/terminal/policies.cppm create mode 100644 ttwhy/terminal/readers.cppm create mode 100644 ttwhy/terminal/scanner.cppm create mode 100644 ttwhy/terminal/scanner.tests.cpp create mode 100644 ttwhy/terminal/scoped_attributes.cppm create mode 100644 ttwhy/terminal/signal_handling.cppm diff --git a/CMakeLists.txt b/CMakeLists.txt index 337086f..bdda024 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -65,20 +65,17 @@ target_sources("ttwhy-core" PUBLIC "ttwhy/lib.cppm" - "ttwhy/io/mod.cppm" - "ttwhy/io/readers.cppm" - "ttwhy/io/scoped_attributes.cppm" - "ttwhy/io/signal_handling.cppm" - - "ttwhy/routers/mod.cppm" - "ttwhy/routers/concepts.cppm" - "ttwhy/routers/echo.cppm" - - "ttwhy/scanners/mod.cppm" - "ttwhy/scanners/concepts.cppm" - "ttwhy/scanners/terminal_events.cppm" - "ttwhy/scanners/terminal_policies.cppm" - "ttwhy/scanners/terminal_scanner.cppm" + "ttwhy/core/mod.cppm" + "ttwhy/core/concepts.cppm" + + "ttwhy/terminal/mod.cppm" + "ttwhy/terminal/echo.cppm" + "ttwhy/terminal/events.cppm" + "ttwhy/terminal/policies.cppm" + "ttwhy/terminal/readers.cppm" + "ttwhy/terminal/scanner.cppm" + "ttwhy/terminal/scoped_attributes.cppm" + "ttwhy/terminal/signal_handling.cppm" ) @@ -122,8 +119,8 @@ if(BUILD_TESTING) add_executable("ttwhy::tests" ALIAS "ttwhy-tests") target_sources("ttwhy-tests" PRIVATE - "ttwhy/routers/echo.tests.cpp" - "ttwhy/scanners/terminal_scanner.tests.cpp" + "ttwhy/terminal/echo.tests.cpp" + "ttwhy/terminal/scanner.tests.cpp" ) target_link_libraries("ttwhy-tests" PRIVATE diff --git a/apps/echo.cpp b/apps/echo.cpp index ab635a7..c13ef92 100644 --- a/apps/echo.cpp +++ b/apps/echo.cpp @@ -3,14 +3,13 @@ #include -import ttwhy.io; -import ttwhy.routers; +import ttwhy.terminal; auto app(int in, int out, int error) -> asio::awaitable { using namespace asio::experimental::awaitable_operators; - auto terminal_attributes = ttwhy::io::scoped_attributes{fileno(stdin)} // + auto terminal_attributes = ttwhy::terminal::scoped_attributes{fileno(stdin)} // .canonical_mode(false) .echo(false); @@ -20,10 +19,10 @@ auto app(int in, int out, int error) -> asio::awaitable auto output_stream = asio::posix::stream_descriptor{executor, out}; auto error_stream = asio::posix::stream_descriptor{executor, error}; - auto router = ttwhy::routers::echo{output_stream}; + auto router = ttwhy::terminal::echo{output_stream}; - co_await (ttwhy::io::handle_signals(error_stream, terminal_attributes) || - ttwhy::io::read_events(input_stream, router)); + co_await (ttwhy::terminal::handle_signals(error_stream, terminal_attributes) || + ttwhy::terminal::read_events(input_stream, router)); } auto main() -> int diff --git a/ttwhy/core/concepts.cppm b/ttwhy/core/concepts.cppm new file mode 100644 index 0000000..f7ac583 --- /dev/null +++ b/ttwhy/core/concepts.cppm @@ -0,0 +1,30 @@ +module; + +#include + +#include +#include + +export module ttwhy.core:concepts; + +namespace ttwhy +{ + + export template + concept scanner = requires(Candidate a, std::span buffer) { + { a.process(buffer) } -> std::same_as; + { a.timeout() } -> std::same_as; + { a.is_pending() } -> std::same_as; + }; + + export template + concept router = requires(Candidate candidate, Event event) { + { candidate.process(event) } -> std::same_as>; + }; + + export template + concept event_sink = requires(Sink sink, Event event) { + { sink(event) } -> std::same_as; + }; + +} // namespace ttwhy diff --git a/ttwhy/core/mod.cppm b/ttwhy/core/mod.cppm new file mode 100644 index 0000000..a6bf056 --- /dev/null +++ b/ttwhy/core/mod.cppm @@ -0,0 +1,3 @@ +export module ttwhy.core; + +export import :concepts; diff --git a/ttwhy/io/mod.cppm b/ttwhy/io/mod.cppm deleted file mode 100644 index 8deff38..0000000 --- a/ttwhy/io/mod.cppm +++ /dev/null @@ -1,5 +0,0 @@ -export module ttwhy.io; - -export import :readers; -export import :scoped_attributes; -export import :signal_handling; diff --git a/ttwhy/io/readers.cppm b/ttwhy/io/readers.cppm deleted file mode 100644 index 56ce2b0..0000000 --- a/ttwhy/io/readers.cppm +++ /dev/null @@ -1,93 +0,0 @@ -module; - -#include -#include - -#include -#include -#include -#include - -export module ttwhy.io:readers; - -import ttwhy.routers; -import ttwhy.scanners; - -namespace ttwhy::io -{ - - export template AppRouter> - auto read_events(InputStream & stream, AppRouter & router) -> asio::awaitable - { - using namespace asio::experimental::awaitable_operators; - using namespace std::chrono_literals; - - auto executor = co_await asio::this_coro::executor; - auto timer = asio::steady_timer{executor}; - - auto queue = std::vector{}; - queue.reserve(16); - - auto sink = [&queue](auto const & event) { - queue.push_back(event); - }; - - auto scanner = scanners::terminal_scanner{sink}; - - auto raw_buffer = std::array{}; - - while (true) - { - auto error = asio::error_code{}; - auto bytes_read = 0uz; - - if (scanner.is_pending()) - { - timer.expires_after(50ms); - - auto result = co_await (stream.async_read_some(asio::buffer(raw_buffer), asio::as_tuple(asio::use_awaitable)) || - timer.async_wait(asio::as_tuple(asio::use_awaitable))); - - if (result.index() == 0) - { - std::tie(error, bytes_read) = std::get<0>(result); - } - else - { - scanner.timeout(); - for (auto const & event : queue) - { - co_await router.process(event); - } - queue.clear(); - continue; - } - } - else - { - std::tie(error, bytes_read) = - co_await stream.async_read_some(asio::buffer(raw_buffer), asio::as_tuple(asio::use_awaitable)); - } - - if (error) - { - if (error == asio::error::interrupted) - { - continue; - } - co_return; - } - - auto const byte_span = std::span{raw_buffer.data(), bytes_read}; - scanner.process(byte_span); - - for (auto const & event : queue) - { - co_await router.process(event); - } - queue.clear(); - } - } - -} // namespace ttwhy::io diff --git a/ttwhy/io/scoped_attributes.cppm b/ttwhy/io/scoped_attributes.cppm deleted file mode 100644 index 5e42e39..0000000 --- a/ttwhy/io/scoped_attributes.cppm +++ /dev/null @@ -1,135 +0,0 @@ -module; - -#include -#include - -#include -#include -#include - -export module ttwhy.io:scoped_attributes; - -namespace ttwhy::io -{ - - auto static read_attributes(int file_descriptor) -> termios - { - auto active_attributes = termios{}; - if (::tcgetattr(file_descriptor, &active_attributes)) - { - throw std::system_error(errno, std::system_category(), "failed to read termios attributes"); - } - return active_attributes; - } - - auto static get_line_discipline_flag(int file_descriptor, int flag) -> bool - { - auto active_attributes = read_attributes(file_descriptor); - return active_attributes.c_lflag & flag; - } - - auto static set_line_discipline_flag(int file_descriptor, int flag, bool enabled) -> void - { - auto active_attributes = read_attributes(file_descriptor); - - if ((active_attributes.c_lflag & flag) == enabled) - { - return; - } - - if (enabled) - { - active_attributes.c_lflag = active_attributes.c_lflag | flag; - } - else - { - active_attributes.c_lflag = active_attributes.c_lflag & ~flag; - } - - if (::tcsetattr(file_descriptor, TCSANOW, &active_attributes)) - { - throw std::system_error(errno, std::system_category(), "failed to write termios attributes"); - } - } - - //! Store the current TC attributes and restore them on destruction. - export struct scoped_attributes - { - explicit scoped_attributes(int file_descriptor) - : m_file_descriptor{-1} - , m_attributes{} - { - if (isatty(file_descriptor)) - { - m_file_descriptor = dup(file_descriptor); - m_attributes = read_attributes(m_file_descriptor); - } - } - - scoped_attributes(scoped_attributes const &) = delete; - - scoped_attributes(scoped_attributes && other) - : m_file_descriptor{std::exchange(other.m_file_descriptor, -1)} - , m_attributes{std::move(other.m_attributes)} - {} - - ~scoped_attributes() - { - if (m_file_descriptor > 0) - { - ::tcsetattr(m_file_descriptor, TCSANOW, &m_attributes); - ::close(m_file_descriptor); - } - } - - auto operator=(scoped_attributes const &) -> scoped_attributes & = delete; - - auto operator=(scoped_attributes && other) -> scoped_attributes & - { - std::ranges::swap(m_file_descriptor, other.m_file_descriptor); - std::ranges::swap(m_attributes, other.m_attributes); - return *this; - } - - //! Check if canonical mode is currently active - [[nodiscard]] auto canonical_mode() const noexcept -> bool - { - return get_line_discipline_flag(m_file_descriptor, ICANON); - } - - //! Set whether canonical mode is active or not. - auto canonical_mode(bool enabled) & -> scoped_attributes & - { - set_line_discipline_flag(m_file_descriptor, ICANON, enabled); - return *this; - } - - auto canonical_mode(bool enabled) && -> scoped_attributes - { - set_line_discipline_flag(m_file_descriptor, ICANON, enabled); - return std::move(*this); - } - - [[nodiscard]] auto echo() const noexcept -> bool - { - return get_line_discipline_flag(m_file_descriptor, ECHO); - } - - auto echo(bool enabled) & -> scoped_attributes & - { - set_line_discipline_flag(m_file_descriptor, ECHO, enabled); - return *this; - } - - auto echo(bool enabled) && -> scoped_attributes - { - set_line_discipline_flag(m_file_descriptor, ECHO, enabled); - return std::move(*this); - } - - private: - int m_file_descriptor; - termios m_attributes; - }; - -} // namespace ttwhy::io diff --git a/ttwhy/io/signal_handling.cppm b/ttwhy/io/signal_handling.cppm deleted file mode 100644 index 1f3fecc..0000000 --- a/ttwhy/io/signal_handling.cppm +++ /dev/null @@ -1,58 +0,0 @@ -module; - -#include - -#include -#include -#include - -export module ttwhy.io:signal_handling; - -namespace ttwhy::io -{ - - export template - auto handle_signals(Stream & stream, TerminalAttributes & attributes) -> asio::awaitable - { - auto executor = co_await asio::this_coro::executor; - auto signals = asio::signal_set{executor, SIGINT, SIGTERM}; - signals.add(SIGTSTP); - signals.add(SIGCONT); - - while (true) - { - auto [error, signal] = co_await signals.async_wait(asio::as_tuple(asio::use_awaitable)); - if (error) - { - co_return error; - } - - switch (signal) - { - case SIGINT: - case SIGTERM: - { - auto message = std::format("Received signal {}, exiting ...\n", signal); - co_await asio::async_write(stream, asio::buffer(message), asio::use_awaitable); - co_return asio::error_code{}; - } - case SIGTSTP: - { - attributes.canonical_mode(true).echo(true); - ::raise(SIGSTOP); - break; - } - case SIGCONT: - { - attributes.canonical_mode(false).echo(false); - break; - } - } - - if (!error) - { - } - } - } - -} // namespace ttwhy::io diff --git a/ttwhy/lib.cppm b/ttwhy/lib.cppm index 474d6b4..158dcda 100644 --- a/ttwhy/lib.cppm +++ b/ttwhy/lib.cppm @@ -1,5 +1,4 @@ export module ttwhy; -export import ttwhy.io; -export import ttwhy.routers; -export import ttwhy.scanners; +export import ttwhy.core; +export import ttwhy.terminal; diff --git a/ttwhy/routers/concepts.cppm b/ttwhy/routers/concepts.cppm deleted file mode 100644 index cfbcb5a..0000000 --- a/ttwhy/routers/concepts.cppm +++ /dev/null @@ -1,17 +0,0 @@ -module; - -#include - -#include - -export module ttwhy.routers:concepts; - -namespace ttwhy -{ - - export template - concept router = requires(Candidate candidate, Event event) { - { candidate.process(event) } -> std::same_as>; - }; - -} // namespace ttwhy diff --git a/ttwhy/routers/echo.cppm b/ttwhy/routers/echo.cppm deleted file mode 100644 index c9cd7c7..0000000 --- a/ttwhy/routers/echo.cppm +++ /dev/null @@ -1,206 +0,0 @@ -module; - -#include - -#include - -#include -#include -#include - -export module ttwhy.routers:echo; - -import ttwhy.scanners; - -namespace ttwhy::routers -{ - - using namespace std::string_view_literals; - - constexpr auto vte_backspace_sequence = "\b \b"sv; - constexpr auto vte_newline_sequence = "\r\n"sv; - constexpr auto vte_horizontal_tab_sequence = "\t"sv; - constexpr auto vte_cursor_left_sequence = "\x1b[D"sv; - constexpr auto vte_cursor_right_sequence = "\x1b[C"sv; - constexpr auto vte_delete_character_sequence = "\x1b[P"sv; - constexpr auto vte_cursor_left_sequence_format = "\x1b[{}D"sv; - constexpr auto vte_cursor_right_sequence_format = "\x1b[{}C"sv; - - export template - struct echo - { - explicit echo(StreamType & stream) - : m_output_stream{stream} - {} - - [[nodiscard]] auto cursor_position() const noexcept -> std::size_t - { - return m_cursor_position; - } - - [[nodiscard]] auto max_position() const noexcept -> std::size_t - { - return m_max_position; - } - - auto process(scanners::input_event event) -> asio::awaitable - { - co_await std::visit( - [&](auto && event) -> asio::awaitable { - using event_type = std::decay_t; - - if constexpr (std::same_as) - { - co_await process_character(event.value); - } - else if constexpr (std::same_as) - { - co_await process_control_key(event.key); - } - else if constexpr (std::same_as) - { - co_await process_navigation_key(event); - } - }, - event); - } - - private: - auto process_character(char character) -> asio::awaitable - { - auto [error, bytes_written] = - co_await asio::async_write(m_output_stream, asio::buffer(&character, 1), asio::as_tuple(asio::use_awaitable)); - if (!error) - { - m_cursor_position += bytes_written; - m_max_position = std::max(m_max_position, m_cursor_position); - } - } - - auto process_control_key(scanners::control_key key) -> asio::awaitable - { - switch (key) - { - case scanners::control_key::backspace: - if (m_cursor_position > 0) - { - auto [error, bytes_written] = co_await asio::async_write( - m_output_stream, asio::buffer(vte_backspace_sequence), asio::as_tuple(asio::use_awaitable)); - if (!error) - { - if (m_cursor_position == m_max_position) - { - --m_max_position; - } - --m_cursor_position; - } - } - break; - case scanners::control_key::enter: - { - auto [error, bytes_written] = co_await asio::async_write(m_output_stream, asio::buffer(vte_newline_sequence), - asio::as_tuple(asio::use_awaitable)); - if (!error) - { - m_cursor_position = 0; - m_max_position = 0; - } - break; - } - case scanners::control_key::tab: - { - auto [error, bytes_written] = co_await asio::async_write( - m_output_stream, asio::buffer(vte_horizontal_tab_sequence), asio::as_tuple(asio::use_awaitable)); - if (!error) - { - auto const tab_stop = 8uz - (m_cursor_position % 8uz); - m_cursor_position += tab_stop; - m_max_position = std::max(m_max_position, m_cursor_position); - } - break; - } - default: - co_await asio::async_write(m_output_stream, asio::buffer(std::format("{{CTRL:{}}}", std::to_underlying(key))), - asio::as_tuple(asio::use_awaitable)); - break; - }; - } - - auto process_navigation_key(scanners::navigation_event event) -> asio::awaitable - { - switch (event.key) - { - case scanners::navigation_key::left: - if (m_cursor_position > 0) - { - auto [error, bytes_written] = co_await asio::async_write( - m_output_stream, asio::buffer(vte_cursor_left_sequence), asio::as_tuple(asio::use_awaitable)); - if (!error) - { - --m_cursor_position; - } - } - break; - - case scanners::navigation_key::right: - if (m_cursor_position < m_max_position) - { - auto [error, bytes_written] = co_await asio::async_write( - m_output_stream, asio::buffer(vte_cursor_right_sequence), asio::as_tuple(asio::use_awaitable)); - if (!error) - { - ++m_cursor_position; - } - } - break; - - case scanners::navigation_key::home: - if (m_cursor_position > 0) - { - auto sequence = std::format(vte_cursor_left_sequence_format, m_cursor_position); - auto [error, bytes_written] = co_await asio::async_write(m_output_stream, asio::buffer(sequence), - asio::as_tuple(asio::use_awaitable)); - if (!error) - { - m_cursor_position = 0; - } - } - break; - - case scanners::navigation_key::end: - if (m_cursor_position < m_max_position) - { - auto const distance = m_max_position - m_cursor_position; - auto sequence = std::format(vte_cursor_right_sequence_format, distance); - auto [error, bytes_written] = co_await asio::async_write(m_output_stream, asio::buffer(sequence), - asio::as_tuple(asio::use_awaitable)); - if (!error) - { - m_cursor_position = m_max_position; - } - } - break; - - case scanners::navigation_key::delete_key: - if (m_cursor_position < m_max_position) - { - auto [error, bytes_written] = co_await asio::async_write( - m_output_stream, asio::buffer(vte_delete_character_sequence), asio::as_tuple(asio::use_awaitable)); - if (!error) - { - --m_max_position; - } - } - break; - - default: - break; - } - } - - StreamType & m_output_stream; - std::size_t m_cursor_position{}; - std::size_t m_max_position{}; - }; - -} // namespace ttwhy::routers diff --git a/ttwhy/routers/echo.tests.cpp b/ttwhy/routers/echo.tests.cpp deleted file mode 100644 index da2db40..0000000 --- a/ttwhy/routers/echo.tests.cpp +++ /dev/null @@ -1,257 +0,0 @@ -#include - -#include - -#include -#include -#include - -import ttwhy.routers; -import ttwhy.scanners; - -using namespace std::string_view_literals; - -namespace -{ - struct mock_stream - { - using executor_type = asio::any_io_executor; - - asio::any_io_executor executor; - std::string written{}; - - explicit mock_stream(asio::any_io_executor exec) - : executor{std::move(exec)} - {} - - auto get_executor() const noexcept -> executor_type - { - return executor; - } - - template - auto async_write_some(ConstBufferSequence const & buffers, Token && token) - { - return asio::async_initiate( - [this](auto handler, ConstBufferSequence bufs) { - auto const total = asio::buffer_size(bufs); - auto const offset = written.size(); - written.resize(offset + total); - asio::buffer_copy(asio::buffer(written.data() + offset, total), bufs); - asio::post(executor, [handler = std::move(handler), total]() mutable { - std::move(handler)(asio::error_code{}, total); - }); - }, - token, buffers); - } - }; - - struct incomplete_router_no_process - { - }; - - struct incomplete_router_wrong_return - { - auto process(ttwhy::scanners::input_event) -> void; - }; - - struct incomplete_router_wrong_event - { - auto process(int) -> asio::awaitable; - }; -} // namespace - -TEST_CASE("The router concept validates router candidate interfaces", "[router][concept]") -{ - STATIC_REQUIRE(ttwhy::router, ttwhy::scanners::input_event>); - STATIC_REQUIRE_FALSE(ttwhy::router); - STATIC_REQUIRE_FALSE(ttwhy::router); - STATIC_REQUIRE_FALSE(ttwhy::router); -} - -SCENARIO("The echo router handles terminal navigation within the valid printed area", "[router][echo]") -{ - GIVEN("An initialized echo router with a mock stream") - { - auto context = asio::io_context{}; - auto stream = mock_stream{context.get_executor()}; - auto router = ttwhy::routers::echo{stream}; - - auto run_coro = [&](asio::awaitable coro) { - asio::co_spawn(context, std::move(coro), asio::detached); - context.restart(); - context.run(); - }; - - WHEN("Typing characters") - { - run_coro(router.process(ttwhy::scanners::character_event{'A'})); - run_coro(router.process(ttwhy::scanners::character_event{'B'})); - - THEN("Characters are echoed and positions advance") - { - CHECK(stream.written == "AB"); - CHECK(router.cursor_position() == 2); - CHECK(router.max_position() == 2); - } - - AND_WHEN("Navigating left") - { - stream.written.clear(); - run_coro(router.process(ttwhy::scanners::navigation_event{ttwhy::scanners::navigation_key::left})); - - THEN("The cursor moves left") - { - CHECK(stream.written == "\x1b[D"); - CHECK(router.cursor_position() == 1); - CHECK(router.max_position() == 2); - } - - AND_WHEN("Navigating left again to position 0") - { - stream.written.clear(); - run_coro(router.process(ttwhy::scanners::navigation_event{ttwhy::scanners::navigation_key::left})); - - THEN("The cursor reaches 0") - { - CHECK(stream.written == "\x1b[D"); - CHECK(router.cursor_position() == 0); - CHECK(router.max_position() == 2); - } - - AND_WHEN("Attempting to navigate left beyond 0") - { - stream.written.clear(); - run_coro(router.process(ttwhy::scanners::navigation_event{ttwhy::scanners::navigation_key::left})); - - THEN("Navigation is prevented and nothing is emitted") - { - CHECK(stream.written.empty()); - CHECK(router.cursor_position() == 0); - CHECK(router.max_position() == 2); - } - } - } - } - - AND_WHEN("Attempting to navigate right beyond what was printed") - { - stream.written.clear(); - run_coro(router.process(ttwhy::scanners::navigation_event{ttwhy::scanners::navigation_key::right})); - - THEN("Navigation is prevented and nothing is emitted") - { - CHECK(stream.written.empty()); - CHECK(router.cursor_position() == 2); - CHECK(router.max_position() == 2); - } - } - - AND_WHEN("Navigating left and then overwriting with new character") - { - run_coro(router.process(ttwhy::scanners::navigation_event{ttwhy::scanners::navigation_key::left})); - stream.written.clear(); - run_coro(router.process(ttwhy::scanners::character_event{'X'})); - - THEN("The new character is written and max position is preserved") - { - CHECK(stream.written == "X"); - CHECK(router.cursor_position() == 2); - CHECK(router.max_position() == 2); - } - } - - AND_WHEN("Using Home and End keys") - { - stream.written.clear(); - run_coro(router.process(ttwhy::scanners::navigation_event{ttwhy::scanners::navigation_key::home})); - - THEN("Home navigates to 0") - { - CHECK(stream.written == "\x1b[2D"); - CHECK(router.cursor_position() == 0); - CHECK(router.max_position() == 2); - } - - AND_WHEN("Pressing Home when already at 0") - { - stream.written.clear(); - run_coro(router.process(ttwhy::scanners::navigation_event{ttwhy::scanners::navigation_key::home})); - - THEN("Nothing is emitted") - { - CHECK(stream.written.empty()); - CHECK(router.cursor_position() == 0); - } - } - - AND_WHEN("Pressing End from 0") - { - stream.written.clear(); - run_coro(router.process(ttwhy::scanners::navigation_event{ttwhy::scanners::navigation_key::end})); - - THEN("End moves to max position") - { - CHECK(stream.written == "\x1b[2C"); - CHECK(router.cursor_position() == 2); - CHECK(router.max_position() == 2); - } - } - } - - AND_WHEN("Pressing Enter") - { - stream.written.clear(); - run_coro(router.process(ttwhy::scanners::control_event{ttwhy::scanners::control_key::enter})); - - THEN("Newline is emitted and positions reset") - { - CHECK(stream.written == "\r\n"); - CHECK(router.cursor_position() == 0); - CHECK(router.max_position() == 0); - } - } - - AND_WHEN("Pressing Backspace at position 0 after reset") - { - run_coro(router.process(ttwhy::scanners::control_event{ttwhy::scanners::control_key::enter})); - stream.written.clear(); - run_coro(router.process(ttwhy::scanners::control_event{ttwhy::scanners::control_key::backspace})); - - THEN("Backspace does nothing at position 0") - { - CHECK(stream.written.empty()); - CHECK(router.cursor_position() == 0); - CHECK(router.max_position() == 0); - } - } - - AND_WHEN("Using Delete key") - { - run_coro(router.process(ttwhy::scanners::navigation_event{ttwhy::scanners::navigation_key::left})); - stream.written.clear(); - run_coro(router.process(ttwhy::scanners::navigation_event{ttwhy::scanners::navigation_key::delete_key})); - - THEN("Delete character sequence is emitted and max position shrinks") - { - CHECK(stream.written == "\x1b[P"); - CHECK(router.cursor_position() == 1); - CHECK(router.max_position() == 1); - } - - AND_WHEN("Pressing Delete when cursor is at max position") - { - stream.written.clear(); - run_coro(router.process(ttwhy::scanners::navigation_event{ttwhy::scanners::navigation_key::delete_key})); - - THEN("Nothing is emitted") - { - CHECK(stream.written.empty()); - CHECK(router.cursor_position() == 1); - CHECK(router.max_position() == 1); - } - } - } - } - } -} diff --git a/ttwhy/routers/mod.cppm b/ttwhy/routers/mod.cppm deleted file mode 100644 index d1d2d93..0000000 --- a/ttwhy/routers/mod.cppm +++ /dev/null @@ -1,4 +0,0 @@ -export module ttwhy.routers; - -export import :echo; -export import :concepts; diff --git a/ttwhy/scanners/concepts.cppm b/ttwhy/scanners/concepts.cppm deleted file mode 100644 index 5c29b5f..0000000 --- a/ttwhy/scanners/concepts.cppm +++ /dev/null @@ -1,30 +0,0 @@ -module; - -#include -#include - -export module ttwhy.scanners:concepts; - -import :terminal_events; - -namespace ttwhy -{ - - export template - concept scanner = requires(Candidate a, std::span buffer) { - { a.process(buffer) } -> std::same_as; - { a.timeout() } -> std::same_as; - { a.is_pending() } -> std::same_as; - }; - - export template - concept event_sink = requires(Sink sink, Event event) { - { sink(event) } -> std::same_as; - }; - - export template - concept terminal_event_sink = - event_sink && event_sink && - event_sink; - -} // namespace ttwhy diff --git a/ttwhy/scanners/mod.cppm b/ttwhy/scanners/mod.cppm deleted file mode 100644 index b422a38..0000000 --- a/ttwhy/scanners/mod.cppm +++ /dev/null @@ -1,6 +0,0 @@ -export module ttwhy.scanners; - -export import :concepts; -export import :terminal_events; -export import :terminal_policies; -export import :terminal_scanner; diff --git a/ttwhy/scanners/terminal_events.cppm b/ttwhy/scanners/terminal_events.cppm deleted file mode 100644 index 3e4c849..0000000 --- a/ttwhy/scanners/terminal_events.cppm +++ /dev/null @@ -1,100 +0,0 @@ -module; - -#include -#include -#include - -export module ttwhy.scanners:terminal_events; - -export namespace ttwhy::scanners -{ - enum struct control_key : std::uint8_t - { - enter, - escape, - tab, - backspace, - }; - - enum struct navigation_key : std::uint8_t - { - up, - down, - left, - right, - home, - end, - delete_key, - insert_key, - page_up, - page_down, - }; - - enum struct modifier : std::uint8_t - { - none = 0, - shift = 1, - alt = 2, - ctrl = 4, - meta = 8, - }; - - enum struct modifier_set : std::uint8_t - { - }; - - constexpr auto operator|(modifier lhs, modifier rhs) -> modifier_set - { - return static_cast(std::to_underlying(lhs) | std::to_underlying(rhs)); - } - - constexpr auto operator|(modifier_set lhs, modifier rhs) -> modifier_set - { - return static_cast(std::to_underlying(lhs) | std::to_underlying(rhs)); - } - - constexpr auto operator&(modifier lhs, modifier rhs) -> modifier_set - { - return static_cast(std::to_underlying(lhs) & std::to_underlying(rhs)); - } - - constexpr auto operator&(modifier_set lhs, modifier rhs) -> modifier_set - { - return static_cast(std::to_underlying(lhs) & std::to_underlying(rhs)); - } - - template... Modifier> - constexpr auto has_all(modifier_set mask, Modifier... flag) -> bool - { - return (true && ... && (mask & flag)); - } - - template... Modifier> - constexpr auto has_any(modifier_set mask, Modifier... flag) -> bool - { - return (false || ... || (mask & flag)); - } - - struct character_event - { - char value{}; - }; - - struct control_event - { - control_key key{}; - }; - - struct navigation_event - { - navigation_key key{}; - modifier_set modifiers{}; - }; - - struct ctrl_chord_event - { - char key{}; - }; - - using input_event = std::variant; -} // namespace ttwhy::scanners diff --git a/ttwhy/scanners/terminal_policies.cppm b/ttwhy/scanners/terminal_policies.cppm deleted file mode 100644 index e3c889c..0000000 --- a/ttwhy/scanners/terminal_policies.cppm +++ /dev/null @@ -1,161 +0,0 @@ -module; - -#include -#include - -export module ttwhy.scanners:terminal_policies; - -import :concepts; -import :terminal_events; - -namespace ttwhy::scanners -{ - - namespace detail - { - constexpr auto map_vt220_keypad(char terminator) -> std::optional - { - switch (terminator) - { - case '1': - case '7': - return navigation_key::home; - case '2': - return navigation_key::insert_key; - case '3': - return navigation_key::delete_key; - case '4': - case '8': - return navigation_key::end; - case '5': - return navigation_key::page_up; - case '6': - return navigation_key::page_down; - default: - return std::nullopt; - } - } - - constexpr auto map_vt100_cursor(char terminator) -> std::optional - { - switch (terminator) - { - case 'A': - return navigation_key::up; - case 'B': - return navigation_key::down; - case 'C': - return navigation_key::right; - case 'D': - return navigation_key::left; - case 'H': - return navigation_key::home; - case 'F': - return navigation_key::end; - default: - return std::nullopt; - } - } - - } // namespace detail - - export struct ansi_policy - { - constexpr auto static resolve_vt220_keypad(std::string & buffer, ttwhy::terminal_event_sink auto & sink) -> void - { - if (buffer.empty()) - { - return; - } - - if (auto key = detail::map_vt220_keypad(buffer.front())) - { - sink(navigation_event{*key}); - } - buffer.clear(); - } - - constexpr auto static resolve_vt100_cursor(char terminator, std::string &, ttwhy::terminal_event_sink auto & sink) - -> void - { - if (auto key = detail::map_vt100_cursor(terminator)) - { - sink(navigation_event{*key}); - } - } - - constexpr auto static resolve_ss3(char terminator, ttwhy::terminal_event_sink auto & sink) -> void - { - if (auto key = detail::map_vt100_cursor(terminator)) - { - sink(navigation_event{*key}); - } - } - }; - - export struct xterm_policy - { - constexpr auto static parse_modifiers(std::string_view buffer) -> modifier_set - { - auto delimiter = buffer.find(';'); - if (delimiter == std::string_view::npos) - { - return {}; - } - - auto modifier_string = buffer.substr(delimiter + 1); - auto modifier_value = 0u; - - for (auto character : modifier_string) - { - if (character >= '0' && character <= '9') - { - modifier_value = modifier_value * 10 + static_cast(character - '0'); - } - } - - if (modifier_value <= 1) - { - return {}; - } - - return static_cast(modifier_value - 1); - } - - constexpr auto static resolve_vt220_keypad(std::string & buffer, ttwhy::terminal_event_sink auto & sink) -> void - { - if (buffer.empty()) - { - return; - } - - auto modifiers = parse_modifiers(buffer); - - if (auto key = detail::map_vt220_keypad(buffer.front())) - { - sink(navigation_event{*key, modifiers}); - } - buffer.clear(); - } - - constexpr auto static resolve_vt100_cursor(char terminator, std::string & buffer, - ttwhy::terminal_event_sink auto & sink) -> void - { - auto modifiers = parse_modifiers(buffer); - - if (auto key = detail::map_vt100_cursor(terminator)) - { - sink(navigation_event{*key, modifiers}); - } - } - - constexpr auto static resolve_ss3(char terminator, ttwhy::terminal_event_sink auto & sink) -> void - { - if (auto key = detail::map_vt100_cursor(terminator)) - { - sink(navigation_event{*key}); - } - } - }; - -} // namespace ttwhy::scanners diff --git a/ttwhy/scanners/terminal_scanner.cppm b/ttwhy/scanners/terminal_scanner.cppm deleted file mode 100644 index a791909..0000000 --- a/ttwhy/scanners/terminal_scanner.cppm +++ /dev/null @@ -1,236 +0,0 @@ -module; - -#include -#include - -#include -#include -#include - -export module ttwhy.scanners:terminal_scanner; - -import :concepts; -import :terminal_events; -import :terminal_policies; - -namespace ttwhy::scanners::detail -{ - - /// Events - - struct byte_received - { - char value; - }; - - struct c1_received - { - unsigned char value; - }; - - struct timeout_expired - { - }; - - /// States - - constexpr auto idle = boost::sml::state; - constexpr auto escape_sequence = boost::sml::state; - constexpr auto csi_sequence = boost::sml::state; - constexpr auto ss3_sequence = boost::sml::state; - - /// Guards - - constexpr auto is_backspace = [](byte_received e) { - return e.value == '\x08' || e.value == '\x7f'; - }; - - constexpr auto is_tab = [](byte_received e) { - return e.value == '\x09'; - }; - - constexpr auto is_enter = [](byte_received e) { - return e.value == '\x0a' || e.value == '\x0d'; - }; - - constexpr auto is_escape = [](byte_received e) { - return e.value == '\x1b'; - }; - - constexpr auto is_printable = [](byte_received e) { - return e.value >= ' ' && e.value <= '~'; - }; - - constexpr auto is_c0_chord = [](byte_received e) { - return e.value >= '\0' && e.value <= '\x1f' && !is_backspace(e) && !is_tab(e) && !is_enter(e) && !is_escape(e); - }; - - constexpr auto is_fe = [](byte_received e) { - return e.value >= '@' && e.value <= '_'; - }; - - constexpr auto is_csi = [](c1_received e) { - return e.value == u'\x9b'; - }; - - constexpr auto is_ss3 = [](c1_received e) { - return e.value == u'\x8f'; - }; - - constexpr auto is_unhandled_c1 = [](c1_received e) { - return !(is_csi(e) || is_ss3(e)); - }; - - constexpr auto is_csi_param = [](byte_received e) { - return e.value >= ' ' && e.value <= '?'; - }; - - constexpr auto is_vt100_terminator = [](byte_received e) { - return e.value >= '@' && e.value <= '}'; - }; - - constexpr auto is_vt220_terminator = [](byte_received e) { - return e.value == '~'; - }; - - /// Transitions - - template - struct transition_table - { - auto operator()() const noexcept - { - using namespace boost::sml; - - constexpr auto push_character = [](byte_received event, Sink & sink) { - sink(character_event{event.value}); - }; - - constexpr auto push_backspace = [](Sink & sink) { - sink(control_event{control_key::backspace}); - }; - - constexpr auto push_tab = [](Sink & sink) { - sink(control_event(control_key::tab)); - }; - - constexpr auto push_enter = [](Sink & sink) { - sink(control_event(control_key::enter)); - }; - - constexpr auto push_c0_chord = [](byte_received event, Sink & sink) { - sink(ctrl_chord_event{static_cast(event.value + 0x40)}); - }; - - constexpr auto dispatch_c1 = [](byte_received event, back::process process) { - process(c1_received{static_cast(event.value + 0x40)}); - }; - - constexpr auto fallback_fe = [](c1_received event, Sink & sink) { - sink(control_event{control_key::escape}); - sink(character_event{static_cast(event.value - 0x40)}); - }; - - constexpr auto emit_timeout_escape = [](Sink & sink, std::string & buffer) { - sink(control_event{control_key::escape}); - buffer.clear(); - }; - - constexpr auto fallback_escape = [](byte_received event, Sink & sink) { - sink(control_event{control_key::escape}); - if (event.value >= ' ' && event.value <= '~') - { - sink(character_event{event.value}); - } - }; - - constexpr auto clear_csi = [](std::string & buffer) { - buffer.clear(); - }; - - constexpr auto push_csi_parameter = [](byte_received const & event, std::string & buffer) { - buffer.push_back(event.value); - }; - - constexpr auto resolve_vt100_cursor = [](byte_received event, std::string & buffer, Sink & sink) { - TerminalPolicy::resolve_vt100_cursor(event.value, buffer, sink); - buffer.clear(); - }; - - constexpr auto resolve_vt220_keypad = [](std::string & buffer, Sink & sink) { - TerminalPolicy::resolve_vt220_keypad(buffer, sink); - buffer.clear(); - }; - - constexpr auto resolve_ss3 = [](byte_received event, Sink & sink) { - TerminalPolicy::resolve_ss3(event.value, sink); - }; - - // clang-format off - return make_transition_table( - *idle + event[is_escape] = escape_sequence, - idle + event[is_backspace] / push_backspace = idle, - idle + event[is_tab] / push_tab = idle, - idle + event[is_enter] / push_enter = idle, - idle + event[is_c0_chord] / push_c0_chord = idle, - idle + event[is_printable] / push_character = idle, - - escape_sequence + event[is_fe] / dispatch_c1 = idle, - escape_sequence + event[!is_fe] / fallback_escape = idle, - escape_sequence + event / emit_timeout_escape = idle, - - idle + event[is_csi] / clear_csi = csi_sequence, - idle + event[is_ss3] = ss3_sequence, - idle + event[is_unhandled_c1] / fallback_fe = idle, - - csi_sequence + event[is_csi_param] / push_csi_parameter = csi_sequence, - csi_sequence + event[is_vt220_terminator] / resolve_vt220_keypad = idle, - csi_sequence + event[is_vt100_terminator] / resolve_vt100_cursor = idle, - csi_sequence + event / emit_timeout_escape = idle, - - ss3_sequence + event / resolve_ss3 = idle, - ss3_sequence + event / emit_timeout_escape = idle - ); - // clang-format on - } - }; - -} // namespace ttwhy::scanners::detail - -export namespace ttwhy::scanners -{ - - template - struct terminal_scanner - { - explicit terminal_scanner(Sink & sink) - : m_state_machine{sink, m_csi_buffer} - {} - - auto process(std::span buffer) -> void - { - std::ranges::for_each(buffer, [&](auto byte) { m_state_machine.process_event(detail::byte_received{byte}); }); - } - - auto timeout() - { - m_state_machine.process_event(detail::timeout_expired{}); - } - - [[nodiscard]] auto is_pending() const -> bool - { - return m_state_machine.is(detail::escape_sequence) || // - m_state_machine.is(detail::csi_sequence) || // - m_state_machine.is(detail::ss3_sequence); - } - - private: - std::string m_csi_buffer{}; - boost::sml::sm, boost::sml::process_queue> - m_state_machine; - }; - - template - using ansi = terminal_scanner; - -} // namespace ttwhy::scanners diff --git a/ttwhy/scanners/terminal_scanner.tests.cpp b/ttwhy/scanners/terminal_scanner.tests.cpp deleted file mode 100644 index f4ea6ad..0000000 --- a/ttwhy/scanners/terminal_scanner.tests.cpp +++ /dev/null @@ -1,100 +0,0 @@ -#include - -#include -#include -#include - -import ttwhy.scanners; - -using namespace std::string_view_literals; - -[[nodiscard]] constexpr auto static is_character(ttwhy::scanners::input_event & event, char expected) -> bool -{ - auto const * data = std::get_if(&event); - return data != nullptr && data->value == expected; -} - -[[nodiscard]] constexpr auto static is_control(ttwhy::scanners::input_event & event, - ttwhy::scanners::control_key expected) -> bool -{ - auto const * data = std::get_if(&event); - return data != nullptr && data->key == expected; -} - -SCENARIO("The ANSI scanner processes printable ASCII and standard C0 control characters", "[scanner][ansi]") -{ - GIVEN("An initialized scanner and event sink") - { - auto queue = std::vector{}; - auto sink = [&queue](auto const & event) { - queue.push_back(event); - }; - auto scanner = ttwhy::scanners::ansi{sink}; - - WHEN("Processing a standard printable character") - { - scanner.process("A"sv); - - THEN("It yields a single character event") - { - REQUIRE(queue.size() == 1); - CHECK(is_character(queue.at(0), 'A')); - } - } - - WHEN("Processing a BS byte (\\x08)") - { - scanner.process("\x08"sv); - - THEN("It yields a backspace control event") - { - REQUIRE(queue.size() == 1); - CHECK(is_control(queue.at(0), ttwhy::scanners::control_key::backspace)); - } - } - - WHEN("Processing a DEL byte (\\x7f as BS)") - { - scanner.process("\x7f"sv); - - THEN("It yields a backspace control event") - { - REQUIRE(queue.size() == 1); - CHECK(is_control(queue.at(0), ttwhy::scanners::control_key::backspace)); - } - } - - WHEN("Processing a TAB byte") - { - scanner.process("\x09"sv); - - THEN("It yields a tab control event") - { - REQUIRE(queue.size() == 1); - CHECK(is_control(queue.at(0), ttwhy::scanners::control_key::tab)); - } - } - - WHEN("Processing an LF byte") - { - scanner.process("\x0a"sv); - - THEN("It yields an enter control event") - { - REQUIRE(queue.size() == 1); - CHECK(is_control(queue.at(0), ttwhy::scanners::control_key::enter)); - } - } - - WHEN("Processing an CR byte") - { - scanner.process("\x0d"sv); - - THEN("It yields an enter control event") - { - REQUIRE(queue.size() == 1); - CHECK(is_control(queue.at(0), ttwhy::scanners::control_key::enter)); - } - } - } -} diff --git a/ttwhy/terminal/echo.cppm b/ttwhy/terminal/echo.cppm new file mode 100644 index 0000000..660cf42 --- /dev/null +++ b/ttwhy/terminal/echo.cppm @@ -0,0 +1,207 @@ +module; + +#include + +#include + +#include +#include +#include + +export module ttwhy.terminal:echo; + +import :events; +import ttwhy.core; + +namespace ttwhy::terminal +{ + + using namespace std::string_view_literals; + + constexpr auto vte_backspace_sequence = "\b \b"sv; + constexpr auto vte_newline_sequence = "\r\n"sv; + constexpr auto vte_horizontal_tab_sequence = "\t"sv; + constexpr auto vte_cursor_left_sequence = "\x1b[D"sv; + constexpr auto vte_cursor_right_sequence = "\x1b[C"sv; + constexpr auto vte_delete_character_sequence = "\x1b[P"sv; + constexpr auto vte_cursor_left_sequence_format = "\x1b[{}D"sv; + constexpr auto vte_cursor_right_sequence_format = "\x1b[{}C"sv; + + export template + struct echo + { + explicit echo(StreamType & stream) + : m_output_stream{stream} + {} + + [[nodiscard]] auto cursor_position() const noexcept -> std::size_t + { + return m_cursor_position; + } + + [[nodiscard]] auto max_position() const noexcept -> std::size_t + { + return m_max_position; + } + + auto process(input_event event) -> asio::awaitable + { + co_await std::visit( + [&](auto && event) -> asio::awaitable { + using event_type = std::decay_t; + + if constexpr (std::same_as) + { + co_await process_character(event.value); + } + else if constexpr (std::same_as) + { + co_await process_control_key(event.key); + } + else if constexpr (std::same_as) + { + co_await process_navigation_key(event); + } + }, + event); + } + + private: + auto process_character(char character) -> asio::awaitable + { + auto [error, bytes_written] = + co_await asio::async_write(m_output_stream, asio::buffer(&character, 1), asio::as_tuple(asio::use_awaitable)); + if (!error) + { + m_cursor_position += bytes_written; + m_max_position = std::max(m_max_position, m_cursor_position); + } + } + + auto process_control_key(control_key key) -> asio::awaitable + { + switch (key) + { + case control_key::backspace: + if (m_cursor_position > 0) + { + auto [error, bytes_written] = co_await asio::async_write( + m_output_stream, asio::buffer(vte_backspace_sequence), asio::as_tuple(asio::use_awaitable)); + if (!error) + { + if (m_cursor_position == m_max_position) + { + --m_max_position; + } + --m_cursor_position; + } + } + break; + case control_key::enter: + { + auto [error, bytes_written] = co_await asio::async_write(m_output_stream, asio::buffer(vte_newline_sequence), + asio::as_tuple(asio::use_awaitable)); + if (!error) + { + m_cursor_position = 0; + m_max_position = 0; + } + break; + } + case control_key::tab: + { + auto [error, bytes_written] = co_await asio::async_write( + m_output_stream, asio::buffer(vte_horizontal_tab_sequence), asio::as_tuple(asio::use_awaitable)); + if (!error) + { + auto const tab_stop = 8uz - (m_cursor_position % 8uz); + m_cursor_position += tab_stop; + m_max_position = std::max(m_max_position, m_cursor_position); + } + break; + } + default: + co_await asio::async_write(m_output_stream, asio::buffer(std::format("{{CTRL:{}}}", std::to_underlying(key))), + asio::as_tuple(asio::use_awaitable)); + break; + }; + } + + auto process_navigation_key(navigation_event event) -> asio::awaitable + { + switch (event.key) + { + case navigation_key::left: + if (m_cursor_position > 0) + { + auto [error, bytes_written] = co_await asio::async_write( + m_output_stream, asio::buffer(vte_cursor_left_sequence), asio::as_tuple(asio::use_awaitable)); + if (!error) + { + --m_cursor_position; + } + } + break; + + case navigation_key::right: + if (m_cursor_position < m_max_position) + { + auto [error, bytes_written] = co_await asio::async_write( + m_output_stream, asio::buffer(vte_cursor_right_sequence), asio::as_tuple(asio::use_awaitable)); + if (!error) + { + ++m_cursor_position; + } + } + break; + + case navigation_key::home: + if (m_cursor_position > 0) + { + auto sequence = std::format(vte_cursor_left_sequence_format, m_cursor_position); + auto [error, bytes_written] = co_await asio::async_write(m_output_stream, asio::buffer(sequence), + asio::as_tuple(asio::use_awaitable)); + if (!error) + { + m_cursor_position = 0; + } + } + break; + + case navigation_key::end: + if (m_cursor_position < m_max_position) + { + auto const distance = m_max_position - m_cursor_position; + auto sequence = std::format(vte_cursor_right_sequence_format, distance); + auto [error, bytes_written] = co_await asio::async_write(m_output_stream, asio::buffer(sequence), + asio::as_tuple(asio::use_awaitable)); + if (!error) + { + m_cursor_position = m_max_position; + } + } + break; + + case navigation_key::delete_key: + if (m_cursor_position < m_max_position) + { + auto [error, bytes_written] = co_await asio::async_write( + m_output_stream, asio::buffer(vte_delete_character_sequence), asio::as_tuple(asio::use_awaitable)); + if (!error) + { + --m_max_position; + } + } + break; + + default: + break; + } + } + + StreamType & m_output_stream; + std::size_t m_cursor_position{}; + std::size_t m_max_position{}; + }; + +} // namespace ttwhy::terminal diff --git a/ttwhy/terminal/echo.tests.cpp b/ttwhy/terminal/echo.tests.cpp new file mode 100644 index 0000000..ba07d55 --- /dev/null +++ b/ttwhy/terminal/echo.tests.cpp @@ -0,0 +1,257 @@ +#include + +#include + +#include +#include +#include + +import ttwhy.core; +import ttwhy.terminal; + +using namespace std::string_view_literals; + +namespace +{ + struct mock_stream + { + using executor_type = asio::any_io_executor; + + asio::any_io_executor executor; + std::string written{}; + + explicit mock_stream(asio::any_io_executor exec) + : executor{std::move(exec)} + {} + + auto get_executor() const noexcept -> executor_type + { + return executor; + } + + template + auto async_write_some(ConstBufferSequence const & buffers, Token && token) + { + return asio::async_initiate( + [this](auto handler, ConstBufferSequence bufs) { + auto const total = asio::buffer_size(bufs); + auto const offset = written.size(); + written.resize(offset + total); + asio::buffer_copy(asio::buffer(written.data() + offset, total), bufs); + asio::post(executor, [handler = std::move(handler), total]() mutable { + std::move(handler)(asio::error_code{}, total); + }); + }, + token, buffers); + } + }; + + struct incomplete_router_no_process + { + }; + + struct incomplete_router_wrong_return + { + auto process(ttwhy::terminal::input_event) -> void; + }; + + struct incomplete_router_wrong_event + { + auto process(int) -> asio::awaitable; + }; +} // namespace + +TEST_CASE("The router concept validates router candidate interfaces", "[router][concept]") +{ + STATIC_REQUIRE(ttwhy::router, ttwhy::terminal::input_event>); + STATIC_REQUIRE_FALSE(ttwhy::router); + STATIC_REQUIRE_FALSE(ttwhy::router); + STATIC_REQUIRE_FALSE(ttwhy::router); +} + +SCENARIO("The echo router handles terminal navigation within the valid printed area", "[router][echo]") +{ + GIVEN("An initialized echo router with a mock stream") + { + auto context = asio::io_context{}; + auto stream = mock_stream{context.get_executor()}; + auto router = ttwhy::terminal::echo{stream}; + + auto run_coro = [&](asio::awaitable coro) { + asio::co_spawn(context, std::move(coro), asio::detached); + context.restart(); + context.run(); + }; + + WHEN("Typing characters") + { + run_coro(router.process(ttwhy::terminal::character_event{'A'})); + run_coro(router.process(ttwhy::terminal::character_event{'B'})); + + THEN("Characters are echoed and positions advance") + { + CHECK(stream.written == "AB"); + CHECK(router.cursor_position() == 2); + CHECK(router.max_position() == 2); + } + + AND_WHEN("Navigating left") + { + stream.written.clear(); + run_coro(router.process(ttwhy::terminal::navigation_event{ttwhy::terminal::navigation_key::left})); + + THEN("The cursor moves left") + { + CHECK(stream.written == "\x1b[D"); + CHECK(router.cursor_position() == 1); + CHECK(router.max_position() == 2); + } + + AND_WHEN("Navigating left again to position 0") + { + stream.written.clear(); + run_coro(router.process(ttwhy::terminal::navigation_event{ttwhy::terminal::navigation_key::left})); + + THEN("The cursor reaches 0") + { + CHECK(stream.written == "\x1b[D"); + CHECK(router.cursor_position() == 0); + CHECK(router.max_position() == 2); + } + + AND_WHEN("Attempting to navigate left beyond 0") + { + stream.written.clear(); + run_coro(router.process(ttwhy::terminal::navigation_event{ttwhy::terminal::navigation_key::left})); + + THEN("Navigation is prevented and nothing is emitted") + { + CHECK(stream.written.empty()); + CHECK(router.cursor_position() == 0); + CHECK(router.max_position() == 2); + } + } + } + } + + AND_WHEN("Attempting to navigate right beyond what was printed") + { + stream.written.clear(); + run_coro(router.process(ttwhy::terminal::navigation_event{ttwhy::terminal::navigation_key::right})); + + THEN("Navigation is prevented and nothing is emitted") + { + CHECK(stream.written.empty()); + CHECK(router.cursor_position() == 2); + CHECK(router.max_position() == 2); + } + } + + AND_WHEN("Navigating left and then overwriting with new character") + { + run_coro(router.process(ttwhy::terminal::navigation_event{ttwhy::terminal::navigation_key::left})); + stream.written.clear(); + run_coro(router.process(ttwhy::terminal::character_event{'X'})); + + THEN("The new character is written and max position is preserved") + { + CHECK(stream.written == "X"); + CHECK(router.cursor_position() == 2); + CHECK(router.max_position() == 2); + } + } + + AND_WHEN("Using Home and End keys") + { + stream.written.clear(); + run_coro(router.process(ttwhy::terminal::navigation_event{ttwhy::terminal::navigation_key::home})); + + THEN("Home navigates to 0") + { + CHECK(stream.written == "\x1b[2D"); + CHECK(router.cursor_position() == 0); + CHECK(router.max_position() == 2); + } + + AND_WHEN("Pressing Home when already at 0") + { + stream.written.clear(); + run_coro(router.process(ttwhy::terminal::navigation_event{ttwhy::terminal::navigation_key::home})); + + THEN("Nothing is emitted") + { + CHECK(stream.written.empty()); + CHECK(router.cursor_position() == 0); + } + } + + AND_WHEN("Pressing End from 0") + { + stream.written.clear(); + run_coro(router.process(ttwhy::terminal::navigation_event{ttwhy::terminal::navigation_key::end})); + + THEN("End moves to max position") + { + CHECK(stream.written == "\x1b[2C"); + CHECK(router.cursor_position() == 2); + CHECK(router.max_position() == 2); + } + } + } + + AND_WHEN("Pressing Enter") + { + stream.written.clear(); + run_coro(router.process(ttwhy::terminal::control_event{ttwhy::terminal::control_key::enter})); + + THEN("Newline is emitted and positions reset") + { + CHECK(stream.written == "\r\n"); + CHECK(router.cursor_position() == 0); + CHECK(router.max_position() == 0); + } + } + + AND_WHEN("Pressing Backspace at position 0 after reset") + { + run_coro(router.process(ttwhy::terminal::control_event{ttwhy::terminal::control_key::enter})); + stream.written.clear(); + run_coro(router.process(ttwhy::terminal::control_event{ttwhy::terminal::control_key::backspace})); + + THEN("Backspace does nothing at position 0") + { + CHECK(stream.written.empty()); + CHECK(router.cursor_position() == 0); + CHECK(router.max_position() == 0); + } + } + + AND_WHEN("Using Delete key") + { + run_coro(router.process(ttwhy::terminal::navigation_event{ttwhy::terminal::navigation_key::left})); + stream.written.clear(); + run_coro(router.process(ttwhy::terminal::navigation_event{ttwhy::terminal::navigation_key::delete_key})); + + THEN("Delete character sequence is emitted and max position shrinks") + { + CHECK(stream.written == "\x1b[P"); + CHECK(router.cursor_position() == 1); + CHECK(router.max_position() == 1); + } + + AND_WHEN("Pressing Delete when cursor is at max position") + { + stream.written.clear(); + run_coro(router.process(ttwhy::terminal::navigation_event{ttwhy::terminal::navigation_key::delete_key})); + + THEN("Nothing is emitted") + { + CHECK(stream.written.empty()); + CHECK(router.cursor_position() == 1); + CHECK(router.max_position() == 1); + } + } + } + } + } +} diff --git a/ttwhy/terminal/events.cppm b/ttwhy/terminal/events.cppm new file mode 100644 index 0000000..93b2326 --- /dev/null +++ b/ttwhy/terminal/events.cppm @@ -0,0 +1,107 @@ +module; + +#include +#include +#include + +export module ttwhy.terminal:events; + +import ttwhy.core; + +export namespace ttwhy::terminal +{ + enum struct control_key : std::uint8_t + { + enter, + escape, + tab, + backspace, + }; + + enum struct navigation_key : std::uint8_t + { + up, + down, + left, + right, + home, + end, + delete_key, + insert_key, + page_up, + page_down, + }; + + enum struct modifier : std::uint8_t + { + none = 0, + shift = 1, + alt = 2, + ctrl = 4, + meta = 8, + }; + + enum struct modifier_set : std::uint8_t + { + }; + + constexpr auto operator|(modifier lhs, modifier rhs) -> modifier_set + { + return static_cast(std::to_underlying(lhs) | std::to_underlying(rhs)); + } + + constexpr auto operator|(modifier_set lhs, modifier rhs) -> modifier_set + { + return static_cast(std::to_underlying(lhs) | std::to_underlying(rhs)); + } + + constexpr auto operator&(modifier lhs, modifier rhs) -> modifier_set + { + return static_cast(std::to_underlying(lhs) & std::to_underlying(rhs)); + } + + constexpr auto operator&(modifier_set lhs, modifier rhs) -> modifier_set + { + return static_cast(std::to_underlying(lhs) & std::to_underlying(rhs)); + } + + template... Modifier> + constexpr auto has_all(modifier_set mask, Modifier... flag) -> bool + { + return (true && ... && (mask & flag)); + } + + template... Modifier> + constexpr auto has_any(modifier_set mask, Modifier... flag) -> bool + { + return (false || ... || (mask & flag)); + } + + struct character_event + { + char value{}; + }; + + struct control_event + { + control_key key{}; + }; + + struct navigation_event + { + navigation_key key{}; + modifier_set modifiers{}; + }; + + struct ctrl_chord_event + { + char key{}; + }; + + using input_event = std::variant; + + template + concept event_sink = ttwhy::event_sink && ttwhy::event_sink && + ttwhy::event_sink; + +} // namespace ttwhy::terminal diff --git a/ttwhy/terminal/mod.cppm b/ttwhy/terminal/mod.cppm new file mode 100644 index 0000000..2608c14 --- /dev/null +++ b/ttwhy/terminal/mod.cppm @@ -0,0 +1,9 @@ +export module ttwhy.terminal; + +export import :echo; +export import :events; +export import :policies; +export import :readers; +export import :scanner; +export import :scoped_attributes; +export import :signal_handling; diff --git a/ttwhy/terminal/policies.cppm b/ttwhy/terminal/policies.cppm new file mode 100644 index 0000000..acf9371 --- /dev/null +++ b/ttwhy/terminal/policies.cppm @@ -0,0 +1,158 @@ +module; + +#include +#include + +export module ttwhy.terminal:policies; + +import :events; + +namespace ttwhy::terminal +{ + + namespace detail + { + constexpr auto map_vt220_keypad(char terminator) -> std::optional + { + switch (terminator) + { + case '1': + case '7': + return navigation_key::home; + case '2': + return navigation_key::insert_key; + case '3': + return navigation_key::delete_key; + case '4': + case '8': + return navigation_key::end; + case '5': + return navigation_key::page_up; + case '6': + return navigation_key::page_down; + default: + return std::nullopt; + } + } + + constexpr auto map_vt100_cursor(char terminator) -> std::optional + { + switch (terminator) + { + case 'A': + return navigation_key::up; + case 'B': + return navigation_key::down; + case 'C': + return navigation_key::right; + case 'D': + return navigation_key::left; + case 'H': + return navigation_key::home; + case 'F': + return navigation_key::end; + default: + return std::nullopt; + } + } + + } // namespace detail + + export struct ansi_policy + { + constexpr auto static resolve_vt220_keypad(std::string & buffer, event_sink auto & sink) -> void + { + if (buffer.empty()) + { + return; + } + + if (auto key = detail::map_vt220_keypad(buffer.front())) + { + sink(navigation_event{*key}); + } + buffer.clear(); + } + + constexpr auto static resolve_vt100_cursor(char terminator, std::string &, event_sink auto & sink) -> void + { + if (auto key = detail::map_vt100_cursor(terminator)) + { + sink(navigation_event{*key}); + } + } + + constexpr auto static resolve_ss3(char terminator, event_sink auto & sink) -> void + { + if (auto key = detail::map_vt100_cursor(terminator)) + { + sink(navigation_event{*key}); + } + } + }; + + export struct xterm_policy + { + constexpr auto static parse_modifiers(std::string_view buffer) -> modifier_set + { + auto delimiter = buffer.find(';'); + if (delimiter == std::string_view::npos) + { + return {}; + } + + auto modifier_string = buffer.substr(delimiter + 1); + auto modifier_value = 0u; + + for (auto character : modifier_string) + { + if (character >= '0' && character <= '9') + { + modifier_value = modifier_value * 10 + static_cast(character - '0'); + } + } + + if (modifier_value <= 1) + { + return {}; + } + + return static_cast(modifier_value - 1); + } + + constexpr auto static resolve_vt220_keypad(std::string & buffer, event_sink auto & sink) -> void + { + if (buffer.empty()) + { + return; + } + + auto modifiers = parse_modifiers(buffer); + + if (auto key = detail::map_vt220_keypad(buffer.front())) + { + sink(navigation_event{*key, modifiers}); + } + buffer.clear(); + } + + constexpr auto static resolve_vt100_cursor(char terminator, std::string & buffer, event_sink auto & sink) -> void + { + auto modifiers = parse_modifiers(buffer); + + if (auto key = detail::map_vt100_cursor(terminator)) + { + sink(navigation_event{*key, modifiers}); + } + } + + constexpr auto static resolve_ss3(char terminator, event_sink auto & sink) -> void + { + if (auto key = detail::map_vt100_cursor(terminator)) + { + sink(navigation_event{*key}); + } + } + }; + +} // namespace ttwhy::terminal diff --git a/ttwhy/terminal/readers.cppm b/ttwhy/terminal/readers.cppm new file mode 100644 index 0000000..ffa5687 --- /dev/null +++ b/ttwhy/terminal/readers.cppm @@ -0,0 +1,94 @@ +module; + +#include +#include + +#include +#include +#include +#include + +export module ttwhy.terminal:readers; + +import :events; +import :policies; +import :scanner; +import ttwhy.core; + +namespace ttwhy::terminal +{ + + export template AppRouter> + auto read_events(InputStream & stream, AppRouter & router) -> asio::awaitable + { + using namespace asio::experimental::awaitable_operators; + using namespace std::chrono_literals; + + auto executor = co_await asio::this_coro::executor; + auto timer = asio::steady_timer{executor}; + + auto queue = std::vector{}; + queue.reserve(16); + + auto sink = [&queue](auto const & event) { + queue.push_back(event); + }; + + auto scanner = terminal::scanner{sink}; + + auto raw_buffer = std::array{}; + + while (true) + { + auto error = asio::error_code{}; + auto bytes_read = 0uz; + + if (scanner.is_pending()) + { + timer.expires_after(50ms); + + auto result = co_await (stream.async_read_some(asio::buffer(raw_buffer), asio::as_tuple(asio::use_awaitable)) || + timer.async_wait(asio::as_tuple(asio::use_awaitable))); + + if (result.index() == 0) + { + std::tie(error, bytes_read) = std::get<0>(result); + } + else + { + scanner.timeout(); + for (auto const & event : queue) + { + co_await router.process(event); + } + queue.clear(); + continue; + } + } + else + { + std::tie(error, bytes_read) = + co_await stream.async_read_some(asio::buffer(raw_buffer), asio::as_tuple(asio::use_awaitable)); + } + + if (error) + { + if (error == asio::error::interrupted) + { + continue; + } + co_return; + } + + auto const byte_span = std::span{raw_buffer.data(), bytes_read}; + scanner.process(byte_span); + + for (auto const & event : queue) + { + co_await router.process(event); + } + queue.clear(); + } + } + +} // namespace ttwhy::terminal diff --git a/ttwhy/terminal/scanner.cppm b/ttwhy/terminal/scanner.cppm new file mode 100644 index 0000000..8a722b4 --- /dev/null +++ b/ttwhy/terminal/scanner.cppm @@ -0,0 +1,236 @@ +module; + +#include +#include + +#include +#include +#include + +export module ttwhy.terminal:scanner; + +import :events; +import :policies; +import ttwhy.core; + +namespace ttwhy::terminal::detail +{ + + /// Events + + struct byte_received + { + char value; + }; + + struct c1_received + { + unsigned char value; + }; + + struct timeout_expired + { + }; + + /// States + + constexpr auto idle = boost::sml::state; + constexpr auto escape_sequence = boost::sml::state; + constexpr auto csi_sequence = boost::sml::state; + constexpr auto ss3_sequence = boost::sml::state; + + /// Guards + + constexpr auto is_backspace = [](byte_received e) { + return e.value == '\x08' || e.value == '\x7f'; + }; + + constexpr auto is_tab = [](byte_received e) { + return e.value == '\x09'; + }; + + constexpr auto is_enter = [](byte_received e) { + return e.value == '\x0a' || e.value == '\x0d'; + }; + + constexpr auto is_escape = [](byte_received e) { + return e.value == '\x1b'; + }; + + constexpr auto is_printable = [](byte_received e) { + return e.value >= ' ' && e.value <= '~'; + }; + + constexpr auto is_c0_chord = [](byte_received e) { + return e.value >= '\0' && e.value <= '\x1f' && !is_backspace(e) && !is_tab(e) && !is_enter(e) && !is_escape(e); + }; + + constexpr auto is_fe = [](byte_received e) { + return e.value >= '@' && e.value <= '_'; + }; + + constexpr auto is_csi = [](c1_received e) { + return e.value == u'\x9b'; + }; + + constexpr auto is_ss3 = [](c1_received e) { + return e.value == u'\x8f'; + }; + + constexpr auto is_unhandled_c1 = [](c1_received e) { + return !(is_csi(e) || is_ss3(e)); + }; + + constexpr auto is_csi_param = [](byte_received e) { + return e.value >= ' ' && e.value <= '?'; + }; + + constexpr auto is_vt100_terminator = [](byte_received e) { + return e.value >= '@' && e.value <= '}'; + }; + + constexpr auto is_vt220_terminator = [](byte_received e) { + return e.value == '~'; + }; + + /// Transitions + + template + struct transition_table + { + auto operator()() const noexcept + { + using namespace boost::sml; + + constexpr auto push_character = [](byte_received event, Sink & sink) { + sink(character_event{event.value}); + }; + + constexpr auto push_backspace = [](Sink & sink) { + sink(control_event{control_key::backspace}); + }; + + constexpr auto push_tab = [](Sink & sink) { + sink(control_event(control_key::tab)); + }; + + constexpr auto push_enter = [](Sink & sink) { + sink(control_event(control_key::enter)); + }; + + constexpr auto push_c0_chord = [](byte_received event, Sink & sink) { + sink(ctrl_chord_event{static_cast(event.value + 0x40)}); + }; + + constexpr auto dispatch_c1 = [](byte_received event, back::process process) { + process(c1_received{static_cast(event.value + 0x40)}); + }; + + constexpr auto fallback_fe = [](c1_received event, Sink & sink) { + sink(control_event{control_key::escape}); + sink(character_event{static_cast(event.value - 0x40)}); + }; + + constexpr auto emit_timeout_escape = [](Sink & sink, std::string & buffer) { + sink(control_event{control_key::escape}); + buffer.clear(); + }; + + constexpr auto fallback_escape = [](byte_received event, Sink & sink) { + sink(control_event{control_key::escape}); + if (event.value >= ' ' && event.value <= '~') + { + sink(character_event{event.value}); + } + }; + + constexpr auto clear_csi = [](std::string & buffer) { + buffer.clear(); + }; + + constexpr auto push_csi_parameter = [](byte_received const & event, std::string & buffer) { + buffer.push_back(event.value); + }; + + constexpr auto resolve_vt100_cursor = [](byte_received event, std::string & buffer, Sink & sink) { + TerminalPolicy::resolve_vt100_cursor(event.value, buffer, sink); + buffer.clear(); + }; + + constexpr auto resolve_vt220_keypad = [](std::string & buffer, Sink & sink) { + TerminalPolicy::resolve_vt220_keypad(buffer, sink); + buffer.clear(); + }; + + constexpr auto resolve_ss3 = [](byte_received event, Sink & sink) { + TerminalPolicy::resolve_ss3(event.value, sink); + }; + + // clang-format off + return make_transition_table( + *idle + event[is_escape] = escape_sequence, + idle + event[is_backspace] / push_backspace = idle, + idle + event[is_tab] / push_tab = idle, + idle + event[is_enter] / push_enter = idle, + idle + event[is_c0_chord] / push_c0_chord = idle, + idle + event[is_printable] / push_character = idle, + + escape_sequence + event[is_fe] / dispatch_c1 = idle, + escape_sequence + event[!is_fe] / fallback_escape = idle, + escape_sequence + event / emit_timeout_escape = idle, + + idle + event[is_csi] / clear_csi = csi_sequence, + idle + event[is_ss3] = ss3_sequence, + idle + event[is_unhandled_c1] / fallback_fe = idle, + + csi_sequence + event[is_csi_param] / push_csi_parameter = csi_sequence, + csi_sequence + event[is_vt220_terminator] / resolve_vt220_keypad = idle, + csi_sequence + event[is_vt100_terminator] / resolve_vt100_cursor = idle, + csi_sequence + event / emit_timeout_escape = idle, + + ss3_sequence + event / resolve_ss3 = idle, + ss3_sequence + event / emit_timeout_escape = idle + ); + // clang-format on + } + }; + +} // namespace ttwhy::terminal::detail + +export namespace ttwhy::terminal +{ + + template + struct scanner + { + explicit scanner(Sink & sink) + : m_state_machine{sink, m_csi_buffer} + {} + + auto process(std::span buffer) -> void + { + std::ranges::for_each(buffer, [&](auto byte) { m_state_machine.process_event(detail::byte_received{byte}); }); + } + + auto timeout() + { + m_state_machine.process_event(detail::timeout_expired{}); + } + + [[nodiscard]] auto is_pending() const -> bool + { + return m_state_machine.is(detail::escape_sequence) || // + m_state_machine.is(detail::csi_sequence) || // + m_state_machine.is(detail::ss3_sequence); + } + + private: + std::string m_csi_buffer{}; + boost::sml::sm, boost::sml::process_queue> + m_state_machine; + }; + + template + using ansi = scanner; + +} // namespace ttwhy::terminal diff --git a/ttwhy/terminal/scanner.tests.cpp b/ttwhy/terminal/scanner.tests.cpp new file mode 100644 index 0000000..64acb2d --- /dev/null +++ b/ttwhy/terminal/scanner.tests.cpp @@ -0,0 +1,101 @@ +#include + +#include +#include +#include + +import ttwhy.core; +import ttwhy.terminal; + +using namespace std::string_view_literals; + +[[nodiscard]] constexpr auto static is_character(ttwhy::terminal::input_event & event, char expected) -> bool +{ + auto const * data = std::get_if(&event); + return data != nullptr && data->value == expected; +} + +[[nodiscard]] constexpr auto static is_control(ttwhy::terminal::input_event & event, + ttwhy::terminal::control_key expected) -> bool +{ + auto const * data = std::get_if(&event); + return data != nullptr && data->key == expected; +} + +SCENARIO("The ANSI scanner processes printable ASCII and standard C0 control characters", "[scanner][ansi]") +{ + GIVEN("An initialized scanner and event sink") + { + auto queue = std::vector{}; + auto sink = [&queue](auto const & event) { + queue.push_back(event); + }; + auto scanner = ttwhy::terminal::ansi{sink}; + + WHEN("Processing a standard printable character") + { + scanner.process("A"sv); + + THEN("It yields a single character event") + { + REQUIRE(queue.size() == 1); + CHECK(is_character(queue.at(0), 'A')); + } + } + + WHEN("Processing a BS byte (\\x08)") + { + scanner.process("\x08"sv); + + THEN("It yields a backspace control event") + { + REQUIRE(queue.size() == 1); + CHECK(is_control(queue.at(0), ttwhy::terminal::control_key::backspace)); + } + } + + WHEN("Processing a DEL byte (\\x7f as BS)") + { + scanner.process("\x7f"sv); + + THEN("It yields a backspace control event") + { + REQUIRE(queue.size() == 1); + CHECK(is_control(queue.at(0), ttwhy::terminal::control_key::backspace)); + } + } + + WHEN("Processing a TAB byte") + { + scanner.process("\x09"sv); + + THEN("It yields a tab control event") + { + REQUIRE(queue.size() == 1); + CHECK(is_control(queue.at(0), ttwhy::terminal::control_key::tab)); + } + } + + WHEN("Processing an LF byte") + { + scanner.process("\x0a"sv); + + THEN("It yields an enter control event") + { + REQUIRE(queue.size() == 1); + CHECK(is_control(queue.at(0), ttwhy::terminal::control_key::enter)); + } + } + + WHEN("Processing an CR byte") + { + scanner.process("\x0d"sv); + + THEN("It yields an enter control event") + { + REQUIRE(queue.size() == 1); + CHECK(is_control(queue.at(0), ttwhy::terminal::control_key::enter)); + } + } + } +} diff --git a/ttwhy/terminal/scoped_attributes.cppm b/ttwhy/terminal/scoped_attributes.cppm new file mode 100644 index 0000000..2ca34a3 --- /dev/null +++ b/ttwhy/terminal/scoped_attributes.cppm @@ -0,0 +1,135 @@ +module; + +#include +#include + +#include +#include +#include + +export module ttwhy.terminal:scoped_attributes; + +namespace ttwhy::terminal +{ + + auto static read_attributes(int file_descriptor) -> termios + { + auto active_attributes = termios{}; + if (::tcgetattr(file_descriptor, &active_attributes)) + { + throw std::system_error(errno, std::system_category(), "failed to read termios attributes"); + } + return active_attributes; + } + + auto static get_line_discipline_flag(int file_descriptor, int flag) -> bool + { + auto active_attributes = read_attributes(file_descriptor); + return active_attributes.c_lflag & flag; + } + + auto static set_line_discipline_flag(int file_descriptor, int flag, bool enabled) -> void + { + auto active_attributes = read_attributes(file_descriptor); + + if ((active_attributes.c_lflag & flag) == enabled) + { + return; + } + + if (enabled) + { + active_attributes.c_lflag = active_attributes.c_lflag | flag; + } + else + { + active_attributes.c_lflag = active_attributes.c_lflag & ~flag; + } + + if (::tcsetattr(file_descriptor, TCSANOW, &active_attributes)) + { + throw std::system_error(errno, std::system_category(), "failed to write termios attributes"); + } + } + + //! Store the current TC attributes and restore them on destruction. + export struct scoped_attributes + { + explicit scoped_attributes(int file_descriptor) + : m_file_descriptor{-1} + , m_attributes{} + { + if (isatty(file_descriptor)) + { + m_file_descriptor = dup(file_descriptor); + m_attributes = read_attributes(m_file_descriptor); + } + } + + scoped_attributes(scoped_attributes const &) = delete; + + scoped_attributes(scoped_attributes && other) + : m_file_descriptor{std::exchange(other.m_file_descriptor, -1)} + , m_attributes{std::move(other.m_attributes)} + {} + + ~scoped_attributes() + { + if (m_file_descriptor > 0) + { + ::tcsetattr(m_file_descriptor, TCSANOW, &m_attributes); + ::close(m_file_descriptor); + } + } + + auto operator=(scoped_attributes const &) -> scoped_attributes & = delete; + + auto operator=(scoped_attributes && other) -> scoped_attributes & + { + std::ranges::swap(m_file_descriptor, other.m_file_descriptor); + std::ranges::swap(m_attributes, other.m_attributes); + return *this; + } + + //! Check if canonical mode is currently active + [[nodiscard]] auto canonical_mode() const noexcept -> bool + { + return get_line_discipline_flag(m_file_descriptor, ICANON); + } + + //! Set whether canonical mode is active or not. + auto canonical_mode(bool enabled) & -> scoped_attributes & + { + set_line_discipline_flag(m_file_descriptor, ICANON, enabled); + return *this; + } + + auto canonical_mode(bool enabled) && -> scoped_attributes + { + set_line_discipline_flag(m_file_descriptor, ICANON, enabled); + return std::move(*this); + } + + [[nodiscard]] auto echo() const noexcept -> bool + { + return get_line_discipline_flag(m_file_descriptor, ECHO); + } + + auto echo(bool enabled) & -> scoped_attributes & + { + set_line_discipline_flag(m_file_descriptor, ECHO, enabled); + return *this; + } + + auto echo(bool enabled) && -> scoped_attributes + { + set_line_discipline_flag(m_file_descriptor, ECHO, enabled); + return std::move(*this); + } + + private: + int m_file_descriptor; + termios m_attributes; + }; + +} // namespace ttwhy::terminal diff --git a/ttwhy/terminal/signal_handling.cppm b/ttwhy/terminal/signal_handling.cppm new file mode 100644 index 0000000..a3b5d4d --- /dev/null +++ b/ttwhy/terminal/signal_handling.cppm @@ -0,0 +1,58 @@ +module; + +#include + +#include +#include +#include + +export module ttwhy.terminal:signal_handling; + +namespace ttwhy::terminal +{ + + export template + auto handle_signals(Stream & stream, TerminalAttributes & attributes) -> asio::awaitable + { + auto executor = co_await asio::this_coro::executor; + auto signals = asio::signal_set{executor, SIGINT, SIGTERM}; + signals.add(SIGTSTP); + signals.add(SIGCONT); + + while (true) + { + auto [error, signal] = co_await signals.async_wait(asio::as_tuple(asio::use_awaitable)); + if (error) + { + co_return error; + } + + switch (signal) + { + case SIGINT: + case SIGTERM: + { + auto message = std::format("Received signal {}, exiting ...\n", signal); + co_await asio::async_write(stream, asio::buffer(message), asio::use_awaitable); + co_return asio::error_code{}; + } + case SIGTSTP: + { + attributes.canonical_mode(true).echo(true); + ::raise(SIGSTOP); + break; + } + case SIGCONT: + { + attributes.canonical_mode(false).echo(false); + break; + } + } + + if (!error) + { + } + } + } + +} // namespace ttwhy::terminal -- cgit v1.2.3