From b051983db21290871c7a33cf8189f415c07f8a24 Mon Sep 17 00:00:00 2001 From: Felix Morgner Date: Mon, 26 Oct 2026 19:15:33 +0100 Subject: terminal: improve function and control key handling --- ttwhy/terminal/events.cppm | 38 +++++- ttwhy/terminal/policies.cppm | 159 +++++++++++++++++++--- ttwhy/terminal/scanner.cppm | 6 + ttwhy/terminal/scanner.tests.cpp | 283 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 462 insertions(+), 24 deletions(-) (limited to 'ttwhy') diff --git a/ttwhy/terminal/events.cppm b/ttwhy/terminal/events.cppm index ce4a7d5..5df397d 100644 --- a/ttwhy/terminal/events.cppm +++ b/ttwhy/terminal/events.cppm @@ -34,6 +34,31 @@ export namespace ttwhy::terminal page_down, }; + //! Represents terminal function keys (F1 through F20). + enum struct function_key : std::uint8_t + { + f1, + f2, + f3, + f4, + f5, + f6, + f7, + f8, + f9, + f10, + f11, + f12, + f13, + f14, + f15, + f16, + f17, + f18, + f19, + f20, + }; + //! Represents individual keyboard modifier keys. enum struct modifier : std::uint8_t { @@ -138,14 +163,23 @@ export namespace ttwhy::terminal char key{}; }; + //! Represents a terminal function key event. + struct function_key_event + { + function_key key{}; + modifier_set modifiers{}; + }; + //! Variant representing any input event produced by a terminal scanner. - using input_event = std::variant; + using input_event = + std::variant; //! Validates that a sink can receive all terminal input event types. //! //! @tparam Sink The sink callable type to validate. template concept event_sink = ttwhy::event_sink && ttwhy::event_sink && - ttwhy::event_sink; + ttwhy::event_sink && ttwhy::event_sink && + ttwhy::event_sink; } // namespace ttwhy::terminal diff --git a/ttwhy/terminal/policies.cppm b/ttwhy/terminal/policies.cppm index 83694b8..49712aa 100644 --- a/ttwhy/terminal/policies.cppm +++ b/ttwhy/terminal/policies.cppm @@ -12,29 +12,99 @@ namespace ttwhy::terminal namespace detail { - constexpr auto map_vt220_keypad(char terminator) -> std::optional + constexpr auto parse_primary_parameter(std::string_view buffer) -> std::optional { - switch (terminator) + auto delimiter = buffer.find(';'); + auto primary_slice = buffer.substr(0, delimiter); + if (primary_slice.empty()) + { + return std::nullopt; + } + + auto value = 0u; + for (auto character : primary_slice) + { + if (character < '0' || character > '9') + { + return std::nullopt; + } + value = value * 10 + static_cast(character - '0'); + } + return value; + } + + constexpr auto map_vt220_navigation(unsigned int code) -> std::optional + { + switch (code) { - case '1': - case '7': + case 1: + case 7: return navigation_key::home; - case '2': + case 2: return navigation_key::insert_key; - case '3': + case 3: return navigation_key::delete_key; - case '4': - case '8': + case 4: + case 8: return navigation_key::end; - case '5': + case 5: return navigation_key::page_up; - case '6': + case 6: return navigation_key::page_down; default: return std::nullopt; } } + constexpr auto map_vt220_function(unsigned int code) -> std::optional + { + switch (code) + { + case 11: + return function_key::f1; + case 12: + return function_key::f2; + case 13: + return function_key::f3; + case 14: + return function_key::f4; + case 15: + return function_key::f5; + case 17: + return function_key::f6; + case 18: + return function_key::f7; + case 19: + return function_key::f8; + case 20: + return function_key::f9; + case 21: + return function_key::f10; + case 23: + return function_key::f11; + case 24: + return function_key::f12; + case 25: + return function_key::f13; + case 26: + return function_key::f14; + case 28: + return function_key::f15; + case 29: + return function_key::f16; + case 31: + return function_key::f17; + case 32: + return function_key::f18; + case 33: + return function_key::f19; + case 34: + return function_key::f20; + default: + return std::nullopt; + } + } + constexpr auto map_vt100_cursor(char terminator) -> std::optional { switch (terminator) @@ -56,15 +126,32 @@ namespace ttwhy::terminal } } + constexpr auto map_ss3_function(char terminator) -> std::optional + { + switch (terminator) + { + case 'P': + return function_key::f1; + case 'Q': + return function_key::f2; + case 'R': + return function_key::f3; + case 'S': + return function_key::f4; + default: + return std::nullopt; + } + } + } // namespace detail //! Policy for decoding standard ANSI/VT escape sequences. export struct ansi_policy { - //! Resolve a VT220 keypad sequence and emit the corresponding navigation event. + //! Resolve a VT220 keypad sequence and emit the corresponding navigation or function event. //! //! @param buffer The CSI parameter buffer. - //! @param sink The event sink to receive the resolved navigation event. + //! @param sink The event sink to receive the resolved event. constexpr auto static resolve_vt220_keypad(std::string & buffer, event_sink auto & sink) -> void { if (buffer.empty()) @@ -72,9 +159,16 @@ namespace ttwhy::terminal return; } - if (auto key = detail::map_vt220_keypad(buffer.front())) + if (auto code = detail::parse_primary_parameter(buffer)) { - sink(navigation_event{*key}); + if (auto key = detail::map_vt220_navigation(*code)) + { + sink(navigation_event{*key}); + } + else if (auto key = detail::map_vt220_function(*code)) + { + sink(function_key_event{*key}); + } } buffer.clear(); } @@ -91,16 +185,20 @@ namespace ttwhy::terminal } } - //! Resolve an SS3 single-shift sequence and emit the corresponding navigation event. + //! Resolve an SS3 single-shift sequence and emit the corresponding navigation or function event. //! //! @param terminator The SS3 terminator character indicating direction or key. - //! @param sink The event sink to receive the resolved navigation event. + //! @param sink The event sink to receive the resolved event. constexpr auto static resolve_ss3(char terminator, event_sink auto & sink) -> void { if (auto key = detail::map_vt100_cursor(terminator)) { sink(navigation_event{*key}); } + else if (auto key = detail::map_ss3_function(terminator)) + { + sink(function_key_event{*key}); + } } }; @@ -120,6 +218,12 @@ namespace ttwhy::terminal } auto modifier_string = buffer.substr(delimiter + 1); + auto next_delimiter = modifier_string.find(';'); + if (next_delimiter != std::string_view::npos) + { + modifier_string = modifier_string.substr(0, next_delimiter); + } + auto modifier_value = 0u; for (auto character : modifier_string) @@ -138,10 +242,10 @@ namespace ttwhy::terminal return static_cast(modifier_value - 1); } - //! Resolve a VT220 keypad sequence with modifiers and emit the navigation event. + //! Resolve a VT220 keypad sequence with modifiers and emit the navigation or function event. //! //! @param buffer The CSI parameter buffer containing key and modifier values. - //! @param sink The event sink to receive the resolved navigation event. + //! @param sink The event sink to receive the resolved event. constexpr auto static resolve_vt220_keypad(std::string & buffer, event_sink auto & sink) -> void { if (buffer.empty()) @@ -151,9 +255,16 @@ namespace ttwhy::terminal auto modifiers = parse_modifiers(buffer); - if (auto key = detail::map_vt220_keypad(buffer.front())) + if (auto code = detail::parse_primary_parameter(buffer)) { - sink(navigation_event{*key, modifiers}); + if (auto key = detail::map_vt220_navigation(*code)) + { + sink(navigation_event{*key, modifiers}); + } + else if (auto key = detail::map_vt220_function(*code)) + { + sink(function_key_event{*key, modifiers}); + } } buffer.clear(); } @@ -173,16 +284,20 @@ namespace ttwhy::terminal } } - //! Resolve an SS3 single-shift sequence and emit the corresponding navigation event. + //! Resolve an SS3 single-shift sequence and emit the corresponding navigation or function event. //! //! @param terminator The SS3 terminator character indicating direction or key. - //! @param sink The event sink to receive the resolved navigation event. + //! @param sink The event sink to receive the resolved event. constexpr auto static resolve_ss3(char terminator, event_sink auto & sink) -> void { if (auto key = detail::map_vt100_cursor(terminator)) { sink(navigation_event{*key}); } + else if (auto key = detail::map_ss3_function(terminator)) + { + sink(function_key_event{*key}); + } } }; diff --git a/ttwhy/terminal/scanner.cppm b/ttwhy/terminal/scanner.cppm index f1540f2..bda9d88 100644 --- a/ttwhy/terminal/scanner.cppm +++ b/ttwhy/terminal/scanner.cppm @@ -250,4 +250,10 @@ export namespace ttwhy::terminal template using ansi = scanner; + //! Convenience alias for a terminal scanner configured with the xterm policy. + //! + //! @tparam Sink The event sink callable type. + template + using xterm = scanner; + } // namespace ttwhy::terminal diff --git a/ttwhy/terminal/scanner.tests.cpp b/ttwhy/terminal/scanner.tests.cpp index 64acb2d..b2a7f1e 100644 --- a/ttwhy/terminal/scanner.tests.cpp +++ b/ttwhy/terminal/scanner.tests.cpp @@ -22,6 +22,22 @@ using namespace std::string_view_literals; return data != nullptr && data->key == expected; } +[[nodiscard]] constexpr auto static is_navigation(ttwhy::terminal::input_event const & event, + ttwhy::terminal::navigation_key expected_key, + ttwhy::terminal::modifier_set expected_modifiers = {}) -> bool +{ + auto const * data = std::get_if(&event); + return data != nullptr && data->key == expected_key && data->modifiers == expected_modifiers; +} + +[[nodiscard]] constexpr auto static is_function(ttwhy::terminal::input_event const & event, + ttwhy::terminal::function_key expected_key, + ttwhy::terminal::modifier_set expected_modifiers = {}) -> bool +{ + auto const * data = std::get_if(&event); + return data != nullptr && data->key == expected_key && data->modifiers == expected_modifiers; +} + SCENARIO("The ANSI scanner processes printable ASCII and standard C0 control characters", "[scanner][ansi]") { GIVEN("An initialized scanner and event sink") @@ -99,3 +115,270 @@ SCENARIO("The ANSI scanner processes printable ASCII and standard C0 control cha } } } + +SCENARIO("The ANSI scanner processes VT220 keypad and cursor navigation sequences", "[scanner][ansi]") +{ + GIVEN("An initialized ANSI scanner and event sink") + { + auto queue = std::vector{}; + auto sink = [&queue](auto const & event) { + queue.push_back(event); + }; + auto scanner = ttwhy::terminal::ansi{sink}; + + WHEN("Processing VT220 keypad Home (\\x1b[1~)") + { + scanner.process("\x1b[1~"sv); + + THEN("It yields a navigation event for home") + { + REQUIRE(queue.size() == 1); + CHECK(is_navigation(queue.at(0), ttwhy::terminal::navigation_key::home)); + } + } + + WHEN("Processing VT220 keypad Insert (\\x1b[2~)") + { + scanner.process("\x1b[2~"sv); + + THEN("It yields a navigation event for insert") + { + REQUIRE(queue.size() == 1); + CHECK(is_navigation(queue.at(0), ttwhy::terminal::navigation_key::insert_key)); + } + } + + WHEN("Processing VT220 keypad Delete (\\x1b[3~)") + { + scanner.process("\x1b[3~"sv); + + THEN("It yields a navigation event for delete") + { + REQUIRE(queue.size() == 1); + CHECK(is_navigation(queue.at(0), ttwhy::terminal::navigation_key::delete_key)); + } + } + + WHEN("Processing VT220 keypad End (\\x1b[4~)") + { + scanner.process("\x1b[4~"sv); + + THEN("It yields a navigation event for end") + { + REQUIRE(queue.size() == 1); + CHECK(is_navigation(queue.at(0), ttwhy::terminal::navigation_key::end)); + } + } + + WHEN("Processing VT220 keypad Page Up (\\x1b[5~)") + { + scanner.process("\x1b[5~"sv); + + THEN("It yields a navigation event for page up") + { + REQUIRE(queue.size() == 1); + CHECK(is_navigation(queue.at(0), ttwhy::terminal::navigation_key::page_up)); + } + } + + WHEN("Processing VT220 keypad Page Down (\\x1b[6~)") + { + scanner.process("\x1b[6~"sv); + + THEN("It yields a navigation event for page down") + { + REQUIRE(queue.size() == 1); + CHECK(is_navigation(queue.at(0), ttwhy::terminal::navigation_key::page_down)); + } + } + + WHEN("Processing VT100 cursor arrows") + { + scanner.process("\x1b[A\x1b[B\x1b[C\x1b[D"sv); + + THEN("It yields navigation events for up, down, right, and left") + { + REQUIRE(queue.size() == 4); + CHECK(is_navigation(queue.at(0), ttwhy::terminal::navigation_key::up)); + CHECK(is_navigation(queue.at(1), ttwhy::terminal::navigation_key::down)); + CHECK(is_navigation(queue.at(2), ttwhy::terminal::navigation_key::right)); + CHECK(is_navigation(queue.at(3), ttwhy::terminal::navigation_key::left)); + } + } + } +} + +SCENARIO("The ANSI scanner processes multi-digit function keys and SS3 sequences", "[scanner][ansi]") +{ + GIVEN("An initialized ANSI scanner and event sink") + { + auto queue = std::vector{}; + auto sink = [&queue](auto const & event) { + queue.push_back(event); + }; + auto scanner = ttwhy::terminal::ansi{sink}; + + WHEN("Processing F1 (\\x1b[11~)") + { + scanner.process("\x1b[11~"sv); + + THEN("It yields a function key event for F1 and not home") + { + REQUIRE(queue.size() == 1); + CHECK(is_function(queue.at(0), ttwhy::terminal::function_key::f1)); + } + } + + WHEN("Processing F5 (\\x1b[15~)") + { + scanner.process("\x1b[15~"sv); + + THEN("It yields a function key event for F5 and not home") + { + REQUIRE(queue.size() == 1); + CHECK(is_function(queue.at(0), ttwhy::terminal::function_key::f5)); + } + } + + WHEN("Processing F6 (\\x1b[17~)") + { + scanner.process("\x1b[17~"sv); + + THEN("It yields a function key event for F6") + { + REQUIRE(queue.size() == 1); + CHECK(is_function(queue.at(0), ttwhy::terminal::function_key::f6)); + } + } + + WHEN("Processing F12 (\\x1b[24~)") + { + scanner.process("\x1b[24~"sv); + + THEN("It yields a function key event for F12") + { + REQUIRE(queue.size() == 1); + CHECK(is_function(queue.at(0), ttwhy::terminal::function_key::f12)); + } + } + + WHEN("Processing F20 (\\x1b[34~)") + { + scanner.process("\x1b[34~"sv); + + THEN("It yields a function key event for F20") + { + REQUIRE(queue.size() == 1); + CHECK(is_function(queue.at(0), ttwhy::terminal::function_key::f20)); + } + } + + WHEN("Processing an unmapped multi-digit code (\\x1b[99~)") + { + scanner.process("\x1b[99~"sv); + + THEN("No event is emitted") + { + CHECK(queue.empty()); + } + } + + WHEN("Processing SS3 function keys F1 through F4 (\\x1bOP to \\x1bOS)") + { + scanner.process("\x1bOP\x1bOQ\x1bOR\x1bOS"sv); + + THEN("It yields function key events for F1 through F4") + { + REQUIRE(queue.size() == 4); + CHECK(is_function(queue.at(0), ttwhy::terminal::function_key::f1)); + CHECK(is_function(queue.at(1), ttwhy::terminal::function_key::f2)); + CHECK(is_function(queue.at(2), ttwhy::terminal::function_key::f3)); + CHECK(is_function(queue.at(3), ttwhy::terminal::function_key::f4)); + } + } + } +} + +SCENARIO("The xterm scanner decodes keypad sequences and function keys with modifiers", "[scanner][xterm]") +{ + GIVEN("An initialized xterm scanner and event sink") + { + auto queue = std::vector{}; + auto sink = [&queue](auto const & event) { + queue.push_back(event); + }; + auto scanner = ttwhy::terminal::xterm{sink}; + + WHEN("Processing Shift+Home (\\x1b[1;2~)") + { + scanner.process("\x1b[1;2~"sv); + + THEN("It yields a navigation event for Home with shift modifier") + { + REQUIRE(queue.size() == 1); + CHECK(is_navigation(queue.at(0), ttwhy::terminal::navigation_key::home, + ttwhy::terminal::modifier::shift | ttwhy::terminal::modifier::none)); + } + } + + WHEN("Processing Ctrl+Delete (\\x1b[3;5~)") + { + scanner.process("\x1b[3;5~"sv); + + THEN("It yields a navigation event for Delete with ctrl modifier") + { + REQUIRE(queue.size() == 1); + CHECK(is_navigation(queue.at(0), ttwhy::terminal::navigation_key::delete_key, + ttwhy::terminal::modifier::ctrl | ttwhy::terminal::modifier::none)); + } + } + + WHEN("Processing Shift+F5 (\\x1b[15;2~)") + { + scanner.process("\x1b[15;2~"sv); + + THEN("It yields a function key event for F5 with shift modifier") + { + REQUIRE(queue.size() == 1); + CHECK(is_function(queue.at(0), ttwhy::terminal::function_key::f5, + ttwhy::terminal::modifier::shift | ttwhy::terminal::modifier::none)); + } + } + + WHEN("Processing Ctrl+F5 (\\x1b[15;5~)") + { + scanner.process("\x1b[15;5~"sv); + + THEN("It yields a function key event for F5 with ctrl modifier") + { + REQUIRE(queue.size() == 1); + CHECK(is_function(queue.at(0), ttwhy::terminal::function_key::f5, + ttwhy::terminal::modifier::ctrl | ttwhy::terminal::modifier::none)); + } + } + + WHEN("Processing Alt+F12 (\\x1b[24;3~)") + { + scanner.process("\x1b[24;3~"sv); + + THEN("It yields a function key event for F12 with alt modifier") + { + REQUIRE(queue.size() == 1); + CHECK(is_function(queue.at(0), ttwhy::terminal::function_key::f12, + ttwhy::terminal::modifier::alt | ttwhy::terminal::modifier::none)); + } + } + + WHEN("Processing Shift+Up cursor (\\x1b[1;2A)") + { + scanner.process("\x1b[1;2A"sv); + + THEN("It yields a navigation event for Up with shift modifier") + { + REQUIRE(queue.size() == 1); + CHECK(is_navigation(queue.at(0), ttwhy::terminal::navigation_key::up, + ttwhy::terminal::modifier::shift | ttwhy::terminal::modifier::none)); + } + } + } +} -- cgit v1.2.3