diff options
Diffstat (limited to 'ttwhy/terminal')
| -rw-r--r-- | ttwhy/terminal/echo.cppm | 207 | ||||
| -rw-r--r-- | ttwhy/terminal/echo.tests.cpp | 257 | ||||
| -rw-r--r-- | ttwhy/terminal/events.cppm | 107 | ||||
| -rw-r--r-- | ttwhy/terminal/mod.cppm | 9 | ||||
| -rw-r--r-- | ttwhy/terminal/policies.cppm | 158 | ||||
| -rw-r--r-- | ttwhy/terminal/readers.cppm | 94 | ||||
| -rw-r--r-- | ttwhy/terminal/scanner.cppm | 236 | ||||
| -rw-r--r-- | ttwhy/terminal/scanner.tests.cpp | 101 | ||||
| -rw-r--r-- | ttwhy/terminal/scoped_attributes.cppm | 135 | ||||
| -rw-r--r-- | ttwhy/terminal/signal_handling.cppm | 58 |
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 |
