aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorFelix Morgner <felix.morgner@gmail.com>2026-09-22 15:46:22 +0200
committerFelix Morgner <felix.morgner@gmail.com>2026-09-22 15:46:22 +0200
commitdb2cf722c5e5b5399c8163a1d77cccf963c6fa78 (patch)
tree2f62337591b5402fd0a932a4b137db980b7e8d89
parent10e049303048028577215157dd1fc87543ca2871 (diff)
downloadttwhy-db2cf722c5e5b5399c8163a1d77cccf963c6fa78.tar.xz
ttwhy-db2cf722c5e5b5399c8163a1d77cccf963c6fa78.zip
lib/routers: handle navigation keys in echo router
-rw-r--r--CMakeLists.txt1
-rw-r--r--ttwhy/routers/echo.cppm140
-rw-r--r--ttwhy/routers/echo.tests.cpp235
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);
+ }
+ }
+ }
+ }
+ }
+}