aboutsummaryrefslogtreecommitdiff
path: root/ttwhy/terminal
diff options
context:
space:
mode:
authorFelix Morgner <felix.morgner@gmail.com>2026-10-26 19:15:33 +0100
committerFelix Morgner <felix.morgner@gmail.com>2026-10-26 19:15:33 +0100
commitb051983db21290871c7a33cf8189f415c07f8a24 (patch)
tree4258f9fdc05f5eec0341147047d8eb40ea055454 /ttwhy/terminal
parent79a35574ce6d472f7a7cc4a200a1ed7befe34066 (diff)
downloadttwhy-b051983db21290871c7a33cf8189f415c07f8a24.tar.xz
ttwhy-b051983db21290871c7a33cf8189f415c07f8a24.zip
terminal: improve function and control key handling
Diffstat (limited to 'ttwhy/terminal')
-rw-r--r--ttwhy/terminal/events.cppm38
-rw-r--r--ttwhy/terminal/policies.cppm159
-rw-r--r--ttwhy/terminal/scanner.cppm6
-rw-r--r--ttwhy/terminal/scanner.tests.cpp283
4 files changed, 462 insertions, 24 deletions
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<character_event, control_event, navigation_event, ctrl_chord_event>;
+ using input_event =
+ std::variant<character_event, control_event, navigation_event, ctrl_chord_event, function_key_event>;
//! Validates that a sink can receive all terminal input event types.
//!
//! @tparam Sink The sink callable type to validate.
template<typename Sink>
concept event_sink = ttwhy::event_sink<Sink, character_event> && ttwhy::event_sink<Sink, control_event> &&
- ttwhy::event_sink<Sink, navigation_event>;
+ ttwhy::event_sink<Sink, navigation_event> && ttwhy::event_sink<Sink, ctrl_chord_event> &&
+ ttwhy::event_sink<Sink, function_key_event>;
} // 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<navigation_key>
+ constexpr auto parse_primary_parameter(std::string_view buffer) -> std::optional<unsigned int>
{
- 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<unsigned int>(character - '0');
+ }
+ return value;
+ }
+
+ constexpr auto map_vt220_navigation(unsigned int code) -> std::optional<navigation_key>
+ {
+ 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<function_key>
+ {
+ 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<navigation_key>
{
switch (terminator)
@@ -56,15 +126,32 @@ namespace ttwhy::terminal
}
}
+ constexpr auto map_ss3_function(char terminator) -> std::optional<function_key>
+ {
+ 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_set>(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<event_sink Sink>
using ansi = scanner<Sink, ansi_policy>;
+ //! Convenience alias for a terminal scanner configured with the xterm policy.
+ //!
+ //! @tparam Sink The event sink callable type.
+ template<event_sink Sink>
+ using xterm = scanner<Sink, xterm_policy>;
+
} // 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<ttwhy::terminal::navigation_event>(&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<ttwhy::terminal::function_key_event>(&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<ttwhy::terminal::input_event>{};
+ 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<ttwhy::terminal::input_event>{};
+ 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<ttwhy::terminal::input_event>{};
+ 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));
+ }
+ }
+ }
+}