diff --git a/README.md b/README.md index caf0a37..27c7faa 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,7 @@ Single-header CLI library intended for use in embedded systems (like STM32 or Ar * Live autocompletion (see demo above, can be disabled) * Tab (jump to end of current autocompletion) and backspace (remove char) support * History support (navigate with up and down keypress) +* Limited cursor support (navigate inside input with left and right keypress) * Any byte-stream interface is supported (for example, UART) * Single-header distribution @@ -166,6 +167,7 @@ Terminal is required for correct experience. Following control sequences are res * \b removes last typed character * \t moves cursor to the end of autocompleted command * Esc[A (key up) and Esc[B (key down) navigates through history +* Esc[C (key right) and Esc[D (key left) moves the cursor left and right If you run CLI through a serial port (like on Arduino with its UART-USB converter), you can use for example PuTTY (Windows) or XTerm (Linux). diff --git a/examples/arduino-cli/arduino-cli.ino b/examples/arduino-cli/arduino-cli.ino index 9368f6a..8c914bd 100644 --- a/examples/arduino-cli/arduino-cli.ino +++ b/examples/arduino-cli/arduino-cli.ino @@ -21,7 +21,7 @@ #include "embedded_cli.h" // 164 bytes is minimum size for this params on Arduino Nano -#define CLI_BUFFER_SIZE 164 +#define CLI_BUFFER_SIZE 166 #define CLI_RX_BUFFER_SIZE 16 #define CLI_CMD_BUFFER_SIZE 32 #define CLI_HISTORY_SIZE 32 diff --git a/examples/win32-example/main.cpp b/examples/win32-example/main.cpp index 17aab1e..30a6241 100644 --- a/examples/win32-example/main.cpp +++ b/examples/win32-example/main.cpp @@ -107,16 +107,26 @@ int main() { auto key = event.wVirtualKeyCode; - // key up and down - if (key == 38 || key == 40) { + // key up, down, left and right + if (key >= 37 && key <= 40) { // send as escape sequence embeddedCliReceiveChar(cli, '\x1B'); embeddedCliReceiveChar(cli, '['); - if (key == 38) + switch (key) { + case 38: embeddedCliReceiveChar(cli, 'A'); - else + break; + case 40: embeddedCliReceiveChar(cli, 'B'); + break; + case 37: + embeddedCliReceiveChar(cli, 'D'); + break; + case 39: + embeddedCliReceiveChar(cli, 'C'); + break; + } } char aChar = event.uChar.AsciiChar; diff --git a/lib/src/embedded_cli.c b/lib/src/embedded_cli.c index 9664703..549bb29 100644 --- a/lib/src/embedded_cli.c +++ b/lib/src/embedded_cli.c @@ -1,5 +1,6 @@ #include #include +#include #include "embedded_cli.h" @@ -58,6 +59,16 @@ */ #define CLI_FLAG_AUTOCOMPLETE_ENABLED 0x20u +/** +* Indicates that cursor direction should be forward +*/ +#define CURSOR_DIRECTION_FORWARD true + +/** +* Indicates that cursor direction should be backward +*/ +#define CURSOR_DIRECTION_BACKWARD false + typedef struct EmbeddedCliImpl EmbeddedCliImpl; typedef struct AutocompletedCommand AutocompletedCommand; typedef struct FifoBuf FifoBuf; @@ -160,6 +171,12 @@ struct EmbeddedCliImpl { * Flags are defined as CLI_FLAG_* */ uint8_t flags; + + /** + * Cursor position for current command from right to left + * 0 = end of command + */ + uint16_t cursorPos; }; struct AutocompletedCommand { @@ -194,6 +211,29 @@ static const uint16_t cliInternalBindingCount = 1; static const char *lineBreak = "\r\n"; +/* References for VT100 escape sequences: + * https://learn.microsoft.com/en-us/windows/console/console-virtual-terminal-sequences + * https://ecma-international.org/publications-and-standards/standards/ecma-48/ + */ + +/** Escape sequence - Cursor forward (right) */ +static const char *escSeqCursorRight = "\x1B[C"; + +/** Escape sequence - Cursor backward (left) */ +static const char *escSeqCursorLeft = "\x1B[D"; + +/** Escape sequence - Cursor save position */ +static const char *escSeqCursorSave = "\x1B[s"; + +/** Escape sequence - Cursor restore position */ +static const char *escSeqCursorRestore = "\x1B[u"; + +/** Escape sequence - Cursor insert character (ICH) */ +static const char *escSeqInsertChar = "\x1B[@"; + +/** Escape sequence - Cursor delete character (DCH) */ +static const char *escSeqDeleteChar = "\x1B[P"; + /** * Navigate through command history back and forth. If navigateUp is true, * navigate to older commands, otherwise navigate to newer. @@ -299,6 +339,14 @@ static void clearCurrentLine(EmbeddedCli *cli); */ static void writeToOutput(EmbeddedCli *cli, const char *str); +/** + * Move cursor forward (right) by given number of positions + * @param cli + * @param count + * @param direction: true = forward (right), false = backward (left) + */ +static void moveCursor(EmbeddedCli* cli, uint16_t count, bool direction); + /** * Returns true if provided char is a supported control char: * \r, \n, \b or 0x7F (treated as \b) @@ -459,6 +507,7 @@ EmbeddedCli *embeddedCliNew(EmbeddedCliConfig *config) { impl->maxBindingsCount = (uint16_t) (config->maxBindingCount + cliInternalBindingCount); impl->lastChar = '\0'; impl->invitation = config->invitation; + impl->cursorPos = 0; initInternalBindings(cli); @@ -533,10 +582,16 @@ void embeddedCliPrint(EmbeddedCli *cli, const char *string) { PREPARE_IMPL(cli); + // Save cursor position + uint16_t cursorPosSave = impl->cursorPos; + // remove chars for autocompletion and live command if (!IS_FLAG_SET(impl->flags, CLI_FLAG_DIRECT_PRINT)) clearCurrentLine(cli); + // Restore cursor position + impl->cursorPos = cursorPosSave; + // print provided string writeToOutput(cli, string); writeToOutput(cli, lineBreak); @@ -546,6 +601,7 @@ void embeddedCliPrint(EmbeddedCli *cli, const char *string) { writeToOutput(cli, impl->invitation); writeToOutput(cli, impl->cmdBuffer); impl->inputLineLength = impl->cmdSize; + moveCursor(cli, impl->cursorPos, CURSOR_DIRECTION_BACKWARD); printLiveAutocompletion(cli); } @@ -677,6 +733,7 @@ static void navigateHistory(EmbeddedCli *cli, bool navigateUp) { writeToOutput(cli, impl->cmdBuffer); impl->inputLineLength = impl->cmdSize; + impl->cursorPos = 0; printLiveAutocompletion(cli); } @@ -693,6 +750,16 @@ static void onEscapedInput(EmbeddedCli *cli, char c) { // there might be extra chars between [ and A/B, just ignore them navigateHistory(cli, c == 'A'); } + + if (c == 'C' && impl->cursorPos > 0) { + impl->cursorPos--; + writeToOutput(cli, escSeqCursorRight); + } + + if (c == 'D' && impl->cursorPos < strlen(impl->cmdBuffer)) { + impl->cursorPos++; + writeToOutput(cli, escSeqCursorLeft); + } } } @@ -703,9 +770,16 @@ static void onCharInput(EmbeddedCli *cli, char c) { if (impl->cmdSize + 2 >= impl->cmdMaxSize) return; - impl->cmdBuffer[impl->cmdSize] = c; + size_t insertPos = strlen(impl->cmdBuffer) - impl->cursorPos; + + memmove(&impl->cmdBuffer[insertPos + 1], &impl->cmdBuffer[insertPos], impl->cursorPos + 1); + ++impl->cmdSize; - impl->cmdBuffer[impl->cmdSize] = '\0'; + ++impl->inputLineLength; + impl->cmdBuffer[insertPos] = c; + + if (impl->cursorPos > 0) + writeToOutput(cli, escSeqInsertChar); // Insert Character cli->writeChar(cli, c); } @@ -730,16 +804,17 @@ static void onControlInput(EmbeddedCli *cli, char c) { impl->cmdBuffer[impl->cmdSize] = '\0'; impl->inputLineLength = 0; impl->history.current = 0; + impl->cursorPos = 0; writeToOutput(cli, impl->invitation); - } else if ((c == '\b' || c == 0x7F) && impl->cmdSize > 0) { + } else if ((c == '\b' || c == 0x7F) && ((impl->cmdSize - impl->cursorPos) > 0)) { // remove char from screen - cli->writeChar(cli, '\b'); - cli->writeChar(cli, ' '); - cli->writeChar(cli, '\b'); + writeToOutput(cli, escSeqCursorLeft); // Move cursor to left + writeToOutput(cli, escSeqDeleteChar); // And remove character // and from buffer + size_t insertPos = strlen(impl->cmdBuffer) - impl->cursorPos; + memmove(&impl->cmdBuffer[insertPos - 1], &impl->cmdBuffer[insertPos], impl->cursorPos + 1); --impl->cmdSize; - impl->cmdBuffer[impl->cmdSize] = '\0'; } else if (c == '\t') { onAutocompleteRequest(cli); } @@ -971,6 +1046,11 @@ static void printLiveAutocompletion(EmbeddedCli *cli) { cmd.autocompletedLen = impl->cmdSize; } + // save cursor location + writeToOutput(cli, escSeqCursorSave); + + moveCursor(cli, impl->cursorPos, CURSOR_DIRECTION_FORWARD); + // print live autocompletion (or nothing, if it doesn't exist) for (size_t i = impl->cmdSize; i < cmd.autocompletedLen; ++i) { cli->writeChar(cli, cmd.firstCandidate[i]); @@ -980,10 +1060,9 @@ static void printLiveAutocompletion(EmbeddedCli *cli) { cli->writeChar(cli, ' '); } impl->inputLineLength = cmd.autocompletedLen; - cli->writeChar(cli, '\r'); - // print current command again so cursor is moved to initial place - writeToOutput(cli, impl->invitation); - writeToOutput(cli, impl->cmdBuffer); + + // restore cursor + writeToOutput(cli, escSeqCursorRestore); } static void onAutocompleteRequest(EmbeddedCli *cli) { @@ -1003,9 +1082,10 @@ static void onAutocompleteRequest(EmbeddedCli *cli) { } impl->cmdBuffer[cmd.autocompletedLen] = '\0'; - writeToOutput(cli, &impl->cmdBuffer[impl->cmdSize]); + writeToOutput(cli, &impl->cmdBuffer[impl->cmdSize - impl->cursorPos]); impl->cmdSize = cmd.autocompletedLen; impl->inputLineLength = impl->cmdSize; + impl->cursorPos = 0; // Cursor has been moved to the end return; } @@ -1042,6 +1122,8 @@ static void clearCurrentLine(EmbeddedCli *cli) { } cli->writeChar(cli, '\r'); impl->inputLineLength = 0; + + impl->cursorPos = 0; } static void writeToOutput(EmbeddedCli *cli, const char *str) { @@ -1052,6 +1134,18 @@ static void writeToOutput(EmbeddedCli *cli, const char *str) { } } +static void moveCursor(EmbeddedCli* cli, uint16_t count, bool direction) { + // Check if we need to send any command + if (count == 0) + return; + + // 5 = uint16_t max, 3 = escape sequence, 1 = string termination + char escBuffer[5 + 3 + 1] = { 0 }; + char dirChar = direction ? escSeqCursorRight[2] : escSeqCursorLeft[2]; + sprintf(escBuffer, "\x1B[%u%c", count, dirChar); + writeToOutput(cli, escBuffer); +} + static bool isControlChar(char c) { return c == '\r' || c == '\n' || c == '\b' || c == '\t' || c == 0x7F; } diff --git a/tests/CliWrapper.cpp b/tests/CliWrapper.cpp index 79a0595..cbadf01 100644 --- a/tests/CliWrapper.cpp +++ b/tests/CliWrapper.cpp @@ -2,6 +2,8 @@ #include "CliWrapper.h" #include +#include +#include // expand implementation when single header version is tested #define EMBEDDED_CLI_IMPL @@ -80,32 +82,96 @@ CliWrapper::Display CliWrapper::getDisplay() { std::vector output; std::string line; + std::string escapeSequenceCount; size_t cursorPosition = 0; + + enum CharacterMode { NORMAL, ESCAPE_MODE_START, ESCAPE_MODE_START_PARSE }; + CharacterMode charMode = NORMAL; + + // Variable for saving the cursor position + size_t cursorPosSave = 0; for (auto c: txQueue) { - if (c == '\b') { - if (cursorPosition > 0 && (cursorPosition - 1) < line.size()) { - line.erase(cursorPosition - 1, 1); - --cursorPosition; + switch (charMode) { + default: + case NORMAL: + if (c == '\x1B') { + charMode = ESCAPE_MODE_START; + // Clear the count in case it was constructed during the previous escape sequence + escapeSequenceCount.clear(); } - } else if (c == '\r') { - cursorPosition = 0; - } else if (c == '\n') { - cursorPosition = 0; - output.push_back(line); - line.clear(); - } else { - if (line.size() > cursorPosition) { + else if (c == '\b') { + if (cursorPosition > 0 && (cursorPosition - 1) < line.size()) { + line.erase(cursorPosition - 1, 1); + --cursorPosition; + } + } + else if (c == '\r') { + cursorPosition = 0; + } + else if (c == '\n') { + cursorPosition = 0; + output.push_back(line); + line.clear(); + } + else { + if (line.size() > cursorPosition) { + line.erase(cursorPosition, 1); + } + line.insert(cursorPosition, 1, c); + ++cursorPosition; + } + break; + case ESCAPE_MODE_START: + if (c == '[') + charMode = ESCAPE_MODE_START_PARSE; + else + charMode = NORMAL; + break; + case ESCAPE_MODE_START_PARSE: + // Check if the escape sequence has a count associated with it, for example 'ESC[C' + if (isdigit(c)) { + escapeSequenceCount.push_back(c); + break; + } + + if (c == 'C') { + if (escapeSequenceCount.empty()) + cursorPosition++; + else + cursorPosition += strtoul(escapeSequenceCount.c_str(), NULL, 10); + } + else if (c == 'D') { + if (escapeSequenceCount.empty()) + cursorPosition--; + else + cursorPosition -= strtoul(escapeSequenceCount.c_str(), NULL, 10); + } + else if (c == 's') { + cursorPosSave = cursorPosition; + } + else if (c == 'u') { + cursorPosition = cursorPosSave; + } + else if (c == '@') { + line.insert(cursorPosition, 1, ' '); + } + else if (c == 'P') { line.erase(cursorPosition, 1); } - line.insert(cursorPosition, 1, c); - ++cursorPosition; + + // Return to normal mode + charMode = NORMAL; + + break; } } + output.push_back(line); for (auto &l: output) { trimStr(l); + removeEscSeq(l); } return Display{ @@ -118,6 +184,7 @@ std::string CliWrapper::getRawOutput() { txQueue.push_back('\0'); std::string output = txQueue.data(); txQueue.pop_back(); + removeEscSeq(output); return output; } @@ -150,6 +217,12 @@ void CliWrapper::trimStr(std::string &str) { } } +void CliWrapper::removeEscSeq(std::string& str) { + // Regex to find and delete escape sequences. NOTE This might need to be updated in the future + std::regex escapeSeqRe("\\x1b\\[[0-9]*[ABCDEFGM78dsu@PXLMJK]"); + str = regex_replace(str, escapeSeqRe, ""); +} + void CliWrapper::onBoundCommand(Command command) { calledBindings.push_back(std::move(command)); } diff --git a/tests/CliWrapper.h b/tests/CliWrapper.h index 477c833..5bb4897 100644 --- a/tests/CliWrapper.h +++ b/tests/CliWrapper.h @@ -136,6 +136,12 @@ class CliWrapper { void onBoundCommand(Command command); static void trimStr(std::string &str); + + /** + * Remove escape sequences from given string + * @param input string + */ + static void removeEscSeq(std::string& str); }; diff --git a/tests/cli/AutocompleteTest.cpp b/tests/cli/AutocompleteTest.cpp index 0d5537d..92bf58f 100644 --- a/tests/cli/AutocompleteTest.cpp +++ b/tests/cli/AutocompleteTest.cpp @@ -112,6 +112,17 @@ TEST_CASE("CLI. Autocomplete enabled", "[cli]") { REQUIRE(displayed.cursorColumn == 3); } + SECTION("Autocomplete when inside of input has been modified") { + cli.send("res"); + cli.sendLine("\x1B[D\x1B[D"); // Move left two characters + cli.process(); + + auto displayed = cli.getDisplay(); + + REQUIRE(displayed.lines.size() == 2); + REQUIRE(displayed.lines[0] == "> reset-"); + } + SECTION("Live autocomplete when no candidates") { cli.send("m"); cli.process(); diff --git a/tests/cli/BaseTest.cpp b/tests/cli/BaseTest.cpp index 899e896..a7ca3a2 100644 --- a/tests/cli/BaseTest.cpp +++ b/tests/cli/BaseTest.cpp @@ -2,6 +2,7 @@ #include "CliBuilder.h" #include +#include TEST_CASE("CLI. Base tests", "[cli]") { @@ -110,6 +111,76 @@ TEST_CASE("CLI. Base tests", "[cli]") { REQUIRE(commands.back().args[0] == "led"); } + SECTION("Move cursor left") { + cli.send("get lft\x1B[D\x1B[De"); + cli.process(); + + auto displayed = cli.getDisplay(); + + REQUIRE(displayed.lines.size() == 1); + REQUIRE(displayed.lines[0] == "> get left"); + REQUIRE(displayed.cursorColumn == 8); + } + + SECTION("Move cursor left and remove some chars") { + cli.send("test"); + cli.send("\x1B[D\x1B[D"); // Move left two characters + cli.send("\b\b\b\b\b"); // Try to delete more characters than remaining + cli.send("almo"); + cli.process(); + + auto displayed = cli.getDisplay(); + + REQUIRE(displayed.lines.size() == 1); + REQUIRE(displayed.lines[0] == "> almost"); + } + + SECTION("Move cursor right") { + cli.send("get right\x1B[C\x1B[C"); + cli.process(); + + auto displayed = cli.getDisplay(); + + REQUIRE(displayed.lines.size() == 1); + REQUIRE(displayed.lines[0] == "> get right"); + REQUIRE(displayed.cursorColumn == 11); + } + + SECTION("Move cursor left then right") { + cli.send("ge oth\x1B[D\x1B[D\x1B[D\x1B[Dt\x1B[Cb"); + cli.process(); + + auto displayed = cli.getDisplay(); + + REQUIRE(displayed.lines.size() == 1); + REQUIRE(displayed.lines[0] == "> get both"); + REQUIRE(displayed.cursorColumn == 7); + } + + SECTION("Command that is too long") { + size_t cmdMax = embeddedCliDefaultConfig()->cmdBufferSize; + std::string cmdMaxTest = std::string(cmdMax/2, 'x'); + + // Split command into two to prevent getting the fifo full + cli.send(cmdMaxTest); + cli.process(); + cli.sendLine(cmdMaxTest); + cli.process(); + + auto displayed = cli.getDisplay(); + + std::string cliRawOutput = cli.getRawOutput(); + + auto xcount = std::count(cliRawOutput.begin(), cliRawOutput.end(), 'x'); + + REQUIRE(displayed.lines.size() == 2); + // We are only displaying (cmdMax - 2) 'x's, + // but two additional characters are the invitation and the space + REQUIRE(displayed.lines[0].size() == cmdMax); + // Check that the two extra x's will be dropped + REQUIRE(xcount == cmdMax - 2); + } + SECTION("Unknown command") { // unknown commands are only possible when onCommand callback is not set cli.raw()->onCommand = nullptr; @@ -152,7 +223,7 @@ TEST_CASE("CLI. Base tests", "[cli]") { SECTION("Escape sequences") { SECTION("Escape sequences don't show up in output") { - cli.send("t\x1B[Ae\x1B[10As\x1B[Bt\x1B[C\x1B[D1"); + cli.send("t\x1B[Ae\x1B[10As\x1B[Bt\x1B[D\x1B[C1"); cli.process(); auto displayed = cli.getDisplay(); diff --git a/tests/cli/PrintTest.cpp b/tests/cli/PrintTest.cpp index 82cb66b..57a0f85 100644 --- a/tests/cli/PrintTest.cpp +++ b/tests/cli/PrintTest.cpp @@ -8,6 +8,7 @@ TEST_CASE("CLI. Printing", "[cli]") { CliWrapper cli = CliBuilder().build(); SECTION("Print with no command input") { + cli.process(); cli.print("test print"); auto display = cli.getDisplay(); @@ -61,4 +62,18 @@ TEST_CASE("CLI. Printing", "[cli]") { REQUIRE(displayed.lines[1] == "> get"); REQUIRE(displayed.cursorColumn == 3); } + + SECTION("Print with cursor in the middle of the input") { + cli.send("test"); + cli.send("\x1B[D\x1B[D\x1B[D"); // Move left three characters + cli.process(); + cli.print("print"); + + auto displayed = cli.getDisplay(); + + REQUIRE(displayed.lines.size() == 2); + REQUIRE(displayed.lines[0] == "print"); + REQUIRE(displayed.lines[1] == "> test"); + REQUIRE(displayed.cursorColumn == 3); + } }