Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add limited cursor support (left / right) #45

Merged
merged 12 commits into from
Jan 16, 2024
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
icamaster marked this conversation as resolved.
Show resolved Hide resolved
* Any byte-stream interface is supported (for example, UART)
* Single-header distribution

Expand Down Expand Up @@ -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).
Expand Down
18 changes: 14 additions & 4 deletions examples/win32-example/main.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
102 changes: 92 additions & 10 deletions lib/src/embedded_cli.c
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
#include <stdlib.h>
#include <string.h>
#include <stdio.h>

#include "embedded_cli.h"

Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -194,6 +211,26 @@ static const uint16_t cliInternalBindingCount = 1;

static const char *lineBreak = "\r\n";

/* Reference for VT100 escape sequences: https://learn.microsoft.com/en-us/windows/console/console-virtual-terminal-sequences */
icamaster marked this conversation as resolved.
Show resolved Hide resolved

/** 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 */
icamaster marked this conversation as resolved.
Show resolved Hide resolved
static const char *escSeqInsertChar = "\x1B[@";

/** Escape sequence - Cursor delete character */
icamaster marked this conversation as resolved.
Show resolved Hide resolved
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.
Expand Down Expand Up @@ -299,6 +336,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)
Expand Down Expand Up @@ -459,6 +504,7 @@ EmbeddedCli *embeddedCliNew(EmbeddedCliConfig *config) {
impl->maxBindingsCount = (uint16_t) (config->maxBindingCount + cliInternalBindingCount);
impl->lastChar = '\0';
impl->invitation = config->invitation;
impl->cursorPos = 0;

initInternalBindings(cli);

Expand Down Expand Up @@ -546,6 +592,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);
}
Expand Down Expand Up @@ -677,6 +724,7 @@ static void navigateHistory(EmbeddedCli *cli, bool navigateUp) {

writeToOutput(cli, impl->cmdBuffer);
impl->inputLineLength = impl->cmdSize;
impl->cursorPos = 0;

printLiveAutocompletion(cli);
}
Expand All @@ -693,6 +741,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);
}
}
}

Expand All @@ -703,9 +761,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);
}
Expand All @@ -730,16 +795,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) {
// 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);
}
Expand Down Expand Up @@ -971,6 +1037,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]);
Expand All @@ -980,10 +1051,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) {
Expand Down Expand Up @@ -1052,6 +1122,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;
}
Expand Down
100 changes: 86 additions & 14 deletions tests/CliWrapper.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
#include "CliWrapper.h"

#include <stdexcept>
#include <regex>
#include <ctype.h>

// expand implementation when single header version is tested
#define EMBEDDED_CLI_IMPL
Expand Down Expand Up @@ -80,32 +82,95 @@ CliWrapper::Display CliWrapper::getDisplay() {
std::vector<std::string> 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 <n> associated with it, for example 'ESC[<n>C'
if (isdigit(c)) {
escapeSequenceCount.push_back(c);
break;
}

if (c == 'C') {
// At the moment, this is the only escape sequence that takes a count (number) before the command
if (escapeSequenceCount.empty())
cursorPosition++;
else
cursorPosition += strtoul(escapeSequenceCount.c_str(), NULL, 10);
}
else if (c == 'D') {
if (cursorPosition > 0)
cursorPosition--;
}
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{
Expand All @@ -118,6 +183,7 @@ std::string CliWrapper::getRawOutput() {
txQueue.push_back('\0');
std::string output = txQueue.data();
txQueue.pop_back();
removeEscSeq(output);
return output;
}

Expand Down Expand Up @@ -150,6 +216,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));
}
Expand Down
Loading