diff options
| author | Felix Morgner <felix.morgner@gmail.com> | 2026-09-22 15:46:22 +0200 |
|---|---|---|
| committer | Felix Morgner <felix.morgner@gmail.com> | 2026-09-22 15:46:22 +0200 |
| commit | db2cf722c5e5b5399c8163a1d77cccf963c6fa78 (patch) | |
| tree | 2f62337591b5402fd0a932a4b137db980b7e8d89 | |
| parent | 10e049303048028577215157dd1fc87543ca2871 (diff) | |
| download | ttwhy-db2cf722c5e5b5399c8163a1d77cccf963c6fa78.tar.xz ttwhy-db2cf722c5e5b5399c8163a1d77cccf963c6fa78.zip | |
lib/routers: handle navigation keys in echo router
| -rw-r--r-- | CMakeLists.txt | 1 | ||||
| -rw-r--r-- | ttwhy/routers/echo.cppm | 140 | ||||
| -rw-r--r-- | ttwhy/routers/echo.tests.cpp | 235 |
3 files changed, 358 insertions, 18 deletions
diff --git a/CMakeLists.txt b/CMakeLists.txt index 71b27c6..84d89a3 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -112,6 +112,7 @@ 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" ) diff --git a/ttwhy/routers/echo.cppm b/ttwhy/routers/echo.cppm index 7f2550e..6fe60cb 100644 --- a/ttwhy/routers/echo.cppm +++ b/ttwhy/routers/echo.cppm @@ -2,6 +2,9 @@ module; #include <asio.hpp> +#include <algorithm> + +#include <cstddef> #include <format> #include <string_view> @@ -17,7 +20,12 @@ namespace ttwhy::routers constexpr auto vte_backspace_sequence = "\b \b"sv; constexpr auto vte_newline_sequence = "\r\n"sv; - constexpr auto vte_horizontal_tab_sequqnce = "\t"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<typename StreamType> struct echo @@ -28,6 +36,16 @@ namespace ttwhy::routers : 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<void> { co_await std::visit( @@ -53,7 +71,13 @@ namespace ttwhy::routers private: auto process_character(char character) -> asio::awaitable<void> { - co_await asio::async_write(m_output_stream, asio::buffer(&character, 1), asio::as_tuple(asio::use_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<void> @@ -61,17 +85,43 @@ namespace ttwhy::routers switch (key) { case scanners::control_key::backspace: - co_await asio::async_write(m_output_stream, asio::buffer(vte_backspace_sequence), - asio::as_tuple(asio::use_awaitable)); + 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: - co_await asio::async_write(m_output_stream, asio::buffer(vte_newline_sequence), - asio::as_tuple(asio::use_awaitable)); + { + 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: - co_await asio::async_write(m_output_stream, asio::buffer(vte_horizontal_tab_sequqnce), - asio::as_tuple(asio::use_awaitable)); + { + 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)); @@ -81,25 +131,79 @@ namespace ttwhy::routers auto process_navigation_key(scanners::navigation_event event) -> asio::awaitable<void> { - auto modifiers = std::to_underlying(event.modifiers); - 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: - { - auto message = std::format("{{DEL, MODS:{}}}", static_cast<int>(modifiers)); - co_await asio::async_write(m_output_stream, asio::buffer(message), asio::as_tuple(asio::use_awaitable)); + 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: - { - auto message = std::format("{{NAV:{}, MODS:{}}}", static_cast<int>(event.key), static_cast<int>(modifiers)); - co_await asio::async_write(m_output_stream, asio::buffer(message), asio::as_tuple(asio::use_awaitable)); - } + 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 new file mode 100644 index 0000000..f12d08b --- /dev/null +++ b/ttwhy/routers/echo.tests.cpp @@ -0,0 +1,235 @@ +#include <asio.hpp> + +#include <catch2/catch_test_macros.hpp> + +#include <string> +#include <string_view> +#include <utility> + +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<typename ConstBufferSequence, typename Token> + auto async_write_some(ConstBufferSequence const & buffers, Token && token) + { + return asio::async_initiate<Token, void(asio::error_code, std::size_t)>( + [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); + } + }; +} // namespace + +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<void> 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); + } + } + } + } + } +} |
