aboutsummaryrefslogtreecommitdiff
path: root/ttwhy/terminal
diff options
context:
space:
mode:
authorFelix Morgner <felix.morgner@gmail.com>2026-09-22 22:47:33 +0200
committerFelix Morgner <felix.morgner@gmail.com>2026-09-22 22:47:33 +0200
commitd05b9343f69a0841043963088e0003368e205bd9 (patch)
tree8cde07ff86d27cbc808a0a149b89d0024b4a586c /ttwhy/terminal
parent22f6534081acfabdab4956d337604a21e7da5b64 (diff)
downloadttwhy-master.tar.xz
ttwhy-master.zip
lib: restructure into vertical slicesHEADmaster
Diffstat (limited to 'ttwhy/terminal')
-rw-r--r--ttwhy/terminal/echo.cppm207
-rw-r--r--ttwhy/terminal/echo.tests.cpp257
-rw-r--r--ttwhy/terminal/events.cppm107
-rw-r--r--ttwhy/terminal/mod.cppm9
-rw-r--r--ttwhy/terminal/policies.cppm158
-rw-r--r--ttwhy/terminal/readers.cppm94
-rw-r--r--ttwhy/terminal/scanner.cppm236
-rw-r--r--ttwhy/terminal/scanner.tests.cpp101
-rw-r--r--ttwhy/terminal/scoped_attributes.cppm135
-rw-r--r--ttwhy/terminal/signal_handling.cppm58
10 files changed, 1362 insertions, 0 deletions
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 <asio.hpp>
+
+#include <algorithm>
+
+#include <cstddef>
+#include <format>
+#include <string_view>
+
+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<typename StreamType>
+ 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<void>
+ {
+ co_await std::visit(
+ [&](auto && event) -> asio::awaitable<void> {
+ using event_type = std::decay_t<decltype(event)>;
+
+ if constexpr (std::same_as<character_event, event_type>)
+ {
+ co_await process_character(event.value);
+ }
+ else if constexpr (std::same_as<control_event, event_type>)
+ {
+ co_await process_control_key(event.key);
+ }
+ else if constexpr (std::same_as<navigation_event, event_type>)
+ {
+ co_await process_navigation_key(event);
+ }
+ },
+ event);
+ }
+
+ private:
+ auto process_character(char character) -> asio::awaitable<void>
+ {
+ 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<void>
+ {
+ 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<void>
+ {
+ 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 <asio.hpp>
+
+#include <catch2/catch_test_macros.hpp>
+
+#include <string>
+#include <string_view>
+#include <utility>
+
+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<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);
+ }
+ };
+
+ 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<void>;
+ };
+} // namespace
+
+TEST_CASE("The router concept validates router candidate interfaces", "[router][concept]")
+{
+ STATIC_REQUIRE(ttwhy::router<ttwhy::terminal::echo<mock_stream>, ttwhy::terminal::input_event>);
+ STATIC_REQUIRE_FALSE(ttwhy::router<incomplete_router_no_process, ttwhy::terminal::input_event>);
+ STATIC_REQUIRE_FALSE(ttwhy::router<incomplete_router_wrong_return, ttwhy::terminal::input_event>);
+ STATIC_REQUIRE_FALSE(ttwhy::router<incomplete_router_wrong_event, ttwhy::terminal::input_event>);
+}
+
+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<void> 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 <cstdint>
+#include <utility>
+#include <variant>
+
+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<modifier_set>(std::to_underlying(lhs) | std::to_underlying(rhs));
+ }
+
+ constexpr auto operator|(modifier_set lhs, modifier rhs) -> modifier_set
+ {
+ return static_cast<modifier_set>(std::to_underlying(lhs) | std::to_underlying(rhs));
+ }
+
+ constexpr auto operator&(modifier lhs, modifier rhs) -> modifier_set
+ {
+ return static_cast<modifier_set>(std::to_underlying(lhs) & std::to_underlying(rhs));
+ }
+
+ constexpr auto operator&(modifier_set lhs, modifier rhs) -> modifier_set
+ {
+ return static_cast<modifier_set>(std::to_underlying(lhs) & std::to_underlying(rhs));
+ }
+
+ template<std::same_as<modifier>... Modifier>
+ constexpr auto has_all(modifier_set mask, Modifier... flag) -> bool
+ {
+ return (true && ... && (mask & flag));
+ }
+
+ template<std::same_as<modifier>... 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<character_event, control_event, navigation_event, ctrl_chord_event>;
+
+ template<typename Sink>
+ concept event_sink = ttwhy::event_sink<Sink, character_event> && ttwhy::event_sink<Sink, control_event> &&
+ ttwhy::event_sink<Sink, navigation_event>;
+
+} // 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 <optional>
+#include <string>
+
+export module ttwhy.terminal:policies;
+
+import :events;
+
+namespace ttwhy::terminal
+{
+
+ namespace detail
+ {
+ constexpr auto map_vt220_keypad(char terminator) -> std::optional<navigation_key>
+ {
+ 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<navigation_key>
+ {
+ 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<unsigned int>(character - '0');
+ }
+ }
+
+ if (modifier_value <= 1)
+ {
+ return {};
+ }
+
+ return static_cast<modifier_set>(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 <asio.hpp>
+#include <asio/experimental/awaitable_operators.hpp>
+
+#include <array>
+#include <chrono>
+#include <span>
+#include <vector>
+
+export module ttwhy.terminal:readers;
+
+import :events;
+import :policies;
+import :scanner;
+import ttwhy.core;
+
+namespace ttwhy::terminal
+{
+
+ export template<typename TerminalPolicy = xterm_policy, typename InputStream, ttwhy::router<input_event> AppRouter>
+ auto read_events(InputStream & stream, AppRouter & router) -> asio::awaitable<void>
+ {
+ 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<input_event>{};
+ queue.reserve(16);
+
+ auto sink = [&queue](auto const & event) {
+ queue.push_back(event);
+ };
+
+ auto scanner = terminal::scanner<decltype(sink), TerminalPolicy>{sink};
+
+ auto raw_buffer = std::array<char, 64>{};
+
+ 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<char const>{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 <algorithm>
+#include <boost/sml.hpp>
+
+#include <queue>
+#include <span>
+#include <string>
+
+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<class idle>;
+ constexpr auto escape_sequence = boost::sml::state<class escape_sequence>;
+ constexpr auto csi_sequence = boost::sml::state<class csi_sequence>;
+ constexpr auto ss3_sequence = boost::sml::state<class ss3_sequence>;
+
+ /// 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<event_sink Sink, typename TerminalPolicy>
+ 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<char>(event.value + 0x40)});
+ };
+
+ constexpr auto dispatch_c1 = [](byte_received event, back::process<c1_received> process) {
+ process(c1_received{static_cast<unsigned char>(event.value + 0x40)});
+ };
+
+ constexpr auto fallback_fe = [](c1_received event, Sink & sink) {
+ sink(control_event{control_key::escape});
+ sink(character_event{static_cast<char>(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<byte_received>[is_escape] = escape_sequence,
+ idle + event<byte_received>[is_backspace] / push_backspace = idle,
+ idle + event<byte_received>[is_tab] / push_tab = idle,
+ idle + event<byte_received>[is_enter] / push_enter = idle,
+ idle + event<byte_received>[is_c0_chord] / push_c0_chord = idle,
+ idle + event<byte_received>[is_printable] / push_character = idle,
+
+ escape_sequence + event<byte_received>[is_fe] / dispatch_c1 = idle,
+ escape_sequence + event<byte_received>[!is_fe] / fallback_escape = idle,
+ escape_sequence + event<timeout_expired> / emit_timeout_escape = idle,
+
+ idle + event<c1_received>[is_csi] / clear_csi = csi_sequence,
+ idle + event<c1_received>[is_ss3] = ss3_sequence,
+ idle + event<c1_received>[is_unhandled_c1] / fallback_fe = idle,
+
+ csi_sequence + event<byte_received>[is_csi_param] / push_csi_parameter = csi_sequence,
+ csi_sequence + event<byte_received>[is_vt220_terminator] / resolve_vt220_keypad = idle,
+ csi_sequence + event<byte_received>[is_vt100_terminator] / resolve_vt100_cursor = idle,
+ csi_sequence + event<timeout_expired> / emit_timeout_escape = idle,
+
+ ss3_sequence + event<byte_received> / resolve_ss3 = idle,
+ ss3_sequence + event<timeout_expired> / emit_timeout_escape = idle
+ );
+ // clang-format on
+ }
+ };
+
+} // namespace ttwhy::terminal::detail
+
+export namespace ttwhy::terminal
+{
+
+ template<event_sink Sink, typename TerminalPolicy>
+ struct scanner
+ {
+ explicit scanner(Sink & sink)
+ : m_state_machine{sink, m_csi_buffer}
+ {}
+
+ auto process(std::span<char const> 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<detail::transition_table<Sink, TerminalPolicy>, boost::sml::process_queue<std::queue>>
+ m_state_machine;
+ };
+
+ template<event_sink Sink>
+ using ansi = scanner<Sink, ansi_policy>;
+
+} // 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 <catch2/catch_test_macros.hpp>
+
+#include <string_view>
+#include <variant>
+#include <vector>
+
+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<ttwhy::terminal::character_event>(&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<ttwhy::terminal::control_event>(&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<ttwhy::terminal::input_event>{};
+ 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 <termios.h>
+#include <unistd.h>
+
+#include <cerrno>
+#include <system_error>
+#include <utility>
+
+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 <asio.hpp>
+
+#include <csignal>
+#include <format>
+#include <system_error>
+
+export module ttwhy.terminal:signal_handling;
+
+namespace ttwhy::terminal
+{
+
+ export template<typename Stream, typename TerminalAttributes>
+ auto handle_signals(Stream & stream, TerminalAttributes & attributes) -> asio::awaitable<std::error_code>
+ {
+ 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